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/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/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/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/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..9fd84a0c --- /dev/null +++ b/apps/api-v2/src/sections/showcases/dto/create.showcase.dto.ts @@ -0,0 +1,39 @@ +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.', + example: 'Empire State Building', + }) + @IsString() + @IsNotEmpty() + @MaxLength(255) + title: 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: + '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 new file mode 100644 index 00000000..2ec1b628 --- /dev/null +++ b/apps/api-v2/src/sections/showcases/dto/showcase.dto.ts @@ -0,0 +1,115 @@ +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; + + @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 { + @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 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 new file mode 100644 index 00000000..22bb20a2 --- /dev/null +++ b/apps/api-v2/src/sections/showcases/dto/update.showcase.dto.ts @@ -0,0 +1,39 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsISO8601, IsNotEmpty, IsOptional, IsString, IsUUID, MaxLength } from 'class-validator'; + +export class UpdateShowcaseDto { + @ApiPropertyOptional({ + description: 'The title of the showcase.', + example: 'Empire State Building', + }) + @IsString() + @IsNotEmpty() + @MaxLength(255) + @IsOptional() + title?: string; + + @ApiPropertyOptional({ + description: 'The city the showcase was built in.', + example: 'New York', + }) + @IsString() + @MaxLength(255) + @IsOptional() + city?: 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 new file mode 100644 index 00000000..20be256c --- /dev/null +++ b/apps/api-v2/src/sections/showcases/showcases.controller.ts @@ -0,0 +1,190 @@ +import { Body, Controller, Delete, Get, Param, Post, Put, UploadedFile, UseInterceptors } from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { + 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 { 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) {} + + /** + * 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; + + if (teamId) { + return await this.showcasesService.findAllForTeam( + teamId, + Boolean(slug), + pagination, + sorting.sortBy, + sorting.order, + showcaseFilter, + ); + } + + return await this.showcasesService.findAll(pagination, sorting.sortBy, sorting.order, showcaseFilter); + } + + /** + * 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 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 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 new file mode 100644 index 00000000..ec164756 --- /dev/null +++ b/apps/api-v2/src/sections/showcases/showcases.module.ts @@ -0,0 +1,12 @@ +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: [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 new file mode 100644 index 00000000..1780e01a --- /dev/null +++ b/apps/api-v2/src/sections/showcases/showcases.service.ts @@ -0,0 +1,248 @@ +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 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. + */ +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, + 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: { select: IMAGE_SELECT }, + 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: { select: IMAGE_SELECT } }, + }); + } + + /** + * 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: { select: IMAGE_SELECT } }, + }); + + 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: { select: IMAGE_SELECT } }, + }); + + 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/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/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..ef192a27 --- /dev/null +++ b/apps/api-v2/test/sections/showcases/showcases.routes.spec.ts @@ -0,0 +1,187 @@ +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: { + 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 new file mode 100644 index 00000000..18f52f3c --- /dev/null +++ b/apps/api-v2/test/sections/showcases/showcases.service.spec.ts @@ -0,0 +1,298 @@ +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: { + 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 } }, + }, + }); + 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: { + select: { id: true, name: true, hash: true, width: true, height: true, checked: true, createdAt: 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: { + 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' }); + }); + + 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(); + }); + }); +}); 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"