Перейти к содержимому

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


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"

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.json
git commit -m "feat(project-api): add data-access lib with DTOs and status transitions"

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.json
git commit -m "feat(project-api): add ProjectModule with controller, service, and repository"

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.json
git commit -m "feat(project-api): wire up ProjectModule and add dev/build scripts"

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