Project API Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Implement CRUD API for customer project orders on the CrewsForge freelance marketplace.
Architecture: Single ProjectModule (controller -> service -> repository) in a shared lib, consumed by the project-api app. Data-access lib for DTOs and constants. Follows existing user-api/auth-api patterns exactly.
Tech Stack: NestJS, Prisma, class-validator, class-transformer, nestjs-cls (transactions), Swagger
Task 1: Add Prisma schema (enums + Project model)
Заголовок раздела «Task 1: Add Prisma schema (enums + Project model)»Files:
- Modify:
apps/core-api/prisma/schema.prisma
Step 1: Add enums and Project model to schema
Add after the last enum in schema.prisma:
enum ProjectStatus { DRAFT PUBLISHED HIRING IN_PROGRESS REVIEW COMPLETED CANCELLED DISPUTED}
enum ProjectBudgetType { FIXED HOURLY}
enum ProjectCategoryType { WEB_DEVELOPMENT MOBILE_DEVELOPMENT DESIGN MARKETING COPYWRITING DATA_SCIENCE DEV_OPS QA_TESTING OTHER}Add after the last model:
model Project { id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid title String description String category ProjectCategoryType status ProjectStatus @default(DRAFT) budgetType ProjectBudgetType @map("budget_type") budgetMin Decimal? @map("budget_min") @db.Decimal(12, 2) budgetMax Decimal? @map("budget_max") @db.Decimal(12, 2) currency String @default("USD") @db.VarChar(3) deadline DateTime? customerId String @map("customer_id") @db.Uuid
createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at")
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
@@index([customerId]) @@index([status]) @@index([category]) @@map("projects")}Add projects Project[] relation to the Customer model (after walriderThreads):
projects Project[]Step 2: Generate Prisma client
Run: npx prisma generate --schema=apps/core-api/prisma/schema.prisma
Expected: “Generated Prisma Client”
Step 3: Create and apply migration
Run: npx prisma migrate dev --name add_project_model --schema=apps/core-api/prisma/schema.prisma
Expected: Migration created and applied successfully
Step 4: Commit
git add apps/core-api/prisma/git commit -m "feat(prisma): add Project model with status, budget, and category enums"Task 2: Create data-access lib (DTOs + constants)
Заголовок раздела «Task 2: Create data-access lib (DTOs + constants)»Files:
- Create:
libs/apis/providers/project-api/data-access/project.json - Create:
libs/apis/providers/project-api/data-access/src/index.ts - Create:
libs/apis/providers/project-api/data-access/src/lib/index.ts - Create:
libs/apis/providers/project-api/data-access/src/lib/constants/index.ts - Create:
libs/apis/providers/project-api/data-access/src/lib/constants/project-status-transitions.ts - Create:
libs/apis/providers/project-api/data-access/src/lib/dtos/index.ts - Create:
libs/apis/providers/project-api/data-access/src/lib/dtos/project.dto.ts - Modify:
tsconfig.base.json(add path alias)
Step 1: Create project.json for data-access lib
File: libs/apis/providers/project-api/data-access/project.json
{ "name": "project-api-data-access", "$schema": "../../../../../node_modules/nx/schemas/project-schema.json", "sourceRoot": "libs/apis/providers/project-api/data-access/src", "projectType": "library", "tags": [], "// targets": "to see all targets run: nx show project project-api-data-access --web", "targets": {}}Step 2: Create status transitions constant
File: libs/apis/providers/project-api/data-access/src/lib/constants/project-status-transitions.ts
import { ProjectStatus } from '@prisma/client';
export const PROJECT_STATUS_TRANSITIONS: Record<ProjectStatus, ProjectStatus[]> = { [ProjectStatus.DRAFT]: [ProjectStatus.PUBLISHED], [ProjectStatus.PUBLISHED]: [ProjectStatus.HIRING, ProjectStatus.CANCELLED], [ProjectStatus.HIRING]: [ProjectStatus.IN_PROGRESS, ProjectStatus.CANCELLED], [ProjectStatus.IN_PROGRESS]: [ProjectStatus.REVIEW, ProjectStatus.CANCELLED, ProjectStatus.DISPUTED], [ProjectStatus.REVIEW]: [ProjectStatus.COMPLETED, ProjectStatus.IN_PROGRESS], [ProjectStatus.DISPUTED]: [ProjectStatus.CANCELLED, ProjectStatus.IN_PROGRESS], [ProjectStatus.COMPLETED]: [], [ProjectStatus.CANCELLED]: [],};File: libs/apis/providers/project-api/data-access/src/lib/constants/index.ts
export * from './project-status-transitions';Step 3: Create DTOs
File: libs/apis/providers/project-api/data-access/src/lib/dtos/project.dto.ts
import { ApiProperty, OmitType, PartialType } from '@nestjs/swagger';import { Expose, Type } from 'class-transformer';import { IsDate, IsDecimal, IsEnum, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min, MinLength,} from 'class-validator';import { Trim } from '@crewsforge-back/apis/shared';import { ProjectBudgetType, ProjectCategoryType, ProjectStatus } from '@prisma/client';
// --- Base ---
export class ProjectDto { @ApiProperty() @Expose() @IsUUID() id!: string;
@ApiProperty() @Expose() @IsString() @MinLength(3) @MaxLength(255) @Trim() title!: string;
@ApiProperty() @Expose() @IsString() @MinLength(10) @Trim() description!: string;
@ApiProperty({ enum: ProjectCategoryType }) @Expose() @IsEnum(ProjectCategoryType) category!: ProjectCategoryType;
@ApiProperty({ enum: ProjectStatus }) @Expose() @IsEnum(ProjectStatus) status!: ProjectStatus;
@ApiProperty({ enum: ProjectBudgetType }) @Expose() @IsEnum(ProjectBudgetType) budgetType!: ProjectBudgetType;
@ApiProperty({ required: false, type: String }) @Expose() @IsOptional() @IsDecimal() budgetMin?: string | null;
@ApiProperty({ required: false, type: String }) @Expose() @IsOptional() @IsDecimal() budgetMax?: string | null;
@ApiProperty({ default: 'USD' }) @Expose() @IsString() @MaxLength(3) currency!: string;
@ApiProperty({ required: false }) @Expose() @IsOptional() @IsDate() @Type(() => Date) deadline?: Date | null;
@ApiProperty() @Expose() @IsUUID() customerId!: string;
@ApiProperty() @Expose() @IsDate() createdAt!: Date;
@ApiProperty() @Expose() @IsDate() updatedAt!: Date;}
const excludeBaseField = ['id', 'createdAt', 'updatedAt'] as const;
// --- Response ---
export class ProjectResponseDto extends ProjectDto {}
// --- Create ---
export class ProjectCreateDto extends OmitType(ProjectDto, [ ...excludeBaseField, 'status', 'customerId',]) {}
// --- Update (only DRAFT) ---
export class ProjectUpdateDto extends PartialType( OmitType(ProjectDto, [...excludeBaseField, 'status', 'customerId'])) {}
// --- Update Status ---
export class ProjectUpdateStatusDto { @ApiProperty({ enum: ProjectStatus }) @Expose() @IsEnum(ProjectStatus) status!: ProjectStatus;}
// --- List Query ---
export class ProjectListQueryDto { @ApiProperty({ enum: ProjectStatus, required: false }) @Expose() @IsOptional() @IsEnum(ProjectStatus) status?: ProjectStatus;
@ApiProperty({ default: 1, required: false }) @Expose() @IsOptional() @IsInt() @Min(1) @Type(() => Number) page?: number;
@ApiProperty({ default: 10, required: false }) @Expose() @IsOptional() @IsInt() @Min(1) @Type(() => Number) perPage?: number;}File: libs/apis/providers/project-api/data-access/src/lib/dtos/index.ts
export * from './project.dto';Step 4: Create index files
File: libs/apis/providers/project-api/data-access/src/lib/index.ts
export * from './constants';export * from './dtos';File: libs/apis/providers/project-api/data-access/src/index.ts
export * from './lib';Step 5: Add path alias in tsconfig.base.json
Add to compilerOptions.paths:
"@crewsforge-back/apis/providers/project-api/data-access": [ "libs/apis/providers/project-api/data-access/src/index.ts"]Step 6: Commit
git add libs/apis/providers/project-api/data-access/ tsconfig.base.jsongit commit -m "feat(project-api): add data-access lib with DTOs and status transitions"Task 3: Create feature lib (ProjectModule)
Заголовок раздела «Task 3: Create feature lib (ProjectModule)»Files:
- Create:
libs/apis/providers/project-api/features/project/project.json - Create:
libs/apis/providers/project-api/features/project/src/index.ts - Create:
libs/apis/providers/project-api/features/project/src/lib/project.repository.ts - Create:
libs/apis/providers/project-api/features/project/src/lib/project.service.ts - Create:
libs/apis/providers/project-api/features/project/src/lib/project.controller.ts - Create:
libs/apis/providers/project-api/features/project/src/lib/project.module.ts - Modify:
tsconfig.base.json(add path alias)
Step 1: Create project.json for feature lib
File: libs/apis/providers/project-api/features/project/project.json
{ "name": "project-api-feature-project", "$schema": "../../../../../../node_modules/nx/schemas/project-schema.json", "sourceRoot": "libs/apis/providers/project-api/features/project/src", "projectType": "library", "tags": [], "// targets": "to see all targets run: nx show project project-api-feature-project --web", "targets": {}}Step 2: Create repository
File: libs/apis/providers/project-api/features/project/src/lib/project.repository.ts
import { Injectable } from '@nestjs/common';import { Prisma } from '@prisma/client';import { TransactionHost } from '@nestjs-cls/transactional';import { TransactionalAdapterPrisma } from '@nestjs-cls/transactional-adapter-prisma';import { getPaginate, PaginateInput, toPaginateResponse } from '@crewsforge-back/apis/shared';
export type ProjectFindManyAndPaginateArgs = Prisma.ProjectFindManyArgs & { paginate: PaginateInput };
@Injectable()export class ProjectRepository { constructor(private readonly txHost: TransactionHost<TransactionalAdapterPrisma>) {}
create(args: Prisma.ProjectCreateArgs) { return this.txHost.tx.project.create(args); }
update(args: Prisma.ProjectUpdateArgs) { return this.txHost.tx.project.update(args); }
findUnique(args: Prisma.ProjectFindUniqueArgs) { return this.txHost.tx.project.findUnique(args); }
findMany(args?: Prisma.ProjectFindManyArgs) { return this.txHost.tx.project.findMany(args); }
findManyAndCount(args: Prisma.ProjectFindManyArgs = {}) { return Promise.all([ this.txHost.tx.project.findMany(args), this.txHost.tx.project.count({ where: args.where }), ]); }
async findManyAndPaginate({ paginate, ...args }: ProjectFindManyAndPaginateArgs) { const { skip, take } = getPaginate(paginate); const [data, count] = await this.findManyAndCount({ ...args, skip, take }); return toPaginateResponse({ data, count, paginate }); }
delete(args: Prisma.ProjectDeleteArgs) { return this.txHost.tx.project.delete(args); }}Step 3: Create service
File: libs/apis/providers/project-api/features/project/src/lib/project.service.ts
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';import { Prisma, ProjectStatus } from '@prisma/client';import { Transactional } from '@nestjs-cls/transactional';import { ProjectCreateDto, ProjectListQueryDto, ProjectUpdateDto, ProjectUpdateStatusDto, PROJECT_STATUS_TRANSITIONS,} from '@crewsforge-back/apis/providers/project-api/data-access';
import { ProjectRepository } from './project.repository';
@Injectable()export class ProjectService { constructor(private readonly projectRepository: ProjectRepository) {}
@Transactional() async create(customerId: string, dto: ProjectCreateDto) { const data: Prisma.ProjectCreateInput = { title: dto.title, description: dto.description, category: dto.category, budgetType: dto.budgetType, budgetMin: dto.budgetMin ?? undefined, budgetMax: dto.budgetMax ?? undefined, currency: dto.currency ?? 'USD', deadline: dto.deadline ?? undefined, customer: { connect: { id: customerId } }, };
return this.projectRepository.create({ data }); }
async findMany(customerId: string, query: ProjectListQueryDto) { const where: Prisma.ProjectWhereInput = { customerId };
if (query.status) { where.status = query.status; }
return this.projectRepository.findManyAndPaginate({ where, orderBy: { createdAt: 'desc' }, paginate: { page: query.page ?? 1, perPage: query.perPage ?? 10, }, }); }
async findOneByIdOrFail(id: string, customerId: string) { const project = await this.projectRepository.findUnique({ where: { id } });
if (!project) { throw new NotFoundException('Project not found'); }
if (project.customerId !== customerId) { throw new ForbiddenException('Access denied'); }
return project; }
@Transactional() async updateOneById(id: string, customerId: string, dto: ProjectUpdateDto) { const project = await this.findOneByIdOrFail(id, customerId);
if (project.status !== ProjectStatus.DRAFT) { throw new BadRequestException('Only DRAFT projects can be updated'); }
return this.projectRepository.update({ where: { id }, data: { ...(dto.title !== undefined && { title: dto.title }), ...(dto.description !== undefined && { description: dto.description }), ...(dto.category !== undefined && { category: dto.category }), ...(dto.budgetType !== undefined && { budgetType: dto.budgetType }), ...(dto.budgetMin !== undefined && { budgetMin: dto.budgetMin }), ...(dto.budgetMax !== undefined && { budgetMax: dto.budgetMax }), ...(dto.currency !== undefined && { currency: dto.currency }), ...(dto.deadline !== undefined && { deadline: dto.deadline }), }, }); }
@Transactional() async updateStatus(id: string, customerId: string, dto: ProjectUpdateStatusDto) { const project = await this.findOneByIdOrFail(id, customerId);
const allowedTransitions = PROJECT_STATUS_TRANSITIONS[project.status];
if (!allowedTransitions.includes(dto.status)) { throw new BadRequestException( `Cannot transition from ${project.status} to ${dto.status}` ); }
return this.projectRepository.update({ where: { id }, data: { status: dto.status }, }); }
@Transactional() async deleteOneById(id: string, customerId: string) { const project = await this.findOneByIdOrFail(id, customerId);
if (project.status !== ProjectStatus.DRAFT) { throw new BadRequestException('Only DRAFT projects can be deleted'); }
return this.projectRepository.delete({ where: { id } }); }}Step 4: Create controller
File: libs/apis/providers/project-api/features/project/src/lib/project.controller.ts
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Patch, Post, Query, UseGuards,} from '@nestjs/common';import { ApiBearerAuth, ApiCreatedResponse, ApiOkResponse, ApiTags } from '@nestjs/swagger';import { CUSTOMER_ACCESS_TOKEN_STRATEGY_NAME, CustomerGuard, GetTokenPayload, MapperService,} from '@crewsforge-back/apis/shared';import { ProjectCreateDto, ProjectListQueryDto, ProjectResponseDto, ProjectUpdateDto, ProjectUpdateStatusDto,} from '@crewsforge-back/apis/providers/project-api/data-access';import { TokenPayload } from '@crewsforge-back/apis/providers/auth-api/features/token';
import { ProjectService } from './project.service';
@ApiTags('customers/projects')@ApiBearerAuth(CUSTOMER_ACCESS_TOKEN_STRATEGY_NAME)@UseGuards(CustomerGuard)@Controller({ path: 'customers/projects', version: '1' })export class ProjectController { constructor( private readonly mapperService: MapperService, private readonly projectService: ProjectService, ) {}
@ApiCreatedResponse({ type: ProjectResponseDto }) @Post() async create( @GetTokenPayload() payload: TokenPayload, @Body() dto: ProjectCreateDto, ) { const project = await this.projectService.create(payload.customerId, dto); return this.mapperService.toResponse(project, ProjectResponseDto); }
@ApiOkResponse() @Get() async findMany( @GetTokenPayload() payload: TokenPayload, @Query() query: ProjectListQueryDto, ) { const result = await this.projectService.findMany(payload.customerId, query); return this.mapperService.toPaginateResponse(result, ProjectResponseDto); }
@ApiOkResponse({ type: ProjectResponseDto }) @Get(':projectId') async findOne( @GetTokenPayload() payload: TokenPayload, @Param('projectId') projectId: string, ) { const project = await this.projectService.findOneByIdOrFail(projectId, payload.customerId); return this.mapperService.toResponse(project, ProjectResponseDto); }
@ApiOkResponse({ type: ProjectResponseDto }) @HttpCode(HttpStatus.OK) @Patch(':projectId') async update( @GetTokenPayload() payload: TokenPayload, @Param('projectId') projectId: string, @Body() dto: ProjectUpdateDto, ) { const project = await this.projectService.updateOneById(projectId, payload.customerId, dto); return this.mapperService.toResponse(project, ProjectResponseDto); }
@ApiOkResponse({ type: ProjectResponseDto }) @HttpCode(HttpStatus.OK) @Patch(':projectId/status') async updateStatus( @GetTokenPayload() payload: TokenPayload, @Param('projectId') projectId: string, @Body() dto: ProjectUpdateStatusDto, ) { const project = await this.projectService.updateStatus(projectId, payload.customerId, dto); return this.mapperService.toResponse(project, ProjectResponseDto); }
@ApiOkResponse({ type: ProjectResponseDto }) @HttpCode(HttpStatus.OK) @Delete(':projectId') async delete( @GetTokenPayload() payload: TokenPayload, @Param('projectId') projectId: string, ) { const project = await this.projectService.deleteOneById(projectId, payload.customerId); return this.mapperService.toResponse(project, ProjectResponseDto); }}Step 5: Create module
File: libs/apis/providers/project-api/features/project/src/lib/project.module.ts
import { Module } from '@nestjs/common';import { JwtModule } from '@nestjs/jwt';import { MapperService, CustomerGuard } from '@crewsforge-back/apis/shared';import { JwtCustomerConfigModule } from '@crewsforge-back/apis/configs/auth-api/jwt-customer';import { PrismaClientModule } from '@crewsforge-back/apis/utils/prisma-client';
import { ProjectController } from './project.controller';import { ProjectService } from './project.service';import { ProjectRepository } from './project.repository';
@Module({ imports: [PrismaClientModule, JwtModule.register({}), JwtCustomerConfigModule], controllers: [ProjectController], providers: [ProjectService, ProjectRepository, MapperService, CustomerGuard], exports: [ProjectService],})export class ProjectModule {}Step 6: Create index file
File: libs/apis/providers/project-api/features/project/src/index.ts
export * from './lib/project.module';export * from './lib/project.service';Step 7: Add path alias in tsconfig.base.json
Add to compilerOptions.paths:
"@crewsforge-back/apis/providers/project-api/features/project": [ "libs/apis/providers/project-api/features/project/src/index.ts"]Step 8: Commit
git add libs/apis/providers/project-api/features/ tsconfig.base.jsongit commit -m "feat(project-api): add ProjectModule with controller, service, and repository"Task 4: Wire up app module + scripts
Заголовок раздела «Task 4: Wire up app module + scripts»Files:
- Modify:
apps/project-api/src/app/app.module.ts - Modify:
package.json
Step 1: Update app.module.ts to import ProjectModule
Add import at top:
import { ProjectModule } from '@crewsforge-back/apis/providers/project-api/features/project';Add ProjectModule to the imports array (after ClsModule.forRoot block):
ProjectModule,Step 2: Add scripts to package.json
Add to scripts:
"dev:project-api": "nx serve project-api","build:project-api": "nx build project-api"Step 3: Commit
git add apps/project-api/src/app/app.module.ts package.jsongit commit -m "feat(project-api): wire up ProjectModule and add dev/build scripts"Task 5: Verify build and test
Заголовок раздела «Task 5: Verify build and test»Step 1: Build project-api
Run: npx nx build project-api
Expected: Build succeeds without errors
Step 2: Start project-api in development
Run: npm run dev:project-api
Expected: App starts on port 4002, Swagger available at http://localhost:4002/api
Step 3: Verify Swagger shows endpoints
Open http://localhost:4002/api in browser. Expected: 6 endpoints under “customers/projects” tag.
Step 4: Push changes
git push origin feat/project-service