diff --git a/prisma/migrations/20260624000000_add_project_showcase_cms/migration.sql b/prisma/migrations/20260624000000_add_project_showcase_cms/migration.sql index 59fce76..c20e5a1 100644 --- a/prisma/migrations/20260624000000_add_project_showcase_cms/migration.sql +++ b/prisma/migrations/20260624000000_add_project_showcase_cms/migration.sql @@ -51,6 +51,17 @@ CREATE TABLE projects."project_showcase_post_categories" ( UNIQUE ("projectShowcasePostId", "categoryId") ); +CREATE TABLE projects."project_showcase_post_media" ( + "id" BIGSERIAL PRIMARY KEY, + "projectShowcasePostId" BIGINT NOT NULL, + "type" VARCHAR(255) NOT NULL, + "url" VARCHAR(2048) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT now(), + "createdBy" BIGINT NOT NULL, + CONSTRAINT "project_showcase_post_media_project_showcase_post_fkey" + FOREIGN KEY ("projectShowcasePostId") REFERENCES projects."project_showcase_posts"("id") ON DELETE CASCADE +); + -- Indexes for query performance CREATE INDEX "project_showcase_posts_status_idx" ON projects."project_showcase_posts"("status"); CREATE INDEX "project_showcase_posts_project_id_idx" ON projects."project_showcase_posts"("projectId"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6345d55..d803149 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1075,6 +1075,7 @@ model ProjectShowcasePost { project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) industries ProjectShowcasePostIndustry[] categories ProjectShowcasePostCategory[] + media ProjectShowcasePostMedia[] @@index([status]) @@index([projectId]) @@ -1130,3 +1131,18 @@ model ProjectShowcasePostCategory { @@index([categoryId]) @@map("project_showcase_post_categories") } + +/// Media assets attached to project showcase posts. +model ProjectShowcasePostMedia { + id BigInt @id @default(autoincrement()) + projectShowcasePostId BigInt + type String + url String + createdAt DateTime @default(now()) + createdBy BigInt + + projectShowcasePost ProjectShowcasePost @relation(fields: [projectShowcasePostId], references: [id], onDelete: Cascade) + + @@index([projectShowcasePostId]) + @@map("project_showcase_post_media") +} diff --git a/src/api/project-showcase-post/dto/create-project-showcase-post.dto.ts b/src/api/project-showcase-post/dto/create-project-showcase-post.dto.ts index afca11a..88d055e 100644 --- a/src/api/project-showcase-post/dto/create-project-showcase-post.dto.ts +++ b/src/api/project-showcase-post/dto/create-project-showcase-post.dto.ts @@ -1,6 +1,16 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsArray, IsEnum, IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator'; +import { Type } from 'class-transformer'; +import { + IsArray, + IsEnum, + IsNotEmpty, + IsOptional, + IsString, + IsUUID, + ValidateNested, +} from 'class-validator'; import { ProjectShowcasePostStatus } from '@prisma/client'; +import { ProjectShowcasePostMediaInputDto } from './project-showcase-post-media-input.dto'; export class CreateProjectShowcasePostDto { @ApiProperty({ description: 'Post title.' }) @@ -35,4 +45,11 @@ export class CreateProjectShowcasePostDto { @IsArray() @IsUUID('4', { each: true }) challengeIds?: string[]; + + @ApiPropertyOptional({ type: [ProjectShowcasePostMediaInputDto] }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ProjectShowcasePostMediaInputDto) + media?: ProjectShowcasePostMediaInputDto[]; } diff --git a/src/api/project-showcase-post/dto/project-showcase-post-media-input.dto.ts b/src/api/project-showcase-post/dto/project-showcase-post-media-input.dto.ts new file mode 100644 index 0000000..e1762e0 --- /dev/null +++ b/src/api/project-showcase-post/dto/project-showcase-post-media-input.dto.ts @@ -0,0 +1,13 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString, IsUrl } from 'class-validator'; + +export class ProjectShowcasePostMediaInputDto { + @ApiProperty({ description: 'MIME type of the media asset.' }) + @IsString() + @IsNotEmpty() + type: string; + + @ApiProperty({ description: 'URL of the media asset.' }) + @IsUrl() + url: string; +} diff --git a/src/api/project-showcase-post/dto/project-showcase-post-media.dto.ts b/src/api/project-showcase-post/dto/project-showcase-post-media.dto.ts new file mode 100644 index 0000000..c52dfff --- /dev/null +++ b/src/api/project-showcase-post/dto/project-showcase-post-media.dto.ts @@ -0,0 +1,24 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString, IsUrl } from 'class-validator'; + +export class ProjectShowcasePostMediaDto { + @ApiProperty({ description: 'Media asset id.' }) + @IsString() + @IsNotEmpty() + id: string; + + @ApiProperty({ description: 'MIME type of the media asset.' }) + @IsString() + @IsNotEmpty() + type: string; + + @ApiProperty({ description: 'URL of the media asset.' }) + @IsUrl() + url: string; + + @ApiProperty({ description: 'Timestamp when media asset was created.' }) + createdAt: Date; + + @ApiProperty({ description: 'User id who created the media asset.' }) + createdBy: string; +} diff --git a/src/api/project-showcase-post/dto/project-showcase-post-response.dto.ts b/src/api/project-showcase-post/dto/project-showcase-post-response.dto.ts index 4991c84..dcc64bb 100644 --- a/src/api/project-showcase-post/dto/project-showcase-post-response.dto.ts +++ b/src/api/project-showcase-post/dto/project-showcase-post-response.dto.ts @@ -1,5 +1,6 @@ -import { ApiProperty } from '@nestjs/swagger'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ProjectShowcasePostStatus } from '@prisma/client'; +import { ProjectShowcasePostMediaDto } from './project-showcase-post-media.dto'; export class ProjectShowcasePostResponseDto { @ApiProperty() @@ -26,6 +27,9 @@ export class ProjectShowcasePostResponseDto { @ApiProperty({ type: [Object] }) categories: Array<{ id: string; name: string }>; + @ApiPropertyOptional({ type: [ProjectShowcasePostMediaDto] }) + media?: ProjectShowcasePostMediaDto[]; + @ApiProperty() createdById: number; diff --git a/src/api/project-showcase-post/project-showcase-post.service.spec.ts b/src/api/project-showcase-post/project-showcase-post.service.spec.ts index 1c26c60..2eb482a 100644 --- a/src/api/project-showcase-post/project-showcase-post.service.spec.ts +++ b/src/api/project-showcase-post/project-showcase-post.service.spec.ts @@ -55,6 +55,15 @@ describe('ProjectShowcasePostService', () => { }, }, ], + media: [ + { + id: BigInt(101), + type: 'image/png', + url: 'https://example.com/image.png', + createdAt: new Date('2026-01-01T00:00:00Z'), + createdBy: BigInt(42), + }, + ], ...overrides, } as const; } @@ -188,12 +197,72 @@ describe('ProjectShowcasePostService', () => { categories: { create: [{ categoryId: BigInt(7) }], }, + media: { + create: [], + }, }), }), ); expect(response.status).toBe('DRAFT'); }); + it('creates a new project showcase post with media assets', async () => { + prismaMock.projectShowcasePost.create.mockResolvedValue( + buildPostRecord({ + status: 'DRAFT', + media: [ + { + id: BigInt(101), + type: 'image/png', + url: 'https://example.com/image.png', + createdAt: new Date('2026-01-01T00:00:00Z'), + createdBy: BigInt(42), + }, + ], + }), + ); + + const response = await service.createPost( + '1001', + { + title: 'New post', + content: 'New content', + industryIds: ['5'], + categoryIds: ['7'], + media: [ + { + type: 'image/png', + url: 'https://example.com/image.png', + }, + ], + }, + user, + ); + + expect(prismaMock.projectShowcasePost.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + media: { + create: [ + { + type: 'image/png', + url: 'https://example.com/image.png', + createdBy: 42, + }, + ], + }, + }), + }), + ); + expect(response.media).toEqual([ + expect.objectContaining({ + id: '101', + type: 'image/png', + url: 'https://example.com/image.png', + }), + ]); + }); + it('throws NotFoundException when create hits an industry foreign key constraint', async () => { const error = Object.create( Prisma.PrismaClientKnownRequestError.prototype, @@ -300,6 +369,66 @@ describe('ProjectShowcasePostService', () => { expect(response.title).toBe('Updated title'); }); + it('updates a post with media assets', async () => { + prismaMock.projectShowcasePost.findFirst.mockResolvedValue( + buildPostRecord(), + ); + prismaMock.projectShowcasePost.update.mockResolvedValue( + buildPostRecord({ + title: 'Updated title', + media: [ + { + id: BigInt(102), + type: 'video/mp4', + url: 'https://example.com/video.mp4', + createdAt: new Date('2026-01-01T00:00:00Z'), + createdBy: BigInt(42), + }, + ], + }), + ); + + const response = await service.updatePost( + '1001', + '10', + { + title: 'Updated title', + media: [ + { + type: 'video/mp4', + url: 'https://example.com/video.mp4', + }, + ], + }, + user, + ); + + expect(prismaMock.projectShowcasePost.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: BigInt(10) }, + data: expect.objectContaining({ + media: { + deleteMany: {}, + create: [ + { + type: 'video/mp4', + url: 'https://example.com/video.mp4', + createdBy: 42, + }, + ], + }, + }), + }), + ); + expect(response.media).toEqual([ + expect.objectContaining({ + id: '102', + type: 'video/mp4', + url: 'https://example.com/video.mp4', + }), + ]); + }); + it('throws NotFoundException when update hits an industry foreign key constraint', async () => { prismaMock.projectShowcasePost.findFirst.mockResolvedValue( buildPostRecord(), diff --git a/src/api/project-showcase-post/project-showcase-post.service.ts b/src/api/project-showcase-post/project-showcase-post.service.ts index 0069af3..8ba1f8a 100644 --- a/src/api/project-showcase-post/project-showcase-post.service.ts +++ b/src/api/project-showcase-post/project-showcase-post.service.ts @@ -44,6 +44,7 @@ export class ProjectShowcasePostService { categories: { include: { category: true }, }, + media: true, }, orderBy, skip, @@ -90,6 +91,7 @@ export class ProjectShowcasePostService { categories: { include: { category: true }, }, + media: true, }, orderBy, skip, @@ -151,6 +153,7 @@ export class ProjectShowcasePostService { categories: { include: { category: true }, }, + media: true, }, }); @@ -204,6 +207,13 @@ export class ProjectShowcasePostService { categories: { create: categoryIds.map((categoryId) => ({ categoryId })), }, + media: { + create: (dto.media || []).map((asset) => ({ + type: asset.type, + url: asset.url, + createdBy: BigInt(auditUserId), + })), + }, }, include: { industries: { @@ -212,6 +222,7 @@ export class ProjectShowcasePostService { categories: { include: { category: true }, }, + media: true, }, }); @@ -286,6 +297,18 @@ export class ProjectShowcasePostService { }; } + if (typeof dto.media !== 'undefined' && Array.isArray(dto.media)) { + const auditUserId = BigInt(getAuditUserId(user)); + updateData.media = { + deleteMany: {}, + create: dto.media.map((mediaItem) => ({ + type: mediaItem.type, + url: mediaItem.url, + createdBy: auditUserId, + })), + }; + } + try { const updated = await this.prisma.projectShowcasePost.update({ where: { @@ -299,6 +322,7 @@ export class ProjectShowcasePostService { categories: { include: { category: true }, }, + media: true, }, }); @@ -435,6 +459,13 @@ export class ProjectShowcasePostService { post: ProjectShowcasePost & { industries: { industry: { id: bigint; name: string } }[]; categories: { category: { id: bigint; name: string } }[]; + media: { + id: bigint; + type: string; + url: string; + createdAt: Date; + createdBy: bigint; + }[]; }, ): ProjectShowcasePostResponseDto { return { @@ -456,6 +487,13 @@ export class ProjectShowcasePostService { id: String(entry.category.id), name: entry.category.name, })), + media: post.media.map((entry) => ({ + id: String(entry.id), + type: entry.type, + url: entry.url, + createdAt: entry.createdAt, + createdBy: String(entry.createdBy), + })), }; }