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
7 changes: 7 additions & 0 deletions .agents/skills/devlog-backend-domain/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,13 @@ check current implementation because older plans can describe superseded fields.
`class-validator`. Preserve these established trade-offs for ordinary work.
Greater framework independence is a separate architectural decision, not an
excuse to refactor the feature while implementing unrelated behavior.
- For new persistence contracts, use the shared `RepositoryInterface` or
`SearchableRepositoryInterface`; use feature-specific `SearchParams` and
`SearchResult` types for searchable child records. Give each use case its own
file, explicit input type, and `UseCaseContract` implementation. Follow the
neighboring project child use cases for visible ownership, archive, and child
membership checks instead of a feature-specific base class for routine checks.
See [backend organization](../../../docs/guides/backend_structure.md#repository-and-use-case-contracts).

## Complete the affected slice

Expand Down
9 changes: 9 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,15 @@ Run commands from the repository root using pnpm 11.21.0.

Write TypeScript throughout. Follow the nearest ESLint configuration: `apps/api` uses ESLint with Prettier, while `apps/web` uses ESLint with React Hooks and React Refresh rules. Format API TypeScript with `pnpm --filter api format`; let Prettier determine spacing and line endings. Use PascalCase for React components, Nest classes, and interfaces; camelCase for functions and variables; and kebab-case filenames such as `user-profile.tsx`. Keep Nest modules, controllers, and services grouped by feature under `apps/api/src/`.

For backend features, follow the existing shared contracts instead of defining
parallel ones: repository contracts use `RepositoryInterface` or
`SearchableRepositoryInterface`, and searchable repositories use feature-specific
`SearchParams` and `SearchResult` types. Put each use case in its own file with
an explicit input type and `UseCaseContract`. Follow the nearest project child
use case for ownership and membership checks; do not add a feature-specific
base class solely to share those routine checks. See the
[backend organization guide](docs/guides/backend_structure.md).

For components from the shadcn/ui library, always use the shadcn CLI command to
add or update them instead of writing the component file manually. If the CLI
cannot be executed or does not support the required change, stop and ask the
Expand Down
4 changes: 3 additions & 1 deletion Dockerfile.vercel
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,6 @@ WORKDIR /app
COPY --from=build /app /app
ENV NODE_ENV=production
ENV PORT=80
CMD ["node", "dist/src/main.js"]
# Vercel injects the production database URL only at runtime. Apply additive
# Prisma migrations before accepting requests; Prisma coordinates concurrent starts.
CMD ["sh", "-c", "./node_modules/.bin/prisma migrate deploy && exec node dist/src/main.js"]
4 changes: 2 additions & 2 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,9 @@
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"cookie-parser": "^1.4.7",
"dotenv": "^17.4.1",
"pg": "^8.22.0",
"prisma": "^7.9.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"uuid": "^11.1.1"
Expand All @@ -57,14 +59,12 @@
"@types/node": "^24.0.0",
"@types/pg": "^8.20.0",
"@types/supertest": "^7.0.0",
"dotenv": "^17.4.1",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2",
"globals": "^17.0.0",
"jest": "^30.0.0",
"prettier": "^3.4.2",
"prisma": "^7.9.1",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
CREATE TABLE "project_todos" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"project_id" UUID NOT NULL,
"title" VARCHAR(200) NOT NULL,
"completed_at" TIMESTAMPTZ(6),
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(6) NOT NULL,
CONSTRAINT "project_todos_pkey" PRIMARY KEY ("id")
);

CREATE INDEX "idx_project_todos_order" ON "project_todos"("project_id", "completed_at", "created_at", "id");
ALTER TABLE "project_todos" ADD CONSTRAINT "project_todos_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;
14 changes: 14 additions & 0 deletions apps/api/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ model Project {
commands ProjectCommand[]
resources ProjectResource[]
environments ProjectEnvironment[]
todos ProjectTodo[]

@@unique([userId, name], map: "projects_user_id_name_key")
@@index([userId], map: "idx_projects_user")
Expand Down Expand Up @@ -178,6 +179,19 @@ model ProjectEnvironment {
@@map("project_environments")
}

model ProjectTodo {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
projectId String @map("project_id") @db.Uuid
title String @db.VarChar(200)
completedAt DateTime? @map("completed_at") @db.Timestamptz(6)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6)
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)

@@index([projectId, completedAt, createdAt, id], map: "idx_project_todos_order")
@@map("project_todos")
}

