Skip to content

Commit 4afb37c

Browse files
committed
PM-5307 - project showcase post API
1 parent 4de30be commit 4afb37c

24 files changed

Lines changed: 1455 additions & 2 deletions

src/api/api.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { ProjectInviteModule } from './project-invite/project-invite.module';
1212
import { ProjectMemberModule } from './project-member/project-member.module';
1313
import { ProjectPhaseModule } from './project-phase/project-phase.module';
1414
import { ProjectSettingModule } from './project-setting/project-setting.module';
15+
import { ProjectShowcasePostModule } from './project-showcase-post/project-showcase-post.module';
1516
import { ProjectModule } from './project/project.module';
1617

1718
/**
@@ -39,6 +40,7 @@ import { ProjectModule } from './project/project.module';
3940
GlobalProvidersModule,
4041
CopilotModule,
4142
MetadataModule,
43+
ProjectShowcasePostModule,
4244
ProjectModule,
4345
ProjectMemberModule,
4446
ProjectInviteModule,

src/api/metadata/metadata.module.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import { ProductTemplateModule } from './product-template/product-template.modul
1111
import { ProjectTemplateModule } from './project-template/project-template.module';
1212
import { ProjectTypeModule } from './project-type/project-type.module';
1313
import { WorkManagementPermissionModule } from './work-management-permission/work-management-permission.module';
14+
import { ProjectPostIndustryModule } from './project-post-industry/project-post-industry.module';
15+
import { ProjectPostCategoryModule } from './project-post-category/project-post-category.module';
1416

1517
/**
1618
* Aggregates all metadata sub-modules used by the projects API.
@@ -26,6 +28,8 @@ import { WorkManagementPermissionModule } from './work-management-permission/wor
2628
* - `PlanConfigModule`: versioned plan configuration definitions.
2729
* - `PriceConfigModule`: versioned pricing configuration definitions.
2830
* - `WorkManagementPermissionModule`: permission policy records by template.
31+
* - `ProjectPostIndustryModule`: project showcase post industry taxonomy.
32+
* - `ProjectPostCategoryModule`: project showcase post category taxonomy.
2933
*
3034
* It also registers `MetadataListController` and `MetadataListService` for the
3135
* consolidated metadata list endpoint.
@@ -42,6 +46,8 @@ import { WorkManagementPermissionModule } from './work-management-permission/wor
4246
PlanConfigModule,
4347
PriceConfigModule,
4448
WorkManagementPermissionModule,
49+
ProjectPostIndustryModule,
50+
ProjectPostCategoryModule,
4551
],
4652
controllers: [MetadataListController],
4753
providers: [MetadataListService],
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { ApiProperty } from '@nestjs/swagger';
2+
import { IsNotEmpty, IsString } from 'class-validator';
3+
4+
export class CreateProjectPostCategoryDto {
5+
@ApiProperty({ description: 'Category name' })
6+
@IsString()
7+
@IsNotEmpty()
8+
name: string;
9+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { ApiProperty } from '@nestjs/swagger';
2+
3+
export class ProjectPostCategoryResponseDto {
4+
@ApiProperty()
5+
id: string;
6+
7+
@ApiProperty()
8+
name: string;
9+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { PartialType } from '@nestjs/mapped-types';
2+
import { CreateProjectPostCategoryDto } from './create-project-post-category.dto';
3+
4+
export class UpdateProjectPostCategoryDto extends PartialType(
5+
CreateProjectPostCategoryDto,
6+
) {}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import {
2+
Body,
3+
Controller,
4+
Delete,
5+
Get,
6+
HttpCode,
7+
Param,
8+
Patch,
9+
Post,
10+
} from '@nestjs/common';
11+
import {
12+
ApiBearerAuth,
13+
ApiOperation,
14+
ApiParam,
15+
ApiResponse,
16+
ApiTags,
17+
} from '@nestjs/swagger';
18+
import { CurrentUser } from 'src/shared/decorators/currentUser.decorator';
19+
import { AdminOnly } from 'src/shared/guards/adminOnly.guard';
20+
import { AnyAuthenticated } from 'src/shared/guards/tokenRoles.guard';
21+
import { JwtUser } from 'src/shared/modules/global/jwt.service';
22+
import { getAuditUserIdNumber } from '../utils/metadata-utils';
23+
import { CreateProjectPostCategoryDto } from './dto/create-project-post-category.dto';
24+
import { ProjectPostCategoryResponseDto } from './dto/project-post-category-response.dto';
25+
import { UpdateProjectPostCategoryDto } from './dto/update-project-post-category.dto';
26+
import { ProjectPostCategoryService } from './project-post-category.service';
27+
28+
@ApiTags('Metadata - Project Post Categories')
29+
@ApiBearerAuth()
30+
@AnyAuthenticated()
31+
@Controller('/projects/metadata/projectPostCategories')
32+
export class ProjectPostCategoryController {
33+
constructor(
34+
private readonly service: ProjectPostCategoryService,
35+
) {}
36+
37+
@Get()
38+
@ApiOperation({ summary: 'List project showcase post categories' })
39+
@ApiResponse({ status: 200, type: [ProjectPostCategoryResponseDto] })
40+
async list(): Promise<ProjectPostCategoryResponseDto[]> {
41+
return this.service.findAll();
42+
}
43+
44+
@Get(':id')
45+
@ApiOperation({ summary: 'Get project showcase post category by id' })
46+
@ApiParam({ name: 'id', description: 'Category id' })
47+
@ApiResponse({ status: 200, type: ProjectPostCategoryResponseDto })
48+
@ApiResponse({ status: 404, description: 'Not found' })
49+
async getOne(
50+
@Param('id') id: string,
51+
): Promise<ProjectPostCategoryResponseDto> {
52+
return this.service.findById(id);
53+
}
54+
55+
@Post()
56+
@AdminOnly()
57+
@ApiOperation({ summary: 'Create project showcase post category' })
58+
@ApiResponse({ status: 201, type: ProjectPostCategoryResponseDto })
59+
@ApiResponse({ status: 403, description: 'Forbidden' })
60+
async create(
61+
@Body() dto: CreateProjectPostCategoryDto,
62+
@CurrentUser() user: JwtUser,
63+
): Promise<ProjectPostCategoryResponseDto> {
64+
return this.service.create(dto, getAuditUserIdNumber(user));
65+
}
66+
67+
@Patch(':id')
68+
@AdminOnly()
69+
@ApiOperation({ summary: 'Update project showcase post category' })
70+
@ApiParam({ name: 'id', description: 'Category id' })
71+
@ApiResponse({ status: 200, type: ProjectPostCategoryResponseDto })
72+
@ApiResponse({ status: 403, description: 'Forbidden' })
73+
@ApiResponse({ status: 404, description: 'Not found' })
74+
async update(
75+
@Param('id') id: string,
76+
@Body() dto: UpdateProjectPostCategoryDto,
77+
@CurrentUser() user: JwtUser,
78+
): Promise<ProjectPostCategoryResponseDto> {
79+
return this.service.update(id, dto, getAuditUserIdNumber(user));
80+
}
81+
82+
@Delete(':id')
83+
@HttpCode(204)
84+
@AdminOnly()
85+
@ApiOperation({ summary: 'Delete project showcase post category (soft delete)' })
86+
@ApiParam({ name: 'id', description: 'Category id' })
87+
@ApiResponse({ status: 204, description: 'Deleted' })
88+
@ApiResponse({ status: 403, description: 'Forbidden' })
89+
@ApiResponse({ status: 404, description: 'Not found' })
90+
async delete(
91+
@Param('id') id: string,
92+
@CurrentUser() user: JwtUser,
93+
): Promise<void> {
94+
await this.service.delete(id, getAuditUserIdNumber(user));
95+
}
96+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { Module } from '@nestjs/common';
2+
import { GlobalProvidersModule } from 'src/shared/modules/global/globalProviders.module';
3+
import { ProjectPostCategoryController } from './project-post-category.controller';
4+
import { ProjectPostCategoryService } from './project-post-category.service';
5+
6+
@Module({
7+
imports: [GlobalProvidersModule],
8+
controllers: [ProjectPostCategoryController],
9+
providers: [ProjectPostCategoryService],
10+
exports: [ProjectPostCategoryService],
11+
})
12+
export class ProjectPostCategoryModule {}
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import {
2+
ConflictException,
3+
HttpException,
4+
Injectable,
5+
NotFoundException,
6+
} from '@nestjs/common';
7+
import { Prisma, ProjectPostCategory } from '@prisma/client';
8+
import { PrismaErrorService } from 'src/shared/modules/global/prisma-error.service';
9+
import { PrismaService } from 'src/shared/modules/global/prisma.service';
10+
import { EventBusService } from 'src/shared/modules/global/eventBus.service';
11+
import {
12+
PROJECT_METADATA_RESOURCE,
13+
publishMetadataEvent,
14+
} from '../utils/metadata-event.utils';
15+
import { toSerializable } from '../utils/metadata-utils';
16+
import { CreateProjectPostCategoryDto } from './dto/create-project-post-category.dto';
17+
import { ProjectPostCategoryResponseDto } from './dto/project-post-category-response.dto';
18+
import { UpdateProjectPostCategoryDto } from './dto/update-project-post-category.dto';
19+
20+
@Injectable()
21+
export class ProjectPostCategoryService {
22+
constructor(
23+
private readonly prisma: PrismaService,
24+
private readonly prismaErrorService: PrismaErrorService,
25+
private readonly eventBusService: EventBusService,
26+
) {}
27+
28+
async findAll(): Promise<ProjectPostCategoryResponseDto[]> {
29+
const records = await this.prisma.projectPostCategory.findMany({
30+
orderBy: [{ id: 'asc' }],
31+
});
32+
33+
return records.map((record) => this.toDto(record));
34+
}
35+
36+
async findById(id: string): Promise<ProjectPostCategoryResponseDto> {
37+
const parsedId = parseInt(id, 10);
38+
if (Number.isNaN(parsedId)) {
39+
throw new NotFoundException(`Category not found for id ${id}.`);
40+
}
41+
42+
const record = await this.prisma.projectPostCategory.findFirst({
43+
where: {
44+
id: BigInt(parsedId),
45+
},
46+
});
47+
48+
if (!record) {
49+
throw new NotFoundException(`Category not found for id ${id}.`);
50+
}
51+
52+
return this.toDto(record);
53+
}
54+
55+
async create(
56+
dto: CreateProjectPostCategoryDto,
57+
userId: number,
58+
): Promise<ProjectPostCategoryResponseDto> {
59+
try {
60+
const existing = await this.prisma.projectPostCategory.findFirst({
61+
where: {
62+
name: dto.name,
63+
},
64+
});
65+
66+
if (existing) {
67+
throw new ConflictException(
68+
`Project showcase post category already exists for name ${dto.name}.`,
69+
);
70+
}
71+
72+
const created = await this.prisma.projectPostCategory.create({
73+
data: {
74+
name: dto.name,
75+
},
76+
});
77+
78+
await publishMetadataEvent(
79+
this.eventBusService,
80+
'PROJECT_METADATA_CREATE',
81+
PROJECT_METADATA_RESOURCE.PROJECT_POST_CATEGORY,
82+
String(created.id),
83+
created,
84+
userId,
85+
);
86+
87+
return this.toDto(created);
88+
} catch (error) {
89+
this.handleError(error, `create project showcase post category ${dto.name}`);
90+
}
91+
}
92+
93+
async update(
94+
id: string,
95+
dto: UpdateProjectPostCategoryDto,
96+
userId: number,
97+
): Promise<ProjectPostCategoryResponseDto> {
98+
const parsedId = parseInt(id, 10);
99+
if (Number.isNaN(parsedId)) {
100+
throw new NotFoundException(`Category not found for id ${id}.`);
101+
}
102+
103+
const existing = await this.prisma.projectPostCategory.findFirst({
104+
where: {
105+
id: BigInt(parsedId),
106+
},
107+
});
108+
109+
if (!existing) {
110+
throw new NotFoundException(`Category not found for id ${id}.`);
111+
}
112+
113+
const updated = await this.prisma.projectPostCategory.update({
114+
where: { id: BigInt(parsedId) },
115+
data: {
116+
...(typeof dto.name === 'undefined' ? {} : { name: dto.name }),
117+
},
118+
});
119+
120+
await publishMetadataEvent(
121+
this.eventBusService,
122+
'PROJECT_METADATA_UPDATE',
123+
PROJECT_METADATA_RESOURCE.PROJECT_POST_CATEGORY,
124+
String(updated.id),
125+
updated,
126+
userId,
127+
);
128+
129+
return this.toDto(updated);
130+
}
131+
132+
async delete(id: string, userId: number): Promise<void> {
133+
const parsedId = parseInt(id, 10);
134+
if (Number.isNaN(parsedId)) {
135+
throw new NotFoundException(`Category not found for id ${id}.`);
136+
}
137+
138+
const existing = await this.prisma.projectPostCategory.findFirst({
139+
where: {
140+
id: BigInt(parsedId),
141+
},
142+
select: { id: true },
143+
});
144+
145+
if (!existing) {
146+
throw new NotFoundException(`Category not found for id ${id}.`);
147+
}
148+
149+
await this.prisma.projectPostCategory.delete({
150+
where: { id: BigInt(parsedId) },
151+
});
152+
153+
await publishMetadataEvent(
154+
this.eventBusService,
155+
'PROJECT_METADATA_DELETE',
156+
PROJECT_METADATA_RESOURCE.PROJECT_POST_CATEGORY,
157+
String(parsedId),
158+
{ id: parsedId },
159+
userId,
160+
);
161+
}
162+
163+
private toDto(record: ProjectPostCategory): ProjectPostCategoryResponseDto {
164+
return {
165+
id: String(record.id),
166+
name: record.name,
167+
};
168+
}
169+
170+
private handleError(error: unknown, operation: string): never {
171+
if (error instanceof HttpException) {
172+
throw error;
173+
}
174+
175+
this.prismaErrorService.handleError(error, operation);
176+
}
177+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { ApiProperty } from '@nestjs/swagger';
2+
import { IsNotEmpty, IsString } from 'class-validator';
3+
4+
export class CreateProjectPostIndustryDto {
5+
@ApiProperty({ description: 'Industry name' })
6+
@IsString()
7+
@IsNotEmpty()
8+
name: string;
9+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { ApiProperty } from '@nestjs/swagger';
2+
3+
export class ProjectPostIndustryResponseDto {
4+
@ApiProperty()
5+
id: string;
6+
7+
@ApiProperty()
8+
name: string;
9+
}

0 commit comments

Comments
 (0)