diff --git a/.sdlc/features/data-models/design.md b/.sdlc/features/data-models/design.md new file mode 100644 index 0000000..2446c88 --- /dev/null +++ b/.sdlc/features/data-models/design.md @@ -0,0 +1,569 @@ +# Design: Core Data Models & Persistence + +## Architecture Approach + +This feature adds four new domain entities (Project, Task, Label, Assignment) and a task-label join to the existing studio-api service. The implementation follows the established hexagonal architecture pattern demonstrated by the `Example` entity, replicating the same layering: + +``` +domain → service → port (interface) → adapter/postgres (implementation) +``` + +**What's new:** +- 5 domain entities with strong-typed IDs and constructor validation +- 5 port interfaces defining repository contracts +- 5 PostgreSQL adapter implementations (new `adapter/postgres/` package) +- 4 service-layer orchestrators (TaskLabel and Assignment share a service) +- 4 handler files with CRUD + association endpoints +- SQL migrations creating 5 tables with FK constraints and indexes +- `main.go` wired to connect to PostgreSQL, run migrations, and inject postgres adapters + +**What's modified:** +- `main.go` — adds database connection, migration runner, new service/handler wiring +- `internal/api/routes.go` — adds new route registrations +- `internal/api/spec.go` — adds OpenAPI schemas and endpoint documentation +- `internal/domain/errors.go` — adds domain errors for new entities +- `internal/config/config.go` — already has `Database` config (no changes needed) + +**What's preserved:** +- The existing `Example` entity and its in-memory adapter remain untouched (per spec) + +## Data Model Changes + +### Entity Relationship Diagram + +``` +┌──────────────┐ ┌──────────────────┐ ┌──────────────┐ +│ projects │ │ tasks │ │ labels │ +├──────────────┤ ├──────────────────┤ ├──────────────┤ +│ id (PK,UUID) │──1:N──│ id (PK,UUID) │ │ id (PK,UUID) │ +│ name (UNIQ) │ │ project_id (FK) │ │ name (UNIQ) │ +│ description │ │ title │ │ color │ +│ created_at │ │ description │ │ created_at │ +│ updated_at │ │ status │ └──────┬───────┘ +└──────────────┘ │ priority │ │ + │ created_at │ │ + │ updated_at │ │ + └───────┬──────────┘ │ + │ │ + ┌──────────┴──────────┐ ┌────────┴────────┐ + │ assignments │ │ task_labels │ + ├─────────────────────┤ ├─────────────────┤ + │ id (PK,UUID) │ │ task_id (FK,PK) │ + │ task_id (FK) │ │ label_id (FK,PK)│ + │ user_id (STRING) │ │ created_at │ + │ assigned_at │ └─────────────────┘ + └─────────────────────┘ +``` + +### Domain Types + +Each entity follows the `Example` pattern with strong-typed IDs: + +```go +// internal/domain/project.go +type ProjectID string +type Project struct { + ID ProjectID + Name string + Description string + CreatedAt time.Time + UpdatedAt time.Time +} + +// internal/domain/task.go +type TaskID string +type TaskStatus string +type TaskPriority string +const ( + TaskStatusOpen TaskStatus = "open" + TaskStatusInProgress TaskStatus = "in_progress" + TaskStatusDone TaskStatus = "done" + TaskStatusCancelled TaskStatus = "cancelled" +) +const ( + TaskPriorityLow TaskPriority = "low" + TaskPriorityMedium TaskPriority = "medium" + TaskPriorityHigh TaskPriority = "high" + TaskPriorityCritical TaskPriority = "critical" +) +type Task struct { + ID TaskID + ProjectID ProjectID + Title string + Description string + Status TaskStatus + Priority TaskPriority + CreatedAt time.Time + UpdatedAt time.Time +} + +// internal/domain/label.go +type LabelID string +type Label struct { + ID LabelID + Name string + Color string + CreatedAt time.Time +} + +// internal/domain/assignment.go +type AssignmentID string +type Assignment struct { + ID AssignmentID + TaskID TaskID + UserID string + AssignedAt time.Time +} + +// internal/domain/task_label.go +type TaskLabel struct { + TaskID TaskID + LabelID LabelID + CreatedAt time.Time +} +``` + +### SQL Migrations + +Three migration files in `services/studio-api/migrations/`: + +**`001_create_projects.sql`** +```sql +CREATE TABLE projects ( + id UUID PRIMARY KEY, + name VARCHAR(100) NOT NULL UNIQUE, + description VARCHAR(500), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +``` + +**`002_create_tasks.sql`** +```sql +CREATE TABLE tasks ( + id UUID PRIMARY KEY, + project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + title VARCHAR(200) NOT NULL, + description VARCHAR(2000), + status VARCHAR(20) NOT NULL DEFAULT 'open', + priority VARCHAR(20) NOT NULL DEFAULT 'medium', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_tasks_project_id ON tasks(project_id); +CREATE INDEX idx_tasks_status ON tasks(status); +CREATE INDEX idx_tasks_priority ON tasks(priority); +``` + +**`003_create_labels_and_associations.sql`** +```sql +CREATE TABLE labels ( + id UUID PRIMARY KEY, + name VARCHAR(50) NOT NULL UNIQUE, + color VARCHAR(7), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE task_labels ( + task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + label_id UUID NOT NULL REFERENCES labels(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (task_id, label_id) +); + +CREATE INDEX idx_task_labels_label_id ON task_labels(label_id); + +CREATE TABLE assignments ( + id UUID PRIMARY KEY, + task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + user_id VARCHAR(255) NOT NULL, + assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(task_id, user_id) +); + +CREATE INDEX idx_assignments_task_id ON assignments(task_id); +CREATE INDEX idx_assignments_user_id ON assignments(user_id); +``` + +## API Changes + +All endpoints are under `/api/studio-api/` to match existing ingress routing. + +### Projects + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| `GET` | `/projects` | No | List all projects | +| `GET` | `/projects/{id}` | No | Get project by ID | +| `POST` | `/projects` | Yes | Create project | +| `PUT` | `/projects/{id}` | Yes | Update project | +| `DELETE` | `/projects/{id}` | Yes | Delete project (cascades) | + +**Request/Response shapes:** + +```json +// POST /projects +Request: { "name": "My Project", "description": "optional" } +Response: { "data": { "id": "uuid", "name": "...", "description": "...", "created_at": "...", "updated_at": "..." } } + +// GET /projects +Response: { "data": [{ "id": "...", ... }] } +``` + +### Tasks + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| `GET` | `/projects/{projectId}/tasks` | No | List tasks in project (filterable) | +| `POST` | `/projects/{projectId}/tasks` | Yes | Create task in project | +| `GET` | `/tasks/{id}` | No | Get task by ID | +| `PUT` | `/tasks/{id}` | Yes | Update task | +| `DELETE` | `/tasks/{id}` | Yes | Delete task | + +**Query parameters for list:** `?status=open&priority=high` + +**Request/Response shapes:** + +```json +// POST /projects/{projectId}/tasks +Request: { "title": "Implement login", "description": "...", "status": "open", "priority": "high" } +Response: { "data": { "id": "uuid", "project_id": "uuid", "title": "...", "description": "...", "status": "open", "priority": "high", "created_at": "...", "updated_at": "..." } } +``` + +### Labels + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| `GET` | `/labels` | No | List all labels | +| `GET` | `/labels/{id}` | No | Get label by ID | +| `POST` | `/labels` | Yes | Create label | +| `PUT` | `/labels/{id}` | Yes | Update label | +| `DELETE` | `/labels/{id}` | Yes | Delete label (cascades) | + +**Request/Response shapes:** + +```json +// POST /labels +Request: { "name": "Bug", "color": "#FF5733" } +Response: { "data": { "id": "uuid", "name": "Bug", "color": "#FF5733", "created_at": "..." } } +``` + +### Task-Label Associations + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| `GET` | `/tasks/{taskId}/labels` | No | List labels for a task | +| `POST` | `/tasks/{taskId}/labels/{labelId}` | Yes | Attach label to task | +| `DELETE` | `/tasks/{taskId}/labels/{labelId}` | Yes | Remove label from task | + +### Task-User Assignments + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| `GET` | `/tasks/{taskId}/assignments` | No | List assignments for a task | +| `POST` | `/tasks/{taskId}/assignments` | Yes | Assign user to task | +| `DELETE` | `/tasks/{taskId}/assignments/{assignmentId}` | Yes | Remove assignment | + +**Request/Response shapes:** + +```json +// POST /tasks/{taskId}/assignments +Request: { "user_id": "user-123" } +Response: { "data": { "id": "uuid", "task_id": "uuid", "user_id": "user-123", "assigned_at": "..." } } +``` + +## Component Diagram + +``` +┌─────────────────────────────────────────────────────────────┐ +│ HTTP Layer │ +│ routes.go → handlers/{project,task,label,association}.go │ +│ Uses: app.Wrap(), app.BindAndValidate(), chi.URLParam() │ +│ Returns: httpresponse.OK/Created/NoContent, httperror.* │ +└──────────────────────────┬──────────────────────────────────┘ + │ calls service methods +┌──────────────────────────▼──────────────────────────────────┐ +│ Service Layer │ +│ service/{project,task,label,association}.go │ +│ Business logic: duplicate checks, FK validation, │ +│ UUID generation, domain entity construction │ +└──────────────────────────┬──────────────────────────────────┘ + │ calls port interfaces +┌──────────────────────────▼──────────────────────────────────┐ +│ Port Layer │ +│ port/{project,task,label,task_label,assignment}.go │ +│ Repository interfaces for each entity │ +└──────────────────────────┬──────────────────────────────────┘ + │ implemented by +┌──────────────────────────▼──────────────────────────────────┐ +│ Adapter Layer │ +│ adapter/postgres/{project,task,label,task_label, │ +│ assignment}.go │ +│ Raw SQL via sqlx (pool.DB.SelectContext, GetContext, etc.) │ +└──────────────────────────┬──────────────────────────────────┘ + │ +┌──────────────────────────▼──────────────────────────────────┐ +│ PostgreSQL │ +│ projects, tasks, labels, task_labels, assignments tables │ +│ FK constraints with ON DELETE CASCADE │ +│ Unique constraints for name deduplication │ +└─────────────────────────────────────────────────────────────┘ + + Wiring (main.go) +┌─────────────────────────────────────────────────────────────┐ +│ 1. database.MustConnect(ctx, cfg.Database.URL, opts) │ +│ 2. database.MustRunMigrations(ctx, pool, migrationsFS, dir) │ +│ 3. postgres.NewXxxRepository(pool.DB) for each entity │ +│ 4. service.NewXxxService(repo, logger) for each entity │ +│ 5. api.RegisterRoutes(app, projectSvc, taskSvc, ...) │ +│ 6. pool.Close() via app.OnShutdown() │ +└─────────────────────────────────────────────────────────────┘ +``` + +## File Inventory + +### New Files + +| File | Purpose | +|------|---------| +| `internal/domain/project.go` | Project entity, ProjectID, constructor, validation | +| `internal/domain/task.go` | Task entity, TaskID, status/priority enums, constructor, validation | +| `internal/domain/label.go` | Label entity, LabelID, constructor, validation | +| `internal/domain/assignment.go` | Assignment entity, AssignmentID, constructor | +| `internal/domain/task_label.go` | TaskLabel join entity | +| `internal/port/project.go` | ProjectRepository interface | +| `internal/port/task.go` | TaskRepository interface | +| `internal/port/label.go` | LabelRepository interface | +| `internal/port/task_label.go` | TaskLabelRepository interface | +| `internal/port/assignment.go` | AssignmentRepository interface | +| `internal/adapter/postgres/project.go` | PostgreSQL ProjectRepository | +| `internal/adapter/postgres/task.go` | PostgreSQL TaskRepository | +| `internal/adapter/postgres/label.go` | PostgreSQL LabelRepository | +| `internal/adapter/postgres/task_label.go` | PostgreSQL TaskLabelRepository | +| `internal/adapter/postgres/assignment.go` | PostgreSQL AssignmentRepository | +| `internal/service/project.go` | ProjectService with business logic | +| `internal/service/task.go` | TaskService with business logic | +| `internal/service/label.go` | LabelService with business logic | +| `internal/service/association.go` | AssociationService (task-labels + assignments) | +| `internal/api/handlers/project.go` | Project CRUD handlers | +| `internal/api/handlers/task.go` | Task CRUD handlers | +| `internal/api/handlers/label.go` | Label CRUD handlers | +| `internal/api/handlers/association.go` | Task-label and assignment handlers | +| `migrations/001_create_projects.sql` | Projects table | +| `migrations/002_create_tasks.sql` | Tasks table + indexes | +| `migrations/003_create_labels_and_associations.sql` | Labels, task_labels, assignments tables | + +### New Test Files + +| File | Purpose | +|------|---------| +| `internal/domain/project_test.go` | Project constructor/validation tests | +| `internal/domain/task_test.go` | Task constructor/validation/enum tests | +| `internal/domain/label_test.go` | Label constructor/validation tests | +| `internal/service/project_test.go` | ProjectService unit tests (mock repo) | +| `internal/service/task_test.go` | TaskService unit tests (mock repo) | +| `internal/service/label_test.go` | LabelService unit tests (mock repo) | +| `internal/service/association_test.go` | AssociationService unit tests | +| `internal/api/handlers/project_test.go` | Project handler tests (httptest) | +| `internal/api/handlers/task_test.go` | Task handler tests (httptest) | +| `internal/api/handlers/label_test.go` | Label handler tests (httptest) | +| `internal/api/handlers/association_test.go` | Association handler tests (httptest) | + +### Modified Files + +| File | Changes | +|------|---------| +| `internal/domain/errors.go` | Add Project/Task/Label/Assignment error sentinels | +| `internal/api/routes.go` | Add route registrations for all new endpoints | +| `internal/api/spec.go` | Add OpenAPI schemas and paths for all new endpoints | +| `cmd/server/main.go` | Add DB connection, migrations, postgres adapters, new services | + +## Error Handling Strategy + +### Domain Errors (in `errors.go`) + +```go +// Project errors +var ( + ErrProjectNotFound = errors.New("project not found") + ErrDuplicateProject = errors.New("project with this name already exists") + ErrInvalidProjectName = errors.New("invalid project name") +) + +// Task errors +var ( + ErrTaskNotFound = errors.New("task not found") + ErrInvalidTaskTitle = errors.New("invalid task title") + ErrInvalidTaskStatus = errors.New("invalid task status") + ErrInvalidTaskPriority = errors.New("invalid task priority") +) + +// Label errors +var ( + ErrLabelNotFound = errors.New("label not found") + ErrDuplicateLabel = errors.New("label with this name already exists") + ErrInvalidLabelName = errors.New("invalid label name") + ErrInvalidLabelColor = errors.New("invalid label color") +) + +// Association errors +var ( + ErrDuplicateTaskLabel = errors.New("label already attached to task") + ErrTaskLabelNotFound = errors.New("task-label association not found") + ErrDuplicateAssignment = errors.New("user already assigned to task") + ErrAssignmentNotFound = errors.New("assignment not found") +) +``` + +### Handler Error Mapping + +Each handler file includes a `mapDomainError` function (following the `Example` pattern) that converts domain errors to HTTP errors: + +| Domain Error | HTTP Status | Response | +|-------------|-------------|----------| +| `ErrXxxNotFound` | 404 | `httperror.NotFound(msg)` | +| `ErrDuplicateXxx` | 409 | `httperror.Conflict(msg)` | +| `ErrInvalidXxx` | 400 | `httperror.BadRequest(msg)` | +| Validation errors | 400 | Returned by `app.BindAndValidate()` | +| Unknown errors | 500 | Passed through to `app.Wrap()` | + +### PostgreSQL Error Handling in Adapters + +Postgres adapters detect constraint violations to return appropriate domain errors: + +```go +func isDuplicateKeyError(err error) bool { + var pgErr *pq.Error + if errors.As(err, &pgErr) { + return pgErr.Code == "23505" // unique_violation + } + return false +} + +func isForeignKeyError(err error) bool { + var pgErr *pq.Error + if errors.As(err, &pgErr) { + return pgErr.Code == "23503" // foreign_key_violation + } + return false +} +``` + +## Security Considerations + +### Authentication + +- **Read endpoints** (GET) are public (no auth required) — follows existing `Example` pattern +- **Write endpoints** (POST, PUT, DELETE) require authentication via `auth.Middleware()` — follows existing pattern +- Auth middleware is opt-in, configured via `AUTH_ENABLED` env var + +### Input Validation + +All inputs are validated at two levels: + +1. **Handler layer**: `app.BindAndValidate()` with struct tags for format validation + - String lengths (min/max) + - Required fields + - Enum values via `oneof` tag + - UUID format for path parameters + - Hex color format via `hex_color` custom validator + +2. **Domain layer**: Constructor validation for business rules + - Name length constraints + - Status/priority enum validation + - Color format validation (regex `^#[0-9A-Fa-f]{6}$`) + +### Data Boundaries + +- `user_id` in assignments is an opaque string — no user entity lookup (per spec) +- All IDs are server-generated UUIDs — clients cannot choose IDs +- SQL queries use parameterized statements (`$1`, `$2`) — no SQL injection risk +- JSON serialization uses struct tags — no field leakage + +### Cascade Deletes + +- Cascades are handled by PostgreSQL FK constraints (`ON DELETE CASCADE`) +- Deleting a project cascades to its tasks, which cascades to task_labels and assignments +- Deleting a label cascades to task_labels +- No application-level cascade logic needed + +## Performance Considerations + +### Query Complexity + +- All list queries are simple `SELECT` with optional `WHERE` filters — O(n) scans with index support +- Task listing filters on indexed columns (status, priority, project_id) +- No joins required for primary CRUD operations +- Task-label and assignment lookups use indexed FK columns + +### Indexes + +- `idx_tasks_project_id` — fast task listing by project +- `idx_tasks_status` / `idx_tasks_priority` — fast filtered queries +- `idx_task_labels_label_id` — fast "which tasks have this label" lookups +- `idx_assignments_task_id` / `idx_assignments_user_id` — fast assignment lookups +- Unique constraints on `projects.name` and `labels.name` double as unique indexes + +### Connection Pool + +- Uses existing `pkg/database` pool with sensible defaults (25 max open, 5 idle) +- No additional caching layer needed for v1 (spec explicitly excludes pagination, so data volumes are expected to be small) +- Connection pool is shared across all repositories + +### No Pagination (v1) + +- Per spec, list endpoints return all matching records +- Acceptable for initial release; pagination can be added later as a separate feature +- OpenAPI spec should document that results are unbounded + +## Migration / Rollout Plan + +### Phase 1: Database Schema (migrations) + +1. Add migration files to `services/studio-api/migrations/` +2. Migrations run at startup via `database.MustRunMigrations()` +3. Migrations are idempotent (tracked in `schema_migrations` table) +4. Tables are created in dependency order: projects → tasks → labels → task_labels → assignments + +### Phase 2: Backward-Compatible Wiring + +1. `main.go` adds database connection alongside existing in-memory adapter +2. Existing `Example` entity continues using in-memory adapter (unchanged) +3. New entities use PostgreSQL adapters +4. `RegisterRoutes` signature expands to accept new services +5. All new routes are additive — no existing routes change + +### Phase 3: Testing Strategy + +1. Domain tests: Pure unit tests with no dependencies (table-driven) +2. Service tests: Mock repositories via interface (follows `example_test.go` pattern) +3. Handler tests: `httptest` + chi router with mock services (follows `example_test.go` pattern) +4. No integration tests against real PostgreSQL in v1 (adapters are thin SQL wrappers) +5. All tests run with `cd services/studio-api && go test -v ./...` + +### Rollout Checklist + +- [ ] Migrations run successfully on fresh database +- [ ] All existing tests continue to pass +- [ ] New unit tests pass for all domain, service, and handler layers +- [ ] OpenAPI spec renders correctly at `/docs` +- [ ] `DATABASE_URL` environment variable documented in `.env.example` + +## Key Design Decisions + +1. **Separate migration files per entity group** — Keeps migrations readable and allows partial rollback reasoning. Projects first, tasks second, labels+associations third. + +2. **One service per entity + one AssociationService** — Task-label and assignment operations are thin enough to share a service. Project, Task, and Label each get their own service for clarity. + +3. **Domain validation in constructors** — Follows `Example` pattern. Constructors (`NewProject`, `NewTask`, etc.) validate inputs and return errors. Handlers also validate via struct tags for early feedback. + +4. **PostgreSQL adapter per repository interface** — Each port gets its own adapter file. Adapters use `sqlx` directly (no ORM). Compile-time interface checks via `var _ port.XxxRepository = (*XxxRepository)(nil)`. + +5. **Cascade deletes via FK constraints** — Simpler and more reliable than application-level cascade logic. PostgreSQL handles all cascading automatically. + +6. **Global labels (not project-scoped)** — Per spec. Labels are shared across all projects. Task-label join table connects them. + +7. **`user_id` as opaque string** — No FK to a users table. Supports any identity format (UUID, email, external ID). Per spec decision. + +8. **Status defaults to `open`, priority to `medium`** — Sensible defaults in both SQL schema and domain constructors. Clients can omit these fields on creation. diff --git a/.sdlc/features/data-models/manifest.yaml b/.sdlc/features/data-models/manifest.yaml index 428ed4d..68af2b1 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