enum ProjectStatus {
ACTIVE
PAUSED
Expand Down
23 changes: 23 additions & 0 deletions apps/api/src/project/application/dto/todo/project-todo.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { ProjectTodoEntity } from '@/project/domain/entities/todo/project-todo.entity';

export type ProjectTodoOutput = {
id: string;
projectId: string;
title: string;
completedAt: Date | null;
createdAt: Date;
updatedAt: Date;
};

export class ProjectTodoOutputMapper {
static toOutput(entity: ProjectTodoEntity): ProjectTodoOutput {
return {
id: entity.id,
projectId: entity.projectId,
title: entity.title,
completedAt: entity.completedAt ?? null,
createdAt: entity.createdAt,
updatedAt: entity.updatedAt,
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { NotFoundException } from '@nestjs/common';
import { ProjectEntity } from '@/project/domain/entities/project/project.entity';
import { ProjectStatusEnum } from '@/project/domain/entities/project/project-status-enum';
import { ProjectTodoEntity } from '@/project/domain/entities/todo/project-todo.entity';
import { AddProjectTodoUseCase } from '../../add-project-todo.usecase';
import { UpdateProjectTodoUseCase } from '../../update-project-todo.usecase';
import { RemoveProjectTodoUseCase } from '../../remove-project-todo.usecase';
import { SearchProjectTodoUseCase } from '../../search-project-todo.usecase';
import { SearchResult } from '@/shared/domain/repositories/searchable.repository';

const userId = '37588538-87ef-4776-bc30-c3b7f6280b35';
const projectId = '71ac450c-f334-4d40-bb82-8480d2dd9148';
const otherProjectId = 'eeaf5842-cdfe-4a5b-9076-79647e568684';

describe('Project todo use cases', () => {
const project = new ProjectEntity(
{ userId, name: 'Test', status: ProjectStatusEnum.ACTIVE },
projectId,
);
const todo = new ProjectTodoEntity({ projectId, title: 'Task' });
const projects = { findById: jest.fn() };
const todos = {
insert: jest.fn(),
findById: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
search: jest.fn(),
};

beforeEach(() => {
jest.clearAllMocks();
projects.findById.mockResolvedValue(project);
todos.findById.mockResolvedValue(todo);
});

it('takes ownership from the caller and creates a child through Project', async () => {
const result = await new AddProjectTodoUseCase(
projects as never,
todos as never,
).execute({ userId, projectId, title: ' Task 2 ' });
expect(todos.insert).toHaveBeenCalledWith(
expect.objectContaining({ projectId }),
);
expect(result.title).toBe('Task 2');
});

it('searches only within the owned project using standard search parameters', async () => {
todos.search.mockResolvedValue(
new SearchResult({
items: [todo],
total: 1,
currentPage: 2,
perPage: 10,
}),
);
const result = await new SearchProjectTodoUseCase(
projects as never,
todos as never,
).execute({
userId,
projectId,
page: 2,
perPage: 10,
sort: 'title',
sortDir: 'desc',
});

expect(todos.search).toHaveBeenCalledWith(
expect.objectContaining({
page: 2,
perPage: 10,
sort: 'title',
sortDir: 'desc',
filter: { projectId },
}),
);
expect(result).toMatchObject({
items: [expect.objectContaining({ id: todo.id, projectId })],
total: 1,
currentPage: 2,
perPage: 10,
lastPage: 1,
});
});

it('does not query to-dos for a project owned by another user', async () => {
await expect(
new SearchProjectTodoUseCase(projects as never, todos as never).execute({
userId: otherProjectId,
projectId,
}),
).rejects.toBeInstanceOf(NotFoundException);
expect(todos.search).not.toHaveBeenCalled();
});

it('hides a foreign project and a child from another project', async () => {
await expect(
new AddProjectTodoUseCase(projects as never, todos as never).execute({
userId: otherProjectId,
projectId,
title: 'Task',
}),
).rejects.toBeInstanceOf(NotFoundException);
todos.findById.mockResolvedValue(
new ProjectTodoEntity({ projectId: otherProjectId, title: 'Foreign' }),
);
await expect(
new UpdateProjectTodoUseCase(projects as never, todos as never).execute({
userId,
projectId,
todoId: todo.id,
completed: true,
}),
).rejects.toBeInstanceOf(NotFoundException);
expect(todos.update).not.toHaveBeenCalled();
});

it('blocks mutations on archived projects', async () => {
const archived = new ProjectEntity(
{
userId,
name: 'Archived',
status: ProjectStatusEnum.ACTIVE,
archivedAt: new Date(),
},
projectId,
);
projects.findById.mockResolvedValue(archived);
await expect(
new RemoveProjectTodoUseCase(projects as never, todos as never).execute({
userId,
projectId,
todoId: todo.id,
}),
).rejects.toMatchObject({
error: { archivedAt: ['Archived projects are read-only'] },
});
expect(todos.delete).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { NotFoundException } from '@nestjs/common';
import { UseCaseContract } from '@/shared/application/usecases/use-case-contract';
import { ProjectRepository } from '@/project/domain/repositories/project/project.repository';
import { ProjectTodoRepository } from '@/project/domain/repositories/todo/project-todo.repository';
import {
ProjectTodoOutput,
ProjectTodoOutputMapper,
} from '../../dto/todo/project-todo.dto';

export type AddProjectTodoUseCaseInput = {
userId: string;
projectId: string;
title: string;
};

export class AddProjectTodoUseCase implements UseCaseContract<
AddProjectTodoUseCaseInput,
ProjectTodoOutput
> {
constructor(
private readonly projectRepository: ProjectRepository,
private readonly todoRepository: ProjectTodoRepository,
) {}

async execute(input: AddProjectTodoUseCaseInput): Promise<ProjectTodoOutput> {
const project = await this.projectRepository.findById(input.projectId);
if (!project || project.userId !== input.userId)
throw new NotFoundException('Project not found');

// The project aggregate checks its archive rule when creating a child.
const todo = project.addTodo(input.title);
await this.todoRepository.insert(todo);
return ProjectTodoOutputMapper.toOutput(todo);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { NotFoundException } from '@nestjs/common';
import { UseCaseContract } from '@/shared/application/usecases/use-case-contract';
import { ProjectRepository } from '@/project/domain/repositories/project/project.repository';
import { ProjectTodoRepository } from '@/project/domain/repositories/todo/project-todo.repository';

export type RemoveProjectTodoUseCaseInput = {
userId: string;
projectId: string;
todoId: string;
};

export class RemoveProjectTodoUseCase implements UseCaseContract<
RemoveProjectTodoUseCaseInput,
void
> {
constructor(
private readonly projectRepository: ProjectRepository,
private readonly todoRepository: ProjectTodoRepository,
) {}

async execute(input: RemoveProjectTodoUseCaseInput): Promise<void> {
const project = await this.projectRepository.findById(input.projectId);
if (!project || project.userId !== input.userId)
throw new NotFoundException('Project not found');
project.ensureCanBeModified();

const todo = await this.todoRepository.findById(input.todoId);
if (!todo || todo.projectId !== project.id)
throw new NotFoundException('Todo not found');
await this.todoRepository.delete(todo.id);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { NotFoundException } from '@nestjs/common';
import {
PaginationOutput,
PaginationOutputMapper,
} from '@/shared/application/dtos/pagination-output';
import { SearchInput } from '@/shared/application/dtos/search-input';
import { UseCaseContract } from '@/shared/application/usecases/use-case-contract';
import { ProjectRepository } from '@/project/domain/repositories/project/project.repository';
import {
ProjectTodoFilter,
ProjectTodoRepository,
ProjectTodoSearchParams,
} from '@/project/domain/repositories/todo/project-todo.repository';
import {
ProjectTodoOutput,
ProjectTodoOutputMapper,
} from '../../dto/todo/project-todo.dto';

export type SearchProjectTodoUseCaseInput = Omit<
SearchInput<ProjectTodoFilter>,
'filter'
> & {
userId: string;
projectId: string;
};

export class SearchProjectTodoUseCase implements UseCaseContract<
SearchProjectTodoUseCaseInput,
PaginationOutput<ProjectTodoOutput>
> {
constructor(
private readonly projectRepository: ProjectRepository,
private readonly todoRepository: ProjectTodoRepository,
) {}

async execute(
input: SearchProjectTodoUseCaseInput,
): Promise<PaginationOutput<ProjectTodoOutput>> {
const project = await this.projectRepository.findById(input.projectId);
if (!project || project.userId !== input.userId)
throw new NotFoundException('Project not found');

const result = await this.todoRepository.search(
new ProjectTodoSearchParams({
page: input.page,
perPage: input.perPage ?? 20,
sort: input.sort,
sortDir: input.sortDir,
filter: { projectId: project.id },
}),
);
return PaginationOutputMapper.toOutout(
result.items.map((todo) => ProjectTodoOutputMapper.toOutput(todo)),
result,
);
}
}
Loading
Loading