Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
16 changes: 16 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down Expand Up @@ -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")
}
Original file line number Diff line number Diff line change
@@ -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.' })
Expand Down Expand Up @@ -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[];
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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()
Expand All @@ -26,6 +27,9 @@ export class ProjectShowcasePostResponseDto {
@ApiProperty({ type: [Object] })
categories: Array<{ id: string; name: string }>;

@ApiPropertyOptional({ type: [ProjectShowcasePostMediaDto] })
media?: ProjectShowcasePostMediaDto[];

@ApiProperty()
createdById: number;

Expand Down
129 changes: 129 additions & 0 deletions src/api/project-showcase-post/project-showcase-post.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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,
},
Comment thread
vas3a marked this conversation as resolved.
],
},
}),
}),
);
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,
Expand Down Expand Up @@ -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,
},
Comment thread
vas3a marked this conversation as resolved.
],
},
}),
}),
);
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(),
Expand Down
38 changes: 38 additions & 0 deletions src/api/project-showcase-post/project-showcase-post.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export class ProjectShowcasePostService {
categories: {
include: { category: true },
},
media: true,
},
orderBy,
skip,
Expand Down Expand Up @@ -90,6 +91,7 @@ export class ProjectShowcasePostService {
categories: {
include: { category: true },
},
media: true,
},
orderBy,
skip,
Expand Down Expand Up @@ -151,6 +153,7 @@ export class ProjectShowcasePostService {
categories: {
include: { category: true },
},
media: true,
},
});

Expand Down Expand Up @@ -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),
})),
Comment thread
vas3a marked this conversation as resolved.
},
},
include: {
industries: {
Expand All @@ -212,6 +222,7 @@ export class ProjectShowcasePostService {
categories: {
include: { category: true },
},
media: true,
},
});

Expand Down Expand Up @@ -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: {
Expand All @@ -299,6 +322,7 @@ export class ProjectShowcasePostService {
categories: {
include: { category: true },
},
media: true,
},
});

Expand Down Expand Up @@ -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 {
Expand All @@ -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),
})),
};
}

Expand Down
Loading