-
Notifications
You must be signed in to change notification settings - Fork 0
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 all 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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| import { BadRequestException, NotFoundException } from '@nestjs/common'; | ||
| import { describe, expect, it } from 'vitest'; | ||
| import { requireTitle, toHttpException } from './http-boundary'; | ||
|
|
||
| describe('planning HTTP boundary', () => { | ||
| it('rejects a missing or blank title as a bad request', () => { | ||
| expect(() => requireTitle({})).toThrow(BadRequestException); | ||
| expect(() => requireTitle({ title: ' ' })).toThrow(BadRequestException); | ||
| }); | ||
|
|
||
| it('normalizes a valid title', () => { | ||
| expect(requireTitle({ title: ' Ship MVP ' })).toBe('Ship MVP'); | ||
| }); | ||
|
|
||
| it('maps missing parent entities to not found', () => { | ||
| expect(toHttpException(new Error('Goal not found'))).toBeInstanceOf(NotFoundException); | ||
| expect(toHttpException(new Error('Project not found'))).toBeInstanceOf(NotFoundException); | ||
| }); | ||
|
|
||
| it('maps validation failures to bad request', () => { | ||
| expect(toHttpException(new Error('Identifier must be an opaque non-numeric string'))).toBeInstanceOf( | ||
| BadRequestException, | ||
| ); | ||
| }); | ||
| }); |
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,25 @@ | ||
| import { BadRequestException, HttpException, NotFoundException } from '@nestjs/common'; | ||
|
|
||
| export function requireTitle(body: { title?: unknown } | undefined): string { | ||
| const title = body?.title; | ||
| if (typeof title !== 'string' || !title.trim()) { | ||
| throw new BadRequestException('title is required'); | ||
| } | ||
| return title.trim(); | ||
| } | ||
|
|
||
| export function toHttpException(error: unknown): HttpException { | ||
| if (error instanceof HttpException) { | ||
| return error; | ||
| } | ||
|
|
||
| if (error instanceof Error && error.message.endsWith('not found')) { | ||
| return new NotFoundException(error.message); | ||
| } | ||
|
|
||
| if (error instanceof Error) { | ||
| return new BadRequestException(error.message); | ||
| } | ||
|
|
||
| return new BadRequestException('Invalid request'); | ||
| } |
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,78 @@ | ||
| import { describe, expect, it } from 'vitest'; | ||
| import { InMemoryPlanningRepository, PlanningService } from './planning-domain'; | ||
|
|
||
| const UUID_V4_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; | ||
|
|
||
| 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('generates opaque UUIDv4 identifiers instead of numeric or sequential IDs', () => { | ||
| const service = new PlanningService(new InMemoryPlanningRepository()); | ||
| const goal = service.createGoal('workspace-a', { title: 'Opaque identifiers' }); | ||
| const project = service.createProject('workspace-a', { | ||
| goalId: goal.id, | ||
| title: 'Project', | ||
| }); | ||
| const task = service.createTask('workspace-a', { | ||
| projectId: project.id, | ||
| title: 'Task', | ||
| }); | ||
|
|
||
| for (const id of [goal.id, project.id, task.id]) { | ||
| expect(id).toMatch(UUID_V4_PATTERN); | ||
| expect(id).not.toMatch(/^\d+$/); | ||
| } | ||
| }); | ||
|
|
||
| it('rejects numeric-only workspace identifiers', () => { | ||
| const service = new PlanningService(new InMemoryPlanningRepository()); | ||
|
|
||
| expect(() => service.createGoal('123456', { title: 'Unsafe tenant ID' })).toThrowError( | ||
| 'Identifier must be an opaque non-numeric string', | ||
| ); | ||
| }); | ||
|
|
||
| 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', | ||
| ); | ||
| }); | ||
| }); |
Oops, something went wrong.
Oops, something went wrong.
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.