From b3bdbe5b0318424d6525847cedc6667d373b923c Mon Sep 17 00:00:00 2001 From: Teddy Roncin Date: Sun, 8 Feb 2026 20:38:46 +0100 Subject: [PATCH 1/9] Add link routes --- prisma/schema.prisma | 13 ++++++++ src/exceptions.ts | 14 ++++++-- src/link/dto/req/link-create-req.dto.ts | 14 ++++++++ src/link/dto/req/link-update-req.dto.ts | 18 +++++++++++ src/link/dto/res/link-res.dto.ts | 11 +++++++ src/link/link.controller.ts | 43 +++++++++++++++++++++++++ src/link/link.interface.ts | 21 ++++++++++++ src/link/link.module.ts | 9 ++++++ src/link/link.service.ts | 29 +++++++++++++++++ src/prisma/prisma.service.ts | 2 ++ src/prisma/types.ts | 1 + 11 files changed, 173 insertions(+), 2 deletions(-) create mode 100644 src/link/dto/req/link-create-req.dto.ts create mode 100644 src/link/dto/req/link-update-req.dto.ts create mode 100644 src/link/dto/res/link-res.dto.ts create mode 100644 src/link/link.controller.ts create mode 100644 src/link/link.interface.ts create mode 100644 src/link/link.module.ts create mode 100644 src/link/link.service.ts diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ee6bef46..b3c619b0 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -162,6 +162,16 @@ model GitHubIssue { user User @relation(fields: [userId], references: [id]) } +model Link { + id String @id @default(uuid()) + nameId String @unique + tooltipId String @unique + link String @unique + + name Translation @relation(name: "linkNameTranslation", fields: [nameId], references: [id]) + tooltip Translation @relation(name: "linkTooltipTranslation", fields: [tooltipId], references: [id]) +} + model Semester { code String @id @db.Char(3) start DateTime @db.Date @@ -245,6 +255,8 @@ model Translation { formationFollowingMethodDescriptions UTTFormationFollowingMethod? starCriterionDescriptions UeStarCriterion? ueNames Ueof? + linkName Link? @relation("linkNameTranslation") + linkDescription Link? @relation("linkTooltipTranslation") } model Ue { @@ -906,6 +918,7 @@ enum Permission { API_UPLOAD_ANNALS // Upload an annal API_MODERATE_ANNALS // Moderate annals API_MODERATE_COMMENTS // Moderate comments + API_MODIFY_LINKS USER_SEE_DETAILS // See personal details about someone, even the ones the user decided to hide USER_UPDATE_DETAILS // Update personal details about someone diff --git a/src/exceptions.ts b/src/exceptions.ts index 28c17707..82107082 100644 --- a/src/exceptions.ts +++ b/src/exceptions.ts @@ -84,11 +84,13 @@ export const enum ERROR_CODE { NO_SUCH_UE_AT_SEMESTER = 4414, NO_SUCH_ASSO_ROLE = 4415, NO_SUCH_ASSO_MEMBERSHIP = 4416, + NO_SUCH_LINK = 4417, ANNAL_ALREADY_UPLOADED = 4901, RESOURCE_UNAVAILABLE = 4902, RESOURCE_INVALID_TYPE = 4903, ASSO_ROLE_ALREADY_MOVED = 4904, CREDENTIALS_ALREADY_TAKEN = 5001, + LINK_ALREADY_EXISTS = 5002, HIDDEN_DUCK = 9999, } @@ -382,6 +384,10 @@ export const ErrorData = Object.freeze({ message: 'No such membership in asso: %', httpCode: HttpStatus.NOT_FOUND, }, + [ERROR_CODE.NO_SUCH_LINK]: { + message: 'No link with id: %', + httpCode: HttpStatus.NOT_FOUND, + }, [ERROR_CODE.ANNAL_ALREADY_UPLOADED]: { message: 'A file has alreay been uploaded for this annal', httpCode: HttpStatus.CONFLICT, @@ -394,12 +400,16 @@ export const ErrorData = Object.freeze({ message: 'Resource have incorrect type, expected %', httpCode: HttpStatus.BAD_REQUEST, }, + [ERROR_CODE.ASSO_ROLE_ALREADY_MOVED]: { + message: 'You should not try to update role position simultaneously', + httpCode: HttpStatus.CONFLICT, + }, [ERROR_CODE.CREDENTIALS_ALREADY_TAKEN]: { message: 'The given credentials are already taken', httpCode: HttpStatus.CONFLICT, }, - [ERROR_CODE.ASSO_ROLE_ALREADY_MOVED]: { - message: 'You should not try to update role position simultaneously', + [ERROR_CODE.LINK_ALREADY_EXISTS]: { + message: 'This link already exists', httpCode: HttpStatus.CONFLICT, }, [ERROR_CODE.HIDDEN_DUCK]: { diff --git a/src/link/dto/req/link-create-req.dto.ts b/src/link/dto/req/link-create-req.dto.ts new file mode 100644 index 00000000..d7a9d693 --- /dev/null +++ b/src/link/dto/req/link-create-req.dto.ts @@ -0,0 +1,14 @@ +import { Translation } from '../../../prisma/types'; +import { IsNotEmpty, IsUrl, ValidateNested } from 'class-validator'; + +export class LinkCreateReqDto { + @ValidateNested() + name: Translation; + + @ValidateNested() + tooltip: Translation; + + @IsUrl() + @IsNotEmpty() + link: string; +} \ No newline at end of file diff --git a/src/link/dto/req/link-update-req.dto.ts b/src/link/dto/req/link-update-req.dto.ts new file mode 100644 index 00000000..686f97d1 --- /dev/null +++ b/src/link/dto/req/link-update-req.dto.ts @@ -0,0 +1,18 @@ +import { Translation } from '../../../prisma/types'; +import { IsNotEmpty, IsString, IsUrl, ValidateNested } from 'class-validator'; + +export class LinkUpdateReqDto { + @IsString() + @IsNotEmpty() + id: string; + + @ValidateNested() + name: Translation; + + @ValidateNested() + tooltip: Translation; + + @IsUrl() + @IsNotEmpty() + link: string; +} \ No newline at end of file diff --git a/src/link/dto/res/link-res.dto.ts b/src/link/dto/res/link-res.dto.ts new file mode 100644 index 00000000..7a950f96 --- /dev/null +++ b/src/link/dto/res/link-res.dto.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Translation } from '../../../prisma/types'; + +export class LinkResDto { + id: string; + @ApiProperty({ type: String }) + name: Translation; + @ApiProperty({ type: String }) + tooltip: Translation; + link: string; +} \ No newline at end of file diff --git a/src/link/link.controller.ts b/src/link/link.controller.ts new file mode 100644 index 00000000..9039417b --- /dev/null +++ b/src/link/link.controller.ts @@ -0,0 +1,43 @@ +import { Body, Controller, Get, Patch, Post } from '@nestjs/common'; +import { LinkService } from './link.service'; +import { LinkResDto } from './dto/res/link-res.dto'; +import { pick } from '../utils'; +import { Link } from './link.interface'; +import { IsPublic, RequireApiPermission } from '../auth/decorator'; +import { LinkCreateReqDto } from './dto/req/link-create-req.dto'; +import { AppException, ERROR_CODE } from '../exceptions'; +import { ApiAppErrorResponse } from '../app.dto'; +import { LinkUpdateReqDto } from './dto/req/link-update-req.dto'; + +@Controller('link') +export class LinkController { + constructor(private readonly linkService: LinkService) {} + + @Get() + @IsPublic() + public async get() { + return (await this.linkService.getLinks()).map(this.formatLink); + } + + @Post() + @RequireApiPermission('API_MODIFY_LINKS') + @ApiAppErrorResponse(ERROR_CODE.LINK_ALREADY_EXISTS, 'This link already exists') + public async create(@Body() dto: LinkCreateReqDto) { + if (await this.linkService.linkExists(dto.link)) throw new AppException(ERROR_CODE.LINK_ALREADY_EXISTS); + const link = await this.linkService.create(dto.name, dto.tooltip, dto.link); + return this.formatLink(link); + } + + @Patch('/:id') + @RequireApiPermission('API_MODIFY_LINKS') + @ApiAppErrorResponse(ERROR_CODE.NO_SUCH_LINK) + public async update(@Body() dto: LinkUpdateReqDto) { + if (!(await this.linkService.idExists(dto.id))) throw new AppException(ERROR_CODE.NO_SUCH_LINK, dto.id); + const link = await this.linkService.update(dto.id, pick(dto, 'name', 'tooltip', 'link')); + return this.formatLink(link); + } + + formatLink(link: Link): LinkResDto { + return pick(link, 'id', 'name', 'tooltip', 'link'); + } +} \ No newline at end of file diff --git a/src/link/link.interface.ts b/src/link/link.interface.ts new file mode 100644 index 00000000..0489615d --- /dev/null +++ b/src/link/link.interface.ts @@ -0,0 +1,21 @@ +import { Prisma, PrismaClient } from '@prisma/client'; +import { translationSelect } from '../utils'; +import { generateCustomModel } from '../prisma/prisma.service'; + +const LINK_SELECT_FILTER = { + select: { + id: true, + name: translationSelect, + tooltip: translationSelect, + link: true, + createdAt: true, + }, + orderBy: { + id: 'asc', + }, +} as const satisfies Prisma.LinkFindManyArgs; + +export type Link = Prisma.LinkGetPayload; + +export const generateCustomLinkModel = (prisma: PrismaClient) => + generateCustomModel(prisma, 'link', LINK_SELECT_FILTER, (_, e: Link) => e); diff --git a/src/link/link.module.ts b/src/link/link.module.ts new file mode 100644 index 00000000..a88e8b82 --- /dev/null +++ b/src/link/link.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { LinkService } from './link.service'; +import { LinkController } from './link.controller'; + +@Module({ + controllers: [LinkController], + providers: [LinkService], +}) +export class LinkModule {} diff --git a/src/link/link.service.ts b/src/link/link.service.ts new file mode 100644 index 00000000..b29abbd5 --- /dev/null +++ b/src/link/link.service.ts @@ -0,0 +1,29 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { RawLink, Translation } from '../prisma/types'; +import { Link } from './link.interface'; + +@Injectable() +export class LinkService { + constructor(private readonly prisma: PrismaService) {} + + public getLinks(): Promise { + return this.prisma.normalize.link.findMany({}); + } + + public async idExists(id: string): Promise { + return (await this.prisma.link.count({ where: { id } })) > 0; + } + + public async linkExists(link: string): Promise { + return (await this.prisma.link.count({ where: { link } })) > 0; + } + + public create(name: Translation, tooltip: Translation, link: string): Promise { + return this.prisma.normalize.link.create({ data: { name: {create: name}, tooltip: {create: tooltip}, link } }) + } + + public update(id: string, { name, tooltip, link }: { name?: Translation, tooltip?: Translation, link?: string }): Promise { + return this.prisma.normalize.link.update({ where: { id }, data: { name: {create: name}, tooltip: {create: tooltip}, link } }); + } +} \ No newline at end of file diff --git a/src/prisma/prisma.service.ts b/src/prisma/prisma.service.ts index 1f75e86f..07e97d7e 100644 --- a/src/prisma/prisma.service.ts +++ b/src/prisma/prisma.service.ts @@ -14,6 +14,7 @@ import { generateCustomAssoMembershipModel } from '../assos/interfaces/membershi import { generateCustomAssoMembershipRoleModel } from '../assos/interfaces/membership-role.interface'; import { generateCustomCreditCategoryModel } from '../ue/credit/interfaces/credit-category.interface'; import { generateCustomApplicationModel } from '../auth/application/interfaces/application.interface'; +import { generateCustomLinkModel } from '../link/link.interface'; @Injectable() export class PrismaService extends PrismaClient> { @@ -54,6 +55,7 @@ function createNormalizedEntitiesUtility(prisma: PrismaClient) { assoMembershipRole: generateCustomAssoMembershipRoleModel(prisma), ueCreditCategory: generateCustomCreditCategoryModel(prisma), apiApplication: generateCustomApplicationModel(prisma), + link: generateCustomLinkModel(prisma), }; } diff --git a/src/prisma/types.ts b/src/prisma/types.ts index c29ba04c..f55ae0d5 100644 --- a/src/prisma/types.ts +++ b/src/prisma/types.ts @@ -37,6 +37,7 @@ export { UserPrivacy as RawUserPrivacy, ApiApplication as RawApiApplication, ApiKey as RawApiKey, + Link as RawLink, } from '@prisma/client'; export { RawTranslation }; From c3043597b2aae647d854ee234d620bb9a090c105 Mon Sep 17 00:00:00 2001 From: Teddy Roncin Date: Tue, 10 Feb 2026 23:57:46 +0100 Subject: [PATCH 2/9] Implement link routes --- prisma/schema.prisma | 10 ++-- prisma/seed/utils.ts | 6 +++ src/app.decorator.ts | 18 +++++++ src/app.dto.ts | 28 +++++++++++ src/app.interceptor.ts | 4 +- src/app.module.ts | 2 + src/link/dto/req/link-create-req.dto.ts | 14 ------ src/link/dto/req/link-req.dto.ts | 19 ++++++++ src/link/dto/req/link-update-req.dto.ts | 18 ------- src/link/link.controller.ts | 20 ++++---- src/link/link.interface.ts | 4 -- src/ue/dto/req/ue-search-req.dto.ts | 3 +- src/ue/ue.controller.ts | 15 +++--- test/declarations.d.ts | 5 +- test/declarations.ts | 26 +++++++++- test/e2e/app.e2e-spec.ts | 2 + test/e2e/link/create-link.e2e-spec.ts | 53 ++++++++++++++++++++ test/e2e/link/get-links.e2e-spec.ts | 11 +++++ test/e2e/link/index.ts | 12 +++++ test/e2e/link/update-link.e2e-spec.ts | 64 +++++++++++++++++++++++++ test/utils/fakedb.ts | 19 ++++++++ 21 files changed, 290 insertions(+), 63 deletions(-) create mode 100644 src/app.decorator.ts delete mode 100644 src/link/dto/req/link-create-req.dto.ts create mode 100644 src/link/dto/req/link-req.dto.ts delete mode 100644 src/link/dto/req/link-update-req.dto.ts create mode 100644 test/e2e/link/create-link.e2e-spec.ts create mode 100644 test/e2e/link/get-links.e2e-spec.ts create mode 100644 test/e2e/link/index.ts create mode 100644 test/e2e/link/update-link.e2e-spec.ts diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b3c619b0..68564624 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -504,11 +504,11 @@ model UeCreditCategory { } model UeofInfo { - id String @id @default(uuid()) - minors String? @db.Text - language String? @db.Text - objectivesTranslationId String? @unique - programTranslationId String? @unique + id String @id @default(uuid()) + minors String? @db.Text + language Language + objectivesTranslationId String? @unique + programTranslationId String? @unique objectives Translation? @relation("ueofInfoObjectivesTranslation", fields: [objectivesTranslationId], references: [id], onDelete: Cascade) program Translation? @relation("ueofInfoProgramTranslation", fields: [programTranslationId], references: [id], onDelete: Cascade) diff --git a/prisma/seed/utils.ts b/prisma/seed/utils.ts index 2093fbd7..d02c5647 100644 --- a/prisma/seed/utils.ts +++ b/prisma/seed/utils.ts @@ -128,6 +128,9 @@ declare module '@faker-js/faker' { association: { name: () => string; }; + link: { + link: () => string; + }, }; } } @@ -191,6 +194,9 @@ Faker.prototype.db = { association: { name: () => fakeSafeUniqueData('association', 'name', faker.person.firstName), }, + link: { + link: () => fakeSafeUniqueData('link', 'link', faker.internet.url), + } }; export { Faker }; diff --git a/src/app.decorator.ts b/src/app.decorator.ts new file mode 100644 index 00000000..a3c36299 --- /dev/null +++ b/src/app.decorator.ts @@ -0,0 +1,18 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; +import { Language } from '@prisma/client'; + +/** + * Get the language for which the request was made. + * @returns The language (fr, en, es, de, zh). + * + * @example + * ```typescript + * public async getLinks(@GetLanguage() language: Language) { + * ... + * } + * ``` + */ +export const GetLanguage = createParamDecorator((_, ctx: ExecutionContext) => { + const request = ctx.switchToHttp().getRequest(); + return request.headers['x-language'] as Language; +}); diff --git a/src/app.dto.ts b/src/app.dto.ts index 6d731dbb..a80f1408 100644 --- a/src/app.dto.ts +++ b/src/app.dto.ts @@ -3,6 +3,7 @@ import { applyDecorators, HttpStatus, Injectable } from '@nestjs/common'; import * as ApiResponses from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger'; import { Type } from '@nestjs/common/interfaces/type.interface'; +import { IsOptional, IsString } from 'class-validator'; // Redefine the mixin function in node_modules/.pnpm/@nestjs+common@_class-transformer@_class-validator@_reflect-metadata@_rxjs@/node_modules/@nestjs/common/decorators/core/injectable.decorator.js // This implementation allows to give a name to the class @@ -44,3 +45,30 @@ export function paginatedResponseDto(Base: TBase) { } return mixin(ResponseDto, `${Base.name}$Paginated`); // This is important otherwise you will get always the same instance } + +export class TranslationReqDto { + @IsOptional() + @IsString() + @ApiProperty({ description: "French" }) + fr?: string; + + @IsOptional() + @IsString() + @ApiProperty({ description: "English" }) + en?: string; + + @IsOptional() + @IsString() + @ApiProperty({ description: "Spanish" }) + es?: string; + + @IsOptional() + @IsString() + @ApiProperty({ description: "German" }) + de?: string; + + @IsOptional() + @IsString() + @ApiProperty({ description: "Chinese" }) + zh?: string; +} diff --git a/src/app.interceptor.ts b/src/app.interceptor.ts index 72614d17..a460a70c 100644 --- a/src/app.interceptor.ts +++ b/src/app.interceptor.ts @@ -7,9 +7,9 @@ import { getTranslation } from './utils'; @Injectable() export class TranslationInterceptor implements NestInterceptor { intercept(context: ExecutionContext, next: CallHandler): Observable { - const headerLanguage = context.switchToHttp().getRequest().header('language'); + const headerLanguage = context.switchToHttp().getRequest().header('x-language'); const language = headerLanguage in Language ? (headerLanguage as Language) : 'fr'; - context.switchToHttp().getRequest().headers['language'] = language; + context.switchToHttp().getRequest().headers['x-language'] = language; return next.handle().pipe(map((item) => this.transform(item, language))); } diff --git a/src/app.module.ts b/src/app.module.ts index a9b8f700..a3bbe427 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -15,6 +15,7 @@ import { BranchModule } from './branch/branch.module'; import { AssosModule } from './assos/assos.module'; import { TranslationInterceptor } from './app.interceptor'; import { SemesterModule } from './semester/semester.module'; +import { LinkModule } from './link/link.module'; @Module({ imports: [ @@ -29,6 +30,7 @@ import { SemesterModule } from './semester/semester.module'; TimetableModule, BranchModule, AssosModule, + LinkModule, ], // The providers below are used for all the routes of the api. // For example, the JwtGuard is used for all the routes and checks whether the user is authenticated. diff --git a/src/link/dto/req/link-create-req.dto.ts b/src/link/dto/req/link-create-req.dto.ts deleted file mode 100644 index d7a9d693..00000000 --- a/src/link/dto/req/link-create-req.dto.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Translation } from '../../../prisma/types'; -import { IsNotEmpty, IsUrl, ValidateNested } from 'class-validator'; - -export class LinkCreateReqDto { - @ValidateNested() - name: Translation; - - @ValidateNested() - tooltip: Translation; - - @IsUrl() - @IsNotEmpty() - link: string; -} \ No newline at end of file diff --git a/src/link/dto/req/link-req.dto.ts b/src/link/dto/req/link-req.dto.ts new file mode 100644 index 00000000..4fc47dd6 --- /dev/null +++ b/src/link/dto/req/link-req.dto.ts @@ -0,0 +1,19 @@ +import { IsNotEmpty, IsObject, IsUrl, ValidateNested } from 'class-validator'; +import { TranslationReqDto } from '../../../app.dto'; +import { Type } from 'class-transformer'; + +export class LinkReqDto { + @IsObject() + @ValidateNested() + @Type(() => TranslationReqDto) + name: TranslationReqDto; + + @IsObject() + @ValidateNested() + @Type(() => TranslationReqDto) + tooltip: TranslationReqDto; + + @IsUrl() + @IsNotEmpty() + link: string; +} \ No newline at end of file diff --git a/src/link/dto/req/link-update-req.dto.ts b/src/link/dto/req/link-update-req.dto.ts deleted file mode 100644 index 686f97d1..00000000 --- a/src/link/dto/req/link-update-req.dto.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Translation } from '../../../prisma/types'; -import { IsNotEmpty, IsString, IsUrl, ValidateNested } from 'class-validator'; - -export class LinkUpdateReqDto { - @IsString() - @IsNotEmpty() - id: string; - - @ValidateNested() - name: Translation; - - @ValidateNested() - tooltip: Translation; - - @IsUrl() - @IsNotEmpty() - link: string; -} \ No newline at end of file diff --git a/src/link/link.controller.ts b/src/link/link.controller.ts index 9039417b..91d0d526 100644 --- a/src/link/link.controller.ts +++ b/src/link/link.controller.ts @@ -1,13 +1,15 @@ import { Body, Controller, Get, Patch, Post } from '@nestjs/common'; import { LinkService } from './link.service'; import { LinkResDto } from './dto/res/link-res.dto'; -import { pick } from '../utils'; +import { getTranslation, pick } from '../utils'; import { Link } from './link.interface'; import { IsPublic, RequireApiPermission } from '../auth/decorator'; -import { LinkCreateReqDto } from './dto/req/link-create-req.dto'; +import { LinkReqDto } from './dto/req/link-req.dto'; import { AppException, ERROR_CODE } from '../exceptions'; import { ApiAppErrorResponse } from '../app.dto'; -import { LinkUpdateReqDto } from './dto/req/link-update-req.dto'; +import { Language } from '@prisma/client'; +import { GetLanguage } from '../app.decorator'; +import { UUIDParam } from '../app.pipe'; @Controller('link') export class LinkController { @@ -15,14 +17,14 @@ export class LinkController { @Get() @IsPublic() - public async get() { - return (await this.linkService.getLinks()).map(this.formatLink); + public async get(@GetLanguage() language: Language) { + return (await this.linkService.getLinks()).mappedSort((link) => getTranslation(link.name, language)).map(this.formatLink); } @Post() @RequireApiPermission('API_MODIFY_LINKS') @ApiAppErrorResponse(ERROR_CODE.LINK_ALREADY_EXISTS, 'This link already exists') - public async create(@Body() dto: LinkCreateReqDto) { + public async create(@Body() dto: LinkReqDto) { if (await this.linkService.linkExists(dto.link)) throw new AppException(ERROR_CODE.LINK_ALREADY_EXISTS); const link = await this.linkService.create(dto.name, dto.tooltip, dto.link); return this.formatLink(link); @@ -31,9 +33,9 @@ export class LinkController { @Patch('/:id') @RequireApiPermission('API_MODIFY_LINKS') @ApiAppErrorResponse(ERROR_CODE.NO_SUCH_LINK) - public async update(@Body() dto: LinkUpdateReqDto) { - if (!(await this.linkService.idExists(dto.id))) throw new AppException(ERROR_CODE.NO_SUCH_LINK, dto.id); - const link = await this.linkService.update(dto.id, pick(dto, 'name', 'tooltip', 'link')); + public async update(@UUIDParam('id') id, @Body() dto: LinkReqDto) { + if (!(await this.linkService.idExists(id))) throw new AppException(ERROR_CODE.NO_SUCH_LINK, id); + const link = await this.linkService.update(id, pick(dto, 'name', 'tooltip', 'link')); return this.formatLink(link); } diff --git a/src/link/link.interface.ts b/src/link/link.interface.ts index 0489615d..6be1899a 100644 --- a/src/link/link.interface.ts +++ b/src/link/link.interface.ts @@ -8,10 +8,6 @@ const LINK_SELECT_FILTER = { name: translationSelect, tooltip: translationSelect, link: true, - createdAt: true, - }, - orderBy: { - id: 'asc', }, } as const satisfies Prisma.LinkFindManyArgs; diff --git a/src/ue/dto/req/ue-search-req.dto.ts b/src/ue/dto/req/ue-search-req.dto.ts index c296833b..5e9938b3 100644 --- a/src/ue/dto/req/ue-search-req.dto.ts +++ b/src/ue/dto/req/ue-search-req.dto.ts @@ -1,5 +1,6 @@ import { Type } from 'class-transformer'; import { IsNumber, IsPositive, IsString, IsOptional, Length } from 'class-validator'; +import { Language } from '@prisma/client'; /** * Query parameters of the request to search UEs. @@ -41,5 +42,5 @@ export class UeSearchReqDto { @IsString() @Length(2) @IsOptional() - preferredLang?: string; + preferredLang?: Language; } diff --git a/src/ue/ue.controller.ts b/src/ue/ue.controller.ts index b6426ce6..c7efbb85 100644 --- a/src/ue/ue.controller.ts +++ b/src/ue/ue.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Delete, Get, Headers, Param, Put, Query, Res } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Put, Query, Res } from '@nestjs/common'; import { HttpStatusCode } from 'axios'; import type { Response } from 'express'; import { UeSearchReqDto } from './dto/req/ue-search-req.dto'; @@ -17,6 +17,7 @@ import UeRateCriterionResDto from './dto/res/ue-rate-criterion-res.dto'; import UeRateResDto from './dto/res/ue-rate-res.dto'; import { Language, UserType } from '@prisma/client'; import { UeRating } from './interfaces/rate.interface'; +import { GetLanguage } from '../app.decorator'; @Controller('ue') @ApiTags('UE') @@ -31,7 +32,7 @@ export class UeController { @ApiOkResponse({ type: paginatedResponseDto(UeOverviewResDto) }) async searchUe( @GetUser() user: User, - @Headers('language') language: Language, + @GetLanguage() language: Language, @Query() queryParams: UeSearchReqDto, ): Promise> { const res = await this.ueService.searchUes(queryParams, language); @@ -129,7 +130,7 @@ export class UeController { @RequireUserType('STUDENT') @ApiOperation({ description: 'Get the UEs of the current user.' }) @ApiOkResponse({ type: UeOverviewResDto, isArray: true }) - async getMyUes(@GetUser() user: User, @Headers('language') language: Language): Promise { + async getMyUes(@GetUser() user: User, @GetLanguage() language: Language): Promise { return (await this.ueService.getUesOfUser(user.id)).map((ue) => this.formatUeOverview( ue, @@ -140,8 +141,8 @@ export class UeController { } /** This method chooses an UEOF and displays its basic data */ - private formatUeOverview(ue: Ue, langPref: string[], branchOptionPref: string[]): UeOverviewResDto { - const lowerCasePref = langPref.map((lang) => lang.toLocaleLowerCase()); + private formatUeOverview(ue: Ue, langPref: Language[], branchOptionPref: string[]): UeOverviewResDto { + const lowerCasePref = langPref.map((lang) => lang); // Filters ueofs with ueofs that can be taken with the preferred branch options const availableOf = ue.ueofs.filter((ueof) => branchOptionPref.some((optionPref) => @@ -150,8 +151,8 @@ export class UeController { ); // Chooses an UEOF : the only ueof if there is only one; the one with the preferred language if there is one; the first one otherwise const chosenOf = availableOf.length - ? availableOf.find((ueof) => lowerCasePref.includes(ueof.info.language)) - : (ue.ueofs.find((ueof) => lowerCasePref.includes(ueof.info.language)) ?? ue.ueofs[0]); + ? availableOf.find((ueof) => lowerCasePref.includes(ueof.info.language as Language)) + : (ue.ueofs.find((ueof) => lowerCasePref.includes(ueof.info.language as Language)) ?? ue.ueofs[0]); return { code: ue.code, name: chosenOf.name, diff --git a/test/declarations.d.ts b/test/declarations.d.ts index bf412f0a..5a70115f 100644 --- a/test/declarations.d.ts +++ b/test/declarations.d.ts @@ -6,7 +6,7 @@ import { FakeApiApplication, FakeAssoMembership, FakeAssoMembershipPermission, - FakeAssoMembershipRole, + FakeAssoMembershipRole, FakeLink, FakeUeAnnalType, FakeUeof, } from './utils/fakedb'; @@ -104,8 +104,9 @@ declare module './declarations' { expectCreditCategories(categories: JsonLikeVariant): this; expectApplications(applications: FakeApiApplication[]): this; expectApplication(application: FakeApiApplication): this; - expectPermissions(permissions: PermissionManager): this; + expectLinks(links: FakeLink[]): this; + expectLink(link: JsonLikeVariant): this; withLanguage(language: Language): this; language: Language; diff --git a/test/declarations.ts b/test/declarations.ts index 1d8e1f5f..f4e66000 100644 --- a/test/declarations.ts +++ b/test/declarations.ts @@ -14,7 +14,7 @@ import { FakeUeCreditCategory, FakeApiApplication, FakeAssoMembershipRole, - FakeAssoMembership, + FakeAssoMembership, FakeLink, } from './utils/fakedb'; import { UeAnnalFile } from 'src/ue/annals/interfaces/annal.interface'; import { ConfigModule } from '../src/config/config.module'; @@ -25,6 +25,8 @@ import { Language } from '@prisma/client'; import { DEFAULT_APPLICATION } from '../prisma/seed/utils'; import ApplicationResDto from '../src/auth/application/dto/res/application-res.dto'; import PermissionsResDto from '../src/auth/permissions/dto/res/permissions.dto'; +import { LinkResDto } from '../src/link/dto/res/link-res.dto'; +import { Translation } from '../src/prisma/types'; function expect(this: Spec, obj: JsonLikeVariant) { return this.expectStatus(HttpStatus.OK).$expectRegexableJson(obj); @@ -315,6 +317,27 @@ Spec.prototype.expectPermissions = function (permissions: PermissionManager) { .mappedSort((permission) => permission.permission), } satisfies PermissionsResDto); }; +Spec.prototype.expectLinks = function (this: Spec, links: FakeLink[]) { + return this.expectStatus(HttpStatus.OK).expectJson( + [...links] + .mappedSort((link) => link.name[this.language]) + .map( + (link) => + ({ + ...pick(link as Required, 'id', 'link'), + name: link.name[this.language], + tooltip: link.tooltip[this.language], + }) satisfies TranslationToString, + ), + ); +}; +Spec.prototype.expectLink = function (this: Spec, link: JsonLikeVariant) { + return this.$expectRegexableJson({ + ...pick(link as Required, 'id', 'link'), + name: getTranslation(link.name as Translation, this.language), + tooltip: getTranslation(link.tooltip as Translation, this.language), + } satisfies TranslationToString); +}; export { Spec, JsonLikeVariant, FakeUeWithOfs }; @@ -371,3 +394,4 @@ function generateSchema(obj: JsonLikeVariant): object { return { type: 'boolean' }; } } +type TranslationToString = T extends number | string | null ? T : T extends Array ? Array> : T extends Translation ? string : {[K in keyof T]: TranslationToString}; diff --git a/test/e2e/app.e2e-spec.ts b/test/e2e/app.e2e-spec.ts index 4a16e08f..cbcb4485 100644 --- a/test/e2e/app.e2e-spec.ts +++ b/test/e2e/app.e2e-spec.ts @@ -15,6 +15,7 @@ import * as cas from '../external_services/cas'; import * as timetableProvider from '../external_services/timetable'; import { ConfigModule } from '../../src/config/config.module'; import AssoE2ESpec from './assos'; +import LinkE2ESpec from './link'; describe('EtuUTT API e2e testing', () => { let app: INestApplication; @@ -54,4 +55,5 @@ describe('EtuUTT API e2e testing', () => { TimetableE2ESpec(() => app); // Deactivated, see function UeE2ESpec(() => app); AssoE2ESpec(() => app); + LinkE2ESpec(() => app); }); diff --git a/test/e2e/link/create-link.e2e-spec.ts b/test/e2e/link/create-link.e2e-spec.ts new file mode 100644 index 00000000..c376330b --- /dev/null +++ b/test/e2e/link/create-link.e2e-spec.ts @@ -0,0 +1,53 @@ +import { e2eSuite, JsonLike } from '../../utils/test_utils'; +import * as pactum from 'pactum'; +import * as fakedb from '../../utils/fakedb'; +import { LinkReqDto } from '../../../src/link/dto/req/link-req.dto'; +import { faker } from '@faker-js/faker'; +import { ERROR_CODE } from '../../../src/exceptions'; +import { PermissionManager } from '../../../src/utils'; +import { HttpStatus } from '@nestjs/common'; +import { PrismaService } from '../../../src/prisma/prisma.service'; + +const CreateLinksE2ESpec = e2eSuite('POST /link', (app) => { + const existingLink = fakedb.createLink(app); + const user = fakedb.createUser(app, { permissions: new PermissionManager().with('API_MODIFY_LINKS') }); + const userNoPermission = fakedb.createUser(app); + + const body: LinkReqDto = { + link: faker.db.link.link(), + name: faker.db.translation(faker.company.name), + tooltip: faker.db.translation(faker.company.catchPhrase) + }; + + it('should fail as user is not connected', () => pactum.spec().post('/link').withBody(body).expectAppError(ERROR_CODE.NOT_LOGGED_IN)) + + it('should fail as user does not have permission API_MODIFY_LINKS', () => + pactum + .spec() + .post('/link') + .withBearerToken(userNoPermission.token) + .withBody(body) + .expectAppError(ERROR_CODE.FORBIDDEN_NOT_ENOUGH_API_PERMISSIONS, 'API_MODIFY_LINKS')); + + it('should fail as link already exists', () => + pactum + .spec() + .post('/link') + .withBearerToken(user.token) + .withBody({ ...body, link: existingLink.link }) + .expectAppError(ERROR_CODE.LINK_ALREADY_EXISTS)); + + it('should successfully create the link', async () => { + await pactum + .spec() + .post('/link') + .withBearerToken(user.token) + .withBody(body) + .expectStatus(HttpStatus.CREATED) + .expectLink({ id: JsonLike.UUID, ...body }); + const deleted = await app().get(PrismaService).link.deleteMany({where: {id: { not: existingLink.id }}}); + expect(deleted.count).toBe(1); + }); +}); + +export default CreateLinksE2ESpec; diff --git a/test/e2e/link/get-links.e2e-spec.ts b/test/e2e/link/get-links.e2e-spec.ts new file mode 100644 index 00000000..934840f9 --- /dev/null +++ b/test/e2e/link/get-links.e2e-spec.ts @@ -0,0 +1,11 @@ +import { e2eSuite } from '../../utils/test_utils'; +import * as pactum from 'pactum'; +import * as fakedb from '../../utils/fakedb'; + +const GetLinksE2ESpec = e2eSuite('GET /link', (app) => { + const links = [fakedb.createLink(app), fakedb.createLink(app)]; + + it('should return both links', () => pactum.spec().get('/link').expectLinks(links)); +}); + +export default GetLinksE2ESpec; diff --git a/test/e2e/link/index.ts b/test/e2e/link/index.ts new file mode 100644 index 00000000..cbe16b36 --- /dev/null +++ b/test/e2e/link/index.ts @@ -0,0 +1,12 @@ +import { E2EAppProvider } from '../../utils/test_utils'; +import GetLinksE2ESpec from './get-links.e2e-spec'; +import CreateLinkE2ESpec from './create-link.e2e-spec'; +import UpdateLinkE2ESpec from './update-link.e2e-spec'; + +export default function LinkE2ESpec(app: E2EAppProvider) { + describe('Link', () => { + GetLinksE2ESpec(app); + CreateLinkE2ESpec(app); + UpdateLinkE2ESpec(app); + }); +} diff --git a/test/e2e/link/update-link.e2e-spec.ts b/test/e2e/link/update-link.e2e-spec.ts new file mode 100644 index 00000000..31496154 --- /dev/null +++ b/test/e2e/link/update-link.e2e-spec.ts @@ -0,0 +1,64 @@ +import { Dummies, e2eSuite } from '../../utils/test_utils'; +import * as pactum from 'pactum'; +import * as fakedb from '../../utils/fakedb'; +import { faker } from '@faker-js/faker'; +import { ERROR_CODE } from '../../../src/exceptions'; +import { PermissionManager } from '../../../src/utils'; +import { LinkReqDto } from '../../../src/link/dto/req/link-req.dto'; +import { PrismaService } from '../../../src/prisma/prisma.service'; + +const UpdateLinksE2ESpec = e2eSuite('PATCH /link/:id', (app) => { + let link = fakedb.createLink(app); + const user = fakedb.createUser(app, { permissions: new PermissionManager().with('API_MODIFY_LINKS') }); + const userNoPermission = fakedb.createUser(app); + + const body = () => ({ + link: faker.db.link.link(), + name: faker.db.translation(faker.company.name), + tooltip: faker.db.translation(faker.company.catchPhrase) + } as LinkReqDto); + + it('should fail as user is not connected', () => pactum.spec().patch(`/link/${link.id}`).withBody(body()).expectAppError(ERROR_CODE.NOT_LOGGED_IN)) + + it('should fail as user does not have permission API_MODIFY_LINKS', () => pactum.spec().patch(`/link/${link.id}`).withBearerToken(userNoPermission.token).withBody(body()).expectAppError(ERROR_CODE.FORBIDDEN_NOT_ENOUGH_API_PERMISSIONS, 'API_MODIFY_LINKS')); + + it("should fail as the link doesn't exists", () => + pactum + .spec() + .patch(`/link/${Dummies.UUID}`) + .withBearerToken(user.token) + .withBody(body()) + .expectAppError(ERROR_CODE.NO_SUCH_LINK, Dummies.UUID)); + + it('should successfully update the link', () => { + const thisBody = body() + pactum + .spec() + .patch(`/link/${link.id}`) + .withBearerToken(user.token) + .withBody(thisBody) + .expectLink({ id: link.id, ...thisBody }); + link = {...link, ...thisBody}; + }); + + it('should delete language ES from the name as the value is null, but not DE as field is not set', async () => { + // Build body + const thisBody = body(); + thisBody.name = {...thisBody.name, es: null, de: undefined}; + // Request + await pactum + .spec() + .patch(`/link/${link.id}`) + .withBearerToken(user.token) + .withBody(thisBody) + .expectLink({ ...link, ...thisBody, name: { ...thisBody.name, es: null, de: link.name.de } }); + // Revert deletion of spanish and update link variable to match the database + await app().get(PrismaService).link.update({ + where: { id: link.id }, + data: { name: { update: { es: link.name.es } } } + }); + link = { ...link, ...thisBody, name: { ...thisBody.name, es: link.name.es, de: link.name.de } }; + }); +}); + +export default UpdateLinksE2ESpec; diff --git a/test/utils/fakedb.ts b/test/utils/fakedb.ts index c368ac9b..4b49a55b 100644 --- a/test/utils/fakedb.ts +++ b/test/utils/fakedb.ts @@ -44,6 +44,7 @@ import { CommentStatus } from '../../src/ue/comments/interfaces/comment.interfac import { UeAnnalFile } from '../../src/ue/annals/interfaces/annal.interface'; import { omit, PermissionManager, pick, translationSelect } from '../../src/utils'; import { DEFAULT_APPLICATION } from '../../prisma/seed/utils'; +import { Link } from '../../src/link/link.interface'; /** * The fake entities can be used like normal entities in the it(string, () => void) functions. @@ -118,6 +119,7 @@ export type FakeHomepageWidget = Partial; export type FakeApiApplication = Partial> & { owner: { id: string; firstName: string; lastName: string }; }; +export type FakeLink = Partial> & { name?: Partial; tooltip?: Partial }; export interface FakeEntityMap { assoMembership: { @@ -245,6 +247,10 @@ export interface FakeEntityMap { params: CreateApiApplicationParameter; deps: { owner: FakeUser }; }; + link: { + entity: FakeLink; + params: CreateLinkParameter; + } } export type CreateUserParameters = FakeUser & { password: string }; @@ -1090,6 +1096,19 @@ export const createApplication = entityFaker( }), ); +export type CreateLinkParameter = FakeLink; +export const createLink = entityFaker( + 'link', + { + name: () => faker.db.translation(faker.company.name), + tooltip: () => faker.db.translation(faker.company.catchPhrase), + link: faker.db.link.link, + }, + async (app, params) => app() + .get(PrismaService) + .normalize.link.create({ data: { ...pick(params, 'id', 'link'), name: { create: params.name }, tooltip: { create: params.tooltip } } }), +) + /** * The return type of a fake function, either Promise or FakeEntity depending on whether OnTheFly is true or false */ From abafbe60694507b6c369d253a5054df04f993887 Mon Sep 17 00:00:00 2001 From: Teddy Roncin Date: Wed, 25 Feb 2026 23:48:29 +0100 Subject: [PATCH 3/9] Changed field name "link" to "hyperlink" in Link table in the database --- prisma/schema.prisma | 2 +- prisma/seed/utils.ts | 4 +-- src/app.dto.ts | 1 + src/exceptions.ts | 2 +- src/link/dto/req/link-req.dto.ts | 2 +- src/link/dto/res/link-res.dto.ts | 2 +- src/link/link.controller.ts | 8 +++--- src/link/link.interface.ts | 2 +- src/link/link.service.ts | 40 ++++++++++++++++++++++----- test/declarations.ts | 4 +-- test/e2e/link/create-link.e2e-spec.ts | 4 +-- test/e2e/link/update-link.e2e-spec.ts | 2 +- test/utils/fakedb.ts | 8 ++++-- 13 files changed, 55 insertions(+), 26 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3db9cf43..07e002b9 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -184,7 +184,7 @@ model Link { id String @id @default(uuid()) nameId String @unique tooltipId String @unique - link String @unique + hyperlink String @unique name Translation @relation(name: "linkNameTranslation", fields: [nameId], references: [id]) tooltip Translation @relation(name: "linkTooltipTranslation", fields: [tooltipId], references: [id]) diff --git a/prisma/seed/utils.ts b/prisma/seed/utils.ts index d02c5647..c6852621 100644 --- a/prisma/seed/utils.ts +++ b/prisma/seed/utils.ts @@ -129,7 +129,7 @@ declare module '@faker-js/faker' { name: () => string; }; link: { - link: () => string; + hyperlink: () => string; }, }; } @@ -195,7 +195,7 @@ Faker.prototype.db = { name: () => fakeSafeUniqueData('association', 'name', faker.person.firstName), }, link: { - link: () => fakeSafeUniqueData('link', 'link', faker.internet.url), + hyperlink: () => fakeSafeUniqueData('link', 'hyperlink', faker.internet.url), } }; diff --git a/src/app.dto.ts b/src/app.dto.ts index 03e87e61..84993347 100644 --- a/src/app.dto.ts +++ b/src/app.dto.ts @@ -6,6 +6,7 @@ import { Type } from '@nestjs/common/interfaces/type.interface'; import { IsOptional, IsString } from 'class-validator'; import { HasSomeAmong } from './validation'; import { languages } from './utils'; +import { Language } from '@prisma/client'; // Redefine the mixin function in node_modules/.pnpm/@nestjs+common@_class-transformer@_class-validator@_reflect-metadata@_rxjs@/node_modules/@nestjs/common/decorators/core/injectable.decorator.js // This implementation allows to give a name to the class diff --git a/src/exceptions.ts b/src/exceptions.ts index 2868a34d..67dfcc01 100644 --- a/src/exceptions.ts +++ b/src/exceptions.ts @@ -460,7 +460,7 @@ export const ErrorData = Object.freeze({ httpCode: HttpStatus.CONFLICT, }, [ERROR_CODE.LINK_ALREADY_EXISTS]: { - message: 'This link already exists', + message: 'This hyperlink already exists', httpCode: HttpStatus.CONFLICT, }, [ERROR_CODE.SERVER_DISK_ERROR]: { diff --git a/src/link/dto/req/link-req.dto.ts b/src/link/dto/req/link-req.dto.ts index 4fc47dd6..13e56074 100644 --- a/src/link/dto/req/link-req.dto.ts +++ b/src/link/dto/req/link-req.dto.ts @@ -15,5 +15,5 @@ export class LinkReqDto { @IsUrl() @IsNotEmpty() - link: string; + hyperlink: string; } \ No newline at end of file diff --git a/src/link/dto/res/link-res.dto.ts b/src/link/dto/res/link-res.dto.ts index 7a950f96..d068e6fb 100644 --- a/src/link/dto/res/link-res.dto.ts +++ b/src/link/dto/res/link-res.dto.ts @@ -7,5 +7,5 @@ export class LinkResDto { name: Translation; @ApiProperty({ type: String }) tooltip: Translation; - link: string; + hyperlink: string; } \ No newline at end of file diff --git a/src/link/link.controller.ts b/src/link/link.controller.ts index 91d0d526..f1b5176c 100644 --- a/src/link/link.controller.ts +++ b/src/link/link.controller.ts @@ -25,8 +25,8 @@ export class LinkController { @RequireApiPermission('API_MODIFY_LINKS') @ApiAppErrorResponse(ERROR_CODE.LINK_ALREADY_EXISTS, 'This link already exists') public async create(@Body() dto: LinkReqDto) { - if (await this.linkService.linkExists(dto.link)) throw new AppException(ERROR_CODE.LINK_ALREADY_EXISTS); - const link = await this.linkService.create(dto.name, dto.tooltip, dto.link); + if (await this.linkService.hyperlinkExists(dto.hyperlink)) throw new AppException(ERROR_CODE.LINK_ALREADY_EXISTS); + const link = await this.linkService.create(dto.name, dto.tooltip, dto.hyperlink); return this.formatLink(link); } @@ -35,11 +35,11 @@ export class LinkController { @ApiAppErrorResponse(ERROR_CODE.NO_SUCH_LINK) public async update(@UUIDParam('id') id, @Body() dto: LinkReqDto) { if (!(await this.linkService.idExists(id))) throw new AppException(ERROR_CODE.NO_SUCH_LINK, id); - const link = await this.linkService.update(id, pick(dto, 'name', 'tooltip', 'link')); + const link = await this.linkService.update(id, pick(dto, 'name', 'tooltip', 'hyperlink')); return this.formatLink(link); } formatLink(link: Link): LinkResDto { - return pick(link, 'id', 'name', 'tooltip', 'link'); + return pick(link, 'id', 'name', 'tooltip', 'hyperlink'); } } \ No newline at end of file diff --git a/src/link/link.interface.ts b/src/link/link.interface.ts index 6be1899a..36eed7dc 100644 --- a/src/link/link.interface.ts +++ b/src/link/link.interface.ts @@ -7,7 +7,7 @@ const LINK_SELECT_FILTER = { id: true, name: translationSelect, tooltip: translationSelect, - link: true, + hyperlink: true, }, } as const satisfies Prisma.LinkFindManyArgs; diff --git a/src/link/link.service.ts b/src/link/link.service.ts index b29abbd5..d8ae42d5 100644 --- a/src/link/link.service.ts +++ b/src/link/link.service.ts @@ -1,29 +1,55 @@ import { Injectable } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; -import { RawLink, Translation } from '../prisma/types'; +import { Translation } from '../prisma/types'; import { Link } from './link.interface'; @Injectable() export class LinkService { constructor(private readonly prisma: PrismaService) {} + /** + * Returns all links in the database. + */ public getLinks(): Promise { return this.prisma.normalize.link.findMany({}); } + /** + * Checks if there is a link with a given id in the database. + * @param id Id to search. + */ public async idExists(id: string): Promise { return (await this.prisma.link.count({ where: { id } })) > 0; } - public async linkExists(link: string): Promise { - return (await this.prisma.link.count({ where: { link } })) > 0; + /** + * Checks if an hyperlink exists in the database. + * @param hyperlink Hyperlink to search. + */ + public async hyperlinkExists(hyperlink: string): Promise { + return (await this.prisma.link.count({ where: { hyperlink } })) > 0; } - public create(name: Translation, tooltip: Translation, link: string): Promise { - return this.prisma.normalize.link.create({ data: { name: {create: name}, tooltip: {create: tooltip}, link } }) + /** + * Creates a new link in the database. + * @param name Name of the new link. + * @param tooltip Small description of the new link. + * @param hyperlink Hyperlink of the new link. + * @returns The new link. + */ + public create(name: Translation, tooltip: Translation, hyperlink: string): Promise { + return this.prisma.normalize.link.create({ data: { name: {create: name}, tooltip: {create: tooltip}, hyperlink } }) } - public update(id: string, { name, tooltip, link }: { name?: Translation, tooltip?: Translation, link?: string }): Promise { - return this.prisma.normalize.link.update({ where: { id }, data: { name: {create: name}, tooltip: {create: tooltip}, link } }); + /** + * Updates an existing link. + * @param id Id of the link to modify. + * @param name Name of the link to modify, or undefined if that shouldn't be modified. + * @param tooltip New tooltip of the link, or undefined if that shouldn't be modified. + * @param hyperlink New hyperlink of the link, or undefined if that shouldn't be modified. + * @returns The updated link. + */ + public update(id: string, { name, tooltip, hyperlink }: { name?: Translation, tooltip?: Translation, hyperlink?: string }): Promise { + return this.prisma.normalize.link.update({ where: { id }, data: { name: {create: name}, tooltip: {create: tooltip}, hyperlink } }); } } \ No newline at end of file diff --git a/test/declarations.ts b/test/declarations.ts index b744f0df..e2db88da 100644 --- a/test/declarations.ts +++ b/test/declarations.ts @@ -359,7 +359,7 @@ Spec.prototype.expectLinks = function (this: Spec, links: FakeLink[]) { .map( (link) => ({ - ...pick(link as Required, 'id', 'link'), + ...pick(link as Required, 'id', 'hyperlink'), name: link.name[this.language], tooltip: link.tooltip[this.language], }) satisfies TranslationToString, @@ -368,7 +368,7 @@ Spec.prototype.expectLinks = function (this: Spec, links: FakeLink[]) { }; Spec.prototype.expectLink = function (this: Spec, link: JsonLikeVariant) { return this.$expectRegexableJson({ - ...pick(link as Required, 'id', 'link'), + ...pick(link as Required, 'id', 'hyperlink'), name: getTranslation(link.name as Translation, this.language), tooltip: getTranslation(link.tooltip as Translation, this.language), } satisfies TranslationToString); diff --git a/test/e2e/link/create-link.e2e-spec.ts b/test/e2e/link/create-link.e2e-spec.ts index c376330b..eaad5c50 100644 --- a/test/e2e/link/create-link.e2e-spec.ts +++ b/test/e2e/link/create-link.e2e-spec.ts @@ -14,7 +14,7 @@ const CreateLinksE2ESpec = e2eSuite('POST /link', (app) => { const userNoPermission = fakedb.createUser(app); const body: LinkReqDto = { - link: faker.db.link.link(), + hyperlink: faker.db.link.hyperlink(), name: faker.db.translation(faker.company.name), tooltip: faker.db.translation(faker.company.catchPhrase) }; @@ -34,7 +34,7 @@ const CreateLinksE2ESpec = e2eSuite('POST /link', (app) => { .spec() .post('/link') .withBearerToken(user.token) - .withBody({ ...body, link: existingLink.link }) + .withBody({ ...body, hyperlink: existingLink.hyperlink }) .expectAppError(ERROR_CODE.LINK_ALREADY_EXISTS)); it('should successfully create the link', async () => { diff --git a/test/e2e/link/update-link.e2e-spec.ts b/test/e2e/link/update-link.e2e-spec.ts index 31496154..49966c9f 100644 --- a/test/e2e/link/update-link.e2e-spec.ts +++ b/test/e2e/link/update-link.e2e-spec.ts @@ -13,7 +13,7 @@ const UpdateLinksE2ESpec = e2eSuite('PATCH /link/:id', (app) => { const userNoPermission = fakedb.createUser(app); const body = () => ({ - link: faker.db.link.link(), + hyperlink: faker.db.link.hyperlink(), name: faker.db.translation(faker.company.name), tooltip: faker.db.translation(faker.company.catchPhrase) } as LinkReqDto); diff --git a/test/utils/fakedb.ts b/test/utils/fakedb.ts index b70b12e5..b4d06bad 100644 --- a/test/utils/fakedb.ts +++ b/test/utils/fakedb.ts @@ -43,7 +43,7 @@ import { AppProvider } from './test_utils'; import { ImageMediaPreset, Permission, Sex, TimetableEntryType, UserType } from '@prisma/client'; import { CommentStatus } from '../../src/ue/comments/interfaces/comment.interface'; import { UeAnnalFile } from '../../src/ue/annals/interfaces/annal.interface'; -import { omit, PermissionManager, pick, translationSelect } from '../../src/utils'; +import { languages, omit, PermissionManager, pick, translationSelect } from '../../src/utils'; import { DEFAULT_APPLICATION } from '../../prisma/seed/utils'; import { AssoWeekly } from '../../src/assos/interfaces/weekly.interface'; import { Link } from '../../src/link/link.interface'; @@ -792,6 +792,7 @@ export const createUeof = entityFaker( info: { program: faker.db.translation, objectives: faker.db.translation, + language: faker.helpers.arrayElement(languages) }, workTime: { cm: () => faker.number.int({ min: 0, max: 100 }), @@ -843,6 +844,7 @@ export const createUeof = entityFaker( info: { create: { ...omit(params.info, 'objectives', 'program'), + language: params.info.language ?? faker.helpers.arrayElement(languages), objectives: { create: { fr: 'TODO : implement this value', @@ -1161,11 +1163,11 @@ export const createLink = entityFaker( { name: () => faker.db.translation(faker.company.name), tooltip: () => faker.db.translation(faker.company.catchPhrase), - link: faker.db.link.link, + hyperlink: faker.db.link.hyperlink, }, async (app, params) => app() .get(PrismaService) - .normalize.link.create({ data: { ...pick(params, 'id', 'link'), name: { create: params.name }, tooltip: { create: params.tooltip } } }), + .normalize.link.create({ data: { ...pick(params, 'id', 'hyperlink'), name: { create: params.name }, tooltip: { create: params.tooltip } } }), ) /** From dc738c69f406393dd08d398b09cff01b407e76ce Mon Sep 17 00:00:00 2001 From: Teddy Roncin Date: Sat, 2 May 2026 02:25:34 +0200 Subject: [PATCH 4/9] Alban's comments + new delete route for links --- prisma/schema.prisma | 10 +++-- prisma/seed/utils.ts | 55 +++++++++++++++++++-------- src/link/dto/req/link-req.dto.ts | 10 ++++- src/link/dto/res/link-res.dto.ts | 4 ++ src/link/link.controller.ts | 55 ++++++++++++++++++++------- src/link/link.interface.ts | 2 + src/link/link.service.ts | 35 +++++++++++++++-- test/declarations.d.ts | 1 + test/declarations.ts | 16 +++++++- test/e2e/link/create-link.e2e-spec.ts | 8 ++-- test/e2e/link/delete-link.e2e-spec.ts | 49 ++++++++++++++++++++++++ test/e2e/link/get-links.e2e-spec.ts | 13 ++++++- test/e2e/link/update-link.e2e-spec.ts | 2 +- test/utils/fakedb.ts | 17 +++++++-- test/utils/test_utils.ts | 4 +- 15 files changed, 228 insertions(+), 53 deletions(-) create mode 100644 test/e2e/link/delete-link.e2e-spec.ts diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 07e002b9..b5676813 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -181,10 +181,12 @@ model ImageMedia { } model Link { - id String @id @default(uuid()) - nameId String @unique - tooltipId String @unique - hyperlink String @unique + id String @id @default(uuid()) + position Int @unique + nameId String @unique + tooltipId String @unique + hyperlink String @unique + public Boolean // If you need to be connected to see the link name Translation @relation(name: "linkNameTranslation", fields: [nameId], references: [id]) tooltip Translation @relation(name: "linkTooltipTranslation", fields: [tooltipId], references: [id]) diff --git a/prisma/seed/utils.ts b/prisma/seed/utils.ts index c6852621..e1698973 100644 --- a/prisma/seed/utils.ts +++ b/prisma/seed/utils.ts @@ -12,7 +12,7 @@ export const creditType = ['CS', 'TM', 'EC', 'HT', 'ME', 'ST', 'EE']; /** * Stores all values that should be unique and shall not be used multiple times by faker - * The values of this object are reset using {@link clearUniqueValues} in beforeAll blocks. + * The values of this object are reset using {@link clearFakerExtension} in beforeAll blocks. */ const registeredUniqueValues: { [Type in keyof FakeEntityMap]?: { @@ -58,17 +58,9 @@ export const registerUniqueValue = { - for (const key in registeredUniqueValues) delete registeredUniqueValues[key]; -}; - /** * Function that can generate a safe random unique value. - * It is "safe" in the sense that it will not generate a value that has already been generated since last call to {@link clearUniqueValues}. + * It is "safe" in the sense that it will not generate a value that has already been generated since last call to {@link clearFakerExtension}. * @param table the for which the value is generated. * @param column the column for which the value is generated. * @param generatorFunction the function that generates the value. @@ -91,6 +83,40 @@ function fakeSafeUniqueData & string]?: number; + }; +} = {}; + +/** + * Function that generates 0, then 1, then 2, etc. for each field of any FakeEntity. + * @param table Table in the database. + * @param column Name of the field of the fake entity. + */ +function fakeCounterData & string>(table: T, column: K): number { + if (!(table in registeredCounters)) + registeredCounters[table] = { + [column]: 0, + }; + else if (!(column in registeredCounters[table])) + (registeredCounters[table][column] as number) = 0; + return registeredCounters[table][column]++; +} + +/** + * Clears all unique values that have been registered and makes all values available again. + * This function is called automatically in beforeAll blocks when database is cleared. + */ +export const clearFakerExtension = () => { + for (const key in registeredUniqueValues) delete registeredUniqueValues[key]; + for (const key in registeredCounters) delete registeredCounters[key]; +}; + /** * Extends the faker module with custom functions. * These functions are used to generate values for the database. @@ -130,6 +156,7 @@ declare module '@faker-js/faker' { }; link: { hyperlink: () => string; + position: () => number; }, }; } @@ -181,12 +208,7 @@ Faker.prototype.db = { es: rng(), }), assoMembershipRole: { - position: () => - fakeSafeUniqueData( - 'assoMembershipRole', - 'position', - () => Math.max(...(registeredUniqueValues.assoMembershipRole?.position ?? [0])) + 1, - ), + position: () => fakeCounterData('assoMembershipRole', 'position'), }, ueStarCriterion: { name: () => fakeSafeUniqueData('ueStarCriterion', 'name', faker.word.adjective), @@ -196,6 +218,7 @@ Faker.prototype.db = { }, link: { hyperlink: () => fakeSafeUniqueData('link', 'hyperlink', faker.internet.url), + position: () => fakeCounterData('link', 'position'), } }; diff --git a/src/link/dto/req/link-req.dto.ts b/src/link/dto/req/link-req.dto.ts index 13e56074..575de02c 100644 --- a/src/link/dto/req/link-req.dto.ts +++ b/src/link/dto/req/link-req.dto.ts @@ -1,4 +1,4 @@ -import { IsNotEmpty, IsObject, IsUrl, ValidateNested } from 'class-validator'; +import { IsBoolean, IsNotEmpty, IsNumber, IsObject, IsOptional, IsUrl, ValidateNested } from 'class-validator'; import { TranslationReqDto } from '../../../app.dto'; import { Type } from 'class-transformer'; @@ -16,4 +16,12 @@ export class LinkReqDto { @IsUrl() @IsNotEmpty() hyperlink: string; + + @IsBoolean() + @IsOptional() + public?: boolean = true; + + @IsNumber() + @IsOptional() + position?: number; } \ No newline at end of file diff --git a/src/link/dto/res/link-res.dto.ts b/src/link/dto/res/link-res.dto.ts index d068e6fb..4af98b5a 100644 --- a/src/link/dto/res/link-res.dto.ts +++ b/src/link/dto/res/link-res.dto.ts @@ -8,4 +8,8 @@ export class LinkResDto { @ApiProperty({ type: String }) tooltip: Translation; hyperlink: string; + + // Admin information + @ApiProperty({ description: 'Only with permission API_MODIFY_LINKS.' }) + public?: boolean; } \ No newline at end of file diff --git a/src/link/link.controller.ts b/src/link/link.controller.ts index f1b5176c..3c53f471 100644 --- a/src/link/link.controller.ts +++ b/src/link/link.controller.ts @@ -1,15 +1,17 @@ -import { Body, Controller, Get, Patch, Post } from '@nestjs/common'; +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Patch, Post } from '@nestjs/common'; import { LinkService } from './link.service'; import { LinkResDto } from './dto/res/link-res.dto'; -import { getTranslation, pick } from '../utils'; +import { PermissionManager, pick } from '../utils'; import { Link } from './link.interface'; -import { IsPublic, RequireApiPermission } from '../auth/decorator'; +import { GetUser, IsPublic, RequireApiPermission } from '../auth/decorator'; import { LinkReqDto } from './dto/req/link-req.dto'; import { AppException, ERROR_CODE } from '../exceptions'; import { ApiAppErrorResponse } from '../app.dto'; -import { Language } from '@prisma/client'; -import { GetLanguage } from '../app.decorator'; import { UUIDParam } from '../app.pipe'; +import { User } from '../users/interfaces/user.interface'; +import { GetPermissions } from '../auth/decorator/get-permissions.decorator'; +import { HttpStatusCode } from 'axios'; +import { ApiOperation } from '@nestjs/swagger'; @Controller('link') export class LinkController { @@ -17,29 +19,54 @@ export class LinkController { @Get() @IsPublic() - public async get(@GetLanguage() language: Language) { - return (await this.linkService.getLinks()).mappedSort((link) => getTranslation(link.name, language)).map(this.formatLink); + @ApiOperation({ description: 'Returns all the links. If user is not connected, only public links will get returned.' }) + public async get(@GetUser() user: User, @GetPermissions() permissions: PermissionManager) { + let links = await this.linkService.getLinks(); + if (!user) { + links = links.filter((link) => link.public); + } + return links + .mappedSort((link) => link.position) + .map((link) => this.formatLink(link, permissions.can('API_MODIFY_LINKS'))); } @Post() @RequireApiPermission('API_MODIFY_LINKS') + @ApiOperation({ description: 'Creates a link.' }) @ApiAppErrorResponse(ERROR_CODE.LINK_ALREADY_EXISTS, 'This link already exists') public async create(@Body() dto: LinkReqDto) { if (await this.linkService.hyperlinkExists(dto.hyperlink)) throw new AppException(ERROR_CODE.LINK_ALREADY_EXISTS); - const link = await this.linkService.create(dto.name, dto.tooltip, dto.hyperlink); - return this.formatLink(link); + const link = await this.linkService.create(dto.name, dto.tooltip, dto.hyperlink, dto.public, dto.position); + return this.formatLink(link, true); } @Patch('/:id') @RequireApiPermission('API_MODIFY_LINKS') + @ApiOperation({ description: 'Updates a link with the given body. Any field not in the body will not be updated.' }) @ApiAppErrorResponse(ERROR_CODE.NO_SUCH_LINK) - public async update(@UUIDParam('id') id, @Body() dto: LinkReqDto) { + public async update(@UUIDParam('id') id: string, @Body() dto: LinkReqDto) { if (!(await this.linkService.idExists(id))) throw new AppException(ERROR_CODE.NO_SUCH_LINK, id); - const link = await this.linkService.update(id, pick(dto, 'name', 'tooltip', 'hyperlink')); - return this.formatLink(link); + const link = await this.linkService.update(id, { + ...pick(dto, 'name', 'tooltip', 'hyperlink', 'position'), + public_: dto.public, + }); + return this.formatLink(link, true); } - formatLink(link: Link): LinkResDto { - return pick(link, 'id', 'name', 'tooltip', 'hyperlink'); + @Delete('/:id') + @RequireApiPermission('API_MODIFY_LINKS') + @ApiOperation({ description: 'Deletes a link.' }) + @ApiAppErrorResponse(ERROR_CODE.NO_SUCH_LINK) + public async delete(@UUIDParam('id') id: string) { + if (!(await this.linkService.idExists(id))) throw new AppException(ERROR_CODE.NO_SUCH_LINK, id); + const link = await this.linkService.delete(id); + return this.formatLink(link, true); + } + + formatLink(link: Link, admin: boolean): LinkResDto { + return { + ...pick(link, 'id', 'name', 'tooltip', 'hyperlink'), + ...(admin ? { public: link.public } : {}), + }; } } \ No newline at end of file diff --git a/src/link/link.interface.ts b/src/link/link.interface.ts index 36eed7dc..312fa758 100644 --- a/src/link/link.interface.ts +++ b/src/link/link.interface.ts @@ -5,9 +5,11 @@ import { generateCustomModel } from '../prisma/prisma.service'; const LINK_SELECT_FILTER = { select: { id: true, + position: true, name: translationSelect, tooltip: translationSelect, hyperlink: true, + public: true, }, } as const satisfies Prisma.LinkFindManyArgs; diff --git a/src/link/link.service.ts b/src/link/link.service.ts index d8ae42d5..59b9d0cd 100644 --- a/src/link/link.service.ts +++ b/src/link/link.service.ts @@ -35,10 +35,18 @@ export class LinkService { * @param name Name of the new link. * @param tooltip Small description of the new link. * @param hyperlink Hyperlink of the new link. + * @param public If non-connected people can access the link it. + * @param position 0-based position of the link. * @returns The new link. */ - public create(name: Translation, tooltip: Translation, hyperlink: string): Promise { - return this.prisma.normalize.link.create({ data: { name: {create: name}, tooltip: {create: tooltip}, hyperlink } }) + public async create(name: Translation, tooltip: Translation, hyperlink: string, public_: boolean, position = undefined): Promise { + const linkCount = await this.count(); + if (position === undefined || position > linkCount) { + position = linkCount; + } else { + await this.prisma.link.updateMany({ where: { position: { gte: position } }, data: { position: { increment: 1 } } }); + } + return this.prisma.normalize.link.create({ data: { position: position, name: { create: name }, tooltip: { create: tooltip }, hyperlink, public: public_ } }) } /** @@ -47,9 +55,28 @@ export class LinkService { * @param name Name of the link to modify, or undefined if that shouldn't be modified. * @param tooltip New tooltip of the link, or undefined if that shouldn't be modified. * @param hyperlink New hyperlink of the link, or undefined if that shouldn't be modified. + * @param position 0-based position of the link. + * @param public_ If the link is visible for not-connected users. * @returns The updated link. */ - public update(id: string, { name, tooltip, hyperlink }: { name?: Translation, tooltip?: Translation, hyperlink?: string }): Promise { - return this.prisma.normalize.link.update({ where: { id }, data: { name: {create: name}, tooltip: {create: tooltip}, hyperlink } }); + public async update(id: string, { name, tooltip, hyperlink, position, public_ }: { name?: Translation, tooltip?: Translation, hyperlink?: string, position?: number, public_?: boolean }): Promise { + if (position !== undefined) { + await this.prisma.link.updateMany({ where: { position: { gte: position } }, data: { position: { increment: 1 } } }); + } + return this.prisma.normalize.link.update({ where: { id }, data: { name: { create: name }, tooltip: { create: tooltip }, hyperlink, position, public: public_ } }); + } + + /** + * Deletes a link. All links after this one (with a greater position) will see their position decrease by 1. + * @param id Id of the link to delete. + */ + public async delete(id: string): Promise { + const link = await this.prisma.normalize.link.delete({ where: { id } }); + await this.prisma.link.updateMany({ where: { position: { gt: link.position } }, data: { position: { decrement: 1 } } }); + return link; + } + + public count(): Promise { + return this.prisma.link.count(); } } \ No newline at end of file diff --git a/test/declarations.d.ts b/test/declarations.d.ts index b558ba7e..84f29431 100644 --- a/test/declarations.d.ts +++ b/test/declarations.d.ts @@ -113,6 +113,7 @@ declare module './declarations' { expectAssoWeekly(weekly: JsonLikeVariant, created = false): this; expectAssoWeeklies(app: AppProvider, weeklies: JsonLikeVariant[], count: number): this; expectLinks(links: FakeLink[]): this; + expectLinksForAdmin(links: FakeLink[]): this; expectLink(link: JsonLikeVariant): this; withLanguage(language: Language): this; diff --git a/test/declarations.ts b/test/declarations.ts index e2db88da..de2b353f 100644 --- a/test/declarations.ts +++ b/test/declarations.ts @@ -366,9 +366,23 @@ Spec.prototype.expectLinks = function (this: Spec, links: FakeLink[]) { ), ); }; +Spec.prototype.expectLinksForAdmin = function (this: Spec, links: FakeLink[]) { + return this.expectStatus(HttpStatus.OK).expectJson( + [...links] + .mappedSort((link) => link.name[this.language]) + .map( + (link) => + ({ + ...pick(link as Required, 'id', 'hyperlink', 'public'), + name: link.name[this.language], + tooltip: link.tooltip[this.language], + }) satisfies TranslationToString, + ), + ); +} Spec.prototype.expectLink = function (this: Spec, link: JsonLikeVariant) { return this.$expectRegexableJson({ - ...pick(link as Required, 'id', 'hyperlink'), + ...pick(link as Required, 'id', 'hyperlink', 'public'), name: getTranslation(link.name as Translation, this.language), tooltip: getTranslation(link.tooltip as Translation, this.language), } satisfies TranslationToString); diff --git a/test/e2e/link/create-link.e2e-spec.ts b/test/e2e/link/create-link.e2e-spec.ts index eaad5c50..e0ea4825 100644 --- a/test/e2e/link/create-link.e2e-spec.ts +++ b/test/e2e/link/create-link.e2e-spec.ts @@ -16,10 +16,10 @@ const CreateLinksE2ESpec = e2eSuite('POST /link', (app) => { const body: LinkReqDto = { hyperlink: faker.db.link.hyperlink(), name: faker.db.translation(faker.company.name), - tooltip: faker.db.translation(faker.company.catchPhrase) + tooltip: faker.db.translation(faker.company.catchPhrase), }; - it('should fail as user is not connected', () => pactum.spec().post('/link').withBody(body).expectAppError(ERROR_CODE.NOT_LOGGED_IN)) + it('should fail as user is not connected', () => pactum.spec().post('/link').withBody(body).expectAppError(ERROR_CODE.NOT_LOGGED_IN)); it('should fail as user does not have permission API_MODIFY_LINKS', () => pactum @@ -44,8 +44,8 @@ const CreateLinksE2ESpec = e2eSuite('POST /link', (app) => { .withBearerToken(user.token) .withBody(body) .expectStatus(HttpStatus.CREATED) - .expectLink({ id: JsonLike.UUID, ...body }); - const deleted = await app().get(PrismaService).link.deleteMany({where: {id: { not: existingLink.id }}}); + .expectLink({ id: JsonLike.UUID, ...body, public: true }); + const deleted = await app().get(PrismaService).link.deleteMany({ where: {id: { not: existingLink.id }}}); expect(deleted.count).toBe(1); }); }); diff --git a/test/e2e/link/delete-link.e2e-spec.ts b/test/e2e/link/delete-link.e2e-spec.ts new file mode 100644 index 00000000..c25ecf88 --- /dev/null +++ b/test/e2e/link/delete-link.e2e-spec.ts @@ -0,0 +1,49 @@ +import { Dummies, e2eSuite, JsonLike } from '../../utils/test_utils'; +import * as pactum from 'pactum'; +import * as fakedb from '../../utils/fakedb'; +import { LinkReqDto } from '../../../src/link/dto/req/link-req.dto'; +import { faker } from '@faker-js/faker'; +import { ERROR_CODE } from '../../../src/exceptions'; +import { PermissionManager } from '../../../src/utils'; +import { HttpStatus } from '@nestjs/common'; +import { PrismaService } from '../../../src/prisma/prisma.service'; + +const DeleteLinkE2ESpec = e2eSuite('DELETE /link/:id', (app) => { + const link = fakedb.createLink(app); + const secondLink = fakedb.createLink(app); + const user = fakedb.createUser(app, { permissions: new PermissionManager().with('API_MODIFY_LINKS') }); + const userNoPermission = fakedb.createUser(app); + + it('should fail as user is not connected', () => pactum.spec().delete(`/link/${link.id}`).expectAppError(ERROR_CODE.NOT_LOGGED_IN)); + + it('should fail as user does not have permission API_MODIFY_LINKS', () => + pactum + .spec() + .delete(`/link/${link.id}`) + .withBearerToken(userNoPermission.token) + .expectAppError(ERROR_CODE.FORBIDDEN_NOT_ENOUGH_API_PERMISSIONS, 'API_MODIFY_LINKS')); + + it('should fail as the link does not exist', () => + pactum + .spec() + .delete(`/link/${Dummies.UUID}`) + .withBearerToken(user.token) + .expectAppError(ERROR_CODE.LINK_ALREADY_EXISTS)); + + it('should successfully delete the link', async () => { + await pactum + .spec() + .post(`/link/${link.id}`) + .withBearerToken(user.token) + .expectStatus(HttpStatus.CREATED) + .expectLink(link); + // Verify position of secondLink has changed + const secondLinkFromDb = await app().get(PrismaService).normalize.link.findUnique({ where: { id: secondLink.id } }); + expect(secondLinkFromDb.position).toBe(0); + // Set back position of secondLink to 1 + await app().get(PrismaService).link.update({ where: { id: secondLink.id }, data: { position: 1 } }); + await fakedb.createLink(app, link, true); + }); +}); + +export default DeleteLinkE2ESpec; diff --git a/test/e2e/link/get-links.e2e-spec.ts b/test/e2e/link/get-links.e2e-spec.ts index 934840f9..6b6fcfe8 100644 --- a/test/e2e/link/get-links.e2e-spec.ts +++ b/test/e2e/link/get-links.e2e-spec.ts @@ -1,11 +1,20 @@ import { e2eSuite } from '../../utils/test_utils'; import * as pactum from 'pactum'; import * as fakedb from '../../utils/fakedb'; +import { createUser } from '../../utils/fakedb'; +import { PermissionManager } from '../../../src/utils'; const GetLinksE2ESpec = e2eSuite('GET /link', (app) => { - const links = [fakedb.createLink(app), fakedb.createLink(app)]; + const publicLinks = [fakedb.createLink(app), fakedb.createLink(app)]; + const privateLink = fakedb.createLink(app, { public: false }); + const user = createUser(app); + const userWithPermissions = createUser(app, { permissions: new PermissionManager().with('API_MODIFY_LINKS') }); - it('should return both links', () => pactum.spec().get('/link').expectLinks(links)); + it('should return both public links', () => pactum.spec().get('/link').expectLinks(publicLinks)); + + it('should return all links, including the private link', () => pactum.spec().get('/link').withBearerToken(user.token).expectLinks([...publicLinks, privateLink])); + + it('should return all links, with their public field as user has permission API_MODIFY_LINKS', () => pactum.spec().get('/link').withBearerToken(userWithPermissions.token).expectLinksForAdmin([...publicLinks, privateLink])) }); export default GetLinksE2ESpec; diff --git a/test/e2e/link/update-link.e2e-spec.ts b/test/e2e/link/update-link.e2e-spec.ts index 49966c9f..d2f416be 100644 --- a/test/e2e/link/update-link.e2e-spec.ts +++ b/test/e2e/link/update-link.e2e-spec.ts @@ -15,7 +15,7 @@ const UpdateLinksE2ESpec = e2eSuite('PATCH /link/:id', (app) => { const body = () => ({ hyperlink: faker.db.link.hyperlink(), name: faker.db.translation(faker.company.name), - tooltip: faker.db.translation(faker.company.catchPhrase) + tooltip: faker.db.translation(faker.company.catchPhrase), } as LinkReqDto); it('should fail as user is not connected', () => pactum.spec().patch(`/link/${link.id}`).withBody(body()).expectAppError(ERROR_CODE.NOT_LOGGED_IN)) diff --git a/test/utils/fakedb.ts b/test/utils/fakedb.ts index b4d06bad..d9bf0569 100644 --- a/test/utils/fakedb.ts +++ b/test/utils/fakedb.ts @@ -1164,11 +1164,20 @@ export const createLink = entityFaker( name: () => faker.db.translation(faker.company.name), tooltip: () => faker.db.translation(faker.company.catchPhrase), hyperlink: faker.db.link.hyperlink, + public: true, + position: faker.db.link.position(), }, - async (app, params) => app() - .get(PrismaService) - .normalize.link.create({ data: { ...pick(params, 'id', 'hyperlink'), name: { create: params.name }, tooltip: { create: params.tooltip } } }), -) + async (app, params) => + app() + .get(PrismaService) + .normalize.link.create({ + data: { + ...pick(params, 'id', 'hyperlink', 'public', 'position'), + name: { create: params.name }, + tooltip: { create: params.tooltip }, + }, + }), +); /** * The return type of a fake function, either Promise or FakeEntity depending on whether OnTheFly is true or false diff --git a/test/utils/test_utils.ts b/test/utils/test_utils.ts index 2aa4e471..79f335d4 100644 --- a/test/utils/test_utils.ts +++ b/test/utils/test_utils.ts @@ -3,7 +3,7 @@ import { INestApplication } from '@nestjs/common'; import { TestingModule } from '@nestjs/testing'; import { faker } from '@faker-js/faker'; import { ConfigModule } from '../../src/config/config.module'; -import { clearUniqueValues, generateDefaultApplication } from '../../prisma/seed/utils'; +import { clearFakerExtension, generateDefaultApplication } from '../../prisma/seed/utils'; import { PrismaClient } from '@prisma/client'; /** @@ -45,7 +45,7 @@ function suite(name: string, func: (app: T) => void) { beforeAll(async () => { const prisma = app().get(PrismaService); await cleanDb(prisma); - clearUniqueValues(); + clearFakerExtension(); await generateDefaultApplication(prisma); }, 15000); func(app); From 35b34bac7373c447209afd899433552e0288a7b0 Mon Sep 17 00:00:00 2001 From: Teddy Roncin Date: Sat, 2 May 2026 02:43:39 +0200 Subject: [PATCH 5/9] Lint and fixing build --- prisma/models/application.prisma | 1 + prisma/schema.prisma | 14 -------------- src/app.decorator.ts | 2 +- src/app.dto.ts | 1 - src/link/link.controller.ts | 3 +-- src/link/link.interface.ts | 2 +- src/ue/dto/req/ue-search-req.dto.ts | 2 +- test/e2e/link/delete-link.e2e-spec.ts | 4 +--- 8 files changed, 6 insertions(+), 23 deletions(-) diff --git a/prisma/models/application.prisma b/prisma/models/application.prisma index f57bf7ea..b8d72889 100644 --- a/prisma/models/application.prisma +++ b/prisma/models/application.prisma @@ -45,6 +45,7 @@ enum Permission { API_MODERATE_ANNALS // Moderate annals API_MODERATE_COMMENTS // Moderate comments API_UPLOAD_MEDIA // Upload to media enpoints + API_MODIFY_LINKS // Add / modify / delete links USER_SEE_DETAILS // See personal details about someone, even the ones the user decided to hide USER_UPDATE_DETAILS // Update personal details about someone diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 20c92b93..1168990b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -95,17 +95,3 @@ enum ImageMediaPreset { AVATAR CUSTOM } - -enum Permission { - API_SEE_OPINIONS_UE // See the rates of an UE - API_GIVE_OPINIONS_UE // Rate an UE you have done or are doing - API_SEE_ANNALS // See and download annals - API_UPLOAD_ANNALS // Upload an annal - API_MODERATE_ANNALS // Moderate annals - API_MODERATE_COMMENTS // Moderate comments - API_UPLOAD_MEDIA // Upload to media enpoints - API_MODIFY_LINKS // Add / modify / delete links - - USER_SEE_DETAILS // See personal details about someone, even the ones the user decided to hide - USER_UPDATE_DETAILS // Update personal details about someone -} diff --git a/src/app.decorator.ts b/src/app.decorator.ts index a3c36299..7de23dd8 100644 --- a/src/app.decorator.ts +++ b/src/app.decorator.ts @@ -1,5 +1,5 @@ import { createParamDecorator, ExecutionContext } from '@nestjs/common'; -import { Language } from '@prisma/client'; +import { Language } from './prisma/types'; /** * Get the language for which the request was made. diff --git a/src/app.dto.ts b/src/app.dto.ts index 84993347..03e87e61 100644 --- a/src/app.dto.ts +++ b/src/app.dto.ts @@ -6,7 +6,6 @@ import { Type } from '@nestjs/common/interfaces/type.interface'; import { IsOptional, IsString } from 'class-validator'; import { HasSomeAmong } from './validation'; import { languages } from './utils'; -import { Language } from '@prisma/client'; // Redefine the mixin function in node_modules/.pnpm/@nestjs+common@_class-transformer@_class-validator@_reflect-metadata@_rxjs@/node_modules/@nestjs/common/decorators/core/injectable.decorator.js // This implementation allows to give a name to the class diff --git a/src/link/link.controller.ts b/src/link/link.controller.ts index 3c53f471..3d1ba0b0 100644 --- a/src/link/link.controller.ts +++ b/src/link/link.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Patch, Post } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Patch, Post } from '@nestjs/common'; import { LinkService } from './link.service'; import { LinkResDto } from './dto/res/link-res.dto'; import { PermissionManager, pick } from '../utils'; @@ -10,7 +10,6 @@ import { ApiAppErrorResponse } from '../app.dto'; import { UUIDParam } from '../app.pipe'; import { User } from '../users/interfaces/user.interface'; import { GetPermissions } from '../auth/decorator/get-permissions.decorator'; -import { HttpStatusCode } from 'axios'; import { ApiOperation } from '@nestjs/swagger'; @Controller('link') diff --git a/src/link/link.interface.ts b/src/link/link.interface.ts index 312fa758..9680ac53 100644 --- a/src/link/link.interface.ts +++ b/src/link/link.interface.ts @@ -1,4 +1,4 @@ -import { Prisma, PrismaClient } from '@prisma/client'; +import { Prisma, PrismaClient } from '../prisma/types'; import { translationSelect } from '../utils'; import { generateCustomModel } from '../prisma/prisma.service'; diff --git a/src/ue/dto/req/ue-search-req.dto.ts b/src/ue/dto/req/ue-search-req.dto.ts index 5e9938b3..1f1d9921 100644 --- a/src/ue/dto/req/ue-search-req.dto.ts +++ b/src/ue/dto/req/ue-search-req.dto.ts @@ -1,6 +1,6 @@ import { Type } from 'class-transformer'; import { IsNumber, IsPositive, IsString, IsOptional, Length } from 'class-validator'; -import { Language } from '@prisma/client'; +import { Language } from '../../../prisma/types'; /** * Query parameters of the request to search UEs. diff --git a/test/e2e/link/delete-link.e2e-spec.ts b/test/e2e/link/delete-link.e2e-spec.ts index c25ecf88..425ecb76 100644 --- a/test/e2e/link/delete-link.e2e-spec.ts +++ b/test/e2e/link/delete-link.e2e-spec.ts @@ -1,8 +1,6 @@ -import { Dummies, e2eSuite, JsonLike } from '../../utils/test_utils'; +import { Dummies, e2eSuite } from '../../utils/test_utils'; import * as pactum from 'pactum'; import * as fakedb from '../../utils/fakedb'; -import { LinkReqDto } from '../../../src/link/dto/req/link-req.dto'; -import { faker } from '@faker-js/faker'; import { ERROR_CODE } from '../../../src/exceptions'; import { PermissionManager } from '../../../src/utils'; import { HttpStatus } from '@nestjs/common'; From 0ce1af684520ecd1f5013b90c0fd53bec4197347 Mon Sep 17 00:00:00 2001 From: Teddy Roncin Date: Sun, 3 May 2026 00:05:54 +0200 Subject: [PATCH 6/9] Fix tests --- prisma/seed/utils.ts | 9 ++-- src/assos/assos.service.ts | 59 +++++++++------------------ src/link/link.service.ts | 16 +++++++- test/declarations.ts | 4 +- test/e2e/link/delete-link.e2e-spec.ts | 5 +-- test/e2e/link/index.ts | 2 + test/utils/fakedb.ts | 2 +- 7 files changed, 46 insertions(+), 51 deletions(-) diff --git a/prisma/seed/utils.ts b/prisma/seed/utils.ts index cdf57fed..9285e60d 100644 --- a/prisma/seed/utils.ts +++ b/prisma/seed/utils.ts @@ -97,14 +97,15 @@ const registeredCounters: { * Function that generates 0, then 1, then 2, etc. for each field of any FakeEntity. * @param table Table in the database. * @param column Name of the field of the fake entity. + * @param startCountingFrom Number from which we should start counting. Defaults to 0. */ -function fakeCounterData & string>(table: T, column: K): number { +function fakeCounterData & string>(table: T, column: K, startCountingFrom: number = 0): number { if (!(table in registeredCounters)) registeredCounters[table] = { - [column]: 0, + [column]: startCountingFrom, }; else if (!(column in registeredCounters[table])) - (registeredCounters[table][column] as number) = 0; + (registeredCounters[table][column] as number) = startCountingFrom; return registeredCounters[table][column]++; } @@ -208,7 +209,7 @@ Faker.prototype.db = { es: rng(), }), assoMembershipRole: { - position: () => fakeCounterData('assoMembershipRole', 'position'), + position: () => fakeCounterData('assoMembershipRole', 'position', 1), // Start at 1, as there will be the president role created by default }, ueStarCriterion: { name: () => fakeSafeUniqueData('ueStarCriterion', 'name', faker.word.adjective), diff --git a/src/assos/assos.service.ts b/src/assos/assos.service.ts index e8de7046..fd83f2d6 100644 --- a/src/assos/assos.service.ts +++ b/src/assos/assos.service.ts @@ -232,46 +232,27 @@ export class AssosService { assoId: string, newData: Partial>, ): Promise { - // This poll must be performed the closest possible to the transaction - try { - const [{ position }, { count }] = await this.prisma.$transaction([ - this.prisma.assoMembershipRole.findFirstOrThrow({ - where: { id: roleId, assoId, position: { gte: 0 } }, - select: { position: true }, - }), - this.prisma.assoMembershipRole.updateMany({ - where: { id: roleId, position: { gte: 0 } }, - data: { position: -1 }, - }), - ]); - if (count < 1) throw new AppException(ERROR_CODE.ASSO_ROLE_ALREADY_MOVED); - await this.prisma.$transaction([ - this.prisma.assoMembershipRole.updateMany({ - where: { - position: { - gte: Math.min(position, newData.position), - lte: Math.max(position, newData.position), - }, - }, - data: { - position: { - increment: newData.position !== position ? (newData.position > position ? -1 : 1) : 0, - }, - }, - }), - this.prisma.assoMembershipRole.update({ - where: { id: roleId }, - data: { - position: newData.position, - name: newData.name, - }, - }), - ]); - } catch (e) { - if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2025') - throw new AppException(ERROR_CODE.ASSO_ROLE_ALREADY_MOVED); - throw e; + // Update position + if (newData.position !== undefined) { + const { position: currentPosition } = await this.prisma.assoMembershipRole.findUniqueOrThrow({ where: { id: roleId, assoId } }); + if (currentPosition > newData.position) { + await this.prisma.$transaction([ + this.prisma.assoMembershipRole.updateMany({ where: { position: { gte: newData.position, lt: currentPosition } }, data: { position: { increment: 1 } } }), + this.prisma.assoMembershipRole.update({where: { id: roleId, assoId }, data: { position: newData.position } }), + ]); + } else if (currentPosition < newData.position) { + await this.prisma.$transaction([ + this.prisma.assoMembershipRole.updateMany({ where: { position: { gt: currentPosition, lte: newData.position } }, data: { position: { decrement: 1 } } }), + this.prisma.assoMembershipRole.update({ where: { id: roleId, assoId }, data: { position: newData.position } }), + ]); + } } + // Update the rest (only name in this case) + await this.prisma.assoMembershipRole.update({ + where: { id: roleId, assoId }, + data: { name: newData.name }, + }); + // Return all roles for this asso return this.prisma.assoMembershipRole.findMany({ where: { assoId }, orderBy: { position: 'asc' }, diff --git a/src/link/link.service.ts b/src/link/link.service.ts index 59b9d0cd..1cb970af 100644 --- a/src/link/link.service.ts +++ b/src/link/link.service.ts @@ -60,10 +60,22 @@ export class LinkService { * @returns The updated link. */ public async update(id: string, { name, tooltip, hyperlink, position, public_ }: { name?: Translation, tooltip?: Translation, hyperlink?: string, position?: number, public_?: boolean }): Promise { + // Manage position separately if (position !== undefined) { - await this.prisma.link.updateMany({ where: { position: { gte: position } }, data: { position: { increment: 1 } } }); + const { position: currentPosition } = await this.prisma.link.findUnique({ where: { id } }); + if (currentPosition > position) { + await this.prisma.$transaction([ + this.prisma.link.updateMany({ where: { position: { gte: position, lt: currentPosition } }, data: { position: { increment: 1 } } }), + this.prisma.link.update({where: { id }, data: { position } }), + ]); + } else if (currentPosition < position) { + await this.prisma.$transaction([ + this.prisma.link.updateMany({ where: { position: { gt: currentPosition, lte: position } }, data: { position: { decrement: 1 } } }), + this.prisma.link.update({ where: { id }, data: { position } }), + ]); + } } - return this.prisma.normalize.link.update({ where: { id }, data: { name: { create: name }, tooltip: { create: tooltip }, hyperlink, position, public: public_ } }); + return this.prisma.normalize.link.update({ where: { id }, data: { name: { create: name }, tooltip: { create: tooltip }, hyperlink, public: public_ } }); } /** diff --git a/test/declarations.ts b/test/declarations.ts index 83e86ea9..928d7dc0 100644 --- a/test/declarations.ts +++ b/test/declarations.ts @@ -355,7 +355,7 @@ Spec.prototype.expectAssoWeeklies = function (this: Spec, app: AppProvider, week Spec.prototype.expectLinks = function (this: Spec, links: FakeLink[]) { return this.expectStatus(HttpStatus.OK).expectJson( [...links] - .mappedSort((link) => link.name[this.language]) + .mappedSort((link) => link.position) .map( (link) => ({ @@ -369,7 +369,7 @@ Spec.prototype.expectLinks = function (this: Spec, links: FakeLink[]) { Spec.prototype.expectLinksForAdmin = function (this: Spec, links: FakeLink[]) { return this.expectStatus(HttpStatus.OK).expectJson( [...links] - .mappedSort((link) => link.name[this.language]) + .mappedSort((link) => link.position) .map( (link) => ({ diff --git a/test/e2e/link/delete-link.e2e-spec.ts b/test/e2e/link/delete-link.e2e-spec.ts index 425ecb76..f6fbd00c 100644 --- a/test/e2e/link/delete-link.e2e-spec.ts +++ b/test/e2e/link/delete-link.e2e-spec.ts @@ -26,14 +26,13 @@ const DeleteLinkE2ESpec = e2eSuite('DELETE /link/:id', (app) => { .spec() .delete(`/link/${Dummies.UUID}`) .withBearerToken(user.token) - .expectAppError(ERROR_CODE.LINK_ALREADY_EXISTS)); + .expectAppError(ERROR_CODE.NO_SUCH_LINK, Dummies.UUID)); it('should successfully delete the link', async () => { await pactum .spec() - .post(`/link/${link.id}`) + .delete(`/link/${link.id}`) .withBearerToken(user.token) - .expectStatus(HttpStatus.CREATED) .expectLink(link); // Verify position of secondLink has changed const secondLinkFromDb = await app().get(PrismaService).normalize.link.findUnique({ where: { id: secondLink.id } }); diff --git a/test/e2e/link/index.ts b/test/e2e/link/index.ts index cbe16b36..f578141c 100644 --- a/test/e2e/link/index.ts +++ b/test/e2e/link/index.ts @@ -2,11 +2,13 @@ import { E2EAppProvider } from '../../utils/test_utils'; import GetLinksE2ESpec from './get-links.e2e-spec'; import CreateLinkE2ESpec from './create-link.e2e-spec'; import UpdateLinkE2ESpec from './update-link.e2e-spec'; +import DeleteLinkE2ESpec from './delete-link.e2e-spec'; export default function LinkE2ESpec(app: E2EAppProvider) { describe('Link', () => { GetLinksE2ESpec(app); CreateLinkE2ESpec(app); UpdateLinkE2ESpec(app); + DeleteLinkE2ESpec(app); }); } diff --git a/test/utils/fakedb.ts b/test/utils/fakedb.ts index 28d519b9..9fc52666 100644 --- a/test/utils/fakedb.ts +++ b/test/utils/fakedb.ts @@ -1165,7 +1165,7 @@ export const createLink = entityFaker( tooltip: () => faker.db.translation(faker.company.catchPhrase), hyperlink: faker.db.link.hyperlink, public: true, - position: faker.db.link.position(), + position: faker.db.link.position, }, async (app, params) => app() From fe7e7a2dba16bda48cb4d1139295a2f20058a0a5 Mon Sep 17 00:00:00 2001 From: Teddy Roncin Date: Sun, 3 May 2026 00:08:57 +0200 Subject: [PATCH 7/9] Alban's comment & Lint --- src/assos/assos.service.ts | 2 -- src/link/dto/req/link-req.dto.ts | 12 +++++++++++- test/e2e/link/delete-link.e2e-spec.ts | 1 - 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/assos/assos.service.ts b/src/assos/assos.service.ts index fd83f2d6..32f97c21 100644 --- a/src/assos/assos.service.ts +++ b/src/assos/assos.service.ts @@ -1,5 +1,4 @@ import { Injectable } from '@nestjs/common'; -import { Prisma } from '../prisma/types'; import { ConfigService } from '../config/config.service'; import { PrismaService } from '../prisma/prisma.service'; import { RawAssoMembershipRole } from '../prisma/types'; @@ -8,7 +7,6 @@ import { AssoMembership } from './interfaces/membership.interface'; import { AssoMembershipRole } from './interfaces/membership-role.interface'; import AssosSearchReqDto from './dto/req/assos-search-req.dto'; import AssosMemberUpdateReqDto from './dto/req/assos-member-update.dto'; -import { AppException, ERROR_CODE } from '../exceptions'; import AssosUpdateReqDto from './dto/req/assos-update-req.dto'; @Injectable() diff --git a/src/link/dto/req/link-req.dto.ts b/src/link/dto/req/link-req.dto.ts index 575de02c..d91d94a0 100644 --- a/src/link/dto/req/link-req.dto.ts +++ b/src/link/dto/req/link-req.dto.ts @@ -1,4 +1,13 @@ -import { IsBoolean, IsNotEmpty, IsNumber, IsObject, IsOptional, IsUrl, ValidateNested } from 'class-validator'; +import { + IsBoolean, + IsNotEmpty, + IsNumber, + IsObject, + IsOptional, + IsPositive, + IsUrl, + ValidateNested, +} from 'class-validator'; import { TranslationReqDto } from '../../../app.dto'; import { Type } from 'class-transformer'; @@ -22,6 +31,7 @@ export class LinkReqDto { public?: boolean = true; @IsNumber() + @IsPositive() @IsOptional() position?: number; } \ No newline at end of file diff --git a/test/e2e/link/delete-link.e2e-spec.ts b/test/e2e/link/delete-link.e2e-spec.ts index f6fbd00c..a36d0209 100644 --- a/test/e2e/link/delete-link.e2e-spec.ts +++ b/test/e2e/link/delete-link.e2e-spec.ts @@ -3,7 +3,6 @@ import * as pactum from 'pactum'; import * as fakedb from '../../utils/fakedb'; import { ERROR_CODE } from '../../../src/exceptions'; import { PermissionManager } from '../../../src/utils'; -import { HttpStatus } from '@nestjs/common'; import { PrismaService } from '../../../src/prisma/prisma.service'; const DeleteLinkE2ESpec = e2eSuite('DELETE /link/:id', (app) => { From 67ac53802a480b0b8c18a8469ffbc8da3eeaae76 Mon Sep 17 00:00:00 2001 From: Teddy Roncin Date: Sun, 3 May 2026 00:13:58 +0200 Subject: [PATCH 8/9] LinkReqDto.position had wrong validators --- src/link/dto/req/link-req.dto.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/link/dto/req/link-req.dto.ts b/src/link/dto/req/link-req.dto.ts index d91d94a0..2d4f15ed 100644 --- a/src/link/dto/req/link-req.dto.ts +++ b/src/link/dto/req/link-req.dto.ts @@ -1,11 +1,11 @@ import { - IsBoolean, + IsBoolean, IsInt, IsNotEmpty, IsNumber, IsObject, IsOptional, IsPositive, - IsUrl, + IsUrl, Min, ValidateNested, } from 'class-validator'; import { TranslationReqDto } from '../../../app.dto'; @@ -30,8 +30,8 @@ export class LinkReqDto { @IsOptional() public?: boolean = true; - @IsNumber() - @IsPositive() + @IsInt() + @Min(0) @IsOptional() position?: number; } \ No newline at end of file From 9befa59eec505b7c4f5449e906c63718831c5cba Mon Sep 17 00:00:00 2001 From: Teddy Roncin Date: Sun, 3 May 2026 00:17:41 +0200 Subject: [PATCH 9/9] Lint grrrr --- src/link/dto/req/link-req.dto.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/link/dto/req/link-req.dto.ts b/src/link/dto/req/link-req.dto.ts index 2d4f15ed..86a7c4d1 100644 --- a/src/link/dto/req/link-req.dto.ts +++ b/src/link/dto/req/link-req.dto.ts @@ -1,11 +1,11 @@ import { - IsBoolean, IsInt, + IsBoolean, + IsInt, IsNotEmpty, - IsNumber, IsObject, IsOptional, - IsPositive, - IsUrl, Min, + IsUrl, + Min, ValidateNested, } from 'class-validator'; import { TranslationReqDto } from '../../../app.dto';