Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions apps/planning-service/migrations/0001_initial_planning.sql
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);
5 changes: 5 additions & 0 deletions apps/planning-service/migrations/README.md
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.
25 changes: 25 additions & 0 deletions apps/planning-service/src/http-boundary.test.ts
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,
);
});
});
25 changes: 25 additions & 0 deletions apps/planning-service/src/http-boundary.ts
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');
}
101 changes: 98 additions & 3 deletions apps/planning-service/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,115 @@
import 'reflect-metadata';
import { Controller, Get, Module } from '@nestjs/common';
import {
BadRequestException,
Body,
Controller,
Get,
Headers,
Module,
Param,
Post,
} from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { requireTitle, toHttpException } from './http-boundary';
import {
Goal,
InMemoryPlanningRepository,
PlanningService,
Project,
Task,
} from './planning-domain';

const planningService = new PlanningService(new InMemoryPlanningRepository());

function requireWorkspaceId(value: string | undefined): string {
const workspaceId = value?.trim();
if (!workspaceId) {
throw new BadRequestException('x-workspace-id header is required');
}
return workspaceId;
}

@Controller()
class HealthController {
class PlanningController {
@Get('health')
health(): { status: 'ok'; service: 'planning-service' } {
return { status: 'ok', service: 'planning-service' };
}

@Post('goals')
createGoal(
@Headers('x-workspace-id') workspaceHeader: string | undefined,
@Body() body: { title?: unknown },
): Goal {
try {
return planningService.createGoal(requireWorkspaceId(workspaceHeader), {
title: requireTitle(body),
});
} catch (error) {
throw toHttpException(error);
}
}
Comment thread
seonghobae marked this conversation as resolved.

@Get('goals')
listGoals(@Headers('x-workspace-id') workspaceHeader: string | undefined): Goal[] {
return planningService.listGoals(requireWorkspaceId(workspaceHeader));
}

@Post('goals/:goalId/projects')
createProject(
@Headers('x-workspace-id') workspaceHeader: string | undefined,
@Param('goalId') goalId: string,
@Body() body: { title?: unknown },
): Project {
try {
return planningService.createProject(requireWorkspaceId(workspaceHeader), {
goalId,
title: requireTitle(body),
});
} catch (error) {
throw toHttpException(error);
}
}

@Get('goals/:goalId/projects')
listProjects(
@Headers('x-workspace-id') workspaceHeader: string | undefined,
@Param('goalId') goalId: string,
): Project[] {
return planningService.listProjects(requireWorkspaceId(workspaceHeader), goalId);
}

@Post('projects/:projectId/tasks')
createTask(
@Headers('x-workspace-id') workspaceHeader: string | undefined,
@Param('projectId') projectId: string,
@Body() body: { title?: unknown },
): Task {
try {
return planningService.createTask(requireWorkspaceId(workspaceHeader), {
projectId,
title: requireTitle(body),
});
} catch (error) {
throw toHttpException(error);
}
}

@Get('projects/:projectId/tasks')
listTasks(
@Headers('x-workspace-id') workspaceHeader: string | undefined,
@Param('projectId') projectId: string,
): Task[] {
return planningService.listTasks(requireWorkspaceId(workspaceHeader), projectId);
}
}

@Module({ controllers: [HealthController] })
@Module({ controllers: [PlanningController] })
class AppModule {}

async function bootstrap(): Promise<void> {
const app = await NestFactory.create(AppModule);
app.setGlobalPrefix('v1');
app.enableShutdownHooks();
await app.listen(Number(process.env.PLANNING_SERVICE_PORT ?? 4102), '0.0.0.0');
}
Expand Down
78 changes: 78 additions & 0 deletions apps/planning-service/src/planning-domain.test.ts
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',
);
});
});
Loading
Loading