# 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.