build: /design-feature data-models
Some checks are pending
ci/woodpecker/push/woodpecker Pipeline is pending

This commit is contained in:
rdev-worker 2026-02-11 05:04:28 +00:00
parent fef18b73ca
commit e0b6834743
2 changed files with 570 additions and 1 deletions

View File

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

View File

@ -13,7 +13,7 @@ artifacts:
status: pending
path: audit.md
design:
status: pending
status: draft
path: design.md
qa_plan:
status: pending