Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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.
86 changes: 83 additions & 3 deletions apps/planning-service/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,100 @@
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 {
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: string },
): Goal {
return planningService.createGoal(requireWorkspaceId(workspaceHeader), body);
}
Comment thread
seonghobae marked this conversation as resolved.
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: string },
): Project {
return planningService.createProject(requireWorkspaceId(workspaceHeader), {
goalId,
title: body.title,
});
}

@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: string },
): Task {
return planningService.createTask(requireWorkspaceId(workspaceHeader), {
projectId,
title: body.title,
});
}

@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
50 changes: 50 additions & 0 deletions apps/planning-service/src/planning-domain.test.ts
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',
);
});
});
146 changes: 146 additions & 0 deletions apps/planning-service/src/planning-domain.ts
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 docs/superpowers/plans/2026-08-02-planning-domain-slice.md
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.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved