Skip to content

Commit c2cd5e9

Browse files
authored
Merge pull request #26 from topcoder-platform/PM-5485_project-showcase-media-asset
PM-5485 - media assets for project showcase
2 parents 85bf8d2 + 9ed7b7b commit c2cd5e9

8 files changed

Lines changed: 254 additions & 2 deletions

File tree

prisma/migrations/20260624000000_add_project_showcase_cms/migration.sql

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,17 @@ CREATE TABLE projects."project_showcase_post_categories" (
5151
UNIQUE ("projectShowcasePostId", "categoryId")
5252
);
5353

54+
CREATE TABLE projects."project_showcase_post_media" (
55+
"id" BIGSERIAL PRIMARY KEY,
56+
"projectShowcasePostId" BIGINT NOT NULL,
57+
"type" VARCHAR(255) NOT NULL,
58+
"url" VARCHAR(2048) NOT NULL,
59+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT now(),
60+
"createdBy" BIGINT NOT NULL,
61+
CONSTRAINT "project_showcase_post_media_project_showcase_post_fkey"
62+
FOREIGN KEY ("projectShowcasePostId") REFERENCES projects."project_showcase_posts"("id") ON DELETE CASCADE
63+
);
64+
5465
-- Indexes for query performance
5566
CREATE INDEX "project_showcase_posts_status_idx" ON projects."project_showcase_posts"("status");
5667
CREATE INDEX "project_showcase_posts_project_id_idx" ON projects."project_showcase_posts"("projectId");

prisma/schema.prisma

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1075,6 +1075,7 @@ model ProjectShowcasePost {
10751075
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
10761076
industries ProjectShowcasePostIndustry[]
10771077
categories ProjectShowcasePostCategory[]
1078+
media ProjectShowcasePostMedia[]
10781079
10791080
@@index([status])
10801081
@@index([projectId])
@@ -1130,3 +1131,18 @@ model ProjectShowcasePostCategory {
11301131
@@index([categoryId])
11311132
@@map("project_showcase_post_categories")
11321133
}
1134+
1135+
/// Media assets attached to project showcase posts.
1136+
model ProjectShowcasePostMedia {
1137+
id BigInt @id @default(autoincrement())
1138+
projectShowcasePostId BigInt
1139+
type String
1140+
url String
1141+
createdAt DateTime @default(now())
1142+
createdBy BigInt
1143+
1144+
projectShowcasePost ProjectShowcasePost @relation(fields: [projectShowcasePostId], references: [id], onDelete: Cascade)
1145+
1146+
@@index([projectShowcasePostId])
1147+
@@map("project_showcase_post_media")
1148+
}

src/api/project-showcase-post/dto/create-project-showcase-post.dto.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,16 @@
11
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
2-
import { IsArray, IsEnum, IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator';
2+
import { Type } from 'class-transformer';
3+
import {
4+
IsArray,
5+
IsEnum,
6+
IsNotEmpty,
7+
IsOptional,
8+
IsString,
9+
IsUUID,
10+
ValidateNested,
11+
} from 'class-validator';
312
import { ProjectShowcasePostStatus } from '@prisma/client';
13+
import { ProjectShowcasePostMediaInputDto } from './project-showcase-post-media-input.dto';
414

515
export class CreateProjectShowcasePostDto {
616
@ApiProperty({ description: 'Post title.' })
@@ -35,4 +45,11 @@ export class CreateProjectShowcasePostDto {
3545
@IsArray()
3646
@IsUUID('4', { each: true })
3747
challengeIds?: string[];
48+
49+
@ApiPropertyOptional({ type: [ProjectShowcasePostMediaInputDto] })
50+
@IsOptional()
51+
@IsArray()
52+
@ValidateNested({ each: true })
53+
@Type(() => ProjectShowcasePostMediaInputDto)
54+
media?: ProjectShowcasePostMediaInputDto[];
3855
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { ApiProperty } from '@nestjs/swagger';
2+
import { IsNotEmpty, IsString, IsUrl } from 'class-validator';
3+
4+
export class ProjectShowcasePostMediaInputDto {
5+
@ApiProperty({ description: 'MIME type of the media asset.' })
6+
@IsString()
7+
@IsNotEmpty()
8+
type: string;
9+
10+
@ApiProperty({ description: 'URL of the media asset.' })
11+
@IsUrl()
12+
url: string;
13+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { ApiProperty } from '@nestjs/swagger';
2+
import { IsNotEmpty, IsString, IsUrl } from 'class-validator';
3+
4+
export class ProjectShowcasePostMediaDto {
5+
@ApiProperty({ description: 'Media asset id.' })
6+
@IsString()
7+
@IsNotEmpty()
8+
id: string;
9+
10+
@ApiProperty({ description: 'MIME type of the media asset.' })
11+
@IsString()
12+
@IsNotEmpty()
13+
type: string;
14+
15+
@ApiProperty({ description: 'URL of the media asset.' })
16+
@IsUrl()
17+
url: string;
18+
19+
@ApiProperty({ description: 'Timestamp when media asset was created.' })
20+
createdAt: Date;
21+
22+
@ApiProperty({ description: 'User id who created the media asset.' })
23+
createdBy: string;
24+
}

src/api/project-showcase-post/dto/project-showcase-post-response.dto.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { ApiProperty } from '@nestjs/swagger';
1+
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
22
import { ProjectShowcasePostStatus } from '@prisma/client';
3+
import { ProjectShowcasePostMediaDto } from './project-showcase-post-media.dto';
34

45
export class ProjectShowcasePostResponseDto {
56
@ApiProperty()
@@ -26,6 +27,9 @@ export class ProjectShowcasePostResponseDto {
2627
@ApiProperty({ type: [Object] })
2728
categories: Array<{ id: string; name: string }>;
2829

30+
@ApiPropertyOptional({ type: [ProjectShowcasePostMediaDto] })
31+
media?: ProjectShowcasePostMediaDto[];
32+
2933
@ApiProperty()
3034
createdById: number;
3135

src/api/project-showcase-post/project-showcase-post.service.spec.ts

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,15 @@ describe('ProjectShowcasePostService', () => {
5555
},
5656
},
5757
],
58+
media: [
59+
{
60+
id: BigInt(101),
61+
type: 'image/png',
62+
url: 'https://example.com/image.png',
63+
createdAt: new Date('2026-01-01T00:00:00Z'),
64+
createdBy: BigInt(42),
65+
},
66+
],
5867
...overrides,
5968
} as const;
6069
}
@@ -188,12 +197,72 @@ describe('ProjectShowcasePostService', () => {
188197
categories: {
189198
create: [{ categoryId: BigInt(7) }],
190199
},
200+
media: {
201+
create: [],
202+
},
191203
}),
192204
}),
193205
);
194206
expect(response.status).toBe('DRAFT');
195207
});
196208

