diff --git a/.agents/skills/devlog-backend-domain/SKILL.md b/.agents/skills/devlog-backend-domain/SKILL.md index b463b78..cf31b6a 100644 --- a/.agents/skills/devlog-backend-domain/SKILL.md +++ b/.agents/skills/devlog-backend-domain/SKILL.md @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 42394ff..3d90bae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/Dockerfile.vercel b/Dockerfile.vercel index 72d7acb..5e96328 100644 --- a/Dockerfile.vercel +++ b/Dockerfile.vercel @@ -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"] diff --git a/apps/api/package.json b/apps/api/package.json index 1154540..59e9e13 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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" @@ -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", diff --git a/apps/api/prisma/migrations/20261022000000_project_todos/migration.sql b/apps/api/prisma/migrations/20261022000000_project_todos/migration.sql new file mode 100644 index 0000000..3818417 --- /dev/null +++ b/apps/api/prisma/migrations/20261022000000_project_todos/migration.sql @@ -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; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 0538b17..77e7956 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -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") @@ -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 diff --git a/apps/api/src/project/application/dto/todo/project-todo.dto.ts b/apps/api/src/project/application/dto/todo/project-todo.dto.ts new file mode 100644 index 0000000..73e0904 --- /dev/null +++ b/apps/api/src/project/application/dto/todo/project-todo.dto.ts @@ -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, + }; + } +} diff --git a/apps/api/src/project/application/usecases/todo/__tests__/unit/project-todo.usecases.spec.ts b/apps/api/src/project/application/usecases/todo/__tests__/unit/project-todo.usecases.spec.ts new file mode 100644 index 0000000..9ce8ede --- /dev/null +++ b/apps/api/src/project/application/usecases/todo/__tests__/unit/project-todo.usecases.spec.ts @@ -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(); + }); +}); diff --git a/apps/api/src/project/application/usecases/todo/add-project-todo.usecase.ts b/apps/api/src/project/application/usecases/todo/add-project-todo.usecase.ts new file mode 100644 index 0000000..af558b8 --- /dev/null +++ b/apps/api/src/project/application/usecases/todo/add-project-todo.usecase.ts @@ -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 { + 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); + } +} diff --git a/apps/api/src/project/application/usecases/todo/remove-project-todo.usecase.ts b/apps/api/src/project/application/usecases/todo/remove-project-todo.usecase.ts new file mode 100644 index 0000000..36b0e06 --- /dev/null +++ b/apps/api/src/project/application/usecases/todo/remove-project-todo.usecase.ts @@ -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 { + 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); + } +} diff --git a/apps/api/src/project/application/usecases/todo/search-project-todo.usecase.ts b/apps/api/src/project/application/usecases/todo/search-project-todo.usecase.ts new file mode 100644 index 0000000..331db9f --- /dev/null +++ b/apps/api/src/project/application/usecases/todo/search-project-todo.usecase.ts @@ -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, + 'filter' +> & { + userId: string; + projectId: string; +}; + +export class SearchProjectTodoUseCase implements UseCaseContract< + SearchProjectTodoUseCaseInput, + PaginationOutput +> { + constructor( + private readonly projectRepository: ProjectRepository, + private readonly todoRepository: ProjectTodoRepository, + ) {} + + async execute( + input: SearchProjectTodoUseCaseInput, + ): Promise> { + 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, + ); + } +} diff --git a/apps/api/src/project/application/usecases/todo/update-project-todo.usecase.ts b/apps/api/src/project/application/usecases/todo/update-project-todo.usecase.ts new file mode 100644 index 0000000..656c668 --- /dev/null +++ b/apps/api/src/project/application/usecases/todo/update-project-todo.usecase.ts @@ -0,0 +1,54 @@ +import { + NotFoundException, + UnprocessableEntityException, +} 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 UpdateProjectTodoUseCaseInput = { + userId: string; + projectId: string; + todoId: string; + title?: string; + completed?: boolean; +}; + +export class UpdateProjectTodoUseCase implements UseCaseContract< + UpdateProjectTodoUseCaseInput, + ProjectTodoOutput +> { + constructor( + private readonly projectRepository: ProjectRepository, + private readonly todoRepository: ProjectTodoRepository, + ) {} + + async execute( + input: UpdateProjectTodoUseCaseInput, + ): Promise { + const project = await this.projectRepository.findById(input.projectId); + if (!project || project.userId !== input.userId) + throw new NotFoundException('Project not found'); + project.ensureCanBeModified(); + + // A valid todo ID is still inaccessible through a different parent project. + const todo = await this.todoRepository.findById(input.todoId); + if (!todo || todo.projectId !== project.id) + throw new NotFoundException('Todo not found'); + + if (input.title === undefined && input.completed === undefined) + throw new UnprocessableEntityException( + 'Provide at least one field to update the todo', + ); + + const previousUpdatedAt = todo.updatedAt; + todo.update({ title: input.title, completed: input.completed }); + if (todo.updatedAt !== previousUpdatedAt) + await this.todoRepository.update(todo); + return ProjectTodoOutputMapper.toOutput(todo); + } +} diff --git a/apps/api/src/project/domain/entities/project/project.entity.ts b/apps/api/src/project/domain/entities/project/project.entity.ts index bbb9d50..98c7d43 100644 --- a/apps/api/src/project/domain/entities/project/project.entity.ts +++ b/apps/api/src/project/domain/entities/project/project.entity.ts @@ -8,6 +8,7 @@ import { ProjectResourceEntity } from '../resource/project-resource.entity'; import { ProjectResourceType } from '../resource/project-resource-type.enum'; import { ProjectEnvironmentEntity } from '../environment/project-environment.entity'; import { ProjectEnvironmentCategory } from '../environment/project-environment-category.enum'; +import { ProjectTodoEntity } from '../todo/project-todo.entity'; export type ProjectProps = { userId: string; @@ -93,6 +94,11 @@ export class ProjectEntity extends Entity { return new ProjectEnvironmentEntity({ projectId: this.id, ...input }); } + addTodo(title: string): ProjectTodoEntity { + this.ensureCanBeModified(); + return new ProjectTodoEntity({ projectId: this.id, title }); + } + update(props: ProjectUpdateProps): void { // The application decides whether an empty update is valid input. In the domain, // it is simply a no-op and must not artificially change updatedAt. diff --git a/apps/api/src/project/domain/entities/todo/__tests__/unit/project-todo.entity.spec.ts b/apps/api/src/project/domain/entities/todo/__tests__/unit/project-todo.entity.spec.ts new file mode 100644 index 0000000..87f1556 --- /dev/null +++ b/apps/api/src/project/domain/entities/todo/__tests__/unit/project-todo.entity.spec.ts @@ -0,0 +1,30 @@ +import { ProjectTodoEntity } from '../../project-todo.entity'; + +const projectId = '37588538-87ef-4776-bc30-c3b7f6280b35'; + +describe('ProjectTodoEntity', () => { + it('trims titles and rejects blank or oversized titles', () => { + const todo = new ProjectTodoEntity({ + projectId, + title: ' Ship feature ', + }); + expect(todo.title).toBe('Ship feature'); + expect(() => new ProjectTodoEntity({ projectId, title: ' ' })).toThrow(); + expect( + () => new ProjectTodoEntity({ projectId, title: 'x'.repeat(201) }), + ).toThrow(); + }); + + it('sets and clears completedAt, leaving repeated completion unchanged', () => { + const todo = new ProjectTodoEntity({ projectId, title: 'Ship feature' }); + todo.update({ completed: true }); + const completedAt = todo.completedAt; + const updatedAt = todo.updatedAt; + expect(completedAt).toBeInstanceOf(Date); + todo.update({ completed: true }); + expect(todo.completedAt).toBe(completedAt); + expect(todo.updatedAt).toBe(updatedAt); + todo.update({ completed: false }); + expect(todo.completedAt).toBeUndefined(); + }); +}); diff --git a/apps/api/src/project/domain/entities/todo/project-todo.entity.ts b/apps/api/src/project/domain/entities/todo/project-todo.entity.ts new file mode 100644 index 0000000..843d4c1 --- /dev/null +++ b/apps/api/src/project/domain/entities/todo/project-todo.entity.ts @@ -0,0 +1,80 @@ +import { Entity } from '@/shared/domain/entities/entity'; +import { EntityValidationError } from '@/shared/domain/errors/entity-validation-error'; +import { ProjectTodoValidatorFactory } from '../../validators/todo/project-todo.validator'; + +export type ProjectTodoProps = { + projectId: string; + title: string; + completedAt?: Date; + createdAt: Date; + updatedAt: Date; +}; + +export class ProjectTodoEntity extends Entity { + constructor( + props: Omit & + Partial>, + id?: string, + ) { + const createdAt = props.createdAt ?? new Date(); + const completeProps = { + ...props, + title: typeof props.title === 'string' ? props.title.trim() : props.title, + createdAt, + updatedAt: props.updatedAt ?? createdAt, + }; + ProjectTodoEntity.validate(completeProps); + super(completeProps, id); + } + + update(changes: { title?: string; completed?: boolean }): void { + const title = + changes.title === undefined + ? this.title + : typeof changes.title === 'string' + ? changes.title.trim() + : changes.title; + const titleChanged = title !== this.title; + const completionChanged = + changes.completed !== undefined && + changes.completed !== (this.completedAt !== undefined); + if (!titleChanged && !completionChanged) return; + + const now = new Date(); + // Repeating a completion request preserves the original completion instant. + const nextProps = { + ...this.props, + title, + completedAt: completionChanged + ? changes.completed + ? now + : undefined + : this.completedAt, + updatedAt: now, + }; + ProjectTodoEntity.validate(nextProps); + Object.assign(this.props, nextProps); + } + + get projectId() { + return this.props.projectId; + } + get title() { + return this.props.title; + } + get completedAt() { + return this.props.completedAt; + } + get createdAt() { + return this.props.createdAt; + } + get updatedAt() { + return this.props.updatedAt; + } + + private static validate(props: ProjectTodoProps): void { + const validator = ProjectTodoValidatorFactory.create(); + if (!validator.validate(props)) + throw new EntityValidationError(validator.errors ?? {}); + } +} diff --git a/apps/api/src/project/domain/repositories/todo/project-todo.repository.ts b/apps/api/src/project/domain/repositories/todo/project-todo.repository.ts new file mode 100644 index 0000000..87306cf --- /dev/null +++ b/apps/api/src/project/domain/repositories/todo/project-todo.repository.ts @@ -0,0 +1,22 @@ +import { + SearchableRepositoryInterface, + SearchParams, + SearchResult, +} from '@/shared/domain/repositories/searchable.repository'; +import { ProjectTodoEntity } from '../../entities/todo/project-todo.entity'; + +export type ProjectTodoFilter = { projectId: string }; + +export class ProjectTodoSearchParams extends SearchParams {} + +export class ProjectTodoSearchResult extends SearchResult< + ProjectTodoEntity, + ProjectTodoFilter +> {} + +export type ProjectTodoRepository = SearchableRepositoryInterface< + ProjectTodoEntity, + ProjectTodoFilter, + ProjectTodoSearchParams, + ProjectTodoSearchResult +>; diff --git a/apps/api/src/project/domain/validators/todo/project-todo.validator.ts b/apps/api/src/project/domain/validators/todo/project-todo.validator.ts new file mode 100644 index 0000000..0920e0f --- /dev/null +++ b/apps/api/src/project/domain/validators/todo/project-todo.validator.ts @@ -0,0 +1,46 @@ +import { + IsDate, + IsOptional, + IsString, + IsUUID, + MaxLength, + MinLength, +} from 'class-validator'; +import { ClassValidatorFields } from '@/shared/domain/validators/class-validator-fields'; +import type { ProjectTodoProps } from '../../entities/todo/project-todo.entity'; + +export class ProjectTodoRules { + @IsUUID('4', { message: 'Project ID must be a valid UUID' }) + projectId: string; + + @IsString({ message: 'Todo title must be a string' }) + @MinLength(1, { message: 'Todo title must not be empty' }) + @MaxLength(200, { message: 'Todo title must be at most 200 characters long' }) + title: string; + + @IsOptional() + @IsDate() + completedAt?: Date; + + @IsDate() + createdAt: Date; + + @IsDate() + updatedAt: Date; + + constructor(props: ProjectTodoProps) { + Object.assign(this, props); + } +} + +export class ProjectTodoValidator extends ClassValidatorFields { + validate(data: ProjectTodoRules): boolean { + return super.validate(new ProjectTodoRules(data)); + } +} + +export class ProjectTodoValidatorFactory { + static create(): ProjectTodoValidator { + return new ProjectTodoValidator(); + } +} diff --git a/apps/api/src/project/infrastructure/database/prisma/repositories/todo/__tests__/int/project-todo-prisma.repository.int.spec.ts b/apps/api/src/project/infrastructure/database/prisma/repositories/todo/__tests__/int/project-todo-prisma.repository.int.spec.ts new file mode 100644 index 0000000..214bcc9 --- /dev/null +++ b/apps/api/src/project/infrastructure/database/prisma/repositories/todo/__tests__/int/project-todo-prisma.repository.int.spec.ts @@ -0,0 +1,108 @@ +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 { ProjectTodoSearchParams } from '@/project/domain/repositories/todo/project-todo.repository'; +import { ProjectPrismaRepository } from '@/project/infrastructure/database/prisma/repositories/project/project-prisma.repository'; +import { createIntegrationPrisma } from '@/shared/infrastructure/database/__tests__/int/integration-test-helpers'; +import { PrismaService } from '@/shared/infrastructure/database/prisma.service'; +import { UserEntity } from '@/user/domain/entities/user.entity'; +import { UserPrismaRepository } from '@/user/infrastructure/database/prisma/repositories/user-prisma.repository'; +import { ProjectTodoPrismaRepository } from '../../project-todo-prisma.repository'; + +const userId = '123e4567-e89b-42d3-a456-426614174300'; +const projectId = '123e4567-e89b-42d3-a456-426614174301'; +const secondProjectId = '123e4567-e89b-42d3-a456-426614174305'; +const createdAt = new Date('2026-01-01T00:00:00.000Z'); + +describe('ProjectTodoPrismaRepository (integration)', () => { + let prisma: PrismaService; + let repository: ProjectTodoPrismaRepository; + beforeAll(async () => { + prisma = createIntegrationPrisma(); + await prisma.$connect(); + repository = new ProjectTodoPrismaRepository(prisma); + }); + beforeEach(async () => { + await prisma.user.deleteMany({ where: { id: userId } }); + await new UserPrismaRepository(prisma).insert( + new UserEntity( + { + name: 'Todo owner', + email: 'todo-owner@example.com', + password: 'hashed-password', + }, + userId, + ), + ); + const projects = new ProjectPrismaRepository(prisma); + await projects.insert( + new ProjectEntity( + { + userId, + name: 'Todo project', + status: ProjectStatusEnum.ACTIVE, + }, + projectId, + ), + ); + await projects.insert( + new ProjectEntity( + { + userId, + name: 'Second todo project', + status: ProjectStatusEnum.ACTIVE, + }, + secondProjectId, + ), + ); + }); + afterAll(async () => { + if (prisma) { + await prisma.user.deleteMany({ where: { id: userId } }); + await prisma.$disconnect(); + } + }); + + it('persists duplicates, sorts open-first across pages, and cascades project deletion', async () => { + const completed = new ProjectTodoEntity( + { projectId, title: 'Same title', createdAt }, + '123e4567-e89b-42d3-a456-426614174304', + ); + const firstOpen = new ProjectTodoEntity( + { projectId, title: 'Same title', createdAt }, + '123e4567-e89b-42d3-a456-426614174302', + ); + const secondOpen = new ProjectTodoEntity( + { projectId, title: 'Same title', createdAt }, + '123e4567-e89b-42d3-a456-426614174303', + ); + completed.update({ completed: true }); + for (const entity of [completed, secondOpen, firstOpen]) + await repository.insert(entity); + await repository.insert( + new ProjectTodoEntity( + { projectId: secondProjectId, title: 'Other project task', createdAt }, + '123e4567-e89b-42d3-a456-426614174306', + ), + ); + const search = (page: number) => + repository.search( + new ProjectTodoSearchParams({ + page, + perPage: 2, + filter: { projectId }, + }), + ); + const first = await search(1); + const second = await search(2); + expect([...first.items, ...second.items].map((item) => item.id)).toEqual([ + firstOpen.id, + secondOpen.id, + completed.id, + ]); + expect(second.total).toBe(3); + expect((await repository.findAll()).length).toBe(4); + await prisma.project.delete({ where: { id: projectId } }); + expect(await repository.findById(firstOpen.id)).toBeNull(); + }); +}); diff --git a/apps/api/src/project/infrastructure/database/prisma/repositories/todo/__tests__/unit/project-todo-prisma.repository.spec.ts b/apps/api/src/project/infrastructure/database/prisma/repositories/todo/__tests__/unit/project-todo-prisma.repository.spec.ts new file mode 100644 index 0000000..20b0c2e --- /dev/null +++ b/apps/api/src/project/infrastructure/database/prisma/repositories/todo/__tests__/unit/project-todo-prisma.repository.spec.ts @@ -0,0 +1,124 @@ +import { ProjectTodoPrismaRepository } from '../../project-todo-prisma.repository'; +import { ProjectTodoSearchParams } from '@/project/domain/repositories/todo/project-todo.repository'; + +const projectId = '71ac450c-f334-4d40-bb82-8480d2dd9148'; +const sameTime = new Date('2026-01-01T00:00:00.000Z'); +const model = (id: string, completedAt: Date | null) => ({ + id, + projectId, + title: `Task ${id}`, + completedAt, + createdAt: sameTime, + updatedAt: sameTime, +}); + +describe('ProjectTodoPrismaRepository', () => { + it('pages open tasks before completed tasks with a stable id tie-breaker', async () => { + const open = [ + model('00000000-0000-4000-8000-000000000001', null), + model('00000000-0000-4000-8000-000000000002', null), + model('00000000-0000-4000-8000-000000000003', null), + ]; + const completed = [ + model('00000000-0000-4000-8000-000000000004', new Date()), + model('00000000-0000-4000-8000-000000000005', new Date()), + ]; + const prisma = { + projectTodo: { + count: jest.fn(({ where }: { where: { completedAt?: null } }) => + Promise.resolve(where.completedAt === null ? open.length : 5), + ), + findMany: jest.fn( + ({ + where, + skip, + take, + }: { + where: { completedAt: null | { not: null } }; + skip: number; + take: number; + }) => + Promise.resolve( + (where.completedAt === null ? open : completed).slice( + skip, + skip + take, + ), + ), + ), + }, + }; + const repository = new ProjectTodoPrismaRepository(prisma as never); + const search = (page: number) => + repository.search( + new ProjectTodoSearchParams({ + page, + perPage: 2, + filter: { projectId }, + }), + ); + const first = await search(1); + const second = await search(2); + const third = await search(3); + + expect( + [first, second, third].flatMap((page) => + page.items.map((item) => item.id), + ), + ).toEqual([...open, ...completed].map((item) => item.id)); + expect(second).toMatchObject({ total: 5, currentPage: 2, lastPage: 3 }); + expect(second.filter).toEqual({ projectId }); + expect(prisma.projectTodo.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { projectId, completedAt: null }, + orderBy: [{ createdAt: 'asc' }, { id: 'asc' }], + }), + ); + expect(prisma.projectTodo.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { projectId, completedAt: { not: null } }, + orderBy: [{ createdAt: 'asc' }, { id: 'asc' }], + }), + ); + }); + + it('provides the shared repository findAll operation', async () => { + const records = [model('00000000-0000-4000-8000-000000000001', null)]; + const findMany = jest.fn().mockResolvedValue(records); + const repository = new ProjectTodoPrismaRepository({ + projectTodo: { findMany }, + } as never); + + const result = await repository.findAll(); + + expect(result.map((todo) => todo.id)).toEqual( + records.map((item) => item.id), + ); + expect(repository.sortableFields).toEqual( + expect.arrayContaining(['createdAt', 'title']), + ); + }); + + it('keeps project filtering and open-first ordering when a supported sort is requested', async () => { + const count = jest.fn().mockResolvedValue(1); + const findMany = jest.fn().mockResolvedValue([]); + const repository = new ProjectTodoPrismaRepository({ + projectTodo: { count, findMany }, + } as never); + + await repository.search( + new ProjectTodoSearchParams({ + filter: { projectId }, + sort: 'title', + sortDir: 'desc', + }), + ); + + expect(count).toHaveBeenCalledWith({ where: { projectId } }); + expect(findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { projectId, completedAt: null }, + orderBy: [{ title: 'desc' }, { id: 'asc' }], + }), + ); + }); +}); diff --git a/apps/api/src/project/infrastructure/database/prisma/repositories/todo/models/project-todo-model.mapper.ts b/apps/api/src/project/infrastructure/database/prisma/repositories/todo/models/project-todo-model.mapper.ts new file mode 100644 index 0000000..621e484 --- /dev/null +++ b/apps/api/src/project/infrastructure/database/prisma/repositories/todo/models/project-todo-model.mapper.ts @@ -0,0 +1,28 @@ +import { ProjectTodo } from '@generated/prisma/client'; +import { ProjectTodoEntity } from '@/project/domain/entities/todo/project-todo.entity'; + +export class ProjectTodoModelMapper { + static toEntity(model: ProjectTodo): ProjectTodoEntity { + return new ProjectTodoEntity( + { + projectId: model.projectId, + title: model.title, + completedAt: model.completedAt ?? undefined, + createdAt: model.createdAt, + updatedAt: model.updatedAt, + }, + model.id, + ); + } + + static toPersistence(entity: ProjectTodoEntity) { + return { + id: entity.id, + projectId: entity.projectId, + title: entity.title, + completedAt: entity.completedAt ?? null, + createdAt: entity.createdAt, + updatedAt: entity.updatedAt, + }; + } +} diff --git a/apps/api/src/project/infrastructure/database/prisma/repositories/todo/project-todo-prisma.repository.ts b/apps/api/src/project/infrastructure/database/prisma/repositories/todo/project-todo-prisma.repository.ts new file mode 100644 index 0000000..fcd95b0 --- /dev/null +++ b/apps/api/src/project/infrastructure/database/prisma/repositories/todo/project-todo-prisma.repository.ts @@ -0,0 +1,99 @@ +import { PrismaService } from '@/shared/infrastructure/database/prisma.service'; +import { ProjectTodoEntity } from '@/project/domain/entities/todo/project-todo.entity'; +import { + ProjectTodoRepository, + ProjectTodoSearchParams, + ProjectTodoSearchResult, +} from '@/project/domain/repositories/todo/project-todo.repository'; +import { Prisma } from '@generated/prisma/client'; +import { ProjectTodoModelMapper } from './models/project-todo-model.mapper'; + +export class ProjectTodoPrismaRepository implements ProjectTodoRepository { + sortableFields = ['title', 'createdAt', 'updatedAt']; + + constructor(private readonly prisma: PrismaService) {} + + async insert(entity: ProjectTodoEntity): Promise { + await this.prisma.projectTodo.create({ + data: ProjectTodoModelMapper.toPersistence(entity), + }); + } + + async update(entity: ProjectTodoEntity): Promise { + await this.prisma.projectTodo.update({ + where: { id: entity.id }, + data: { + title: entity.title, + completedAt: entity.completedAt ?? null, + updatedAt: entity.updatedAt, + }, + }); + } + + async delete(id: string): Promise { + await this.prisma.projectTodo.delete({ where: { id } }); + } + + async findById(id: string): Promise { + const model = await this.prisma.projectTodo.findUnique({ where: { id } }); + return model ? ProjectTodoModelMapper.toEntity(model) : null; + } + + async findAll(): Promise { + const models = await this.prisma.projectTodo.findMany(); + return models.map((model) => ProjectTodoModelMapper.toEntity(model)); + } + + async search( + props: ProjectTodoSearchParams, + ): Promise { + // SearchParams permits an empty filter, but to-dos must never be listed across projects. + const projectId = props.filter?.projectId; + if (!projectId) throw new Error('Project ID is required to search todos'); + + const skip = (props.page - 1) * props.perPage; + const [openCount, total] = await Promise.all([ + this.prisma.projectTodo.count({ + where: { projectId, completedAt: null }, + }), + this.prisma.projectTodo.count({ where: { projectId } }), + ]); + const orderBy: Prisma.ProjectTodoOrderByWithRelationInput[] = + props.sort && this.sortableFields.includes(props.sort) + ? [{ [props.sort]: props.sortDir ?? 'asc' }, { id: 'asc' }] + : [{ createdAt: 'asc' }, { id: 'asc' }]; + // Two project-scoped queries preserve createdAt/id ordering within both states. + // A single completedAt sort would incorrectly order completed items by completion time. + const openTake = Math.max(0, Math.min(props.perPage, openCount - skip)); + const open = + openTake > 0 + ? await this.prisma.projectTodo.findMany({ + where: { projectId, completedAt: null }, + orderBy, + skip, + take: openTake, + }) + : []; + const remaining = props.perPage - open.length; + const completed = + remaining > 0 + ? await this.prisma.projectTodo.findMany({ + where: { projectId, completedAt: { not: null } }, + orderBy, + skip: Math.max(0, skip - openCount), + take: remaining, + }) + : []; + return new ProjectTodoSearchResult({ + items: [...open, ...completed].map((model) => + ProjectTodoModelMapper.toEntity(model), + ), + total, + currentPage: props.page, + perPage: props.perPage, + sort: props.sort, + sortDir: props.sortDir, + filter: props.filter, + }); + } +} diff --git a/apps/api/src/project/infrastructure/dto/todo/project-todo.dto.ts b/apps/api/src/project/infrastructure/dto/todo/project-todo.dto.ts new file mode 100644 index 0000000..42de520 --- /dev/null +++ b/apps/api/src/project/infrastructure/dto/todo/project-todo.dto.ts @@ -0,0 +1,50 @@ +import { Transform, Type, type TransformFnParams } from 'class-transformer'; +import { + IsBoolean, + IsInt, + IsString, + MaxLength, + Min, + MinLength, + ValidateIf, +} from 'class-validator'; + +const trimTitle = (params: TransformFnParams): unknown => { + const value: unknown = params.value; + return typeof value === 'string' ? value.trim() : value; +}; + +export class AddProjectTodoDto { + @Transform(trimTitle) + @IsString({ message: 'Todo title must be a string' }) + @MinLength(1, { message: 'Todo title must not be empty' }) + @MaxLength(200, { message: 'Todo title must be at most 200 characters long' }) + title: string; +} + +export class UpdateProjectTodoDto { + @Transform(trimTitle) + @ValidateIf((_, value) => value !== undefined) + @IsString({ message: 'Todo title must be a string' }) + @MinLength(1, { message: 'Todo title must not be empty' }) + @MaxLength(200, { message: 'Todo title must be at most 200 characters long' }) + title?: string; + + @ValidateIf((_, value) => value !== undefined) + @IsBoolean({ message: 'Todo completion must be a boolean' }) + completed?: boolean; +} + +export class SearchProjectTodoDto { + @Type(() => Number) + @ValidateIf((_, value) => value !== undefined) + @IsInt({ message: 'Invalid page number' }) + @Min(1) + page?: number; + + @Type(() => Number) + @ValidateIf((_, value) => value !== undefined) + @IsInt({ message: 'Invalid items per page' }) + @Min(1) + perPage?: number; +} diff --git a/apps/api/src/project/infrastructure/presenter/todo/project-todo.presenter.ts b/apps/api/src/project/infrastructure/presenter/todo/project-todo.presenter.ts new file mode 100644 index 0000000..e7bc930 --- /dev/null +++ b/apps/api/src/project/infrastructure/presenter/todo/project-todo.presenter.ts @@ -0,0 +1,25 @@ +import { ProjectTodoOutput } from '@/project/application/dto/todo/project-todo.dto'; +import { PaginationOutput } from '@/shared/application/dtos/pagination-output'; +import { CollectionPresenter } from '@/shared/infrastructure/presenter/collection.presenter'; + +export class ProjectTodoPresenter { + id: string; + projectId: string; + title: string; + completedAt: Date | null; + createdAt: Date; + updatedAt: Date; + + constructor(output: ProjectTodoOutput) { + Object.assign(this, output); + } +} + +export class ProjectTodoCollectionPresenter extends CollectionPresenter { + data: ProjectTodoPresenter[]; + constructor(output: PaginationOutput) { + const { items, ...pagination } = output; + super(pagination); + this.data = items.map((item) => new ProjectTodoPresenter(item)); + } +} diff --git a/apps/api/src/project/infrastructure/project.controller.ts b/apps/api/src/project/infrastructure/project.controller.ts index eb992c8..c9c506d 100644 --- a/apps/api/src/project/infrastructure/project.controller.ts +++ b/apps/api/src/project/infrastructure/project.controller.ts @@ -83,6 +83,19 @@ import { ProjectEnvironmentCollectionPresenter, ProjectEnvironmentPresenter, } from './presenter/environment/project-environment.presenter'; +import { + AddProjectTodoDto, + SearchProjectTodoDto, + UpdateProjectTodoDto, +} from './dto/todo/project-todo.dto'; +import { AddProjectTodoUseCase } from '../application/usecases/todo/add-project-todo.usecase'; +import { SearchProjectTodoUseCase } from '../application/usecases/todo/search-project-todo.usecase'; +import { UpdateProjectTodoUseCase } from '../application/usecases/todo/update-project-todo.usecase'; +import { RemoveProjectTodoUseCase } from '../application/usecases/todo/remove-project-todo.usecase'; +import { + ProjectTodoCollectionPresenter, + ProjectTodoPresenter, +} from './presenter/todo/project-todo.presenter'; @UseGuards(AuthGuard) @Controller('project') @@ -165,6 +178,18 @@ export class ProjectController { @Inject(RemoveProjectEnvironmentUseCase) private readonly removeProjectEnvironmentUseCase: RemoveProjectEnvironmentUseCase; + @Inject(AddProjectTodoUseCase) + private readonly addProjectTodoUseCase: AddProjectTodoUseCase; + + @Inject(SearchProjectTodoUseCase) + private readonly searchProjectTodoUseCase: SearchProjectTodoUseCase; + + @Inject(UpdateProjectTodoUseCase) + private readonly updateProjectTodoUseCase: UpdateProjectTodoUseCase; + + @Inject(RemoveProjectTodoUseCase) + private readonly removeProjectTodoUseCase: RemoveProjectTodoUseCase; + static projectToResponse(output: ProjectOutput) { return new ProjectPresenter(output); } @@ -255,6 +280,64 @@ export class ProjectController { return new ProjectEnvironmentCollectionPresenter(output); } + @Get(':projectId/todos') + async searchTodos( + @Param('projectId') projectId: string, + @Query() query: SearchProjectTodoDto, + @CurrentUser() user: AuthenticatedUser, + ) { + const output = await this.searchProjectTodoUseCase.execute({ + ...query, + projectId, + userId: user.id, + }); + return new ProjectTodoCollectionPresenter(output); + } + + @Post(':projectId/todos') + async addTodo( + @Param('projectId') projectId: string, + @Body() body: AddProjectTodoDto, + @CurrentUser() user: AuthenticatedUser, + ) { + const output = await this.addProjectTodoUseCase.execute({ + ...body, + projectId, + userId: user.id, + }); + return new ProjectTodoPresenter(output); + } + + @Patch(':projectId/todos/:todoId') + async updateTodo( + @Param('projectId') projectId: string, + @Param('todoId') todoId: string, + @Body() body: UpdateProjectTodoDto, + @CurrentUser() user: AuthenticatedUser, + ) { + const output = await this.updateProjectTodoUseCase.execute({ + ...body, + projectId, + todoId, + userId: user.id, + }); + return new ProjectTodoPresenter(output); + } + + @Delete(':projectId/todos/:todoId') + @HttpCode(HttpStatus.NO_CONTENT) + async removeTodo( + @Param('projectId') projectId: string, + @Param('todoId') todoId: string, + @CurrentUser() user: AuthenticatedUser, + ): Promise { + await this.removeProjectTodoUseCase.execute({ + projectId, + todoId, + userId: user.id, + }); + } + @Post(':projectId/environments') async addEnvironment( @Param('projectId') projectId: string, diff --git a/apps/api/src/project/infrastructure/project.module.ts b/apps/api/src/project/infrastructure/project.module.ts index 70d5cf8..84b80d2 100644 --- a/apps/api/src/project/infrastructure/project.module.ts +++ b/apps/api/src/project/infrastructure/project.module.ts @@ -43,6 +43,12 @@ import { SearchProjectEnvironmentUseCase } from '../application/usecases/environ import { SearchOwnerProjectEnvironmentsUseCase } from '../application/usecases/environment/search-owner-project-environments.usecase'; import { UpdateProjectEnvironmentUseCase } from '../application/usecases/environment/update-project-environment.usecase'; import { RemoveProjectEnvironmentUseCase } from '../application/usecases/environment/remove-project-environment.usecase'; +import { ProjectTodoRepository } from '../domain/repositories/todo/project-todo.repository'; +import { ProjectTodoPrismaRepository } from './database/prisma/repositories/todo/project-todo-prisma.repository'; +import { AddProjectTodoUseCase } from '../application/usecases/todo/add-project-todo.usecase'; +import { SearchProjectTodoUseCase } from '../application/usecases/todo/search-project-todo.usecase'; +import { UpdateProjectTodoUseCase } from '../application/usecases/todo/update-project-todo.usecase'; +import { RemoveProjectTodoUseCase } from '../application/usecases/todo/remove-project-todo.usecase'; @Module({ controllers: [ProjectController], @@ -107,6 +113,36 @@ import { RemoveProjectEnvironmentUseCase } from '../application/usecases/environ new ProjectEnvironmentPrismaRepository(prismaService), inject: ['PrismaService'], }, + { + provide: 'ProjectTodoRepository', + useFactory: (prismaService: PrismaService) => + new ProjectTodoPrismaRepository(prismaService), + inject: ['PrismaService'], + }, + { + provide: AddProjectTodoUseCase, + useFactory: (projects: ProjectRepository, todos: ProjectTodoRepository) => + new AddProjectTodoUseCase(projects, todos), + inject: ['ProjectRepository', 'ProjectTodoRepository'], + }, + { + provide: SearchProjectTodoUseCase, + useFactory: (projects: ProjectRepository, todos: ProjectTodoRepository) => + new SearchProjectTodoUseCase(projects, todos), + inject: ['ProjectRepository', 'ProjectTodoRepository'], + }, + { + provide: UpdateProjectTodoUseCase, + useFactory: (projects: ProjectRepository, todos: ProjectTodoRepository) => + new UpdateProjectTodoUseCase(projects, todos), + inject: ['ProjectRepository', 'ProjectTodoRepository'], + }, + { + provide: RemoveProjectTodoUseCase, + useFactory: (projects: ProjectRepository, todos: ProjectTodoRepository) => + new RemoveProjectTodoUseCase(projects, todos), + inject: ['ProjectRepository', 'ProjectTodoRepository'], + }, { provide: AddProjectEnvironmentUseCase, useFactory: ( diff --git a/apps/api/test/project-todos.e2e-spec.ts b/apps/api/test/project-todos.e2e-spec.ts new file mode 100644 index 0000000..1dad23f --- /dev/null +++ b/apps/api/test/project-todos.e2e-spec.ts @@ -0,0 +1,89 @@ +import { INestApplication } from '@nestjs/common'; +import request from 'supertest'; +import type { App } from 'supertest/types'; +import { PrismaService } from '@/shared/infrastructure/database/prisma.service'; +import { createE2eApp, loginE2eUser, registerE2eUser } from './support/e2e-app'; + +describe('Project todos (e2e)', () => { + let app: INestApplication; + let prisma: PrismaService; + const userIds: string[] = []; + beforeAll(async () => { + app = await createE2eApp(); + prisma = app.get(PrismaService); + }); + afterEach(async () => { + await prisma.user.deleteMany({ where: { id: { in: userIds.splice(0) } } }); + }); + afterAll(async () => { + if (app) await app.close(); + }); + + it('routes authenticated writes, hides foreign children, validates titles, and reads archived projects', async () => { + const owner = await loginE2eUser(app, await registerE2eUser(app, userIds)); + const other = await loginE2eUser(app, await registerE2eUser(app, userIds)); + const project = await owner + .post('/api/project') + .send({ name: 'Todo project' }) + .expect(201); + const otherProject = await owner + .post('/api/project') + .send({ name: 'Other project' }) + .expect(201); + const projectId = (project.body as { id: string }).id; + const otherProjectId = (otherProject.body as { id: string }).id; + const base = `/api/project/${projectId}/todos`; + await request(app.getHttpServer()).get(base).expect(401); + await owner.post(base).send({ title: ' ' }).expect(422); + const created = await owner + .post(base) + .send({ title: ' Ship feature ' }) + .expect(201); + const todoId = (created.body as { id: string }).id; + expect(created.body).toMatchObject({ + projectId, + title: 'Ship feature', + completedAt: null, + }); + await owner + .get(base) + .expect(200) + .expect( + ({ body }: { body: { data: unknown[]; meta: { total: number } } }) => { + expect(body.data).toHaveLength(1); + expect(body.meta.total).toBe(1); + }, + ); + await other.get(base).expect(404); + await other.post(base).send({ title: 'Stolen' }).expect(404); + await other + .patch(`${base}/${todoId}`) + .send({ completed: true }) + .expect(404); + await owner + .patch(`/api/project/${otherProjectId}/todos/${todoId}`) + .send({ completed: true }) + .expect(404); + const done = await owner + .patch(`${base}/${todoId}`) + .send({ completed: true }) + .expect(200); + const again = await owner + .patch(`${base}/${todoId}`) + .send({ completed: true }) + .expect(200); + expect((done.body as { completedAt: string }).completedAt).toBe( + (again.body as { completedAt: string }).completedAt, + ); + await owner.patch(`/api/project/${projectId}/archive`).expect(200); + await owner.get(base).expect(200); + await owner.post(base).send({ title: 'Blocked' }).expect(422); + await owner + .patch(`${base}/${todoId}`) + .send({ completed: false }) + .expect(422); + await owner.delete(`${base}/${todoId}`).expect(422); + await owner.patch(`/api/project/${projectId}/restore`).expect(200); + await owner.delete(`${base}/${todoId}`).expect(204); + }); +}); diff --git a/apps/api/test/vercel-container.test.ts b/apps/api/test/vercel-container.test.ts index d049091..a8e62cd 100644 --- a/apps/api/test/vercel-container.test.ts +++ b/apps/api/test/vercel-container.test.ts @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; import { resolve } from 'node:path'; import { test } from 'node:test'; @@ -14,9 +15,9 @@ function docker(args: string[]) { }).trim(); } -test( - 'the Vercel API image serves health and protects private routes', - { timeout: 180_000 }, +void test( + 'the Vercel API image migrates its database before serving requests', + { timeout: 240_000 }, async () => { docker(['build', '--quiet', '-f', 'Dockerfile.vercel', '-t', image, '.']); assert.equal( @@ -33,20 +34,59 @@ test( 'The container must not contain local environment files', ); - const containerId = docker([ - 'run', - '--detach', - '--rm', - '--publish', - '127.0.0.1::80', - '--env', - 'DATABASE_URL=postgresql://smoke:smoke@127.0.0.1:5432/smoke', - '--env', - 'JWT_SECRET=local-smoke-test-only', - image, - ]); + const network = `devlog-vercel-smoke-${randomUUID()}`; + docker(['network', 'create', network]); + let databaseId: string | undefined; + let containerId: string | undefined; try { + databaseId = docker([ + 'run', + '--detach', + '--rm', + '--network', + network, + '--network-alias', + 'database', + '--env', + 'POSTGRES_USER=smoke', + '--env', + 'POSTGRES_PASSWORD=smoke', + '--env', + 'POSTGRES_DB=smoke', + 'postgres:17-alpine', + ]); + for (let attempt = 0; attempt < 60; attempt++) { + try { + docker([ + 'exec', + databaseId, + 'pg_isready', + '-U', + 'smoke', + '-d', + 'smoke', + ]); + break; + } catch { + await new Promise((done) => setTimeout(done, 500)); + } + } + + containerId = docker([ + 'run', + '--detach', + '--rm', + '--network', + network, + '--publish', + '127.0.0.1::80', + '--env', + 'DATABASE_URL=postgresql://smoke:smoke@database:5432/smoke', + '--env', + 'JWT_SECRET=local-smoke-test-only', + image, + ]); const address = docker(['port', containerId, '80/tcp']); const port = Number(address.split(':').at(-1)); assert.ok(Number.isInteger(port) && port > 0); @@ -68,16 +108,34 @@ test( } assert.equal(health.status, 200); assert.deepEqual(await health.json(), { status: 'ok' }); + assert.equal( + docker([ + 'exec', + databaseId, + 'psql', + '-U', + 'smoke', + '-d', + 'smoke', + '-tAc', + "SELECT to_regclass('public.project_todos')", + ]), + 'project_todos', + ); const privateRoute = await fetch(`http://127.0.0.1:${port}/api/users/me`); assert.equal(privateRoute.status, 401); } finally { // The test owns this disposable container and always stops it. - try { - docker(['stop', '--time', '1', containerId]); - } catch { - // Docker already removed a container that exited on its own. + for (const id of [containerId, databaseId]) { + if (!id) continue; + try { + docker(['stop', '--time', '1', id]); + } catch { + // Docker already removed a container that exited on its own. + } } + docker(['network', 'rm', network]); } }, ); diff --git a/apps/web/README.md b/apps/web/README.md index ee2825b..0eb0bc9 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -20,9 +20,11 @@ the authenticated session endpoint. The private workspace overview lives at The `/projects` page supports paginated search, filtering, and project creation. A project detail page brings together its overview, technologies, -technical entries, environments, commands, resources, and settings. Users can +technical entries, environments, to-dos, commands, resources, and settings. Users can create, edit, and remove project environments, commands, and resources, add and -remove technologies, and manage project metadata and lifecycle. Archived projects are read-only, and +remove technologies, and manage project metadata and lifecycle. The To-dos tab +supports adding, renaming, completing, reopening, and removing small project +actions. Archived projects retain their details for reading but are read-only; permanent deletion is protected by confirmation. Only the project Overview shows a Repositories card below Technologies. It diff --git a/apps/web/e2e/mocked/project-todos.spec.ts b/apps/web/e2e/mocked/project-todos.spec.ts new file mode 100644 index 0000000..555866e --- /dev/null +++ b/apps/web/e2e/mocked/project-todos.spec.ts @@ -0,0 +1,45 @@ +import { expect, test } from "@playwright/test"; +import { authenticatedApi, mockApi, projectId } from "./mock-api"; + +test("opens project to-dos by keyboard and keeps controls reachable on a narrow screen", async ({ + page, +}) => { + const todo = { + id: "44444444-4444-4444-8444-444444444444", + projectId, + title: "Review the release checklist", + completedAt: null, + createdAt: "2026-09-14T10:00:00.000Z", + updatedAt: "2026-09-14T10:00:00.000Z", + }; + const assertNoUnexpected = await mockApi(page, (request, url) => { + if ( + url.pathname === `/api/project/${projectId}/todos` && + request.method() === "GET" + ) { + return { + body: { + data: [todo], + meta: { currentPage: 1, perPage: 20, lastPage: 1, total: 1 }, + }, + }; + } + return authenticatedApi(request, url); + }); + + await page.goto(`/projects/${projectId}`); + const entriesTab = page.getByRole("tab", { name: "Technical entries" }); + await entriesTab.focus(); + await page.keyboard.press("ArrowRight"); + const todosTab = page.getByRole("tab", { name: "To-dos" }); + await expect(todosTab).toBeFocused(); + await expect(todosTab).toHaveAttribute("aria-selected", "true"); + const panel = page.getByRole("tabpanel", { name: "To-dos" }); + await expect(panel.getByText(todo.title)).toBeVisible(); + await page.setViewportSize({ width: 390, height: 844 }); + await expect(panel.getByRole("textbox", { name: "New to-do" })).toBeVisible(); + await expect( + panel.getByRole("button", { name: `Delete ${todo.title}` }), + ).toBeVisible(); + assertNoUnexpected(); +}); diff --git a/apps/web/src/features/projects/api/project-todos.ts b/apps/web/src/features/projects/api/project-todos.ts new file mode 100644 index 0000000..0280182 --- /dev/null +++ b/apps/web/src/features/projects/api/project-todos.ts @@ -0,0 +1,48 @@ +import { api } from "@/api/http"; +import type { Pagination } from "@/api/types"; + +export type ProjectTodo = { + id: string; + projectId: string; + title: string; + completedAt: string | null; + createdAt: string; + updatedAt: string; +}; + +export const projectTodoKeys = { + project: (projectId: string) => ["project-todos", projectId] as const, + page: (projectId: string, page: number) => + [...projectTodoKeys.project(projectId), { page, perPage: 20 }] as const, +}; + +export async function listProjectTodos(projectId: string, page: number) { + const { data } = await api.get>( + `/project/${projectId}/todos`, + { params: { page, perPage: 20 } }, + ); + return data; +} + +export async function createProjectTodo(projectId: string, title: string) { + const { data } = await api.post(`/project/${projectId}/todos`, { + title, + }); + return data; +} + +export async function updateProjectTodo( + projectId: string, + todoId: string, + input: { title?: string; completed?: boolean }, +) { + const { data } = await api.patch( + `/project/${projectId}/todos/${todoId}`, + input, + ); + return data; +} + +export async function deleteProjectTodo(projectId: string, todoId: string) { + await api.delete(`/project/${projectId}/todos/${todoId}`); +} diff --git a/apps/web/src/features/projects/components/project-detail-tabs.tsx b/apps/web/src/features/projects/components/project-detail-tabs.tsx index a4d0fc5..efdac78 100644 --- a/apps/web/src/features/projects/components/project-detail-tabs.tsx +++ b/apps/web/src/features/projects/components/project-detail-tabs.tsx @@ -3,6 +3,7 @@ import type { KeyboardEvent, ReactNode } from "react"; export type ProjectDetailTab = | "overview" | "entries" + | "todos" | "technologies" | "environments" | "commands" @@ -12,6 +13,7 @@ export type ProjectDetailTab = const projectDetailTabs: { id: ProjectDetailTab; label: string }[] = [ { id: "overview", label: "Overview" }, { id: "entries", label: "Technical entries" }, + { id: "todos", label: "To-dos" }, { id: "technologies", label: "Technologies" }, { id: "environments", label: "Environments" }, { id: "commands", label: "Commands" }, diff --git a/apps/web/src/features/projects/components/project-todos-tab.spec.tsx b/apps/web/src/features/projects/components/project-todos-tab.spec.tsx new file mode 100644 index 0000000..decd9e3 --- /dev/null +++ b/apps/web/src/features/projects/components/project-todos-tab.spec.tsx @@ -0,0 +1,263 @@ +import { screen, waitFor, within } from "@testing-library/react"; +import { http, HttpResponse } from "msw"; +import { Route, Routes } from "react-router"; +import { describe, expect, it } from "vitest"; +import { createProjectFixture } from "@/test/factories/project"; +import { deferred } from "@/test/deferred"; +import { server } from "@/test/mocks/server"; +import { apiUrl } from "@/test/mocks/urls"; +import { renderWithProviders } from "@/test/render-with-providers"; +import ProjectDetailPage from "../pages/project-detail-page"; + +const project = createProjectFixture(); +const todo = { + id: "todo-1", + projectId: project.id, + title: "Write release notes", + completedAt: null as string | null, + createdAt: project.createdAt, + updatedAt: project.updatedAt, +}; + +function setup(archived = false) { + let todos = [todo]; + const requests: Array<{ method: string; body?: unknown }> = []; + server.use( + http.get(apiUrl("/tag"), () => + HttpResponse.json({ + data: [], + meta: { currentPage: 1, perPage: 100, lastPage: 1, total: 0 }, + }), + ), + http.get(apiUrl(`/project/${project.id}`), () => + HttpResponse.json({ + ...project, + archivedAt: archived ? project.updatedAt : undefined, + }), + ), + http.get(apiUrl(`/project/${project.id}/technical-entries`), () => + HttpResponse.json({ + data: [], + meta: { currentPage: 1, perPage: 6, lastPage: 1, total: 0 }, + }), + ), + http.get(apiUrl(`/project/${project.id}/commands`), () => + HttpResponse.json({ + data: [], + meta: { currentPage: 1, perPage: 6, lastPage: 1, total: 0 }, + }), + ), + http.get(apiUrl(`/project/${project.id}/resources`), () => + HttpResponse.json({ + data: [], + meta: { currentPage: 1, perPage: 6, lastPage: 1, total: 0 }, + }), + ), + http.get(apiUrl(`/project/${project.id}/todos`), () => + HttpResponse.json({ + data: todos, + meta: { currentPage: 1, perPage: 20, lastPage: 1, total: todos.length }, + }), + ), + http.post(apiUrl(`/project/${project.id}/todos`), async ({ request }) => { + const body = (await request.json()) as { title: string }; + requests.push({ method: "POST", body }); + const created = { ...todo, id: "todo-2", title: body.title }; + todos = [...todos, created]; + return HttpResponse.json(created, { status: 201 }); + }), + http.patch( + apiUrl(`/project/${project.id}/todos/:todoId`), + async ({ request, params }) => { + const body = (await request.json()) as { + title?: string; + completed?: boolean; + }; + requests.push({ method: "PATCH", body }); + todos = todos.map((item) => + item.id === params.todoId + ? { + ...item, + title: body.title ?? item.title, + completedAt: + body.completed === undefined + ? item.completedAt + : body.completed + ? project.updatedAt + : null, + } + : item, + ); + return HttpResponse.json( + todos.find((item) => item.id === params.todoId), + ); + }, + ), + http.delete( + apiUrl(`/project/${project.id}/todos/:todoId`), + ({ params }) => { + requests.push({ method: "DELETE" }); + todos = todos.filter((item) => item.id !== params.todoId); + return new HttpResponse(null, { status: 204 }); + }, + ), + ); + const result = renderWithProviders( + + } /> + , + { route: `/projects/${project.id}` }, + ); + return { ...result, requests }; +} + +describe("project to-dos tab", () => { + it("creates, completes, edits, and deletes a to-do through the project API", async () => { + const { user, requests } = setup(); + await user.click(await screen.findByRole("tab", { name: "To-dos" })); + const panel = await screen.findByRole("tabpanel", { name: "To-dos" }); + expect(await within(panel).findByText(todo.title)).toBeVisible(); + await user.type( + within(panel).getByRole("textbox", { name: "New to-do" }), + " Check deployment ", + ); + await user.click(within(panel).getByRole("button", { name: "Add to-do" })); + expect(await within(panel).findByText("Check deployment")).toBeVisible(); + expect(requests).toContainEqual({ + method: "POST", + body: { title: "Check deployment" }, + }); + await user.click( + within(panel).getByRole("checkbox", { name: `Complete ${todo.title}` }), + ); + await waitFor(() => + expect(requests).toContainEqual({ + method: "PATCH", + body: { completed: true }, + }), + ); + await user.click( + within(panel).getByRole("button", { name: `Edit ${todo.title}` }), + ); + const editInput = within(panel).getByRole("textbox", { + name: "Edit to-do", + }); + await user.clear(editInput); + await user.type(editInput, "Publish release notes"); + await user.click(within(panel).getByRole("button", { name: "Save to-do" })); + expect( + await within(panel).findByText("Publish release notes"), + ).toBeVisible(); + await user.click( + within(panel).getByRole("button", { + name: "Delete Publish release notes", + }), + ); + await user.click(screen.getByRole("button", { name: "Delete to-do" })); + await waitFor(() => + expect(within(panel).queryByText("Publish release notes")).toBeNull(), + ); + expect(requests.some((request) => request.method === "DELETE")).toBe(true); + }); + + it("keeps an archived project's to-dos readable but disables changes", async () => { + const { user } = setup(true); + await user.click(await screen.findByRole("tab", { name: "To-dos" })); + const panel = await screen.findByRole("tabpanel", { name: "To-dos" }); + expect(await within(panel).findByText(todo.title)).toBeVisible(); + expect( + within(panel).getByRole("textbox", { name: "New to-do" }), + ).toBeDisabled(); + expect( + within(panel).getByRole("checkbox", { name: `Complete ${todo.title}` }), + ).toBeDisabled(); + expect( + within(panel).getByRole("button", { name: `Edit ${todo.title}` }), + ).toBeDisabled(); + expect( + within(panel).getByRole("button", { name: `Delete ${todo.title}` }), + ).toBeDisabled(); + }); + + it("shows loading, an API error, retry, and keeps a failed create draft", async () => { + const { user } = setup(); + const gate = deferred(); + let reads = 0; + server.use( + http.get(apiUrl(`/project/${project.id}/todos`), async () => { + reads += 1; + if (reads === 1) { + await gate.promise; + return HttpResponse.json({ message: "Offline" }, { status: 500 }); + } + return HttpResponse.json({ + data: [], + meta: { currentPage: 1, perPage: 20, lastPage: 1, total: 0 }, + }); + }), + http.post(apiUrl(`/project/${project.id}/todos`), () => + HttpResponse.json({ message: "Save failed" }, { status: 500 }), + ), + ); + await user.click(await screen.findByRole("tab", { name: "To-dos" })); + const panel = await screen.findByRole("tabpanel", { name: "To-dos" }); + expect(within(panel).getByText("Loading content...")).toBeVisible(); + gate.resolve(undefined); + expect( + await within(panel).findByText("Could not load this section."), + ).toBeVisible(); + await user.click(within(panel).getByRole("button", { name: "Try again" })); + expect( + await within(panel).findByText("No to-dos have been added yet."), + ).toBeVisible(); + const input = within(panel).getByRole("textbox", { name: "New to-do" }); + await user.type(input, "Keep this draft"); + await user.click(within(panel).getByRole("button", { name: "Add to-do" })); + await waitFor(() => + expect( + within(panel).getByRole("button", { name: "Add to-do" }), + ).toBeEnabled(), + ); + expect(input).toHaveValue("Keep this draft"); + }); + + it("returns to the previous page after deleting its only to-do", async () => { + const { user } = setup(); + let deleted = false; + server.use( + http.get(apiUrl(`/project/${project.id}/todos`), ({ request }) => { + const page = Number(new URL(request.url).searchParams.get("page")); + return HttpResponse.json({ + data: + page === 1 + ? [{ ...todo, id: "first-page", title: "First page task" }] + : deleted + ? [] + : [todo], + meta: { + currentPage: page, + perPage: 20, + lastPage: deleted ? 1 : 2, + total: deleted ? 20 : 21, + }, + }); + }), + http.delete(apiUrl(`/project/${project.id}/todos/${todo.id}`), () => { + deleted = true; + return new HttpResponse(null, { status: 204 }); + }), + ); + await user.click(await screen.findByRole("tab", { name: "To-dos" })); + const panel = await screen.findByRole("tabpanel", { name: "To-dos" }); + await user.click( + await within(panel).findByRole("button", { name: "Next" }), + ); + expect(await within(panel).findByText(todo.title)).toBeVisible(); + await user.click( + within(panel).getByRole("button", { name: `Delete ${todo.title}` }), + ); + await user.click(screen.getByRole("button", { name: "Delete to-do" })); + expect(await within(panel).findByText("First page task")).toBeVisible(); + expect(within(panel).queryByRole("button", { name: "Next" })).toBeNull(); + }); +}); diff --git a/apps/web/src/features/projects/components/project-todos-tab.tsx b/apps/web/src/features/projects/components/project-todos-tab.tsx new file mode 100644 index 0000000..21d1286 --- /dev/null +++ b/apps/web/src/features/projects/components/project-todos-tab.tsx @@ -0,0 +1,306 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { AlertTriangle, Loader2, RefreshCw, Trash2 } from "lucide-react"; +import { useState, type MouseEvent } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogMedia, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { Form } from "@/components/ui/form"; +import { FormInput } from "@/components/ui/form-input"; +import { + DetailPagination, + EmptySection, + LoadingSection, + SectionError, +} from "./project-detail-section-ui"; +import { ProjectDetailSectionFrame } from "./project-detail-section-frame"; +import type { ProjectTodo } from "../api/project-todos"; +import { + useCreateProjectTodo, + useDeleteProjectTodo, + useProjectTodos, + useUpdateProjectTodo, +} from "../hooks/use-project-todos"; + +const titleSchema = z.object({ + title: z + .string() + .trim() + .min(1, "Title is required.") + .max(200, "Title must be at most 200 characters long."), +}); +type TitleValues = z.infer; + +function TodoRow({ + todo, + isArchived, + isOnlyItemOnPage, + onDeleted, +}: { + todo: ProjectTodo; + isArchived: boolean; + isOnlyItemOnPage: boolean; + onDeleted: (wasOnlyItemOnPage: boolean) => void; +}) { + const [editing, setEditing] = useState(false); + const [deleteOpen, setDeleteOpen] = useState(false); + const update = useUpdateProjectTodo(); + const remove = useDeleteProjectTodo(); + const form = useForm({ + resolver: zodResolver(titleSchema), + defaultValues: { title: todo.title }, + }); + + async function save(values: TitleValues) { + try { + await update.mutateAsync({ + projectId: todo.projectId, + todoId: todo.id, + input: { title: values.title }, + }); + setEditing(false); + } catch { + // Keep the editable draft after an API failure; the mutation reports it. + } + } + + async function confirmDelete(event: MouseEvent) { + event.preventDefault(); + // Remember the page before invalidation replaces its data. A successful + // deletion may make the current page disappear from the next response. + const wasOnlyItemOnPage = isOnlyItemOnPage; + try { + await remove.mutateAsync({ projectId: todo.projectId, todoId: todo.id }); + setDeleteOpen(false); + onDeleted(wasOnlyItemOnPage); + } catch { + // Leave confirmation open for a retry. + } + } + + return ( +
  • + + update.mutate({ + projectId: todo.projectId, + todoId: todo.id, + input: { completed: !todo.completedAt }, + }) + } + type="checkbox" + /> +
    + {editing ? ( +
    + +
    + +
    + + +
    + + ) : ( + + {todo.title} + + )} +
    + {!editing ? ( +
    + + + + + + + + + + + Delete this to-do? + + “{todo.title}” will be permanently removed from this project. + + + + + Cancel + + + {remove.isPending ? ( + + ) : null} + Delete to-do + + + + +
    + ) : null} +
  • + ); +} + +export function ProjectTodosTab({ + projectId, + isArchived, +}: { + projectId: string; + isArchived: boolean; +}) { + const [page, setPage] = useState(1); + const query = useProjectTodos(projectId, page); + const create = useCreateProjectTodo(); + const form = useForm({ + resolver: zodResolver(titleSchema), + defaultValues: { title: "" }, + }); + + async function add(values: TitleValues) { + try { + await create.mutateAsync({ projectId, title: values.title }); + form.reset(); + setPage(1); + } catch { + // A failed write leaves the title available for correction or retry. + } + } + + return ( + + {isArchived ? ( +

    + This project is archived. To-dos are read-only until it is restored. +

    + ) : null} +
    + +
    + +
    + +
    + + {query.isFetching && !query.isPending ? ( +

    + + Updating to-dos... +

    + ) : null} + {query.isPending ? ( + + ) : query.isError ? ( + query.refetch()} /> + ) : query.data?.data.length ? ( +
    +
      + {query.data.data.map((todo) => ( + { + if (wasOnlyItemOnPage && page > 1) { + setPage(page - 1); + } + }} + /> + ))} +
    + +
    + ) : ( + No to-dos have been added yet. + )} +
    + ); +} diff --git a/apps/web/src/features/projects/hooks/use-project-todos.ts b/apps/web/src/features/projects/hooks/use-project-todos.ts new file mode 100644 index 0000000..420c433 --- /dev/null +++ b/apps/web/src/features/projects/hooks/use-project-todos.ts @@ -0,0 +1,88 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { getApiErrorMessage } from "@/lib/get-api-error-message"; +import { + createProjectTodo, + deleteProjectTodo, + listProjectTodos, + projectTodoKeys, + updateProjectTodo, +} from "../api/project-todos"; + +export function useProjectTodos(projectId: string, page: number) { + return useQuery({ + queryKey: projectTodoKeys.page(projectId, page), + queryFn: () => listProjectTodos(projectId, page), + enabled: Boolean(projectId), + retry: false, + }); +} + +function useTodoInvalidation() { + const queryClient = useQueryClient(); + // Each project has its own cache branch. A write refreshes all of its pages + // because completing an item can move it later in the server's sort order. + return (projectId: string) => + queryClient.invalidateQueries({ + queryKey: projectTodoKeys.project(projectId), + }); +} + +export function useCreateProjectTodo() { + const invalidate = useTodoInvalidation(); + return useMutation({ + mutationFn: ({ projectId, title }: { projectId: string; title: string }) => + createProjectTodo(projectId, title), + onSuccess: async (todo) => { + toast.success("To-do added."); + await invalidate(todo.projectId); + }, + onError: (error) => + toast.error( + getApiErrorMessage(error, "Could not add the to-do. Try again."), + ), + }); +} + +export function useUpdateProjectTodo() { + const invalidate = useTodoInvalidation(); + return useMutation({ + mutationFn: ({ + projectId, + todoId, + input, + }: { + projectId: string; + todoId: string; + input: { title?: string; completed?: boolean }; + }) => updateProjectTodo(projectId, todoId, input), + onSuccess: async (todo) => { + await invalidate(todo.projectId); + }, + onError: (error) => + toast.error( + getApiErrorMessage(error, "Could not update the to-do. Try again."), + ), + }); +} + +export function useDeleteProjectTodo() { + const invalidate = useTodoInvalidation(); + return useMutation({ + mutationFn: ({ + projectId, + todoId, + }: { + projectId: string; + todoId: string; + }) => deleteProjectTodo(projectId, todoId), + onSuccess: async (_result, { projectId }) => { + toast.success("To-do deleted."); + await invalidate(projectId); + }, + onError: (error) => + toast.error( + getApiErrorMessage(error, "Could not delete the to-do. Try again."), + ), + }); +} diff --git a/apps/web/src/features/projects/pages/project-detail-page.tsx b/apps/web/src/features/projects/pages/project-detail-page.tsx index 57aad4c..38fed05 100644 --- a/apps/web/src/features/projects/pages/project-detail-page.tsx +++ b/apps/web/src/features/projects/pages/project-detail-page.tsx @@ -28,6 +28,7 @@ import type { ProjectCommand, ProjectResource } from "../types/project-detail"; import { useProjectEnvironments } from "@/features/environments/hooks/use-environments"; import { ProjectEnvironmentsTab } from "@/features/environments/components/project-environments-tab"; import type { ProjectEnvironment } from "@/features/environments/types/environment"; +import { ProjectTodosTab } from "../components/project-todos-tab"; export default function ProjectDetailPage() { const { projectId = "" } = useParams<{ projectId: string }>(); @@ -153,6 +154,10 @@ export default function ProjectDetailPage() { /> + + + + projects.id +title VARCHAR(200) NOT NULL +completed_at TIMESTAMPTZ NULL +created_at TIMESTAMPTZ NOT NULL +updated_at TIMESTAMPTZ NOT NULL +``` + +Each row is one action belonging to a project. A separate table supports any +number of actions and lets the application update or remove one without +rewriting a serialized list on `projects`. `completed_at` represents both the +completion state and when it changed; `NULL` means unfinished. Reopening clears +it, so this model records current state rather than a completion history. + +Titles are validated and trimmed in the domain. They are not unique: two +actions may have the same wording. The index on `(project_id, completed_at, +created_at, id)` helps retrieve the unfinished partition and scope both +partitions to a project. The repository combines unfinished and completed +queries, ordering each by creation time and ID for stable pages. Completed +rows may need an additional sort because their completion timestamps differ; +a second index is deferred until list size warrants it. The foreign key +cascades on permanent project deletion; archiving keeps rows readable and +prevents writes through the project aggregate. Ownership is checked through +the parent project. + +--- + ## `project_commands` ```text @@ -497,17 +537,19 @@ Relationships use these behaviors: | `Project` → `TechnicalEntry` | `ON DELETE SET NULL` | | `Project` → `ProjectTechnology` | `ON DELETE CASCADE` | | `Project` → `ProjectEnvironment` | `ON DELETE CASCADE` | +| `Project` → `ProjectTodo` | `ON DELETE CASCADE` | | `Project` → `ProjectCommand` | `ON DELETE CASCADE` | | `Project` → `ProjectResource` | `ON DELETE CASCADE` | | `TechnicalEntry` → `SolutionAttempt` | `ON DELETE CASCADE` | | `TechnicalEntry`/`Tag` → `TechnicalEntryTag` | `ON DELETE CASCADE` | -Directly deleting a project deletes its technologies, environments, commands, -and resources. Associated technical entries are preserved with `project_id = NULL`. +Directly deleting a project deletes its technologies, environments, to-dos, +commands, and resources. Associated technical entries are preserved with +`project_id = NULL`. Deleting a user also cascades to their projects, entries, and tags. Deleting -those projects cascades to their technologies, environments, commands, and -resources. +those projects cascades to their technologies, environments, to-dos, commands, +and resources. In normal use, projects and technical entries are archived rather than permanently deleted. @@ -590,6 +632,23 @@ PROJECT_COMMANDS - created_at - updated_at +PROJECT_ENVIRONMENTS +- id PK +- project_id FK -> PROJECTS.id +- name +- normalized_name +- category +- created_at +- updated_at + +PROJECT_TODOS +- id PK +- project_id FK -> PROJECTS.id +- title +- completed_at +- created_at +- updated_at + PROJECT_RESOURCES - id PK - project_id FK -> PROJECTS.id @@ -709,6 +768,9 @@ ON project_commands (project_id, execution_order); CREATE INDEX idx_resources_project ON project_resources (project_id); + +CREATE INDEX idx_project_todos_order +ON project_todos (project_id, completed_at, created_at, id); ``` The MVP uses the `title` parameter for case-insensitive partial matching on the title only. If search later includes context and conclusion, consider PostgreSQL `tsvector` and a GIN index: @@ -744,8 +806,10 @@ The consolidated schema decisions are: 8. **Make project names unique per user**, technology names unique per project, and resource URLs unique per project. 9. **Allow repeated execution orders** for commands in the same project. 10. **Validate cross-user rules and other invariants in the application**, without composite foreign keys, triggers, or `CHECK constraints`. -11. **Preserve entries when deleting a project**, setting `project_id` to `NULL`, while cascading deletion of project technologies, commands, and resources. +11. **Preserve entries when deleting a project**, setting `project_id` to `NULL`, while cascading deletion of project technologies, environments, to-dos, commands, and resources. 12. **Default new projects to `ACTIVE`**. 13. **Optimize unarchived entry listing** with an index on `(user_id, archived_at, created_at DESC)`, covering filtering and sorting. -This model has **nine tables**, including the `technical_entry_tags` association table. It keeps the MVP small and supports planned features without introducing infrastructure structures outside the product scope. +The original MVP model had **nine tables**, including the +`technical_entry_tags` association table. Project environments and to-dos were +added after MVP 1.0. The current model keeps their lifecycles under `Project`. diff --git a/docs/decisions/devlog-decisions.md b/docs/decisions/devlog-decisions.md index b81be77..24dddbc 100644 --- a/docs/decisions/devlog-decisions.md +++ b/docs/decisions/devlog-decisions.md @@ -312,7 +312,9 @@ A relationship between two entities does not mean they belong to the same aggreg Project (aggregate root) ├── ProjectTechnology ├── ProjectCommand - └── ProjectResource + ├── ProjectResource + ├── ProjectEnvironment + └── ProjectTodo TechnicalEntry (aggregate root) └── SolutionAttempt @@ -330,7 +332,7 @@ Responsibilities are separated as follows: | Aggregate | Child entities | Ownership rule | | --- | --- | --- | -| `Project` | `ProjectTechnology`, `ProjectCommand`, `ProjectResource` | Each entity belongs to one project and cannot exist outside it. | +| `Project` | `ProjectTechnology`, `ProjectCommand`, `ProjectResource`, `ProjectEnvironment`, `ProjectTodo` | Each entity belongs to one project and cannot exist outside it. | | `TechnicalEntry` | `SolutionAttempt` | Each attempt belongs to one entry and exists only for `ISSUE`. | | `Tag` | None of the entities above | Independent user resource related to entries through `TechnicalEntryTag`. | @@ -392,7 +394,9 @@ technical-entry/ POST /technical-entry/:entryId/solution-attempts ``` -Likewise, operations on technologies, commands, and resources must validate `projectId`, project ownership, and resource membership before making changes. +Likewise, operations on technologies, environments, to-dos, commands, and +resources must validate `projectId`, project ownership, and child membership +before making changes. --- @@ -537,6 +541,7 @@ Currently displays: General information Technologies Environments +To-dos Commands Links and resources Related technical entries @@ -682,7 +687,8 @@ The initial version will not include: - Comments. - GitHub integration. - Artificial intelligence integration. -- Kanban or task management. +- Kanban or broader task management. A simple project to-do list was added + after MVP 1.0; it has no workflow board, assignment, priority, or due dates. - Browser command execution. - SSH access. - Continuous monitoring. @@ -719,7 +725,25 @@ cloud services, or logs through environments. --- -## 14. Decision summary +## 14. Post-MVP project to-dos + +Each project has one simple list of actions. A to-do has a title and an optional +completion timestamp. The timestamp supplies the current complete/incomplete +state; reopening clears it, so the feature does not preserve a transition +history. Titles may repeat, and unfinished items appear first in stable +creation order. + +`ProjectTodo` is a child of the `Project` aggregate. The API checks project +ownership and the child's project membership for every operation. Archived +projects retain their list for reading and cannot change it. Permanent project +deletion removes its to-dos through the foreign key cascade. + +The project detail To-dos tab owns this interaction. The list is deliberately +limited to adding, renaming, completing, reopening, and removing actions. +Named lists, manual order, assignment, priorities, and due dates would change +the product contract and are separate decisions. + +## 15. Decision summary The product is a personal technical knowledge base organized by project context. @@ -728,6 +752,7 @@ Technical journal = product core Projects = context and organization Tags = classification by topic or technology Environments = documented project runtime contexts +Project to-dos = small next actions within a project Activity Timeline and Knowledge Overview = implemented views of the current journal Services = later extension diff --git a/docs/diagrams/classes.md b/docs/diagrams/classes.md index 46e0b18..6a7eb21 100644 --- a/docs/diagrams/classes.md +++ b/docs/diagrams/classes.md @@ -38,6 +38,7 @@ class Project { +addCommand(title, command, description, order): ProjectCommand +addResource(label, url, type): ProjectResource +addEnvironment(input): ProjectEnvironment + +addTodo(title): ProjectTodo +update(changes): void +archive(): void +restore(): void @@ -87,6 +88,15 @@ class ProjectEnvironment { +update(changes): void } +class ProjectTodo { + -projectId: UUID + -title: string + -completedAt: Date? + -createdAt: Date + -updatedAt: Date + +update(changes): void +} + class TechnicalEntry { -userId: UUID -projectId: UUID? @@ -187,6 +197,7 @@ Entity <|-- ProjectTechnology Entity <|-- ProjectCommand Entity <|-- ProjectResource Entity <|-- ProjectEnvironment +Entity <|-- ProjectTodo Entity <|-- TechnicalEntry Entity <|-- SolutionAttempt Entity <|-- Tag @@ -199,6 +210,7 @@ Project "1" *-- "0..*" ProjectTechnology : composes Project "1" *-- "0..*" ProjectCommand : composes Project "1" *-- "0..*" ProjectResource : composes Project "1" *-- "0..*" ProjectEnvironment : composes +Project "1" *-- "0..*" ProjectTodo : composes Project "0..1" <-- "0..*" TechnicalEntry : provides context TechnicalEntry "1" *-- "0..*" SolutionAttempt : composes @@ -216,7 +228,7 @@ SolutionAttempt --> SolutionAttemptResult ### Reading the relationships -- `Project` composes technologies, environments, commands, and resources because these objects +- `Project` composes technologies, environments, to-dos, commands, and resources because these objects are created within a project, have no meaning without it, and are deleted in a cascade. - `TechnicalEntry` composes `SolutionAttempt` through the same lifecycle relationship. diff --git a/docs/diagrams/use-cases/devlog-use-cases.puml b/docs/diagrams/use-cases/devlog-use-cases.puml index 1df474c..ab823fc 100644 --- a/docs/diagrams/use-cases/devlog-use-cases.puml +++ b/docs/diagrams/use-cases/devlog-use-cases.puml @@ -53,6 +53,10 @@ rectangle "DevLog API" { usecase "Search owned environments" as UC46 usecase "Update environment" as UC47 usecase "Remove environment" as UC48 + usecase "Add project to-do" as UC49 + usecase "List project to-dos" as UC50 + usecase "Update project to-do" as UC51 + usecase "Remove project to-do" as UC52 } package "Technical journal" { @@ -122,6 +126,10 @@ AuthUser -- UC45 AuthUser -- UC46 AuthUser -- UC47 AuthUser -- UC48 +AuthUser -- UC49 +AuthUser -- UC50 +AuthUser -- UC51 +AuthUser -- UC52 note bottom of UC34 Only ISSUE entries in the OPEN state. diff --git a/docs/guides/backend_structure.md b/docs/guides/backend_structure.md index fce09d2..88c34a3 100644 --- a/docs/guides/backend_structure.md +++ b/docs/guides/backend_structure.md @@ -45,3 +45,25 @@ The test type stays explicit without separating tests from the code they protect - Prefer direct imports; do not create `index.ts` files just to shorten paths. - A capability may depend on another capability's contract when a business rule requires it, but a file's directory should represent its primary responsibility. - Moving files between capabilities must not change HTTP routes, injection contracts, database schemas, or domain behavior. + +## Repository and use-case contracts + +Start a new repository contract from the shared interfaces in +`src/shared/domain/repositories/`. Use `RepositoryInterface` for basic +persistence, or `SearchableRepositoryInterface` +when the feature lists records. Define the feature's filter and extend the +shared `SearchParams` and `SearchResult` classes, as +`ProjectEnvironmentRepository` does. The Prisma adapter implements the full +contract, including `findAll`, `sortableFields`, and `search(props)` when it is +searchable. This keeps pagination and repository operations consistent across +project children. + +Give each use case its own file, explicit input type, and +`UseCaseContract` implementation. The environment add, search, +update, and remove use cases are examples. Each child operation checks the +authenticated user's project ownership; writes also check the project's +archive state, and item operations check the child's project membership. Keep +those checks visible in the use case that performs the operation. A +feature-specific base class for these few repeated checks obscures the flow +and departs from the current project pattern; introduce shared behavior only +when a concrete invariant requires more than routine repetition. diff --git a/docs/guides/vercel-deployment.md b/docs/guides/vercel-deployment.md index f7f9b11..127d64d 100644 --- a/docs/guides/vercel-deployment.md +++ b/docs/guides/vercel-deployment.md @@ -64,6 +64,12 @@ The API uses a container because the Services Node builder did not package all Nest dependencies from this pnpm workspace. The container runs the same compiled Nest server as local development and keeps pnpm's dependency layout. Its image takes longer to build and upload than a bundled Node function. +At container startup, the API runs `prisma migrate deploy` before Nest listens. +Vercel supplies the sensitive production database URL only at runtime, and +Prisma records applied migrations so concurrent container starts remain safe. +This makes pending schema changes part of a deployment, but database +availability now also affects startup time and readiness. Migrations must remain +compatible with the previous deployed code while a new deployment rolls out. `Dockerfile.vercel` generates Prisma Client with a placeholder URL at build time; the actual database URL enters only at runtime. `.dockerignore` excludes local environment files from the image. @@ -116,9 +122,10 @@ same-origin gateway path. Local development still uses `SameSite=Lax` cookie and verify the account cannot read another user's data. The application has no user deletion endpoint; retain the test account unless its ownership is verified before targeted cleanup. -4. Inspect Prisma migration status against the existing database. Do not run - `prisma migrate reset` or a seed script against it. Monitor service errors - and check the public domain's health after each production deployment. +4. Inspect startup logs to confirm `prisma migrate deploy` completed against the + existing database. Do not run `prisma migrate reset` or a seed script against + it. Monitor service errors and check the public domain's health after each + production deployment. 5. A push to `main` should create a Production deployment in `devlog-unified-preview`. Verify its Git commit, Ready state, and the public domain before considering the release complete. To roll back code, promote a diff --git a/docs/usecases/README.md b/docs/usecases/README.md index a7080d1..5636412 100644 --- a/docs/usecases/README.md +++ b/docs/usecases/README.md @@ -12,7 +12,7 @@ Each case follows the full format used in the course: primary actor, interests, Documents are grouped by area: - [Account, authentication, and tags](account-and-tags.md) -- [Projects, technologies, environments, commands, and resources](projects.md) +- [Projects, technologies, environments, to-dos, commands, and resources](projects.md) - [Technical entries, classifications, and attempts](technical-entries.md) - [Endpoint → use case matrix](traceability.md) - [Historical/planned specification](cases.md) @@ -42,8 +42,9 @@ The current code has no external secondary actor. The database, controllers, use | Term | Meaning | | --- | --- | -| Project | Development context to which entries, technologies, environments, commands, and resources may belong. | +| Project | Development context to which entries, technologies, environments, to-dos, commands, and resources may belong. | | Project environment | Documented execution or deployment context with a category and optional operating system and runtime details. | +| Project to-do | Small project action with a title and optional completion time. | | Technical entry | An issue (`ISSUE`) or lesson learned (`LEARNING`). | | Solution attempt | Documented experiment for an issue, with a `FAILED`, `PARTIAL`, or `SUCCESSFUL` result. | | Tag | Reusable classification unique to a user. | @@ -53,7 +54,7 @@ The current code has no external secondary actor. The database, controllers, use ## Modeling decisions - Request authentication is not modeled as `<>` in dozens of cases. The specialized Authenticated user actor communicates the same precondition and keeps the diagram readable. -- Technologies, environments, commands, and resources are project parts: they are created within it and deleted in a cascade when the project is deleted. +- Technologies, environments, to-dos, commands, and resources are project parts: they are created within it and deleted in a cascade when the project is deleted. - Attempts belong to a technical entry and depend on its lifecycle. - The entry/tag link has its own information (`createdAt`), so it appears as an association class. - The project/entry link is an optional association, not composition: deleting the project preserves the entry and only removes its reference. @@ -67,7 +68,7 @@ delete requires an unarchived project and confirmation in the interface. These items were not changed in the models; they are documented to keep the diagrams faithful to the implementation: - The domain calls the intermediate project state `INACTIVE`, while the database stores `PAUSED`. A mapper explicitly translates these values. -- Archived projects are read-only for their details, technologies, environments, commands, and resources. Archived technical entries can still be updated, resolved, reopened, classified, have existing attempts changed/removed, and be deleted. Only adding a new attempt explicitly blocks archived entries. +- Archived projects are read-only for their details, technologies, environments, to-dos, commands, and resources. Archived technical entries can still be updated, resolved, reopened, classified, have existing attempts changed/removed, and be deleted. Only adding a new attempt explicitly blocks archived entries. - Archived technical entries can be restored explicitly through `PATCH /api/technical-entry/:id/restore`; archiving and restoration are separate idempotent operations rather than a toggle. diff --git a/docs/usecases/projects.md b/docs/usecases/projects.md index 8c22061..a68d7fe 100644 --- a/docs/usecases/projects.md +++ b/docs/usecases/projects.md @@ -1,6 +1,6 @@ # Use cases — projects and their details -Use cases UC-10 through UC-29 are restricted to authenticated users. Whenever a +Project use cases are restricted to authenticated users. Whenever a project or detail does not exist, does not belong to the user, or is not within the specified project, the API responds with resource not found. This policy is repeated in alternative flows only when it changes the meaning of the use case. @@ -178,7 +178,7 @@ is repeated in alternative flows only when it changes the meaning of the use cas | Interests | Permanently remove a project and its details without deleting technical entries. | | Preconditions | An owned, unarchived project. | | Trigger | The user requests deletion. | -| Postconditions | The project, technologies, commands, and resources are deleted; associated entries remain without a project. | +| Postconditions | The project and its technologies, environments, to-dos, commands, and resources are deleted; associated entries remain without a project. | | Endpoint | `DELETE /api/project/:id` | ### Main flow @@ -583,3 +583,63 @@ is repeated in alternative flows only when it changes the meaning of the use cas - Missing, foreign, or mismatched environments are reported as not found. - Archived projects cannot have environments removed. + +## UC-49 — Add project to-do + +| Field | Description | +| --- | --- | +| Primary actor | Authenticated user | +| Interests | Record a small next action for a project. | +| Preconditions | An owned, unarchived project. | +| Trigger | The user submits a to-do title. | +| Postconditions | An incomplete to-do belongs to the project. | +| Endpoint | `POST /api/project/:projectId/todos` | + +The system checks project ownership and archive state, trims and validates the +title, then creates a to-do. Titles may repeat because two actions can have the +same wording. An invalid title is rejected without creating a row. + +## UC-50 — List project to-dos + +| Field | Description | +| --- | --- | +| Primary actor | Authenticated user | +| Interests | Review unfinished and completed project actions. | +| Preconditions | An owned project. | +| Trigger | The user opens the To-dos tab or changes its page. | +| Postconditions | No state change. | +| Endpoint | `GET /api/project/:projectId/todos` | + +The system returns a paginated collection with unfinished items first and a +stable creation order within each group. Archived projects remain readable. +Missing or foreign projects are reported as not found. + +## UC-51 — Update project to-do + +| Field | Description | +| --- | --- | +| Primary actor | Authenticated user | +| Interests | Revise an action or mark it complete or incomplete. | +| Preconditions | An owned, unarchived project containing the to-do. | +| Trigger | The user edits a title or toggles completion. | +| Postconditions | The supplied values are updated. | +| Endpoint | `PATCH /api/project/:projectId/todos/:todoId` | + +The request may provide a `title`, a `completed` boolean, or both. Completing +sets `completedAt`; reopening clears it. Repeating the same completion state +preserves the existing timestamp. An empty update or invalid title is rejected. +The API reports a to-do from another project as not found. + +## UC-52 — Remove project to-do + +| Field | Description | +| --- | --- | +| Primary actor | Authenticated user | +| Interests | Remove an action that is no longer relevant. | +| Preconditions | An owned, unarchived project containing the to-do. | +| Trigger | The user confirms removal. | +| Postconditions | The to-do is deleted. | +| Endpoint | `DELETE /api/project/:projectId/todos/:todoId` | + +The system verifies ownership and membership before deletion and returns no +content. Missing, foreign, or mismatched to-dos are reported as not found. diff --git a/docs/usecases/traceability.md b/docs/usecases/traceability.md index 0bb9285..e65c096 100644 --- a/docs/usecases/traceability.md +++ b/docs/usecases/traceability.md @@ -39,6 +39,10 @@ represented in the behavioral documentation. | `GET /api/project/environments` | [UC-46 — Search owned environments](projects.md#uc-46--search-owned-environments) | Yes | | `PATCH /api/project/:projectId/environments/:environmentId` | [UC-47 — Update project environment](projects.md#uc-47--update-project-environment) | Yes | | `DELETE /api/project/:projectId/environments/:environmentId` | [UC-48 — Remove project environment](projects.md#uc-48--remove-project-environment) | Yes | +| `POST /api/project/:projectId/todos` | [UC-49 — Add project to-do](projects.md#uc-49--add-project-to-do) | Yes | +| `GET /api/project/:projectId/todos` | [UC-50 — List project to-dos](projects.md#uc-50--list-project-to-dos) | Yes | +| `PATCH /api/project/:projectId/todos/:todoId` | [UC-51 — Update project to-do](projects.md#uc-51--update-project-to-do) | Yes | +| `DELETE /api/project/:projectId/todos/:todoId` | [UC-52 — Remove project to-do](projects.md#uc-52--remove-project-to-do) | Yes | | `POST /api/technical-entry` | [UC-30 — Create technical entry](technical-entries.md#uc-30--create-technical-entry) | Yes | | `GET /api/technical-entry` | [UC-31 — Search technical entries](technical-entries.md#uc-31--search-technical-entries) | Yes | | `GET /api/technical-entry/:id` | [UC-32 — View technical entry](technical-entries.md#uc-32--view-technical-entry) | Yes | diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 36199c4..eb49cf1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,9 +50,15 @@ importers: cookie-parser: specifier: ^1.4.7 version: 1.4.7 + dotenv: + specifier: ^17.4.1 + version: 17.4.1 pg: specifier: ^8.22.0 version: 8.22.0 + prisma: + specifier: ^7.9.1 + version: 7.9.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(magicast@0.5.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3) reflect-metadata: specifier: ^0.2.2 version: 0.2.2 @@ -99,9 +105,6 @@ importers: '@types/supertest': specifier: ^7.0.0 version: 7.2.1 - dotenv: - specifier: ^17.4.1 - version: 17.4.1 eslint: specifier: ^9.18.0 version: 9.39.5(jiti@2.7.0)(supports-color@8.1.1) @@ -120,9 +123,6 @@ importers: prettier: specifier: ^3.4.2 version: 3.9.6 - prisma: - specifier: ^7.9.1 - version: 7.9.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(magicast@0.5.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3) source-map-support: specifier: ^0.5.21 version: 0.5.21