From 788fe0d2146b30dd8de7f466d643fd3c0d3d5b14 Mon Sep 17 00:00:00 2001 From: Sidney Nemzer Date: Mon, 26 Jan 2026 22:12:07 -0500 Subject: [PATCH 1/4] =?UTF-8?q?feat(api/v2):=20=E2=9C=A8=20Create=20showca?= =?UTF-8?q?se=20routes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api-v2/src/app.module.ts | 2 + .../showcases/dto/create.showcase.dto.ts | 31 ++++ .../sections/showcases/dto/showcase.dto.ts | 73 ++++++++ .../showcases/dto/update.showcase.dto.ts | 15 ++ .../showcases/showcases.controller.ts | 157 ++++++++++++++++++ .../sections/showcases/showcases.module.ts | 9 + .../sections/showcases/showcases.service.ts | 107 ++++++++++++ 7 files changed, 394 insertions(+) create mode 100644 apps/api-v2/src/sections/showcases/dto/create.showcase.dto.ts create mode 100644 apps/api-v2/src/sections/showcases/dto/showcase.dto.ts create mode 100644 apps/api-v2/src/sections/showcases/dto/update.showcase.dto.ts create mode 100644 apps/api-v2/src/sections/showcases/showcases.controller.ts create mode 100644 apps/api-v2/src/sections/showcases/showcases.module.ts create mode 100644 apps/api-v2/src/sections/showcases/showcases.service.ts diff --git a/apps/api-v2/src/app.module.ts b/apps/api-v2/src/app.module.ts index ff303fae..be38e7e7 100644 --- a/apps/api-v2/src/app.module.ts +++ b/apps/api-v2/src/app.module.ts @@ -8,6 +8,7 @@ import { ApplicationsModule } from './sections/applications/applications.module' import { ApplicationTemplatesModule } from './sections/applications/templates/application-templates.module'; import { AuthModule } from './sections/auth/auth.module'; import { ClaimsModule } from './sections/claims/claims.module'; +import { ShowcasesModule } from './sections/showcases/showcases.module'; import { StatusModule } from './sections/status/status.module'; import { UtilityModule } from './sections/utility/utility.module'; @@ -22,6 +23,7 @@ import { UtilityModule } from './sections/utility/utility.module'; AuthModule, ClaimsModule, ConfigModule.forRoot({ isGlobal: true, cache: true }), + ShowcasesModule, StatusModule, UtilityModule, ], diff --git a/apps/api-v2/src/sections/showcases/dto/create.showcase.dto.ts b/apps/api-v2/src/sections/showcases/dto/create.showcase.dto.ts new file mode 100644 index 00000000..9f886d4a --- /dev/null +++ b/apps/api-v2/src/sections/showcases/dto/create.showcase.dto.ts @@ -0,0 +1,31 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { IsISO8601, IsOptional, IsString } from "class-validator"; + +export class CreateShowcaseDto { + @ApiProperty({ + description: "The title of the showcase.", + }) + @IsString() + title: string; + + @ApiProperty({ + description: "The city for the showcase.", + }) + @IsString() + city: string; + + @ApiPropertyOptional({ + description: + "The timestamp when the showcase was created. Defaults to the current time.", + example: "2025-04-19T16:45:18.767Z", + }) + @IsISO8601() + @IsOptional() + createdAt?: string; + + @ApiProperty({ + description: "The ID of the image associated with this showcase.", + }) + @IsString() + imageId: string; +} diff --git a/apps/api-v2/src/sections/showcases/dto/showcase.dto.ts b/apps/api-v2/src/sections/showcases/dto/showcase.dto.ts new file mode 100644 index 00000000..55ed4e16 --- /dev/null +++ b/apps/api-v2/src/sections/showcases/dto/showcase.dto.ts @@ -0,0 +1,73 @@ +import { ApiProperty } from "@nestjs/swagger"; + +export class ShowcaseDto { + @ApiProperty({ + example: "00000000-0000-0000-0000-000000000000", + description: "The unique ID of the showcase.", + }) + id: string; + + @ApiProperty({ + example: "Showcase Title", + description: "The title of the showcase.", + }) + title: string; + + @ApiProperty({ + example: "New York", + description: "The city for the showcase.", + }) + city: string; + + @ApiProperty({ + example: "2025-04-19T16:45:18.767Z", + description: "The timestamp when the showcase was created.", + }) + createdAt: string; + + @ApiProperty({ + example: "00000000-0000-0000-0000-000000000000", + description: "The ID of the build team this showcase belongs to.", + }) + buildTeamId: string; + + @ApiProperty({ + example: "00000000-0000-0000-0000-000000000000", + description: "The ID of the image associated with this showcase.", + }) + imageId: string; + + @ApiProperty({ + example: { + name: "Build Team Name", + location: "Country", + slug: "build-team-slug", + icon: "https://example.com/icon.png", + id: "00000000-0000-0000-0000-000000000000", + }, + description: "The build team object for this showcase.", + required: false, + }) + buildTeam?: { + name: string; + location: string; + slug: string; + icon: string; + id: string; + }; + + @ApiProperty({ + example: { + id: "00000000-0000-0000-0000-000000000000", + name: "image.png", + url: "https://example.com/image.png", + }, + description: "The image object for this showcase.", + required: false, + }) + image?: { + id: string; + name: string; + url: string; + }; +} diff --git a/apps/api-v2/src/sections/showcases/dto/update.showcase.dto.ts b/apps/api-v2/src/sections/showcases/dto/update.showcase.dto.ts new file mode 100644 index 00000000..53cdd00f --- /dev/null +++ b/apps/api-v2/src/sections/showcases/dto/update.showcase.dto.ts @@ -0,0 +1,15 @@ +import { IsOptional, IsString } from "class-validator"; + +export class UpdateShowcaseDto { + @IsString() + @IsOptional() + title: string; + + @IsString() + @IsOptional() + city: string; + + @IsString() + @IsOptional() + imageId: string; +} diff --git a/apps/api-v2/src/sections/showcases/showcases.controller.ts b/apps/api-v2/src/sections/showcases/showcases.controller.ts new file mode 100644 index 00000000..5761ad00 --- /dev/null +++ b/apps/api-v2/src/sections/showcases/showcases.controller.ts @@ -0,0 +1,157 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + Put, + Query, + Req, +} from "@nestjs/common"; +import { ApiBearerAuth, ApiOperation } from "@nestjs/swagger"; +import { Request } from "express"; +import { + ApiDefaultResponse, + ApiErrorResponse, + ApiPaginatedResponseDto, +} from "src/common/decorators/api-response.decorator"; +import { Filter, FilterParams } from "src/common/decorators/filter.decorator"; +import { Filtered } from "src/common/decorators/filtered.decorator"; +import { Paginated } from "src/common/decorators/paginated.decorator"; +import { + Pagination, + PaginationParams, +} from "src/common/decorators/pagination.decorator"; +import { Sortable } from "src/common/decorators/sortable.decorator"; +import { + Sorting, + SortingParams, +} from "src/common/decorators/sorting.decorator"; +import { ShowcasesService } from "./showcases.service"; +import { ShowcaseDto } from "./dto/showcase.dto"; +import { CreateShowcaseDto } from "./dto/create.showcase.dto"; +import { UpdateShowcaseDto } from "./dto/update.showcase.dto"; + +@Controller() +export class ShowcasesController { + constructor(private readonly showcasesService: ShowcasesService) {} + + /** + * Returns all showcases. + */ + @Get("/showcases") + @ApiBearerAuth() + @Sortable({ + defaultSortBy: "createdAt", + allowedFields: ["title", "city", "createdAt", "buildTeamId"], + defaultOrder: "desc", + }) + @Paginated() + @ApiOperation({ + summary: "Get All Showcases", + }) + @Filtered({ + fields: [ + { name: "title", required: false, type: String }, + { name: "city", required: false, type: String }, + { name: "createdAt", required: false, type: String }, + { name: "buildTeamId", required: false, type: String }, + ], + }) + @ApiPaginatedResponseDto(ShowcaseDto, { description: "Success" }) + @ApiErrorResponse({ status: 401, description: "Unauthorized" }) + async getAllShowcases( + @Pagination() pagination: PaginationParams, + @Sorting() sorting: SortingParams, + @Filter() filter: FilterParams, + ) { + return this.showcasesService.findAll( + pagination, + sorting.sortBy, + sorting.order, + filter.filter, + ); + } + + /** + * Returns all showcases for a team. + */ + @Get("/teams/:id/showcases") + @ApiBearerAuth() + @ApiOperation({ + summary: "Get Team Showcases", + description: "Returns all showcases for a given team.", + }) + @ApiPaginatedResponseDto(ShowcaseDto, { description: "Success" }) + @ApiErrorResponse({ status: 401, description: "Unauthorized" }) + async getTeamShowcases( + @Param("id") id: string, + @Query("slug") slug?: string, + ) { + return this.showcasesService.getTeamShowcases(id, !!slug); + } + + /** + * Creates a new showcase. + */ + @Post("/showcases") + @ApiBearerAuth() + @ApiOperation({ + summary: "Create Showcase", + }) + @ApiDefaultResponse(ShowcaseDto, { + status: 201, + description: "Showcase created successfully.", + }) + @ApiErrorResponse({ status: 401, description: "Unauthorized" }) + @ApiErrorResponse({ status: 400, description: "Bad Request" }) + async createShowcase( + @Body() createShowcaseDto: CreateShowcaseDto, + @Req() req: Request, + ) { + return this.showcasesService.createShowcase( + createShowcaseDto, + req.token.id, + ); + } + + /** + * Updates a showcase. + */ + @Put("/showcases/:id") + @ApiBearerAuth() + @ApiOperation({ + summary: "Update Showcase", + }) + @ApiDefaultResponse(ShowcaseDto, { + status: 200, + description: "Showcase updated successfully.", + }) + @ApiErrorResponse({ status: 401, description: "Unauthorized" }) + @ApiErrorResponse({ status: 400, description: "Bad Request" }) + async updateShowcase( + @Param("id") id: string, + @Body() updateShowcaseDto: UpdateShowcaseDto, + ) { + return this.showcasesService.updateShowcase(id, updateShowcaseDto); + } + + /** + * Deletes a showcase. + */ + @Delete("/showcases/:id") + @ApiBearerAuth() + @ApiOperation({ + summary: "Delete Showcase", + }) + @ApiDefaultResponse(ShowcaseDto, { + status: 200, + description: "Showcase deleted successfully.", + }) + @ApiErrorResponse({ status: 401, description: "Unauthorized" }) + @ApiErrorResponse({ status: 400, description: "Bad Request" }) + async deleteShowcase(@Param("id") id: string) { + return this.showcasesService.deleteShowcase(id); + } +} diff --git a/apps/api-v2/src/sections/showcases/showcases.module.ts b/apps/api-v2/src/sections/showcases/showcases.module.ts new file mode 100644 index 00000000..efc5db4c --- /dev/null +++ b/apps/api-v2/src/sections/showcases/showcases.module.ts @@ -0,0 +1,9 @@ +import { Module } from "@nestjs/common"; +import { ShowcasesController } from "./showcases.controller"; +import { PrismaService } from "src/common/db/prisma.service"; + +@Module({ + controllers: [ShowcasesController], + providers: [PrismaService], +}) +export class ShowcasesModule {} diff --git a/apps/api-v2/src/sections/showcases/showcases.service.ts b/apps/api-v2/src/sections/showcases/showcases.service.ts new file mode 100644 index 00000000..404b784f --- /dev/null +++ b/apps/api-v2/src/sections/showcases/showcases.service.ts @@ -0,0 +1,107 @@ +import { Injectable } from "@nestjs/common"; +import { PrismaService } from "src/common/db/prisma.service"; +import { CreateShowcaseDto } from "./dto/create.showcase.dto"; +import { UpdateShowcaseDto } from "./dto/update.showcase.dto"; + +@Injectable() +export class ShowcasesService { + constructor(private readonly prisma: PrismaService) {} + + /** + * Finds all showcases based on pagination, sorting, and filtering parameters. + * @param pagination - Pagination parameters. + * @param sortBy - Field to sort by. + * @param order - Order of sorting (asc/desc). + * @param filter - Filter parameters. + * @returns A paginated response containing the showcases and metadata. + */ + async findAll( + pagination: { page: number; limit: number }, + sortBy?: string, + order?: string, + filter?: Record, + ) { + const sortField = sortBy || "createdAt"; + const sortOrder = order === "desc" ? "desc" : "asc"; + + const take = Math.max(Number(pagination.limit) || 20, 1); + const skip = Math.max((Number(pagination.page) || 1) - 1, 0) * take; + + const where = filter || {}; + + const [showcases, count] = await Promise.all([ + this.prisma.showcase.findMany({ + where, + orderBy: { [sortField]: sortOrder }, + skip, + take, + include: { + image: true, + buildTeam: { + select: { + name: true, + location: true, + slug: true, + icon: true, + id: true, + }, + }, + }, + }), + this.prisma.showcase.count({ where }), + ]); + + return { + data: showcases, + meta: { + page: pagination.page, + perPage: pagination.limit, + totalItems: count, + totalPages: Math.ceil(count / pagination.limit), + }, + }; + } + + async getTeamShowcases(id: string, slug?: boolean) { + const where = slug ? { buildTeam: { slug: id } } : { buildTeam: { id } }; + return this.prisma.showcase.findMany({ + where, + include: { image: true }, + }); + } + + async createShowcase( + createShowcaseDto: CreateShowcaseDto, + buildTeamId: string, + ) { + return this.prisma.showcase.create({ + data: { + title: createShowcaseDto.title, + city: createShowcaseDto.city, + buildTeam: { connect: { id: buildTeamId } }, + createdAt: createShowcaseDto.createdAt, + image: { connect: { id: createShowcaseDto.imageId } }, + }, + include: { image: true }, + }); + } + + async updateShowcase(id: string, updateShowcaseDto: UpdateShowcaseDto) { + return this.prisma.showcase.update({ + where: { id }, + data: { + title: updateShowcaseDto.title, + city: updateShowcaseDto.city, + image: { connect: { id: updateShowcaseDto.imageId } }, + }, + include: { image: true }, + }); + } + + async deleteShowcase(id: string) { + return this.prisma.showcase.delete({ + where: { id }, + include: { image: true }, + }); + } +} From b8b98df0f227b2491f38d81e8647ec1b42140c93 Mon Sep 17 00:00:00 2001 From: Kyan Van den Eynde <66461508+kyanvde@users.noreply.github.com> Date: Thu, 27 Aug 2026 18:23:15 +0200 Subject: [PATCH 2/4] feat(api/v2): :sparkles: Add S3 backed image uploads Showcases need an image before they can be created, and v2 had no way to store one. This adds the missing half: a thin S3Service over the CDN bucket the v1 API already writes to, and an UploadsService that records what it stored as an Upload row. The blur placeholder is plaiceholder's base64 pipeline reimplemented on sharp, because plaiceholder v3 ships ESM only and api-v2 compiles to CommonJS. Unlike v1, the recorded width and height are the real ones rather than the fixed 1920x1080 that fell out of its resize call. Uploads are shared with claims, so deletion only removes a row nothing else points at. Co-Authored-By: Claude Opus 5 --- apps/api-v2/.env.example | 7 +- apps/api-v2/package.json | 5 +- .../src/common/db/external/s3.service.ts | 94 +++++++++++ .../src/common/uploads/uploads.service.ts | 137 +++++++++++++++ .../common/uploads/uploads.service.spec.ts | 156 ++++++++++++++++++ yarn.lock | 3 + 6 files changed, 400 insertions(+), 2 deletions(-) create mode 100644 apps/api-v2/src/common/db/external/s3.service.ts create mode 100644 apps/api-v2/src/common/uploads/uploads.service.ts create mode 100644 apps/api-v2/test/common/uploads/uploads.service.spec.ts diff --git a/apps/api-v2/.env.example b/apps/api-v2/.env.example index 849fd841..60c7a2ef 100644 --- a/apps/api-v2/.env.example +++ b/apps/api-v2/.env.example @@ -1 +1,6 @@ -JWT_SECRET=topsecret \ No newline at end of file +JWT_SECRET=topsecret + +AWS_REGION=reg-1 +AWS_ACCESS_KEY=thirdtopsecret +AWS_SECRET_KEY=fourthtopsecret +AWS_UPLOAD_BUCKET_NAME=uploads diff --git a/apps/api-v2/package.json b/apps/api-v2/package.json index 44796e6e..e08935e7 100644 --- a/apps/api-v2/package.json +++ b/apps/api-v2/package.json @@ -18,6 +18,7 @@ "test:e2e": "jest --config ./test/jest-e2e.json" }, "dependencies": { + "@aws-sdk/client-s3": "^3.787.0", "@nestjs/axios": "^4.0.1", "@nestjs/common": "^11.0.1", "@nestjs/config": "^4.0.2", @@ -31,7 +32,8 @@ "class-validator": "^0.14.2", "helmet": "^8.1.0", "reflect-metadata": "^0.2.2", - "rxjs": "^7.8.1" + "rxjs": "^7.8.1", + "sharp": "^0.34.1" }, "devDependencies": { "@eslint/eslintrc": "^3.2.0", @@ -45,6 +47,7 @@ "@swc/core": "^1.10.7", "@types/express": "^5.0.0", "@types/jest": "^29.5.14", + "@types/multer": "^1.4.12", "@types/node": "^22.10.7", "@types/supertest": "^6.0.2", "eslint": "^9.18.0", diff --git a/apps/api-v2/src/common/db/external/s3.service.ts b/apps/api-v2/src/common/db/external/s3.service.ts new file mode 100644 index 00000000..a2baf8a7 --- /dev/null +++ b/apps/api-v2/src/common/db/external/s3.service.ts @@ -0,0 +1,94 @@ +import { DeleteObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; +import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +/** + * Default endpoint of the BuildTheEarth CDN, which fronts the S3 compatible + * storage the v1 API already writes to. + */ +export const DEFAULT_S3_ENDPOINT = 'https://cdn.buildtheearth.net'; + +/** + * Thin wrapper around the object storage that holds user uploads. + * + * The credentials are optional: an instance without them still starts, but every + * call fails with a 503 instead. That keeps the rest of the API usable in + * development, where the CDN credentials are usually not configured. + */ +@Injectable() +export class S3Service { + private readonly logger = new Logger(S3Service.name); + private readonly client: S3Client | null = null; + private readonly uploadBucket: string | undefined; + + constructor(private readonly configService: ConfigService) { + const accessKeyId = this.configService.get('AWS_ACCESS_KEY'); + const secretAccessKey = this.configService.get('AWS_SECRET_KEY'); + const region = this.configService.get('AWS_REGION'); + + this.uploadBucket = this.configService.get('AWS_UPLOAD_BUCKET_NAME'); + + if (!accessKeyId || !secretAccessKey || !region || !this.uploadBucket) { + this.logger.warn('AWS configuration is missing. S3Service will reject every request.'); + return; + } + + this.client = new S3Client({ + credentials: { accessKeyId, secretAccessKey }, + region, + endpoint: this.configService.get('AWS_ENDPOINT') ?? DEFAULT_S3_ENDPOINT, + forcePathStyle: true, + }); + } + + /** + * Whether the service has enough configuration to talk to the bucket. + */ + get isConfigured(): boolean { + return this.client !== null; + } + + /** + * Writes an object to the upload bucket. + * @param key Key to store the object under. + * @param body Raw bytes of the object. + * @param contentType MIME type reported to clients that fetch the object. + * @throws ServiceUnavailableException if the service is not configured. + */ + async putObject(key: string, body: Buffer, contentType: string): Promise { + const client = this.requireClient(); + + await client.send( + new PutObjectCommand({ + Bucket: this.uploadBucket, + Key: key, + Body: body, + ContentType: contentType, + }), + ); + } + + /** + * Removes an object from the upload bucket. + * @param key Key the object is stored under. + * @throws ServiceUnavailableException if the service is not configured. + */ + async deleteObject(key: string): Promise { + const client = this.requireClient(); + + await client.send( + new DeleteObjectCommand({ + Bucket: this.uploadBucket, + Key: key, + }), + ); + } + + private requireClient(): S3Client { + if (!this.client) { + throw new ServiceUnavailableException('File storage is not configured'); + } + + return this.client; + } +} diff --git a/apps/api-v2/src/common/uploads/uploads.service.ts b/apps/api-v2/src/common/uploads/uploads.service.ts new file mode 100644 index 00000000..87a0d44e --- /dev/null +++ b/apps/api-v2/src/common/uploads/uploads.service.ts @@ -0,0 +1,137 @@ +import { BadRequestException, Injectable, Logger, PayloadTooLargeException } from '@nestjs/common'; +import { randomBytes } from 'crypto'; +import sharp from 'sharp'; +import { PrismaService } from '../db/prisma.service'; +import { S3Service } from '../db/external/s3.service'; + +/** + * Largest image the API accepts. Also handed to multer, which rejects anything + * bigger before it is buffered in full. + */ +export const MAX_UPLOAD_BYTES = 10 * 1024 * 1024; + +/** + * Image formats the CDN is expected to serve back. + */ +export const ALLOWED_UPLOAD_MIME_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/avif', 'image/gif']; + +@Injectable() +export class UploadsService { + private readonly logger = new Logger(UploadsService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly s3: S3Service, + ) {} + + /** + * Stores an image in the upload bucket and records it as an Upload row. + * @param file The multipart file to store. + * @returns The created upload. + * @throws BadRequestException if the file is missing, of an unsupported type or unreadable. + * @throws PayloadTooLargeException if the file exceeds MAX_UPLOAD_BYTES. + */ + async createFromFile(file: Express.Multer.File) { + if (!file?.buffer?.length) { + throw new BadRequestException('No image was uploaded'); + } + + if (!ALLOWED_UPLOAD_MIME_TYPES.includes(file.mimetype)) { + throw new BadRequestException( + `Unsupported image type ${file.mimetype}. Allowed types are: ${ALLOWED_UPLOAD_MIME_TYPES.join(', ')}`, + ); + } + + if (file.size > MAX_UPLOAD_BYTES) { + throw new PayloadTooLargeException(`Images may be at most ${MAX_UPLOAD_BYTES} bytes`); + } + + const { width, height } = await this.readDimensions(file.buffer); + const hash = await this.buildPlaceholder(file.buffer); + const key = randomBytes(32).toString('hex'); + + await this.s3.putObject(key, file.buffer, file.mimetype); + + try { + return await this.prisma.upload.create({ + data: { name: key, hash, width, height }, + }); + } catch (error) { + // The object is already in the bucket at this point, and nothing references + // it, so it would stay there forever if we left it behind. + await this.s3.deleteObject(key).catch((cleanupError: unknown) => { + this.logger.error(`Failed to remove orphaned upload ${key}`, cleanupError); + }); + + throw error; + } + } + + /** + * Deletes an upload and the object behind it, unless something still points at it. + * + * Uploads are shared: the same row can back a claim image as well as any number + * of showcases, so removing one showcase must not pull the image out from under + * the others. + * @param uploadId ID of the upload to remove. + * @returns Whether the upload was deleted. + */ + async deleteIfUnreferenced(uploadId: string): Promise { + const upload = await this.prisma.upload.findUnique({ + where: { id: uploadId }, + select: { + id: true, + name: true, + claimId: true, + _count: { select: { Showcase: true } }, + }, + }); + + if (!upload || upload.claimId || upload._count.Showcase > 0) { + return false; + } + + await this.prisma.upload.delete({ where: { id: upload.id } }); + await this.s3.deleteObject(upload.name); + + return true; + } + + /** + * Reads the real dimensions of an image, which the frontend needs to reserve + * space for it before it has loaded. + * @throws BadRequestException if the buffer is not an image sharp can read. + */ + private async readDimensions(buffer: Buffer): Promise<{ width: number; height: number }> { + const metadata = await sharp(buffer) + .metadata() + .catch(() => { + throw new BadRequestException('The uploaded file could not be read as an image'); + }); + + if (!metadata.width || !metadata.height) { + throw new BadRequestException('The uploaded file could not be read as an image'); + } + + return { width: metadata.width, height: metadata.height }; + } + + /** + * Builds the blurred placeholder that is stored as the upload hash and rendered + * while the full image loads. + * + * This is plaiceholder's `base64` output, reimplemented on top of sharp: v1 uses + * plaiceholder itself, but it ships as ESM only and api-v2 compiles to CommonJS. + * The pipeline is kept identical so both APIs produce interchangeable hashes. + */ + private async buildPlaceholder(buffer: Buffer): Promise { + const { data, info } = await sharp(buffer) + .resize(4, 4, { fit: 'inside' }) + .toFormat('png') + .modulate({ brightness: 1, saturation: 1.2 }) + .normalise() + .toBuffer({ resolveWithObject: true }); + + return `data:image/${info.format};base64,${data.toString('base64')}`; + } +} diff --git a/apps/api-v2/test/common/uploads/uploads.service.spec.ts b/apps/api-v2/test/common/uploads/uploads.service.spec.ts new file mode 100644 index 00000000..3724d8e9 --- /dev/null +++ b/apps/api-v2/test/common/uploads/uploads.service.spec.ts @@ -0,0 +1,156 @@ +import { BadRequestException, PayloadTooLargeException } from '@nestjs/common'; +import sharp from 'sharp'; +import { S3Service } from 'src/common/db/external/s3.service'; +import { PrismaService } from 'src/common/db/prisma.service'; +import { MAX_UPLOAD_BYTES, UploadsService } from 'src/common/uploads/uploads.service'; + +describe('UploadsService', () => { + let uploadsService: UploadsService; + let prismaService: { + upload: { create: jest.Mock; findUnique: jest.Mock; delete: jest.Mock }; + }; + let s3Service: { putObject: jest.Mock; deleteObject: jest.Mock }; + let png: Buffer; + + const fileFrom = (buffer: Buffer, overrides: Partial = {}) => + ({ + buffer, + size: buffer.length, + mimetype: 'image/png', + ...overrides, + }) as Express.Multer.File; + + beforeAll(async () => { + png = await sharp({ + create: { width: 8, height: 6, channels: 3, background: { r: 255, g: 0, b: 0 } }, + }) + .png() + .toBuffer(); + }); + + beforeEach(() => { + prismaService = { + upload: { create: jest.fn(), findUnique: jest.fn(), delete: jest.fn() }, + }; + s3Service = { + putObject: jest.fn().mockResolvedValue(undefined), + deleteObject: jest.fn().mockResolvedValue(undefined), + }; + + uploadsService = new UploadsService(prismaService as unknown as PrismaService, s3Service as unknown as S3Service); + }); + + describe('createFromFile', () => { + it('should store the file and record its real dimensions', async () => { + prismaService.upload.create.mockResolvedValue({ id: 'upload-1' }); + + const result = await uploadsService.createFromFile(fileFrom(png)); + + expect(s3Service.putObject).toHaveBeenCalledWith(expect.any(String), png, 'image/png'); + expect(prismaService.upload.create).toHaveBeenCalledWith({ + data: { + name: expect.any(String), + hash: expect.stringMatching(/^data:image\/png;base64,/), + width: 8, + height: 6, + }, + }); + expect(result).toEqual({ id: 'upload-1' }); + }); + + it('should store the object under the key it records', async () => { + prismaService.upload.create.mockResolvedValue({ id: 'upload-1' }); + + await uploadsService.createFromFile(fileFrom(png)); + + const [storedKey] = s3Service.putObject.mock.calls[0] as [string]; + expect(prismaService.upload.create.mock.calls[0][0].data.name).toBe(storedKey); + }); + + it('should reject an empty file', async () => { + await expect(uploadsService.createFromFile(fileFrom(Buffer.alloc(0)))).rejects.toThrow(BadRequestException); + expect(s3Service.putObject).not.toHaveBeenCalled(); + }); + + it('should reject an unsupported mime type', async () => { + await expect(uploadsService.createFromFile(fileFrom(png, { mimetype: 'application/pdf' }))).rejects.toThrow( + BadRequestException, + ); + expect(s3Service.putObject).not.toHaveBeenCalled(); + }); + + it('should reject a file over the size limit', async () => { + await expect(uploadsService.createFromFile(fileFrom(png, { size: MAX_UPLOAD_BYTES + 1 }))).rejects.toThrow( + PayloadTooLargeException, + ); + expect(s3Service.putObject).not.toHaveBeenCalled(); + }); + + it('should reject bytes that are not a readable image', async () => { + await expect(uploadsService.createFromFile(fileFrom(Buffer.from('not an image')))).rejects.toThrow( + BadRequestException, + ); + expect(s3Service.putObject).not.toHaveBeenCalled(); + }); + + it('should remove the stored object when the row cannot be written', async () => { + prismaService.upload.create.mockRejectedValue(new Error('database is down')); + + await expect(uploadsService.createFromFile(fileFrom(png))).rejects.toThrow('database is down'); + + const [storedKey] = s3Service.putObject.mock.calls[0] as [string]; + expect(s3Service.deleteObject).toHaveBeenCalledWith(storedKey); + }); + }); + + describe('deleteIfUnreferenced', () => { + it('should delete the row and the object when nothing points at it', async () => { + prismaService.upload.findUnique.mockResolvedValue({ + id: 'upload-1', + name: 'object-key', + claimId: null, + _count: { Showcase: 0 }, + }); + + await expect(uploadsService.deleteIfUnreferenced('upload-1')).resolves.toBe(true); + + expect(prismaService.upload.delete).toHaveBeenCalledWith({ where: { id: 'upload-1' } }); + expect(s3Service.deleteObject).toHaveBeenCalledWith('object-key'); + }); + + it('should keep an upload that still backs a claim', async () => { + prismaService.upload.findUnique.mockResolvedValue({ + id: 'upload-1', + name: 'object-key', + claimId: 'claim-1', + _count: { Showcase: 0 }, + }); + + await expect(uploadsService.deleteIfUnreferenced('upload-1')).resolves.toBe(false); + + expect(prismaService.upload.delete).not.toHaveBeenCalled(); + expect(s3Service.deleteObject).not.toHaveBeenCalled(); + }); + + it('should keep an upload that still backs another showcase', async () => { + prismaService.upload.findUnique.mockResolvedValue({ + id: 'upload-1', + name: 'object-key', + claimId: null, + _count: { Showcase: 1 }, + }); + + await expect(uploadsService.deleteIfUnreferenced('upload-1')).resolves.toBe(false); + + expect(prismaService.upload.delete).not.toHaveBeenCalled(); + }); + + it('should do nothing when the upload is already gone', async () => { + prismaService.upload.findUnique.mockResolvedValue(null); + + await expect(uploadsService.deleteIfUnreferenced('upload-1')).resolves.toBe(false); + + expect(prismaService.upload.delete).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index 1ccb4027..a27e6e3c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9845,6 +9845,7 @@ __metadata: version: 0.0.0-use.local resolution: "api-v2@workspace:apps/api-v2" dependencies: + "@aws-sdk/client-s3": "npm:^3.787.0" "@eslint/eslintrc": "npm:^3.2.0" "@eslint/js": "npm:^9.18.0" "@nestjs/axios": "npm:^4.0.1" @@ -9864,6 +9865,7 @@ __metadata: "@swc/core": "npm:^1.10.7" "@types/express": "npm:^5.0.0" "@types/jest": "npm:^29.5.14" + "@types/multer": "npm:^1.4.12" "@types/node": "npm:^22.10.7" "@types/supertest": "npm:^6.0.2" axios: "npm:^1.13.2" @@ -9875,6 +9877,7 @@ __metadata: jest: "npm:^29.7.0" reflect-metadata: "npm:^0.2.2" rxjs: "npm:^7.8.1" + sharp: "npm:^0.34.1" source-map-support: "npm:^0.5.21" supertest: "npm:^7.0.0" ts-jest: "npm:^29.2.5" From fc131a1b6cb0165486684edbb14219681fcd2ea8 Mon Sep 17 00:00:00 2001 From: Kyan Van den Eynde <66461508+kyanvde@users.noreply.github.com> Date: Thu, 27 Aug 2026 18:23:26 +0200 Subject: [PATCH 3/4] feat(api/v2): :lock: Finish the showcase routes with uploads and auth The routes landed as drafts: nothing checked who was calling them, the service was never registered as a provider, and creating a showcase required an image that could not be uploaded yet. Reads stay public, because showcases are what the website puts on its landing page, and gain the `:teamId` prefix the roadmap asks for, with the same `?slug=true` lookup v1 offers. Writes go through TeamScope, so a team can only touch its own showcases, and `approved` is deliberately not updatable: a team must not be able to approve itself onto the front page. Creating a showcase now takes either a multipart image or an existing uploadId, and deleting one takes its image with it unless a claim or another showcase still uses it. Co-Authored-By: Claude Opus 5 --- apps/api-v2/roadmap.md | 10 +- .../showcases/dto/create.showcase.dto.ts | 58 +-- .../sections/showcases/dto/showcase.dto.ts | 170 +++++---- .../showcases/dto/update.showcase.dto.ts | 44 ++- .../showcases/showcases.controller.ts | 325 +++++++++-------- .../sections/showcases/showcases.module.ts | 13 +- .../sections/showcases/showcases.service.ts | 332 ++++++++++++------ .../showcases/showcases.controller.spec.ts | 115 ++++++ .../showcases/showcases.routes.spec.ts | 183 ++++++++++ .../showcases/showcases.service.spec.ts | 288 +++++++++++++++ 10 files changed, 1174 insertions(+), 364 deletions(-) create mode 100644 apps/api-v2/test/sections/showcases/showcases.controller.spec.ts create mode 100644 apps/api-v2/test/sections/showcases/showcases.routes.spec.ts create mode 100644 apps/api-v2/test/sections/showcases/showcases.service.spec.ts diff --git a/apps/api-v2/roadmap.md b/apps/api-v2/roadmap.md index 14064dd7..a8c7e0e5 100644 --- a/apps/api-v2/roadmap.md +++ b/apps/api-v2/roadmap.md @@ -156,11 +156,11 @@ del /claims/[claimId]/images/[imgId] \ ## Showcases -get /showcases \ -get /[teamId]/showcases \ -post /showcases \ -put /showcases/[showId] \ -del /showcases/[showId] +✅ get /showcases \ +✅ get /[teamId]/showcases \ +✅ post /showcases \ +✅ put /showcases/[showId] \ +✅ del /showcases/[showId] ## Members diff --git a/apps/api-v2/src/sections/showcases/dto/create.showcase.dto.ts b/apps/api-v2/src/sections/showcases/dto/create.showcase.dto.ts index 9f886d4a..9fd84a0c 100644 --- a/apps/api-v2/src/sections/showcases/dto/create.showcase.dto.ts +++ b/apps/api-v2/src/sections/showcases/dto/create.showcase.dto.ts @@ -1,31 +1,39 @@ -import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; -import { IsISO8601, IsOptional, IsString } from "class-validator"; +import { ApiPropertyOptional, ApiProperty } from '@nestjs/swagger'; +import { IsISO8601, IsNotEmpty, IsOptional, IsString, IsUUID, MaxLength } from 'class-validator'; export class CreateShowcaseDto { - @ApiProperty({ - description: "The title of the showcase.", - }) - @IsString() - title: string; + @ApiProperty({ + description: 'The title of the showcase.', + example: 'Empire State Building', + }) + @IsString() + @IsNotEmpty() + @MaxLength(255) + title: string; - @ApiProperty({ - description: "The city for the showcase.", - }) - @IsString() - city: string; + @ApiPropertyOptional({ + description: 'The city the showcase was built in.', + example: 'New York', + }) + @IsString() + @IsOptional() + @MaxLength(255) + city?: string; - @ApiPropertyOptional({ - description: - "The timestamp when the showcase was created. Defaults to the current time.", - example: "2025-04-19T16:45:18.767Z", - }) - @IsISO8601() - @IsOptional() - createdAt?: string; + @ApiPropertyOptional({ + description: 'The timestamp when the showcase was created. Defaults to the current time.', + example: '2025-04-19T16:45:18.767Z', + }) + @IsISO8601() + @IsOptional() + createdAt?: string; - @ApiProperty({ - description: "The ID of the image associated with this showcase.", - }) - @IsString() - imageId: string; + @ApiPropertyOptional({ + description: + 'An existing upload to link this showcase to, instead of sending a new image. Mutually exclusive with the image file.', + example: '00000000-0000-0000-0000-000000000000', + }) + @IsUUID() + @IsOptional() + uploadId?: string; } diff --git a/apps/api-v2/src/sections/showcases/dto/showcase.dto.ts b/apps/api-v2/src/sections/showcases/dto/showcase.dto.ts index 55ed4e16..f9d3b766 100644 --- a/apps/api-v2/src/sections/showcases/dto/showcase.dto.ts +++ b/apps/api-v2/src/sections/showcases/dto/showcase.dto.ts @@ -1,73 +1,103 @@ -import { ApiProperty } from "@nestjs/swagger"; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class ShowcaseImageDto { + @ApiProperty({ + example: '00000000-0000-0000-0000-000000000000', + description: 'The unique ID of the upload.', + }) + id: string; + + @ApiProperty({ + example: 'd1f4c0b8f9a24d4f', + description: 'The key the image is stored under in the CDN bucket.', + }) + name: string; + + @ApiProperty({ + example: 'data:image/png;base64,iVBORw0KGgo=', + description: 'A blurred placeholder rendered while the full image loads.', + }) + hash: string; + + @ApiProperty({ example: 1920, description: 'The width of the image in pixels.' }) + width: number; + + @ApiProperty({ example: 1080, description: 'The height of the image in pixels.' }) + height: number; +} + +export class ShowcaseBuildTeamDto { + @ApiProperty({ + example: '00000000-0000-0000-0000-000000000000', + description: 'The unique ID of the build team.', + }) + id: string; + + @ApiProperty({ example: 'Build Team Name', description: 'The name of the build team.' }) + name: string; + + @ApiProperty({ example: 'Country', description: 'The location of the build team.' }) + location: string; + + @ApiProperty({ example: 'build-team-slug', description: 'The slug of the build team.' }) + slug: string; + + @ApiProperty({ example: 'https://example.com/icon.png', description: 'The icon of the build team.' }) + icon: string; +} export class ShowcaseDto { - @ApiProperty({ - example: "00000000-0000-0000-0000-000000000000", - description: "The unique ID of the showcase.", - }) - id: string; - - @ApiProperty({ - example: "Showcase Title", - description: "The title of the showcase.", - }) - title: string; - - @ApiProperty({ - example: "New York", - description: "The city for the showcase.", - }) - city: string; - - @ApiProperty({ - example: "2025-04-19T16:45:18.767Z", - description: "The timestamp when the showcase was created.", - }) - createdAt: string; - - @ApiProperty({ - example: "00000000-0000-0000-0000-000000000000", - description: "The ID of the build team this showcase belongs to.", - }) - buildTeamId: string; - - @ApiProperty({ - example: "00000000-0000-0000-0000-000000000000", - description: "The ID of the image associated with this showcase.", - }) - imageId: string; - - @ApiProperty({ - example: { - name: "Build Team Name", - location: "Country", - slug: "build-team-slug", - icon: "https://example.com/icon.png", - id: "00000000-0000-0000-0000-000000000000", - }, - description: "The build team object for this showcase.", - required: false, - }) - buildTeam?: { - name: string; - location: string; - slug: string; - icon: string; - id: string; - }; - - @ApiProperty({ - example: { - id: "00000000-0000-0000-0000-000000000000", - name: "image.png", - url: "https://example.com/image.png", - }, - description: "The image object for this showcase.", - required: false, - }) - image?: { - id: string; - name: string; - url: string; - }; + @ApiProperty({ + example: '00000000-0000-0000-0000-000000000000', + description: 'The unique ID of the showcase.', + }) + id: string; + + @ApiProperty({ + example: 'Showcase Title', + description: 'The title of the showcase.', + }) + title: string; + + @ApiProperty({ + example: 'New York', + description: 'The city the showcase was built in.', + }) + city: string; + + @ApiProperty({ + example: false, + description: 'Whether the showcase has been approved to appear on the website.', + }) + approved: boolean; + + @ApiProperty({ + example: '2025-04-19T16:45:18.767Z', + description: 'The timestamp when the showcase was created.', + }) + createdAt: string; + + @ApiProperty({ + example: '00000000-0000-0000-0000-000000000000', + description: 'The ID of the build team this showcase belongs to.', + }) + buildTeamId: string; + + @ApiProperty({ + example: '00000000-0000-0000-0000-000000000000', + description: 'The ID of the upload holding the image of this showcase.', + }) + uploadId: string; + + @ApiPropertyOptional({ + type: ShowcaseImageDto, + description: 'The image of this showcase.', + }) + image?: ShowcaseImageDto; + + @ApiPropertyOptional({ + type: ShowcaseBuildTeamDto, + description: 'The build team this showcase belongs to. Only included by the unscoped listing.', + }) + buildTeam?: ShowcaseBuildTeamDto; } diff --git a/apps/api-v2/src/sections/showcases/dto/update.showcase.dto.ts b/apps/api-v2/src/sections/showcases/dto/update.showcase.dto.ts index 53cdd00f..22bb20a2 100644 --- a/apps/api-v2/src/sections/showcases/dto/update.showcase.dto.ts +++ b/apps/api-v2/src/sections/showcases/dto/update.showcase.dto.ts @@ -1,15 +1,39 @@ -import { IsOptional, IsString } from "class-validator"; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsISO8601, IsNotEmpty, IsOptional, IsString, IsUUID, MaxLength } from 'class-validator'; export class UpdateShowcaseDto { - @IsString() - @IsOptional() - title: string; + @ApiPropertyOptional({ + description: 'The title of the showcase.', + example: 'Empire State Building', + }) + @IsString() + @IsNotEmpty() + @MaxLength(255) + @IsOptional() + title?: string; - @IsString() - @IsOptional() - city: string; + @ApiPropertyOptional({ + description: 'The city the showcase was built in.', + example: 'New York', + }) + @IsString() + @MaxLength(255) + @IsOptional() + city?: string; - @IsString() - @IsOptional() - imageId: string; + @ApiPropertyOptional({ + description: 'The timestamp when the showcase was created.', + example: '2025-04-19T16:45:18.767Z', + }) + @IsISO8601() + @IsOptional() + createdAt?: string; + + @ApiPropertyOptional({ + description: 'An existing upload to replace the image of this showcase with.', + example: '00000000-0000-0000-0000-000000000000', + }) + @IsUUID() + @IsOptional() + uploadId?: string; } diff --git a/apps/api-v2/src/sections/showcases/showcases.controller.ts b/apps/api-v2/src/sections/showcases/showcases.controller.ts index 5761ad00..20be256c 100644 --- a/apps/api-v2/src/sections/showcases/showcases.controller.ts +++ b/apps/api-v2/src/sections/showcases/showcases.controller.ts @@ -1,157 +1,190 @@ +import { Body, Controller, Delete, Get, Param, Post, Put, UploadedFile, UseInterceptors } from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; import { - Body, - Controller, - Delete, - Get, - Param, - Post, - Put, - Query, - Req, -} from "@nestjs/common"; -import { ApiBearerAuth, ApiOperation } from "@nestjs/swagger"; -import { Request } from "express"; + ApiBearerAuth, + ApiBody, + ApiConsumes, + ApiExtraModels, + ApiOperation, + ApiParam, + ApiResponse, +} from '@nestjs/swagger'; import { - ApiDefaultResponse, - ApiErrorResponse, - ApiPaginatedResponseDto, -} from "src/common/decorators/api-response.decorator"; -import { Filter, FilterParams } from "src/common/decorators/filter.decorator"; -import { Filtered } from "src/common/decorators/filtered.decorator"; -import { Paginated } from "src/common/decorators/paginated.decorator"; -import { - Pagination, - PaginationParams, -} from "src/common/decorators/pagination.decorator"; -import { Sortable } from "src/common/decorators/sortable.decorator"; -import { - Sorting, - SortingParams, -} from "src/common/decorators/sorting.decorator"; -import { ShowcasesService } from "./showcases.service"; -import { ShowcaseDto } from "./dto/showcase.dto"; -import { CreateShowcaseDto } from "./dto/create.showcase.dto"; -import { UpdateShowcaseDto } from "./dto/update.showcase.dto"; + ApiDefaultResponse, + ApiErrorResponse, + ApiPaginatedResponseDto, +} from 'src/common/decorators/api-response.decorator'; +import { Filter, FilterParams } from 'src/common/decorators/filter.decorator'; +import { Filtered } from 'src/common/decorators/filtered.decorator'; +import { Paginated } from 'src/common/decorators/paginated.decorator'; +import { Pagination, PaginationParams } from 'src/common/decorators/pagination.decorator'; +import { SkipAuth } from 'src/common/decorators/skip-auth.decorator'; +import { Sortable } from 'src/common/decorators/sortable.decorator'; +import { Sorting, SortingParams } from 'src/common/decorators/sorting.decorator'; +import { TeamScope } from 'src/common/decorators/team-scope.decorator'; +import { MAX_UPLOAD_BYTES } from 'src/common/uploads/uploads.service'; +import { ControllerResponse, PaginatedControllerResponse } from 'src/typings'; +import { CreateShowcaseDto } from './dto/create.showcase.dto'; +import { ShowcaseDto } from './dto/showcase.dto'; +import { UpdateShowcaseDto } from './dto/update.showcase.dto'; +import { ShowcasesService } from './showcases.service'; +/** + * Every route is registered twice: once bare, and once behind a `:teamId` + * prefix, so a caller that already carries the team id in its URLs can keep it + * there. The controller therefore has no prefix of its own, since a Nest + * controller prefix cannot be made optional. + * + * Reading is public, because showcases are what the website puts on its landing + * page. Writing is scoped to the authenticated team, so the prefix there has to + * name that same team. See TeamScope. + */ @Controller() export class ShowcasesController { - constructor(private readonly showcasesService: ShowcasesService) {} + constructor(private readonly showcasesService: ShowcasesService) {} + + /** + * Returns showcases, either of the team named in the path or of every team. + */ + @Get(['showcases', ':teamId/showcases']) + @SkipAuth() + @Sortable({ + defaultSortBy: 'createdAt', + allowedFields: ['title', 'city', 'createdAt', 'approved', 'buildTeamId'], + defaultOrder: 'desc', + }) + @Paginated() + @ApiOperation({ + summary: 'Get Showcases', + description: + 'Returns the showcases of the team in the path, or of every team when no team is given. Both forms are public.', + }) + @ApiParam({ + name: 'teamId', + required: false, + description: 'The ID of the build team, or its slug when the slug query parameter is set.', + }) + @Filtered({ + fields: [ + { name: 'title', required: false, type: String }, + { name: 'city', required: false, type: String }, + { name: 'createdAt', required: false, type: Date }, + { name: 'approved', required: false, type: Boolean }, + { name: 'buildTeamId', required: false, type: String }, + { name: 'slug', required: false, type: Boolean }, + ], + }) + @ApiPaginatedResponseDto(ShowcaseDto, { description: 'Success' }) + @ApiErrorResponse({ status: 400, description: 'Bad Request' }) + @ApiErrorResponse({ status: 404, description: 'BuildTeam not found' }) + async getShowcases( + @Param('teamId') teamId: string | undefined, + @Pagination() pagination: PaginationParams, + @Sorting() sorting: SortingParams, + @Filter() filter: FilterParams, + ): PaginatedControllerResponse { + const { slug, ...showcaseFilter }: { slug?: boolean } = filter.filter; - /** - * Returns all showcases. - */ - @Get("/showcases") - @ApiBearerAuth() - @Sortable({ - defaultSortBy: "createdAt", - allowedFields: ["title", "city", "createdAt", "buildTeamId"], - defaultOrder: "desc", - }) - @Paginated() - @ApiOperation({ - summary: "Get All Showcases", - }) - @Filtered({ - fields: [ - { name: "title", required: false, type: String }, - { name: "city", required: false, type: String }, - { name: "createdAt", required: false, type: String }, - { name: "buildTeamId", required: false, type: String }, - ], - }) - @ApiPaginatedResponseDto(ShowcaseDto, { description: "Success" }) - @ApiErrorResponse({ status: 401, description: "Unauthorized" }) - async getAllShowcases( - @Pagination() pagination: PaginationParams, - @Sorting() sorting: SortingParams, - @Filter() filter: FilterParams, - ) { - return this.showcasesService.findAll( - pagination, - sorting.sortBy, - sorting.order, - filter.filter, - ); - } + if (teamId) { + return await this.showcasesService.findAllForTeam( + teamId, + Boolean(slug), + pagination, + sorting.sortBy, + sorting.order, + showcaseFilter, + ); + } - /** - * Returns all showcases for a team. - */ - @Get("/teams/:id/showcases") - @ApiBearerAuth() - @ApiOperation({ - summary: "Get Team Showcases", - description: "Returns all showcases for a given team.", - }) - @ApiPaginatedResponseDto(ShowcaseDto, { description: "Success" }) - @ApiErrorResponse({ status: 401, description: "Unauthorized" }) - async getTeamShowcases( - @Param("id") id: string, - @Query("slug") slug?: string, - ) { - return this.showcasesService.getTeamShowcases(id, !!slug); - } + return await this.showcasesService.findAll(pagination, sorting.sortBy, sorting.order, showcaseFilter); + } - /** - * Creates a new showcase. - */ - @Post("/showcases") - @ApiBearerAuth() - @ApiOperation({ - summary: "Create Showcase", - }) - @ApiDefaultResponse(ShowcaseDto, { - status: 201, - description: "Showcase created successfully.", - }) - @ApiErrorResponse({ status: 401, description: "Unauthorized" }) - @ApiErrorResponse({ status: 400, description: "Bad Request" }) - async createShowcase( - @Body() createShowcaseDto: CreateShowcaseDto, - @Req() req: Request, - ) { - return this.showcasesService.createShowcase( - createShowcaseDto, - req.token.id, - ); - } + /** + * Creates a new showcase for the currently authenticated team. + */ + @Post(['showcases', ':teamId/showcases']) + @UseInterceptors(FileInterceptor('image', { limits: { fileSize: MAX_UPLOAD_BYTES } })) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Create Showcase', + description: + 'Creates a new showcase for the currently authenticated team. Send the image as multipart/form-data, or reference an existing upload with uploadId.', + }) + @ApiParam({ name: 'teamId', required: false, description: 'Must be the authenticated team when given.' }) + @ApiConsumes('multipart/form-data', 'application/json') + // The body is described by hand so the image file can sit next to the DTO fields, + // which means the DTO has to be registered explicitly for the $ref to resolve. + @ApiExtraModels(CreateShowcaseDto) + @ApiBody({ + schema: { + allOf: [ + { $ref: '#/components/schemas/CreateShowcaseDto' }, + { + type: 'object', + properties: { + image: { + type: 'string', + format: 'binary', + description: 'The image of the showcase. Mutually exclusive with uploadId.', + }, + }, + }, + ], + }, + }) + @ApiDefaultResponse(ShowcaseDto, { + status: 201, + description: 'Showcase created successfully.', + }) + @ApiErrorResponse({ status: 400, description: 'Bad Request' }) + @ApiErrorResponse({ status: 401, description: 'Unauthorized' }) + @ApiErrorResponse({ status: 404, description: 'Upload not found' }) + @ApiErrorResponse({ status: 413, description: 'Payload Too Large' }) + async createShowcase( + @Body() createShowcaseDto: CreateShowcaseDto, + @UploadedFile() image: Express.Multer.File | undefined, + @TeamScope() buildTeamId: string, + ): ControllerResponse { + return await this.showcasesService.create(createShowcaseDto, image, buildTeamId); + } - /** - * Updates a showcase. - */ - @Put("/showcases/:id") - @ApiBearerAuth() - @ApiOperation({ - summary: "Update Showcase", - }) - @ApiDefaultResponse(ShowcaseDto, { - status: 200, - description: "Showcase updated successfully.", - }) - @ApiErrorResponse({ status: 401, description: "Unauthorized" }) - @ApiErrorResponse({ status: 400, description: "Bad Request" }) - async updateShowcase( - @Param("id") id: string, - @Body() updateShowcaseDto: UpdateShowcaseDto, - ) { - return this.showcasesService.updateShowcase(id, updateShowcaseDto); - } + /** + * Updates the showcase with the given ID if it belongs to the currently authenticated team. + */ + @Put(['showcases/:id', ':teamId/showcases/:id']) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Update Showcase', + description: 'Updates the showcase with the given ID if it belongs to the currently authenticated team.', + }) + @ApiParam({ name: 'teamId', required: false, description: 'Must be the authenticated team when given.' }) + @ApiDefaultResponse(ShowcaseDto, { description: 'Success' }) + @ApiErrorResponse({ status: 400, description: 'Bad Request' }) + @ApiErrorResponse({ status: 401, description: 'Unauthorized' }) + @ApiErrorResponse({ status: 404, description: 'Showcase not found' }) + async updateShowcase( + @Param('id') id: string, + @Body() updateShowcaseDto: UpdateShowcaseDto, + @TeamScope() buildTeamId: string, + ): ControllerResponse { + return await this.showcasesService.update(id, updateShowcaseDto, buildTeamId); + } - /** - * Deletes a showcase. - */ - @Delete("/showcases/:id") - @ApiBearerAuth() - @ApiOperation({ - summary: "Delete Showcase", - }) - @ApiDefaultResponse(ShowcaseDto, { - status: 200, - description: "Showcase deleted successfully.", - }) - @ApiErrorResponse({ status: 401, description: "Unauthorized" }) - @ApiErrorResponse({ status: 400, description: "Bad Request" }) - async deleteShowcase(@Param("id") id: string) { - return this.showcasesService.deleteShowcase(id); - } + /** + * Deletes the showcase with the given ID if it belongs to the currently authenticated team. + */ + @Delete(['showcases/:id', ':teamId/showcases/:id']) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Delete Showcase', + description: + 'Deletes the showcase with the given ID if it belongs to the currently authenticated team. The image is removed with it, unless a claim or another showcase still uses it.', + }) + @ApiParam({ name: 'teamId', required: false, description: 'Must be the authenticated team when given.' }) + @ApiResponse({ status: 200, description: 'Showcase deleted successfully.' }) + @ApiErrorResponse({ status: 401, description: 'Unauthorized' }) + @ApiErrorResponse({ status: 404, description: 'Showcase not found' }) + async deleteShowcase(@Param('id') id: string, @TeamScope() buildTeamId: string): ControllerResponse { + return await this.showcasesService.delete(id, buildTeamId); + } } diff --git a/apps/api-v2/src/sections/showcases/showcases.module.ts b/apps/api-v2/src/sections/showcases/showcases.module.ts index efc5db4c..ec164756 100644 --- a/apps/api-v2/src/sections/showcases/showcases.module.ts +++ b/apps/api-v2/src/sections/showcases/showcases.module.ts @@ -1,9 +1,12 @@ -import { Module } from "@nestjs/common"; -import { ShowcasesController } from "./showcases.controller"; -import { PrismaService } from "src/common/db/prisma.service"; +import { Module } from '@nestjs/common'; +import { S3Service } from 'src/common/db/external/s3.service'; +import { PrismaService } from 'src/common/db/prisma.service'; +import { UploadsService } from 'src/common/uploads/uploads.service'; +import { ShowcasesController } from './showcases.controller'; +import { ShowcasesService } from './showcases.service'; @Module({ - controllers: [ShowcasesController], - providers: [PrismaService], + controllers: [ShowcasesController], + providers: [ShowcasesService, UploadsService, S3Service, PrismaService], }) export class ShowcasesModule {} diff --git a/apps/api-v2/src/sections/showcases/showcases.service.ts b/apps/api-v2/src/sections/showcases/showcases.service.ts index 404b784f..5aff56aa 100644 --- a/apps/api-v2/src/sections/showcases/showcases.service.ts +++ b/apps/api-v2/src/sections/showcases/showcases.service.ts @@ -1,107 +1,233 @@ -import { Injectable } from "@nestjs/common"; -import { PrismaService } from "src/common/db/prisma.service"; -import { CreateShowcaseDto } from "./dto/create.showcase.dto"; -import { UpdateShowcaseDto } from "./dto/update.showcase.dto"; +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from 'src/common/db/prisma.service'; +import { FilterParams } from 'src/common/decorators/filter.decorator'; +import { PaginationParams } from 'src/common/decorators/pagination.decorator'; +import { SortingParams } from 'src/common/decorators/sorting.decorator'; +import { UploadsService } from 'src/common/uploads/uploads.service'; +import { CreateShowcaseDto } from './dto/create.showcase.dto'; +import { UpdateShowcaseDto } from './dto/update.showcase.dto'; + +/** + * The subset of the build team that is embedded in an unscoped showcase listing, + * so the website can label a showcase without a second request. + */ +const BUILD_TEAM_SELECT = { + id: true, + name: true, + location: true, + slug: true, + icon: true, +} as const; @Injectable() export class ShowcasesService { - constructor(private readonly prisma: PrismaService) {} - - /** - * Finds all showcases based on pagination, sorting, and filtering parameters. - * @param pagination - Pagination parameters. - * @param sortBy - Field to sort by. - * @param order - Order of sorting (asc/desc). - * @param filter - Filter parameters. - * @returns A paginated response containing the showcases and metadata. - */ - async findAll( - pagination: { page: number; limit: number }, - sortBy?: string, - order?: string, - filter?: Record, - ) { - const sortField = sortBy || "createdAt"; - const sortOrder = order === "desc" ? "desc" : "asc"; - - const take = Math.max(Number(pagination.limit) || 20, 1); - const skip = Math.max((Number(pagination.page) || 1) - 1, 0) * take; - - const where = filter || {}; - - const [showcases, count] = await Promise.all([ - this.prisma.showcase.findMany({ - where, - orderBy: { [sortField]: sortOrder }, - skip, - take, - include: { - image: true, - buildTeam: { - select: { - name: true, - location: true, - slug: true, - icon: true, - id: true, - }, - }, - }, - }), - this.prisma.showcase.count({ where }), - ]); - - return { - data: showcases, - meta: { - page: pagination.page, - perPage: pagination.limit, - totalItems: count, - totalPages: Math.ceil(count / pagination.limit), - }, - }; - } - - async getTeamShowcases(id: string, slug?: boolean) { - const where = slug ? { buildTeam: { slug: id } } : { buildTeam: { id } }; - return this.prisma.showcase.findMany({ - where, - include: { image: true }, - }); - } - - async createShowcase( - createShowcaseDto: CreateShowcaseDto, - buildTeamId: string, - ) { - return this.prisma.showcase.create({ - data: { - title: createShowcaseDto.title, - city: createShowcaseDto.city, - buildTeam: { connect: { id: buildTeamId } }, - createdAt: createShowcaseDto.createdAt, - image: { connect: { id: createShowcaseDto.imageId } }, - }, - include: { image: true }, - }); - } - - async updateShowcase(id: string, updateShowcaseDto: UpdateShowcaseDto) { - return this.prisma.showcase.update({ - where: { id }, - data: { - title: updateShowcaseDto.title, - city: updateShowcaseDto.city, - image: { connect: { id: updateShowcaseDto.imageId } }, - }, - include: { image: true }, - }); - } - - async deleteShowcase(id: string) { - return this.prisma.showcase.delete({ - where: { id }, - include: { image: true }, - }); - } + constructor( + private readonly prisma: PrismaService, + private readonly uploads: UploadsService, + ) {} + + /** + * Finds showcases based on pagination, sorting and filtering parameters. + * @param pagination Pagination parameters. + * @param sortBy Field to sort by. + * @param order Order of sorting (asc/desc). + * @param filter Filter parameters. + * @param buildTeamId ID of a team to restrict the result to. + * @returns A paginated response containing the showcases and metadata. + */ + async findAll( + pagination: PaginationParams, + sortBy?: SortingParams['sortBy'], + order?: SortingParams['order'], + filter?: FilterParams['filter'], + buildTeamId?: string, + ) { + const sortField = sortBy || 'createdAt'; + const sortOrder = order === 'desc' ? 'desc' : 'asc'; + + const take = Math.max(Number(pagination.limit) || 20, 1); + const skip = Math.max((Number(pagination.page) || 1) - 1, 0) * take; + + const combinedFilter = { + ...filter, + ...(buildTeamId ? { buildTeamId } : {}), + }; + + const [showcases, count] = await Promise.all([ + this.prisma.showcase.findMany({ + where: combinedFilter, + orderBy: { [sortField]: sortOrder }, + skip, + take, + include: { + image: true, + buildTeam: { select: BUILD_TEAM_SELECT }, + }, + }), + this.prisma.showcase.count({ where: combinedFilter }), + ]); + + return { + data: showcases, + meta: { + page: pagination.page, + perPage: pagination.limit, + totalItems: count, + totalPages: Math.ceil(count / pagination.limit), + }, + }; + } + + /** + * Finds all showcases of the team with the given ID or slug. + * @param teamId ID of the team, or its slug when useSlug is set. + * @param useSlug Whether teamId should be treated as a slug instead of an ID. + * @param pagination Pagination parameters. + * @param sortBy Field to sort by. + * @param order Order of sorting (asc/desc). + * @param filter Filter parameters. + * @returns A paginated response containing the showcases and metadata. + * @throws NotFoundException if no team with the given ID or slug exists. + */ + async findAllForTeam( + teamId: string, + useSlug: boolean, + pagination: PaginationParams, + sortBy?: SortingParams['sortBy'], + order?: SortingParams['order'], + filter?: FilterParams['filter'], + ) { + const buildTeam = await this.prisma.buildTeam.findUnique({ + where: useSlug ? { slug: teamId } : { id: teamId }, + select: { id: true }, + }); + + if (!buildTeam) { + throw new NotFoundException('BuildTeam not found'); + } + + return await this.findAll(pagination, sortBy, order, filter ?? {}, buildTeam.id); + } + + /** + * Creates a showcase for the given team, from either a freshly uploaded image or + * an upload that already exists. + * @param createShowcaseDto The showcase to create. + * @param file The image to upload, when one was sent. + * @param buildTeamId ID of the team the showcase belongs to. + * @returns The created showcase. + * @throws BadRequestException if no image was given, or if both an image and an upload were. + * @throws NotFoundException if the referenced upload does not exist. + */ + async create(createShowcaseDto: CreateShowcaseDto, file: Express.Multer.File | undefined, buildTeamId: string) { + if (file && createShowcaseDto.uploadId) { + throw new BadRequestException('Send either an image file or an uploadId, not both'); + } + + if (!file && !createShowcaseDto.uploadId) { + throw new BadRequestException('An image file or an uploadId is required'); + } + + const uploadId = file + ? (await this.uploads.createFromFile(file)).id + : await this.requireUpload(createShowcaseDto.uploadId as string); + + return await this.prisma.showcase.create({ + data: { + title: createShowcaseDto.title, + city: createShowcaseDto.city, + createdAt: createShowcaseDto.createdAt, + buildTeamId, + uploadId, + }, + include: { image: true }, + }); + } + + /** + * Updates a showcase if it belongs to the given team. + * + * `approved` is deliberately not updatable: a team must not be able to approve + * its own showcases. + * @param id ID of the showcase to update. + * @param updateShowcaseDto The fields to update. + * @param buildTeamId ID of the team the showcase has to belong to. + * @returns The updated showcase. + * @throws NotFoundException if the showcase does not exist, belongs to another team, + * or the referenced upload does not exist. + */ + async update(id: string, updateShowcaseDto: UpdateShowcaseDto, buildTeamId: string) { + const showcase = await this.prisma.showcase.findFirst({ + where: { id, buildTeamId }, + select: { id: true, uploadId: true }, + }); + + if (!showcase) { + throw new NotFoundException('Showcase not found'); + } + + const replacesImage = Boolean(updateShowcaseDto.uploadId) && updateShowcaseDto.uploadId !== showcase.uploadId; + + if (replacesImage) { + await this.requireUpload(updateShowcaseDto.uploadId as string); + } + + const updated = await this.prisma.showcase.update({ + where: { id: showcase.id }, + data: { + title: updateShowcaseDto.title, + city: updateShowcaseDto.city, + createdAt: updateShowcaseDto.createdAt, + uploadId: updateShowcaseDto.uploadId, + }, + include: { image: true }, + }); + + if (replacesImage) { + await this.uploads.deleteIfUnreferenced(showcase.uploadId); + } + + return updated; + } + + /** + * Deletes a showcase if it belongs to the given team, along with its image when + * nothing else references it. + * @param id ID of the showcase to delete. + * @param buildTeamId ID of the team the showcase has to belong to. + * @returns The deleted showcase. + * @throws NotFoundException if the showcase does not exist or belongs to another team. + */ + async delete(id: string, buildTeamId: string) { + const showcase = await this.prisma.showcase.findFirst({ + where: { id, buildTeamId }, + include: { image: true }, + }); + + if (!showcase) { + throw new NotFoundException('Showcase not found'); + } + + await this.prisma.showcase.delete({ where: { id: showcase.id } }); + await this.uploads.deleteIfUnreferenced(showcase.uploadId); + + return showcase; + } + + /** + * Resolves an upload the caller referenced by ID. + * @throws NotFoundException if no such upload exists. + */ + private async requireUpload(uploadId: string): Promise { + const upload = await this.prisma.upload.findUnique({ + where: { id: uploadId }, + select: { id: true }, + }); + + if (!upload) { + throw new NotFoundException('Upload not found'); + } + + return upload.id; + } } diff --git a/apps/api-v2/test/sections/showcases/showcases.controller.spec.ts b/apps/api-v2/test/sections/showcases/showcases.controller.spec.ts new file mode 100644 index 00000000..8a8696a1 --- /dev/null +++ b/apps/api-v2/test/sections/showcases/showcases.controller.spec.ts @@ -0,0 +1,115 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ShowcasesController } from 'src/sections/showcases/showcases.controller'; +import { ShowcasesService } from 'src/sections/showcases/showcases.service'; + +describe('ShowcasesController', () => { + let showcasesController: ShowcasesController; + let showcasesService: { + findAll: jest.Mock; + findAllForTeam: jest.Mock; + create: jest.Mock; + update: jest.Mock; + delete: jest.Mock; + }; + + beforeEach(async () => { + showcasesService = { + findAll: jest.fn(), + findAllForTeam: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [ShowcasesController], + providers: [ + { + provide: ShowcasesService, + useValue: showcasesService, + }, + ], + }).compile(); + + showcasesController = module.get(ShowcasesController); + }); + + describe('getShowcases', () => { + it('should request every showcase when no team is in the path', async () => { + showcasesService.findAll.mockResolvedValue({ + data: [{ id: 'showcase-1' }], + meta: { page: 1, perPage: 20, totalItems: 1, totalPages: 1 }, + }); + + const pagination = { page: 1, limit: 20 }; + const sorting = { sortBy: 'createdAt', order: 'desc' }; + const result = await showcasesController.getShowcases( + undefined, + pagination as never, + sorting as never, + { filter: { city: 'New York' } } as never, + ); + + expect(showcasesService.findAll).toHaveBeenCalledWith(pagination, 'createdAt', 'desc', { city: 'New York' }); + expect(showcasesService.findAllForTeam).not.toHaveBeenCalled(); + expect(result).toEqual({ + data: [{ id: 'showcase-1' }], + meta: { page: 1, perPage: 20, totalItems: 1, totalPages: 1 }, + }); + }); + + it('should scope to the team in the path and keep slug out of the filter', async () => { + showcasesService.findAllForTeam.mockResolvedValue({ data: [], meta: {} }); + + const pagination = { page: 1, limit: 20 }; + const sorting = { sortBy: 'createdAt', order: 'desc' }; + await showcasesController.getShowcases( + 'team-slug', + pagination as never, + sorting as never, + { filter: { slug: true, city: 'New York' } } as never, + ); + + expect(showcasesService.findAllForTeam).toHaveBeenCalledWith('team-slug', true, pagination, 'createdAt', 'desc', { + city: 'New York', + }); + expect(showcasesService.findAll).not.toHaveBeenCalled(); + }); + }); + + describe('createShowcase', () => { + it('should create a showcase for the authenticated team', async () => { + showcasesService.create.mockResolvedValue({ id: 'showcase-1' }); + + const dto = { title: 'Title' }; + const file = { buffer: Buffer.from('image') } as Express.Multer.File; + const result = await showcasesController.createShowcase(dto as never, file, 'team-123'); + + expect(showcasesService.create).toHaveBeenCalledWith(dto, file, 'team-123'); + expect(result).toEqual({ id: 'showcase-1' }); + }); + }); + + describe('updateShowcase', () => { + it('should update the showcase by id', async () => { + showcasesService.update.mockResolvedValue({ id: 'showcase-1' }); + + const dto = { title: 'New Title' }; + const result = await showcasesController.updateShowcase('showcase-1', dto as never, 'team-123'); + + expect(showcasesService.update).toHaveBeenCalledWith('showcase-1', dto, 'team-123'); + expect(result).toEqual({ id: 'showcase-1' }); + }); + }); + + describe('deleteShowcase', () => { + it('should delete the showcase by id', async () => { + showcasesService.delete.mockResolvedValue({ id: 'showcase-1' }); + + const result = await showcasesController.deleteShowcase('showcase-1', 'team-123'); + + expect(showcasesService.delete).toHaveBeenCalledWith('showcase-1', 'team-123'); + expect(result).toEqual({ id: 'showcase-1' }); + }); + }); +}); diff --git a/apps/api-v2/test/sections/showcases/showcases.routes.spec.ts b/apps/api-v2/test/sections/showcases/showcases.routes.spec.ts new file mode 100644 index 00000000..5cb887bb --- /dev/null +++ b/apps/api-v2/test/sections/showcases/showcases.routes.spec.ts @@ -0,0 +1,183 @@ +import { INestApplication, ValidationPipe, VersioningType } from '@nestjs/common'; +import { HttpAdapterHost } from '@nestjs/core'; +import { JwtService } from '@nestjs/jwt'; +import { Test, TestingModule } from '@nestjs/testing'; +import request from 'supertest'; +import { AppModule } from 'src/app.module'; +import { PrismaService } from 'src/common/db/prisma.service'; +import { ExceptionsFilter } from 'src/common/interceptors/error.interceptor'; +import { ResponseInterceptor } from 'src/common/interceptors/response.interceptor'; + +/** + * Showcases are readable by anyone but writable only by the team that owns them, + * and every route exists both bare and behind a `:teamId` prefix. That split only + * holds once the real router is involved, so it is checked end to end here. + */ +describe('showcase routes', () => { + let app: INestApplication; + let token: string; + let prismaService: { + $connect: jest.Mock; + showcase: { + findMany: jest.Mock; + count: jest.Mock; + findFirst: jest.Mock; + create: jest.Mock; + update: jest.Mock; + delete: jest.Mock; + }; + upload: { findUnique: jest.Mock }; + buildTeam: { findUnique: jest.Mock }; + }; + + beforeAll(async () => { + process.env.JWT_SECRET = 'test-secret'; + + prismaService = { + $connect: jest.fn(), + showcase: { + findMany: jest.fn(), + count: jest.fn(), + findFirst: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }, + upload: { findUnique: jest.fn() }, + buildTeam: { findUnique: jest.fn() }, + }; + + const moduleRef: TestingModule = await Test.createTestingModule({ imports: [AppModule] }) + .overrideProvider(PrismaService) + .useValue(prismaService) + .compile(); + + app = moduleRef.createNestApplication(); + app.enableVersioning({ type: VersioningType.URI, defaultVersion: '2' }); + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + transformOptions: { enableImplicitConversion: true }, + }), + ); + app.useGlobalInterceptors(new ResponseInterceptor()); + app.useGlobalFilters(new ExceptionsFilter(app.get(HttpAdapterHost).httpAdapter)); + await app.init(); + + token = await app.get(JwtService).signAsync({ sub: 'team-123', id: 'team-123' }); + }); + + afterAll(async () => { + await app.close(); + delete process.env.JWT_SECRET; + }); + + beforeEach(() => { + jest.clearAllMocks(); + prismaService.showcase.findMany.mockResolvedValue([{ id: 'showcase-1' }]); + prismaService.showcase.count.mockResolvedValue(1); + prismaService.buildTeam.findUnique.mockResolvedValue({ id: 'team-123' }); + }); + + it('serves the unscoped listing without a token', async () => { + const response = await request(app.getHttpServer()).get('/v2/showcases').expect(200); + + expect(prismaService.buildTeam.findUnique).not.toHaveBeenCalled(); + expect(prismaService.showcase.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: {} })); + expect(response.body).toEqual({ + status: 200, + message: 'Success', + data: [{ id: 'showcase-1' }], + meta: { page: 1, perPage: 20, totalItems: 1, totalPages: 1 }, + }); + }); + + it('serves the team listing without a token', async () => { + await request(app.getHttpServer()).get('/v2/team-slug/showcases?slug=true').expect(200); + + expect(prismaService.buildTeam.findUnique).toHaveBeenCalledWith({ + where: { slug: 'team-slug' }, + select: { id: true }, + }); + expect(prismaService.showcase.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { buildTeamId: 'team-123' } }), + ); + }); + + it('answers 404 for a team that does not exist', async () => { + prismaService.buildTeam.findUnique.mockResolvedValue(null); + + await request(app.getHttpServer()).get('/v2/nope/showcases').expect(404); + }); + + it('rejects an unlisted sortBy', async () => { + await request(app.getHttpServer()).get('/v2/showcases?sortBy=uploadId').expect(400); + }); + + it('rejects creating a showcase without a token', async () => { + await request(app.getHttpServer()).post('/v2/showcases').send({ title: 'Title', uploadId: 'upload-1' }).expect(401); + + expect(prismaService.showcase.create).not.toHaveBeenCalled(); + }); + + it('creates a showcase from an existing upload', async () => { + prismaService.upload.findUnique.mockResolvedValue({ id: '00000000-0000-0000-0000-000000000000' }); + prismaService.showcase.create.mockResolvedValue({ id: 'showcase-1' }); + + const response = await request(app.getHttpServer()) + .post('/v2/team-123/showcases') + .set('Authorization', `Bearer ${token}`) + .send({ title: 'Title', city: 'New York', uploadId: '00000000-0000-0000-0000-000000000000' }) + .expect(201); + + expect(prismaService.showcase.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + title: 'Title', + city: 'New York', + buildTeamId: 'team-123', + uploadId: '00000000-0000-0000-0000-000000000000', + }), + }), + ); + expect(response.body.data).toEqual({ id: 'showcase-1' }); + }); + + it('rejects a create that names neither an image nor an upload', async () => { + await request(app.getHttpServer()) + .post('/v2/showcases') + .set('Authorization', `Bearer ${token}`) + .send({ title: 'Title' }) + .expect(400); + + expect(prismaService.showcase.create).not.toHaveBeenCalled(); + }); + + it('refuses a prefix naming a team the token does not belong to', async () => { + await request(app.getHttpServer()) + .put('/v2/someone-else/showcases/showcase-1') + .set('Authorization', `Bearer ${token}`) + .send({ title: 'Title' }) + .expect(404); + + expect(prismaService.showcase.findFirst).not.toHaveBeenCalled(); + }); + + it('deletes a showcase of the authenticated team', async () => { + prismaService.showcase.findFirst.mockResolvedValue({ id: 'showcase-1', uploadId: 'upload-1', image: {} }); + prismaService.upload.findUnique.mockResolvedValue(null); + + await request(app.getHttpServer()) + .delete('/v2/showcases/showcase-1') + .set('Authorization', `Bearer ${token}`) + .expect(200); + + expect(prismaService.showcase.findFirst).toHaveBeenCalledWith({ + where: { id: 'showcase-1', buildTeamId: 'team-123' }, + include: { image: true }, + }); + expect(prismaService.showcase.delete).toHaveBeenCalledWith({ where: { id: 'showcase-1' } }); + }); +}); diff --git a/apps/api-v2/test/sections/showcases/showcases.service.spec.ts b/apps/api-v2/test/sections/showcases/showcases.service.spec.ts new file mode 100644 index 00000000..97651a24 --- /dev/null +++ b/apps/api-v2/test/sections/showcases/showcases.service.spec.ts @@ -0,0 +1,288 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { PrismaService } from 'src/common/db/prisma.service'; +import { UploadsService } from 'src/common/uploads/uploads.service'; +import { ShowcasesService } from 'src/sections/showcases/showcases.service'; + +describe('ShowcasesService', () => { + let showcasesService: ShowcasesService; + let prismaService: { + showcase: { + findMany: jest.Mock; + count: jest.Mock; + create: jest.Mock; + findFirst: jest.Mock; + update: jest.Mock; + delete: jest.Mock; + }; + upload: { findUnique: jest.Mock }; + buildTeam: { findUnique: jest.Mock }; + }; + let uploadsService: { + createFromFile: jest.Mock; + deleteIfUnreferenced: jest.Mock; + }; + + beforeEach(() => { + prismaService = { + showcase: { + findMany: jest.fn(), + count: jest.fn(), + create: jest.fn(), + findFirst: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }, + upload: { findUnique: jest.fn() }, + buildTeam: { findUnique: jest.fn() }, + }; + uploadsService = { + createFromFile: jest.fn(), + deleteIfUnreferenced: jest.fn(), + }; + + showcasesService = new ShowcasesService( + prismaService as unknown as PrismaService, + uploadsService as unknown as UploadsService, + ); + }); + + describe('findAll', () => { + it('should apply pagination, sorting, filter and build team constraints', async () => { + prismaService.showcase.findMany.mockResolvedValue([{ id: 'showcase-1' }]); + prismaService.showcase.count.mockResolvedValue(4); + + const result = await showcasesService.findAll( + { page: 2, limit: 2 } as any, + 'createdAt', + 'desc', + { city: 'New York' } as any, + 'team-123', + ); + + expect(prismaService.showcase.findMany).toHaveBeenCalledWith({ + where: { city: 'New York', buildTeamId: 'team-123' }, + orderBy: { createdAt: 'desc' }, + skip: 2, + take: 2, + include: { + image: true, + buildTeam: { select: { id: true, name: true, location: true, slug: true, icon: true } }, + }, + }); + expect(result).toEqual({ + data: [{ id: 'showcase-1' }], + meta: { page: 2, perPage: 2, totalItems: 4, totalPages: 2 }, + }); + }); + + it('should not constrain by team when none is given', async () => { + prismaService.showcase.findMany.mockResolvedValue([]); + prismaService.showcase.count.mockResolvedValue(0); + + await showcasesService.findAll({ page: 1, limit: 20 } as any); + + expect(prismaService.showcase.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: {} })); + }); + }); + + describe('findAllForTeam', () => { + it('should resolve the team by id', async () => { + prismaService.buildTeam.findUnique.mockResolvedValue({ id: 'team-123' }); + prismaService.showcase.findMany.mockResolvedValue([]); + prismaService.showcase.count.mockResolvedValue(0); + + await showcasesService.findAllForTeam('team-123', false, { page: 1, limit: 20 } as any); + + expect(prismaService.buildTeam.findUnique).toHaveBeenCalledWith({ + where: { id: 'team-123' }, + select: { id: true }, + }); + expect(prismaService.showcase.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { buildTeamId: 'team-123' } }), + ); + }); + + it('should resolve the team by slug when asked to', async () => { + prismaService.buildTeam.findUnique.mockResolvedValue({ id: 'team-123' }); + prismaService.showcase.findMany.mockResolvedValue([]); + prismaService.showcase.count.mockResolvedValue(0); + + await showcasesService.findAllForTeam('team-slug', true, { page: 1, limit: 20 } as any); + + expect(prismaService.buildTeam.findUnique).toHaveBeenCalledWith({ + where: { slug: 'team-slug' }, + select: { id: true }, + }); + }); + + it('should throw when the team does not exist', async () => { + prismaService.buildTeam.findUnique.mockResolvedValue(null); + + await expect(showcasesService.findAllForTeam('nope', false, { page: 1, limit: 20 } as any)).rejects.toThrow( + NotFoundException, + ); + expect(prismaService.showcase.findMany).not.toHaveBeenCalled(); + }); + }); + + describe('create', () => { + it('should upload the image and create the showcase', async () => { + uploadsService.createFromFile.mockResolvedValue({ id: 'upload-1' }); + prismaService.showcase.create.mockResolvedValue({ id: 'showcase-1' }); + + const file = { buffer: Buffer.from('image') } as Express.Multer.File; + const result = await showcasesService.create({ title: 'Title', city: 'New York' } as any, file, 'team-123'); + + expect(uploadsService.createFromFile).toHaveBeenCalledWith(file); + expect(prismaService.showcase.create).toHaveBeenCalledWith({ + data: { + title: 'Title', + city: 'New York', + createdAt: undefined, + buildTeamId: 'team-123', + uploadId: 'upload-1', + }, + include: { image: true }, + }); + expect(result).toEqual({ id: 'showcase-1' }); + }); + + it('should honour a given createdAt', async () => { + uploadsService.createFromFile.mockResolvedValue({ id: 'upload-1' }); + prismaService.showcase.create.mockResolvedValue({ id: 'showcase-1' }); + + await showcasesService.create( + { title: 'Title', createdAt: '2025-04-19T16:45:18.767Z' } as any, + {} as Express.Multer.File, + 'team-123', + ); + + expect(prismaService.showcase.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ createdAt: '2025-04-19T16:45:18.767Z' }), + }), + ); + }); + + it('should link an existing upload instead of uploading', async () => { + prismaService.upload.findUnique.mockResolvedValue({ id: 'upload-1' }); + prismaService.showcase.create.mockResolvedValue({ id: 'showcase-1' }); + + await showcasesService.create({ title: 'Title', uploadId: 'upload-1' } as any, undefined, 'team-123'); + + expect(uploadsService.createFromFile).not.toHaveBeenCalled(); + expect(prismaService.showcase.create).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ uploadId: 'upload-1' }) }), + ); + }); + + it('should reject a request that carries both an image and an upload', async () => { + await expect( + showcasesService.create({ title: 'Title', uploadId: 'upload-1' } as any, {} as Express.Multer.File, 'team-123'), + ).rejects.toThrow(BadRequestException); + expect(uploadsService.createFromFile).not.toHaveBeenCalled(); + }); + + it('should reject a request that carries neither', async () => { + await expect(showcasesService.create({ title: 'Title' } as any, undefined, 'team-123')).rejects.toThrow( + BadRequestException, + ); + }); + + it('should throw when the referenced upload does not exist', async () => { + prismaService.upload.findUnique.mockResolvedValue(null); + + await expect( + showcasesService.create({ title: 'Title', uploadId: 'upload-1' } as any, undefined, 'team-123'), + ).rejects.toThrow(NotFoundException); + expect(prismaService.showcase.create).not.toHaveBeenCalled(); + }); + }); + + describe('update', () => { + it('should update a showcase of the authenticated team', async () => { + prismaService.showcase.findFirst.mockResolvedValue({ id: 'showcase-1', uploadId: 'upload-1' }); + prismaService.showcase.update.mockResolvedValue({ id: 'showcase-1' }); + + const result = await showcasesService.update('showcase-1', { title: 'New Title' } as any, 'team-123'); + + expect(prismaService.showcase.findFirst).toHaveBeenCalledWith({ + where: { id: 'showcase-1', buildTeamId: 'team-123' }, + select: { id: true, uploadId: true }, + }); + expect(prismaService.showcase.update).toHaveBeenCalledWith({ + where: { id: 'showcase-1' }, + data: { + title: 'New Title', + city: undefined, + createdAt: undefined, + uploadId: undefined, + }, + include: { image: true }, + }); + expect(uploadsService.deleteIfUnreferenced).not.toHaveBeenCalled(); + expect(result).toEqual({ id: 'showcase-1' }); + }); + + it('should drop the previous image when it is replaced', async () => { + prismaService.showcase.findFirst.mockResolvedValue({ id: 'showcase-1', uploadId: 'upload-1' }); + prismaService.upload.findUnique.mockResolvedValue({ id: 'upload-2' }); + prismaService.showcase.update.mockResolvedValue({ id: 'showcase-1' }); + + await showcasesService.update('showcase-1', { uploadId: 'upload-2' } as any, 'team-123'); + + expect(uploadsService.deleteIfUnreferenced).toHaveBeenCalledWith('upload-1'); + }); + + it('should keep the image when the same upload is sent again', async () => { + prismaService.showcase.findFirst.mockResolvedValue({ id: 'showcase-1', uploadId: 'upload-1' }); + prismaService.showcase.update.mockResolvedValue({ id: 'showcase-1' }); + + await showcasesService.update('showcase-1', { uploadId: 'upload-1' } as any, 'team-123'); + + expect(uploadsService.deleteIfUnreferenced).not.toHaveBeenCalled(); + }); + + it('should throw when the showcase belongs to another team', async () => { + prismaService.showcase.findFirst.mockResolvedValue(null); + + await expect(showcasesService.update('showcase-1', { title: 'New' } as any, 'team-123')).rejects.toThrow( + NotFoundException, + ); + expect(prismaService.showcase.update).not.toHaveBeenCalled(); + }); + + it('should throw when the replacement upload does not exist', async () => { + prismaService.showcase.findFirst.mockResolvedValue({ id: 'showcase-1', uploadId: 'upload-1' }); + prismaService.upload.findUnique.mockResolvedValue(null); + + await expect(showcasesService.update('showcase-1', { uploadId: 'upload-2' } as any, 'team-123')).rejects.toThrow( + NotFoundException, + ); + expect(prismaService.showcase.update).not.toHaveBeenCalled(); + }); + }); + + describe('delete', () => { + it('should delete the showcase and its image', async () => { + prismaService.showcase.findFirst.mockResolvedValue({ + id: 'showcase-1', + uploadId: 'upload-1', + image: { id: 'upload-1' }, + }); + + const result = await showcasesService.delete('showcase-1', 'team-123'); + + expect(prismaService.showcase.delete).toHaveBeenCalledWith({ where: { id: 'showcase-1' } }); + expect(uploadsService.deleteIfUnreferenced).toHaveBeenCalledWith('upload-1'); + expect(result).toEqual({ id: 'showcase-1', uploadId: 'upload-1', image: { id: 'upload-1' } }); + }); + + it('should throw when the showcase belongs to another team', async () => { + prismaService.showcase.findFirst.mockResolvedValue(null); + + await expect(showcasesService.delete('showcase-1', 'team-123')).rejects.toThrow(NotFoundException); + expect(prismaService.showcase.delete).not.toHaveBeenCalled(); + }); + }); +}); From 955657ffbb8838d79edec55575408b5797c8330d Mon Sep 17 00:00:00 2001 From: Kyan Van den Eynde <66461508+kyanvde@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:13:09 +0200 Subject: [PATCH 4/4] fix(api/v2): :bug: Document every image column a showcase returns Review feedback: the image DTO was missing `checked`, and `include: { image: true }` returned whatever the Upload table happened to hold, including `claimId`. Selecting the columns explicitly makes the response and ShowcaseImageDto the same shape, so the docs cannot drift from what callers actually receive. Co-Authored-By: Claude Opus 5 --- .../sections/showcases/dto/showcase.dto.ts | 12 ++++++++++ .../sections/showcases/showcases.service.ts | 23 +++++++++++++++---- .../showcases/showcases.routes.spec.ts | 6 ++++- .../showcases/showcases.service.spec.ts | 16 ++++++++++--- 4 files changed, 49 insertions(+), 8 deletions(-) diff --git a/apps/api-v2/src/sections/showcases/dto/showcase.dto.ts b/apps/api-v2/src/sections/showcases/dto/showcase.dto.ts index f9d3b766..2ec1b628 100644 --- a/apps/api-v2/src/sections/showcases/dto/showcase.dto.ts +++ b/apps/api-v2/src/sections/showcases/dto/showcase.dto.ts @@ -24,6 +24,18 @@ export class ShowcaseImageDto { @ApiProperty({ example: 1080, description: 'The height of the image in pixels.' }) height: number; + + @ApiProperty({ + example: false, + description: 'Whether the image has been reviewed by a moderator.', + }) + checked: boolean; + + @ApiProperty({ + example: '2025-04-19T16:45:18.767Z', + description: 'The timestamp when the image was uploaded.', + }) + createdAt: string; } export class ShowcaseBuildTeamDto { diff --git a/apps/api-v2/src/sections/showcases/showcases.service.ts b/apps/api-v2/src/sections/showcases/showcases.service.ts index 5aff56aa..1780e01a 100644 --- a/apps/api-v2/src/sections/showcases/showcases.service.ts +++ b/apps/api-v2/src/sections/showcases/showcases.service.ts @@ -7,6 +7,21 @@ import { UploadsService } from 'src/common/uploads/uploads.service'; import { CreateShowcaseDto } from './dto/create.showcase.dto'; import { UpdateShowcaseDto } from './dto/update.showcase.dto'; +/** + * The image columns a showcase carries. Selected explicitly so the response + * matches ShowcaseImageDto exactly, rather than whatever the Upload table + * happens to hold. + */ +const IMAGE_SELECT = { + id: true, + name: true, + hash: true, + width: true, + height: true, + checked: true, + createdAt: true, +} as const; + /** * The subset of the build team that is embedded in an unscoped showcase listing, * so the website can label a showcase without a second request. @@ -60,7 +75,7 @@ export class ShowcasesService { skip, take, include: { - image: true, + image: { select: IMAGE_SELECT }, buildTeam: { select: BUILD_TEAM_SELECT }, }, }), @@ -140,7 +155,7 @@ export class ShowcasesService { buildTeamId, uploadId, }, - include: { image: true }, + include: { image: { select: IMAGE_SELECT } }, }); } @@ -180,7 +195,7 @@ export class ShowcasesService { createdAt: updateShowcaseDto.createdAt, uploadId: updateShowcaseDto.uploadId, }, - include: { image: true }, + include: { image: { select: IMAGE_SELECT } }, }); if (replacesImage) { @@ -201,7 +216,7 @@ export class ShowcasesService { async delete(id: string, buildTeamId: string) { const showcase = await this.prisma.showcase.findFirst({ where: { id, buildTeamId }, - include: { image: true }, + include: { image: { select: IMAGE_SELECT } }, }); if (!showcase) { diff --git a/apps/api-v2/test/sections/showcases/showcases.routes.spec.ts b/apps/api-v2/test/sections/showcases/showcases.routes.spec.ts index 5cb887bb..ef192a27 100644 --- a/apps/api-v2/test/sections/showcases/showcases.routes.spec.ts +++ b/apps/api-v2/test/sections/showcases/showcases.routes.spec.ts @@ -176,7 +176,11 @@ describe('showcase routes', () => { expect(prismaService.showcase.findFirst).toHaveBeenCalledWith({ where: { id: 'showcase-1', buildTeamId: 'team-123' }, - include: { image: true }, + include: { + image: { + select: { id: true, name: true, hash: true, width: true, height: true, checked: true, createdAt: true }, + }, + }, }); expect(prismaService.showcase.delete).toHaveBeenCalledWith({ where: { id: 'showcase-1' } }); }); diff --git a/apps/api-v2/test/sections/showcases/showcases.service.spec.ts b/apps/api-v2/test/sections/showcases/showcases.service.spec.ts index 97651a24..18f52f3c 100644 --- a/apps/api-v2/test/sections/showcases/showcases.service.spec.ts +++ b/apps/api-v2/test/sections/showcases/showcases.service.spec.ts @@ -65,7 +65,9 @@ describe('ShowcasesService', () => { skip: 2, take: 2, include: { - image: true, + image: { + select: { id: true, name: true, hash: true, width: true, height: true, checked: true, createdAt: true }, + }, buildTeam: { select: { id: true, name: true, location: true, slug: true, icon: true } }, }, }); @@ -142,7 +144,11 @@ describe('ShowcasesService', () => { buildTeamId: 'team-123', uploadId: 'upload-1', }, - include: { image: true }, + include: { + image: { + select: { id: true, name: true, hash: true, width: true, height: true, checked: true, createdAt: true }, + }, + }, }); expect(result).toEqual({ id: 'showcase-1' }); }); @@ -218,7 +224,11 @@ describe('ShowcasesService', () => { createdAt: undefined, uploadId: undefined, }, - include: { image: true }, + include: { + image: { + select: { id: true, name: true, hash: true, width: true, height: true, checked: true, createdAt: true }, + }, + }, }); expect(uploadsService.deleteIfUnreferenced).not.toHaveBeenCalled(); expect(result).toEqual({ id: 'showcase-1' });