209+
it('creates a new project showcase post with media assets', async () => {
210+
prismaMock.projectShowcasePost.create.mockResolvedValue(
211+
buildPostRecord({
212+
status: 'DRAFT',
213+
media: [
214+
{
215+
id: BigInt(101),
216+
type: 'image/png',
217+
url: 'https://example.com/image.png',
218+
createdAt: new Date('2026-01-01T00:00:00Z'),
219+
createdBy: BigInt(42),
220+
},
221+
],
222+
}),
223+
);
224+
225+
const response = await service.createPost(
226+
'1001',
227+
{
228+
title: 'New post',
229+
content: 'New content',
230+
industryIds: ['5'],
231+
categoryIds: ['7'],
232+
media: [
233+
{
234+
type: 'image/png',
235+
url: 'https://example.com/image.png',
236+
},
237+
],
238+
},
239+
user,
240+
);
241+
242+
expect(prismaMock.projectShowcasePost.create).toHaveBeenCalledWith(
243+
expect.objectContaining({
244+
data: expect.objectContaining({
245+
media: {
246+
create: [
247+
{
248+
type: 'image/png',
249+
url: 'https://example.com/image.png',
250+
createdBy: 42,
251+
},
252+
],
253+
},
254+
}),
255+
}),
256+
);
257+
expect(response.media).toEqual([
258+
expect.objectContaining({
259+
id: '101',
260+
type: 'image/png',
261+
url: 'https://example.com/image.png',
262+
}),
263+
]);
264+
});
265+
197266
it('throws NotFoundException when create hits an industry foreign key constraint', async () => {
198267
const error = Object.create(
199268
Prisma.PrismaClientKnownRequestError.prototype,
@@ -300,6 +369,66 @@ describe('ProjectShowcasePostService', () => {
300369
expect(response.title).toBe('Updated title');
301370
});
302371

372+
it('updates a post with media assets', async () => {
373+
prismaMock.projectShowcasePost.findFirst.mockResolvedValue(
374+
buildPostRecord(),
375+
);
376+
prismaMock.projectShowcasePost.update.mockResolvedValue(
377+
buildPostRecord({
378+
title: 'Updated title',
379+
media: [
380+
{
381+
id: BigInt(102),
382+
type: 'video/mp4',
383+
url: 'https://example.com/video.mp4',
384+
createdAt: new Date('2026-01-01T00:00:00Z'),
385+
createdBy: BigInt(42),
386+
},
387+
],
388+
}),
389+
);
390+
391+
const response = await service.updatePost(
392+
'1001',
393+
'10',
394+
{
395+
title: 'Updated title',
396+
media: [
397+
{
398+
type: 'video/mp4',
399+
url: 'https://example.com/video.mp4',
400+
},
401+
],
402+
},
403+
user,
404+
);
405+
406+
expect(prismaMock.projectShowcasePost.update).toHaveBeenCalledWith(
407+
expect.objectContaining({
408+
where: { id: BigInt(10) },
409+
data: expect.objectContaining({
410+
media: {
411+
deleteMany: {},
412+
create: [
413+
{
414+
type: 'video/mp4',
415+
url: 'https://example.com/video.mp4',
416+
createdBy: 42,
417+
},
418+
],
419+
},
420+
}),
421+
}),
422+
);
423+
expect(response.media).toEqual([
424+
expect.objectContaining({
425+
id: '102',
426+
type: 'video/mp4',
427+
url: 'https://example.com/video.mp4',
428+
}),
429+
]);
430+
});
431+
303432
it('throws NotFoundException when update hits an industry foreign key constraint', async () => {
304433
prismaMock.projectShowcasePost.findFirst.mockResolvedValue(
305434
buildPostRecord(),

src/api/project-showcase-post/project-showcase-post.service.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ export class ProjectShowcasePostService {
4444
categories: {
4545
include: { category: true },
4646
},
47+
media: true,
4748
},
4849
orderBy,
4950
skip,
@@ -90,6 +91,7 @@ export class ProjectShowcasePostService {
9091
categories: {
9192
include: { category: true },
9293
},
94+
media: true,
9395
},
9496
orderBy,
9597
skip,
@@ -151,6 +153,7 @@ export class ProjectShowcasePostService {
151153
categories: {
152154
include: { category: true },
153155
},
156+
media: true,
154157
},
155158
});
156159

@@ -204,6 +207,13 @@ export class ProjectShowcasePostService {
204207
categories: {
205208
create: categoryIds.map((categoryId) => ({ categoryId })),
206209
},
210+
media: {
211+
create: (dto.media || []).map((asset) => ({
212+
type: asset.type,
213+
url: asset.url,
214+
createdBy: BigInt(auditUserId),
215+
})),
216+
},
207217
},
208218
include: {
209219
industries: {
@@ -212,6 +222,7 @@ export class ProjectShowcasePostService {
212222
categories: {
213223
include: { category: true },
214224
},
225+
media: true,
215226
},
216227
});
217228

@@ -286,6 +297,18 @@ export class ProjectShowcasePostService {
286297
};
287298
}
288299

300+
if (typeof dto.media !== 'undefined' && Array.isArray(dto.media)) {
301+
const auditUserId = BigInt(getAuditUserId(user));
302+
updateData.media = {
303+
deleteMany: {},
304+
create: dto.media.map((mediaItem) => ({
305+
type: mediaItem.type,
306+
url: mediaItem.url,
307+
createdBy: auditUserId,
308+
})),
309+
};
310+
}
311+
289312
try {
290313
const updated = await this.prisma.projectShowcasePost.update({
291314
where: {
@@ -299,6 +322,7 @@ export class ProjectShowcasePostService {
299322
categories: {
300323
include: { category: true },
301324
},
325+
media: true,
302326
},
303327
});
304328

@@ -435,6 +459,13 @@ export class ProjectShowcasePostService {
435459
post: ProjectShowcasePost & {
436460
industries: { industry: { id: bigint; name: string } }[];
437461
categories: { category: { id: bigint; name: string } }[];
462+
media: {
463+
id: bigint;
464+
type: string;
465+
url: string;
466+
createdAt: Date;
467+
createdBy: bigint;
468+
}[];
438469
},
439470
): ProjectShowcasePostResponseDto {
440471
return {
@@ -456,6 +487,13 @@ export class ProjectShowcasePostService {
456487
id: String(entry.category.id),
457488
name: entry.category.name,
458489
})),
490+
media: post.media.map((entry) => ({
491+
id: String(entry.id),
492+
type: entry.type,
493+
url: entry.url,
494+
createdAt: entry.createdAt,
495+
createdBy: String(entry.createdBy),
496+
})),
459497
};
460498
}
461499

0 commit comments

Comments
 (0)