Repository navigation
feat: add tenant-safe planning domain slice #2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 9 commits
Commits
Show all changes
16 commits
Select commit
Hold shift + click to select a range
9bc6981
docs: plan planning domain slice
seonghobae a72a59f
test: define tenant-safe planning behavior
seonghobae 6f87bb3
feat: implement tenant-safe planning domain
seonghobae 52bb2aa
feat: expose planning hierarchy API
seonghobae 6f2a78b
feat: add planning database schema
seonghobae 293ac4d
fix: make tenant foreign keys executable
seonghobae 8f885fd
docs: document planning migrations
seonghobae 1a1c806
docs: complete planning slice checklist
seonghobae c130b0d
Merge branch 'main' into develop
github-actions[bot] 535529a
chore: retrigger planning slice validation
seonghobae d6dfb3b
test: require opaque non-numeric identifiers
seonghobae 497873f
feat: enforce opaque non-numeric identifier policy
seonghobae 56b2d85
docs: adopt opaque non-numeric identifier policy
seonghobae a37d0cf
test: define planning HTTP error mapping
seonghobae 4ba8603
fix: map planning domain errors to HTTP responses
seonghobae f255407
fix: validate planning requests at HTTP boundary
seonghobae File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
45 changes: 45 additions & 0 deletions
45
apps/planning-service/migrations/0001_initial_planning.sql
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| CREATE SCHEMA IF NOT EXISTS planning; | ||
|
|
||
| CREATE TABLE planning.goals ( | ||
| id uuid PRIMARY KEY, | ||
| workspace_id uuid NOT NULL, | ||
| title text NOT NULL CHECK (length(btrim(title)) > 0), | ||
| created_at timestamptz NOT NULL DEFAULT now(), | ||
| CONSTRAINT goals_id_workspace_unique UNIQUE (id, workspace_id) | ||
| ); | ||
|
|
||
| CREATE TABLE planning.projects ( | ||
| id uuid PRIMARY KEY, | ||
| workspace_id uuid NOT NULL, | ||
| goal_id uuid NOT NULL, | ||
| title text NOT NULL CHECK (length(btrim(title)) > 0), | ||
| created_at timestamptz NOT NULL DEFAULT now(), | ||
| CONSTRAINT projects_id_workspace_unique UNIQUE (id, workspace_id), | ||
| CONSTRAINT projects_goal_workspace_fk | ||
| FOREIGN KEY (goal_id, workspace_id) | ||
| REFERENCES planning.goals (id, workspace_id) | ||
| ON DELETE CASCADE | ||
| ); | ||
|
|
||
| CREATE TABLE planning.tasks ( | ||
| id uuid PRIMARY KEY, | ||
| workspace_id uuid NOT NULL, | ||
| project_id uuid NOT NULL, | ||
| title text NOT NULL CHECK (length(btrim(title)) > 0), | ||
| status text NOT NULL DEFAULT 'todo' CHECK (status IN ('todo', 'done')), | ||
| created_at timestamptz NOT NULL DEFAULT now(), | ||
| completed_at timestamptz, | ||
| CONSTRAINT tasks_project_workspace_fk | ||
| FOREIGN KEY (project_id, workspace_id) | ||
| REFERENCES planning.projects (id, workspace_id) | ||
| ON DELETE CASCADE | ||
| ); | ||
|
|
||
| CREATE INDEX goals_workspace_created_idx | ||
| ON planning.goals (workspace_id, created_at DESC); | ||
|
|
||
| CREATE INDEX projects_workspace_goal_idx | ||
| ON planning.projects (workspace_id, goal_id, created_at DESC); | ||
|
|
||
| CREATE INDEX tasks_workspace_project_idx | ||
| ON planning.tasks (workspace_id, project_id, created_at DESC); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| # Planning migrations | ||
|
|
||
| Apply SQL files in lexical order to the PostgreSQL database owned by the Planning service. | ||
|
|
||
| The initial migration enforces tenant boundaries structurally: project-to-goal and task-to-project foreign keys include `workspace_id`, preventing a child record from referencing a parent in another workspace. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| import { describe, expect, it } from 'vitest'; | ||
| import { InMemoryPlanningRepository, PlanningService } from './planning-domain'; | ||
|
|
||
| describe('PlanningService', () => { | ||
| it('creates a Goal → Project → Task hierarchy inside one workspace', () => { | ||
| const service = new PlanningService(new InMemoryPlanningRepository()); | ||
|
|
||
| const goal = service.createGoal('workspace-a', { title: 'Publish LifeOS' }); | ||
| const project = service.createProject('workspace-a', { | ||
| goalId: goal.id, | ||
| title: 'Planning MVP', | ||
| }); | ||
| const task = service.createTask('workspace-a', { | ||
| projectId: project.id, | ||
| title: 'Implement tenant isolation', | ||
| }); | ||
|
|
||
| expect(service.listGoals('workspace-a')).toEqual([goal]); | ||
| expect(service.listProjects('workspace-a', goal.id)).toEqual([project]); | ||
| expect(service.listTasks('workspace-a', project.id)).toEqual([task]); | ||
| }); | ||
|
|
||
| it('does not expose records from another workspace', () => { | ||
| const service = new PlanningService(new InMemoryPlanningRepository()); | ||
|
|
||
| service.createGoal('workspace-a', { title: 'Private goal' }); | ||
|
|
||
| expect(service.listGoals('workspace-b')).toEqual([]); | ||
| }); | ||
|
|
||
| it('rejects a project whose goal belongs to another workspace', () => { | ||
| const service = new PlanningService(new InMemoryPlanningRepository()); | ||
| const goal = service.createGoal('workspace-a', { title: 'Workspace A goal' }); | ||
|
|
||
| expect(() => | ||
| service.createProject('workspace-b', { | ||
| goalId: goal.id, | ||
| title: 'Cross-tenant project', | ||
| }), | ||
| ).toThrowError('Goal not found'); | ||
| }); | ||
|
|
||
| it('rejects blank titles', () => { | ||
| const service = new PlanningService(new InMemoryPlanningRepository()); | ||
|
|
||
| expect(() => service.createGoal('workspace-a', { title: ' ' })).toThrowError( | ||
| 'Title is required', | ||
| ); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,146 @@ | ||
| import { randomUUID } from 'node:crypto'; | ||
|
|
||
| export interface Goal { | ||
| id: string; | ||
| workspaceId: string; | ||
| title: string; | ||
| createdAt: string; | ||
| } | ||
|
|
||
| export interface Project { | ||
| id: string; | ||
| workspaceId: string; | ||
| goalId: string; | ||
| title: string; | ||
| createdAt: string; | ||
| } | ||
|
|
||
| export interface Task { | ||
| id: string; | ||
| workspaceId: string; | ||
| projectId: string; | ||
| title: string; | ||
| status: 'todo' | 'done'; | ||
| createdAt: string; | ||
| } | ||
|
|
||
| export interface PlanningRepository { | ||
| saveGoal(goal: Goal): void; | ||
| saveProject(project: Project): void; | ||
| saveTask(task: Task): void; | ||
| findGoal(workspaceId: string, id: string): Goal | undefined; | ||
| findProject(workspaceId: string, id: string): Project | undefined; | ||
| listGoals(workspaceId: string): Goal[]; | ||
| listProjects(workspaceId: string, goalId: string): Project[]; | ||
| listTasks(workspaceId: string, projectId: string): Task[]; | ||
| } | ||
|
|
||
| export class InMemoryPlanningRepository implements PlanningRepository { | ||
| private readonly goals = new Map<string, Goal>(); | ||
| private readonly projects = new Map<string, Project>(); | ||
| private readonly tasks = new Map<string, Task>(); | ||
|
|
||
| saveGoal(goal: Goal): void { | ||
| this.goals.set(goal.id, goal); | ||
| } | ||
|
|
||
| saveProject(project: Project): void { | ||
| this.projects.set(project.id, project); | ||
| } | ||
|
|
||
| saveTask(task: Task): void { | ||
| this.tasks.set(task.id, task); | ||
| } | ||
|
|
||
| findGoal(workspaceId: string, id: string): Goal | undefined { | ||
| const goal = this.goals.get(id); | ||
| return goal?.workspaceId === workspaceId ? goal : undefined; | ||
| } | ||
|
|
||
| findProject(workspaceId: string, id: string): Project | undefined { | ||
| const project = this.projects.get(id); | ||
| return project?.workspaceId === workspaceId ? project : undefined; | ||
| } | ||
|
|
||
| listGoals(workspaceId: string): Goal[] { | ||
| return [...this.goals.values()].filter((goal) => goal.workspaceId === workspaceId); | ||
| } | ||
|
|
||
| listProjects(workspaceId: string, goalId: string): Project[] { | ||
| return [...this.projects.values()].filter( | ||
| (project) => project.workspaceId === workspaceId && project.goalId === goalId, | ||
| ); | ||
| } | ||
|
|
||
| listTasks(workspaceId: string, projectId: string): Task[] { | ||
| return [...this.tasks.values()].filter( | ||
| (task) => task.workspaceId === workspaceId && task.projectId === projectId, | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| function normalizeTitle(title: string): string { | ||
| const normalized = title.trim(); | ||
| if (!normalized) { | ||
| throw new Error('Title is required'); | ||
| } | ||
| return normalized; | ||
| } | ||
|
|
||
| export class PlanningService { | ||
| constructor(private readonly repository: PlanningRepository) {} | ||
|
|
||
| createGoal(workspaceId: string, input: { title: string }): Goal { | ||
| const goal: Goal = { | ||
| id: randomUUID(), | ||
| workspaceId, | ||
| title: normalizeTitle(input.title), | ||
| createdAt: new Date().toISOString(), | ||
| }; | ||
| this.repository.saveGoal(goal); | ||
| return goal; | ||
| } | ||
|
|
||
| createProject(workspaceId: string, input: { goalId: string; title: string }): Project { | ||
| if (!this.repository.findGoal(workspaceId, input.goalId)) { | ||
| throw new Error('Goal not found'); | ||
| } | ||
| const project: Project = { | ||
| id: randomUUID(), | ||
| workspaceId, | ||
| goalId: input.goalId, | ||
| title: normalizeTitle(input.title), | ||
| createdAt: new Date().toISOString(), | ||
| }; | ||
| this.repository.saveProject(project); | ||
| return project; | ||
| } | ||
|
|
||
| createTask(workspaceId: string, input: { projectId: string; title: string }): Task { | ||
| if (!this.repository.findProject(workspaceId, input.projectId)) { | ||
| throw new Error('Project not found'); | ||
| } | ||
| const task: Task = { | ||
| id: randomUUID(), | ||
| workspaceId, | ||
| projectId: input.projectId, | ||
| title: normalizeTitle(input.title), | ||
| status: 'todo', | ||
| createdAt: new Date().toISOString(), | ||
| }; | ||
| this.repository.saveTask(task); | ||
| return task; | ||
| } | ||
|
|
||
| listGoals(workspaceId: string): Goal[] { | ||
| return this.repository.listGoals(workspaceId); | ||
| } | ||
|
|
||
| listProjects(workspaceId: string, goalId: string): Project[] { | ||
| return this.repository.listProjects(workspaceId, goalId); | ||
| } | ||
|
|
||
| listTasks(workspaceId: string, projectId: string): Task[] { | ||
| return this.repository.listTasks(workspaceId, projectId); | ||
| } | ||
| } |
12 changes: 12 additions & 0 deletions
12
docs/superpowers/plans/2026-08-02-planning-domain-slice.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| # Planning Domain Slice | ||
|
|
||
| **Goal:** Implement the first usable Planning bounded-context slice with tenant-safe Goal → Project → Task behavior and a PostgreSQL migration. | ||
|
|
||
| ## Tasks | ||
|
|
||
| - [x] Define domain types and validation rules for Goal, Project, and Task. | ||
| - [x] Implement a workspace-scoped repository contract and in-memory reference implementation. | ||
| - [x] Prove tenant isolation and parent-child ownership with tests. | ||
| - [x] Expose workspace-scoped REST endpoints from the Planning service. | ||
| - [x] Add the initial PostgreSQL schema migration with foreign keys and workspace indexes. | ||
| - [x] Run CI, SAST, and security scan; fix all actionable findings. | ||
|
coderabbitai[bot] marked this conversation as resolved.
Resolved
|
||
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.