diff --git a/.sdlc/features/data-models/design.md b/.sdlc/features/data-models/design.md new file mode 100644 index 0000000..cc51d40 --- /dev/null +++ b/.sdlc/features/data-models/design.md @@ -0,0 +1,527 @@ +# Design: Core Data Models & Persistence + +## Architecture Approach + +This feature replaces the in-memory example adapter with PostgreSQL-backed persistence for four new domain entities: **Project**, **Task**, **Label**, and **Assignment**. Every layer of the hexagonal architecture is affected: + +| Layer | What Changes | +|-------|-------------| +| **Domain** (`internal/domain/`) | New entity files: `project.go`, `task.go`, `label.go`, `assignment.go`; extended `errors.go` | +| **Ports** (`internal/port/`) | Four new repository interfaces | +| **Adapters** (`internal/adapter/postgres/`) | New package with four Postgres repository implementations | +| **Services** (`internal/service/`) | Four new service files with business logic | +| **Handlers** (`internal/api/handlers/`) | Four new handler files with request/response DTOs | +| **Routes** (`internal/api/routes.go`) | Extended to register all new endpoints | +| **OpenAPI** (`internal/api/spec.go`) | Extended with schemas and paths for all entities | +| **Migrations** (`migrations/`) | Four SQL migration files | +| **Wiring** (`cmd/server/main.go`) | Database connection, migration execution, adapter creation, shutdown hook | + +The existing `Example` entity and its layers remain untouched; new entities follow the identical established patterns. + +## Data Model Changes + +### New Database Tables + +All tables use UUID primary keys, UTC timestamps, and snake_case naming. + +#### `projects` +```sql +CREATE TABLE projects ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(200) NOT NULL, + description VARCHAR(1000) NOT NULL DEFAULT '', + status VARCHAR(20) NOT NULL DEFAULT 'active', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT uq_projects_name UNIQUE (name), + CONSTRAINT chk_projects_status CHECK (status IN ('active', 'archived')) +); +``` + +#### `tasks` +```sql +CREATE TABLE tasks ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + title VARCHAR(300) NOT NULL, + description VARCHAR(5000) NOT NULL DEFAULT '', + status VARCHAR(20) NOT NULL DEFAULT 'todo', + priority VARCHAR(10) NOT NULL DEFAULT 'medium', + position INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT chk_tasks_status CHECK (status IN ('todo', 'in_progress', 'done')), + CONSTRAINT chk_tasks_priority CHECK (priority IN ('low', 'medium', 'high')) +); + +CREATE INDEX idx_tasks_project_id ON tasks(project_id); +CREATE INDEX idx_tasks_status ON tasks(status); +``` + +#### `labels` +```sql +CREATE TABLE labels ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + name VARCHAR(50) NOT NULL, + color VARCHAR(7) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT uq_labels_project_name UNIQUE (project_id, name) +); + +CREATE INDEX idx_labels_project_id ON labels(project_id); +``` + +#### `assignments` +```sql +CREATE TABLE assignments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + assignee VARCHAR(200) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT uq_assignments_task_assignee UNIQUE (task_id, assignee) +); + +CREATE INDEX idx_assignments_task_id ON assignments(task_id); +``` + +### Migration Files + +| File | Purpose | +|------|---------| +| `001_create_projects.sql` | `projects` table with unique name constraint | +| `002_create_tasks.sql` | `tasks` table with FK to projects, status/priority checks, indexes | +| `003_create_labels.sql` | `labels` table with FK to projects, unique (project_id, name) | +| `004_create_assignments.sql` | `assignments` table with FK to tasks, unique (task_id, assignee) | + +Migrations are embedded via `//go:embed migrations/*.sql` and executed with `database.MustRunMigrations()`. Each runs in a transaction. + +### Domain Types + +Each entity gets a strongly-typed ID and pure struct following the `Example` pattern: + +```go +// domain/project.go +type ProjectID string +type ProjectStatus string +const ( + ProjectStatusActive ProjectStatus = "active" + ProjectStatusArchived ProjectStatus = "archived" +) +type Project struct { + ID ProjectID + Name string + Description string + Status ProjectStatus + CreatedAt time.Time + UpdatedAt time.Time +} +``` + +Similar patterns for `Task` (with `TaskStatus`, `TaskPriority`), `Label`, and `Assignment`. Constructors validate required fields and set timestamps. Update methods validate mutable fields and bump `UpdatedAt`. + +## API Changes + +### New Endpoints + +All endpoints are mounted under `/api/studio-api` and follow existing patterns. + +#### Projects + +| Method | Path | Handler | Success | Description | +|--------|------|---------|---------|-------------| +| `POST` | `/projects` | `Create` | 201 | Create project | +| `GET` | `/projects` | `List` | 200 | List all projects | +| `GET` | `/projects/{id}` | `Get` | 200 | Get project by ID | +| `PUT` | `/projects/{id}` | `Update` | 200 | Update project | +| `DELETE` | `/projects/{id}` | `Delete` | 204 | Delete project (cascades) | + +#### Tasks (nested under projects for create/list, flat for get/update/delete) + +| Method | Path | Handler | Success | Description | +|--------|------|---------|---------|-------------| +| `POST` | `/projects/{projectId}/tasks` | `Create` | 201 | Create task in project | +| `GET` | `/projects/{projectId}/tasks` | `ListByProject` | 200 | List tasks in project | +| `GET` | `/tasks/{id}` | `Get` | 200 | Get task by ID | +| `PUT` | `/tasks/{id}` | `Update` | 200 | Update task | +| `DELETE` | `/tasks/{id}` | `Delete` | 204 | Delete task (cascades assignments) | + +#### Labels (nested under projects for create/list, flat for get/update/delete) + +| Method | Path | Handler | Success | Description | +|--------|------|---------|---------|-------------| +| `POST` | `/projects/{projectId}/labels` | `Create` | 201 | Create label in project | +| `GET` | `/projects/{projectId}/labels` | `ListByProject` | 200 | List labels in project | +| `GET` | `/labels/{id}` | `Get` | 200 | Get label by ID | +| `PUT` | `/labels/{id}` | `Update` | 200 | Update label | +| `DELETE` | `/labels/{id}` | `Delete` | 204 | Delete label | + +#### Assignments (nested under tasks) + +| Method | Path | Handler | Success | Description | +|--------|------|---------|---------|-------------| +| `POST` | `/tasks/{taskId}/assignments` | `Create` | 201 | Assign user to task | +| `GET` | `/tasks/{taskId}/assignments` | `ListByTask` | 200 | List task assignments | +| `DELETE` | `/assignments/{id}` | `Delete` | 204 | Remove assignment | + +### Request/Response Shapes + +All responses use the `{data, meta}` envelope via `httpresponse.OK/Created/NoContent`. + +**CreateProjectRequest:** +```json +{ + "name": "My Project", // required, 1-200 chars + "description": "Description" // optional, max 1000 chars +} +``` + +**ProjectResponse:** +```json +{ + "data": { + "id": "uuid", + "name": "My Project", + "description": "Description", + "status": "active", + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z" + } +} +``` + +**CreateTaskRequest:** +```json +{ + "title": "Task title", // required, 1-300 chars + "description": "Details", // optional, max 5000 chars + "priority": "high", // optional, default "medium" + "position": 1 // optional, default 0 +} +``` + +**TaskResponse:** +```json +{ + "data": { + "id": "uuid", + "project_id": "uuid", + "title": "Task title", + "description": "Details", + "status": "todo", + "priority": "high", + "position": 1, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z" + } +} +``` + +**CreateLabelRequest:** +```json +{ + "name": "Bug", // required, 1-50 chars + "color": "#FF5733" // required, hex color +} +``` + +**CreateAssignmentRequest:** +```json +{ + "assignee": "user@example.com" // required, 1-200 chars +} +``` + +**UpdateProjectRequest:** same shape as create, plus optional `status` field (`"active"` or `"archived"`). + +**UpdateTaskRequest:** title, description, status, priority, position -- all mutable fields. + +**UpdateLabelRequest:** name, color -- all mutable fields. + +### Error Responses + +| HTTP Status | When | +|-------------|------| +| 400 | Invalid UUID format, validation failure, invalid status/priority | +| 404 | Entity not found | +| 409 | Duplicate project name, duplicate label name per project, duplicate assignment | +| 422 | Struct validation failure (from `app.BindAndValidate`) | +| 500 | Unexpected infrastructure error | + +## Component Diagram + +``` +┌─────────────────────────────────────────────────────────────┐ +│ HTTP Client │ +└────────────────────────────┬────────────────────────────────┘ + │ + ┌────────▼────────┐ + │ chi Router │ + │ /api/studio-api│ + └────────┬────────┘ + │ + ┌──────────────────┼──────────────────┐ + │ │ │ + ┌───────▼───────┐ ┌───────▼───────┐ ┌───────▼───────┐ + │ Project │ │ Task │ │ Label │ ... + │ Handler │ │ Handler │ │ Handler │ + └───────┬───────┘ └───────┬───────┘ └───────┬───────┘ + │ │ │ + ┌───────▼───────┐ ┌───────▼───────┐ ┌───────▼───────┐ + │ Project │ │ Task │ │ Label │ ... + │ Service │ │ Service │ │ Service │ + └───────┬───────┘ └───────┬───────┘ └───────┬───────┘ + │ │ │ + ┌───────▼───────┐ ┌───────▼───────┐ ┌───────▼───────┐ + │ Project │ │ Task │ │ Label │ ... + │ Repository │ │ Repository │ │ Repository │ + │ (port) │ │ (port) │ │ (port) │ + └───────┬───────┘ └───────┬───────┘ └───────┬───────┘ + │ │ │ + ┌───────▼──────────────────▼──────────────────▼───────┐ + │ Postgres Adapter Layer │ + │ adapter/postgres/project.go │ + │ adapter/postgres/task.go │ + │ adapter/postgres/label.go │ + │ adapter/postgres/assignment.go │ + └──────────────────────┬──────────────────────────────┘ + │ + ┌────────▼────────┐ + │ database.Pool │ + │ (*sqlx.DB) │ + └────────┬────────┘ + │ + ┌────────▼────────┐ + │ PostgreSQL │ + │ studio-db │ + └─────────────────┘ +``` + +### Dependency Injection (main.go) + +``` +config.Load() + → database.MustConnect(ctx, cfg.Database.URL, opts) + → database.MustRunMigrations(ctx, pool, migrationsFS, "migrations") + → postgres.NewProjectRepository(pool.DB) + → postgres.NewTaskRepository(pool.DB) + → postgres.NewLabelRepository(pool.DB) + → postgres.NewAssignmentRepository(pool.DB) + → service.NewProjectService(projectRepo, logger) + → service.NewTaskService(taskRepo, projectRepo, logger) + → service.NewLabelService(labelRepo, projectRepo, logger) + → service.NewAssignmentService(assignmentRepo, taskRepo, logger) + → api.RegisterRoutes(application, services...) + → application.OnShutdown(pool.Close) + → application.Run() +``` + +## File Structure + +``` +services/studio-api/ +├── cmd/server/main.go # Updated: DB connect, migrations, adapter wiring +├── migrations/ +│ ├── 001_create_projects.sql +│ ├── 002_create_tasks.sql +│ ├── 003_create_labels.sql +│ └── 004_create_assignments.sql +├── internal/ +│ ├── domain/ +│ │ ├── errors.go # Extended: new sentinel errors +│ │ ├── project.go # New: Project entity +│ │ ├── task.go # New: Task entity +│ │ ├── label.go # New: Label entity +│ │ └── assignment.go # New: Assignment entity +│ ├── port/ +│ │ ├── project.go # New: ProjectRepository interface +│ │ ├── task.go # New: TaskRepository interface +│ │ ├── label.go # New: LabelRepository interface +│ │ └── assignment.go # New: AssignmentRepository interface +│ ├── adapter/ +│ │ └── postgres/ +│ │ ├── project.go # New: Postgres ProjectRepository +│ │ ├── task.go # New: Postgres TaskRepository +│ │ ├── label.go # New: Postgres LabelRepository +│ │ └── assignment.go # New: Postgres AssignmentRepository +│ ├── service/ +│ │ ├── project.go # New: ProjectService +│ │ ├── project_test.go # New: ProjectService tests +│ │ ├── task.go # New: TaskService +│ │ ├── task_test.go # New: TaskService tests +│ │ ├── label.go # New: LabelService +│ │ ├── label_test.go # New: LabelService tests +│ │ ├── assignment.go # New: AssignmentService +│ │ └── assignment_test.go # New: AssignmentService tests +│ └── api/ +│ ├── routes.go # Updated: register new handlers +│ ├── spec.go # Updated: new schemas and paths +│ └── handlers/ +│ ├── project.go # New: Project handler +│ ├── project_test.go # New: Project handler tests +│ ├── task.go # New: Task handler +│ ├── task_test.go # New: Task handler tests +│ ├── label.go # New: Label handler +│ ├── label_test.go # New: Label handler tests +│ ├── assignment.go # New: Assignment handler +│ └── assignment_test.go # New: Assignment handler tests +``` + +## Error Handling Strategy + +### Domain Layer +Sentinel errors defined in `domain/errors.go`: +- `ErrProjectNotFound`, `ErrTaskNotFound`, `ErrLabelNotFound`, `ErrAssignmentNotFound` +- `ErrDuplicateProjectName`, `ErrDuplicateLabelName`, `ErrDuplicateAssignment` +- `ErrInvalidStatus`, `ErrInvalidPriority` +- `ErrProjectHasTasks` (reserved for future restrict-delete if needed) + +### Adapter Layer (Postgres) +Each Postgres adapter maps database errors to domain errors: +- `sql.ErrNoRows` → `domain.ErrXxxNotFound` +- Unique constraint violation (Postgres error code `23505`) → `domain.ErrDuplicateXxx` + - Detected via `*pq.Error` type assertion with `Code == "23505"` + - The specific constraint name determines which duplicate error to return +- FK violation (code `23503`) → `domain.ErrXxxNotFound` (referenced entity missing) +- All other errors propagate unwrapped (become 500s) + +### Service Layer +Business logic validation before persistence: +- **Create project:** Check `ExistsByName` before insert (avoids race to friendly error) +- **Create task:** Verify project exists via `ProjectRepository.Get` +- **Create label:** Verify project exists, check `ExistsByName` scoped to project +- **Create assignment:** Verify task exists, check `ExistsByTaskAndAssignee` +- Update operations: same duplicate checks when names change + +### Handler Layer +Each handler file contains a `mapDomainError()` function: +```go +func mapDomainError(err error) error { + switch { + case errors.Is(err, domain.ErrProjectNotFound): + return httperror.NotFound("project not found") + case errors.Is(err, domain.ErrDuplicateProjectName): + return httperror.Conflict("project with this name already exists") + // ... + default: + return err // becomes 500 via app.Wrap() + } +} +``` + +### Infrastructure Failures +- Database connection failures: `database.MustConnect` panics on startup (fail-fast) +- Migration failures: `database.MustRunMigrations` panics on startup (fail-fast) +- Runtime query failures: propagate through service → handler → `app.Wrap()` → 500 + +## Security Considerations + +### Input Validation +- **Struct tags:** All request DTOs use `validate:"required,min=X,max=Y"` enforced by `app.BindAndValidate()` +- **UUID format:** All ID parameters validated with `uuid.Parse()` before use +- **Enum validation:** Status and priority values validated in domain constructors and via CHECK constraints +- **Color format:** Label color validated as hex color format (regex `^#[0-9A-Fa-f]{6}$`) in domain constructor +- **SQL injection:** All queries use parameterized placeholders (`$1`, `$2`) via sqlx; no string concatenation + +### Authentication +- Auth is opt-in per the existing pattern in `routes.go` +- Write endpoints (POST, PUT, DELETE) are placed inside the auth-protected group +- Read endpoints (GET) remain public (matching existing Example pattern) +- Auth middleware integration is not part of this feature per spec's out-of-scope + +### Data Boundaries +- Each entity is scoped: tasks/labels belong to a project, assignments belong to a task +- Service layer verifies parent entity exists before creating children +- No cross-service data access; all queries stay within `studio-api`'s database +- `assignee` is an opaque string — no user lookup or validation against external systems + +### Cascading Deletes +- `ON DELETE CASCADE` on FKs means deleting a project removes all its tasks, labels, and transitively all assignments +- This is the spec's chosen behavior; the handler should document this in OpenAPI descriptions + +## Performance Considerations + +### Query Complexity +- All list operations are full table scans scoped by a single FK (e.g., `WHERE project_id = $1`) +- Indexes on `project_id` and `task_id` FKs ensure efficient lookups +- Index on `tasks.status` supports future filtering + +### Connection Pool +- Uses `pkg/database` pool defaults: 25 max open, 5 max idle, 5min lifetime +- Configurable via `DATABASE_MAX_OPEN_CONNS`, `DATABASE_MAX_IDLE_CONNS` env vars +- Pool is shared across all repository adapters + +### Expected Load +- This is a developer-facing studio tool, not a high-traffic public API +- No pagination in v1 (per spec out-of-scope) — acceptable for expected data volumes +- No caching layer needed; direct DB queries are sufficient + +### Future Optimization Points +- Add pagination when list endpoints exceed ~100 records +- Add filtering (by status, priority) as query parameters +- Consider read replicas if read-heavy patterns emerge + +## Migration / Rollout Plan + +### Step 1: Database Setup +- Ensure `DATABASE_URL` is configured in the service environment +- The PostgreSQL database must exist and be accessible + +### Step 2: Migration Execution +- Migrations run automatically on service startup via `database.MustRunMigrations()` +- They are idempotent (tracked in `schema_migrations` table) +- Each migration runs in a transaction — partial failure rolls back + +### Step 3: Code Deployment +- The service binary includes embedded migrations and Postgres adapters +- On startup: connect → migrate → serve +- If migrations fail, the service panics and does not start (fail-fast) + +### Backward Compatibility +- The existing `Example` entity and endpoints remain unchanged +- New endpoints are additive — no existing API contracts are modified +- The in-memory `Example` adapter remains for now (can be migrated to Postgres separately) + +### Rollback Strategy +- If issues are found, revert the deployment to the previous binary +- Migrations are forward-only (no down migrations in this system) +- To revert schema changes, deploy a new migration that drops/alters the tables + +### Health Checks +- Add database health check to the existing health endpoint: + ```go + healthHandler := handlers.NewHealth(logger, app.HealthConfig{ + Service: "studio-api", + Checks: map[string]app.HealthChecker{ + "database": app.PingChecker(pool.Ping), + }, + }) + ``` +- Service reports unhealthy (503) if database is unreachable + +## Design Decisions + +1. **Cascade deletes (not restrict):** The spec calls for `ON DELETE CASCADE`. Deleting a project removes everything underneath. This is simpler and matches the user mental model of "delete project = delete everything in it." + +2. **Separate Assignment entity (not a field on Task):** Supports multiple assignees per task. The `(task_id, assignee)` unique constraint prevents duplicates while allowing flexibility. + +3. **Position field included:** Included per spec. Default 0, managed as a simple integer. No automatic reordering — the client is responsible for setting position values. + +4. **No status transition rules:** Free status changes as specified. Any status can transition to any other valid status. Business rules can be layered on later. + +5. **`DATABASE_URL` configuration:** Uses the existing `config.ReadDatabaseConfig()` + env var pattern. No new database name concept — the database is whatever `DATABASE_URL` points to. + +6. **Postgres adapters take `*sqlx.DB`:** Following the established pattern from `pkg/queue/postgres.go`. The adapter receives the raw `*sqlx.DB` (from `pool.DB`), not the `*database.Pool` wrapper. + +7. **Internal row structs in adapters:** Each Postgres adapter defines a private `xxxRow` struct with `db:"column_name"` tags, and a `toDomain()` converter. This decouples the DB schema from the domain model. + +8. **One handler struct per entity:** Each entity gets its own handler file with request/response DTOs and `mapDomainError()`. This keeps files focused and avoids a monolithic handler. + +9. **TaskService depends on ProjectRepository:** To verify the parent project exists when creating a task. Same pattern for LabelService. AssignmentService depends on TaskRepository. + +10. **No soft deletes:** Hard deletes only, per spec. `deleted_at` columns are not included. diff --git a/.sdlc/features/data-models/manifest.yaml b/.sdlc/features/data-models/manifest.yaml index edd8d96..62a0733 100644 --- a/.sdlc/features/data-models/manifest.yaml +++ b/.sdlc/features/data-models/manifest.yaml @@ -13,7 +13,7 @@ artifacts: status: pending path: audit.md design: - status: pending + status: draft path: design.md qa_plan: status: pending