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 528b7dd9..1168990b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -33,6 +33,18 @@ model ImageMedia { descriptionForAssos Asso[] @relation("descriptionImages") } +model Link { + 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]) +} + model Translation { id String @id @default(uuid()) fr String? @db.Text @@ -57,6 +69,8 @@ model Translation { formationFollowingMethodDescriptions UTTFormationFollowingMethod? starCriterionDescriptions UeStarCriterion? ueNames Ueof? + linkName Link? @relation("linkNameTranslation") + linkDescription Link? @relation("linkTooltipTranslation") } enum AttributeType { diff --git a/prisma/seed/utils.ts b/prisma/seed/utils.ts index edd89b77..9285e60d 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,41 @@ 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. + * @param startCountingFrom Number from which we should start counting. Defaults to 0. + */ +function fakeCounterData & string>(table: T, column: K, startCountingFrom: number = 0): number { + if (!(table in registeredCounters)) + registeredCounters[table] = { + [column]: startCountingFrom, + }; + else if (!(column in registeredCounters[table])) + (registeredCounters[table][column] as number) = startCountingFrom; + 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. @@ -128,6 +155,10 @@ declare module '@faker-js/faker' { association: { name: () => string; }; + link: { + hyperlink: () => string; + position: () => number; + }, }; } } @@ -178,12 +209,7 @@ Faker.prototype.db = { es: rng(), }), assoMembershipRole: { - position: () => - fakeSafeUniqueData( - 'assoMembershipRole', - 'position', - () => Math.max(...(registeredUniqueValues.assoMembershipRole?.position ?? [0])) + 1, - ), + 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), @@ -191,6 +217,10 @@ Faker.prototype.db = { association: { name: () => fakeSafeUniqueData('association', 'name', faker.person.firstName), }, + link: { + hyperlink: () => fakeSafeUniqueData('link', 'hyperlink', faker.internet.url), + position: () => fakeCounterData('link', 'position'), + } }; export { Faker }; diff --git a/src/app.decorator.ts b/src/app.decorator.ts new file mode 100644 index 00000000..7de23dd8 --- /dev/null +++ b/src/app.decorator.ts @@ -0,0 +1,18 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; +import { Language } from './prisma/types'; + +/** + * 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 ee0ec47f..03e87e61 100644 --- a/src/app.dto.ts +++ b/src/app.dto.ts @@ -50,24 +50,28 @@ export function paginatedResponseDto(Base: TBase) { @HasSomeAmong(...languages) export class TranslationReqDto { - @IsString() @IsOptional() + @IsString() + @ApiProperty({ description: "French" }) fr?: string; - @IsString() @IsOptional() + @IsString() + @ApiProperty({ description: "English" }) en?: string; - @IsString() @IsOptional() + @IsString() + @ApiProperty({ description: "Spanish" }) es?: string; - @IsString() @IsOptional() + @IsString() + @ApiProperty({ description: "German" }) de?: string; - @IsString() @IsOptional() + @IsString() + @ApiProperty({ description: "Chinese" }) zh?: string; } - diff --git a/src/app.interceptor.ts b/src/app.interceptor.ts index 15925cce..2b5fa4d6 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 92612c72..ff82a2c6 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -17,6 +17,7 @@ import { TranslationInterceptor } from './app.interceptor'; import { SemesterModule } from './semester/semester.module'; import { ImageMediaModule } from './media/image/imagemedia.module'; import { MailModule } from './mail/mail.module'; +import { LinkModule } from './link/link.module'; @Module({ imports: [ @@ -33,6 +34,7 @@ import { MailModule } from './mail/mail.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/assos/assos.service.ts b/src/assos/assos.service.ts index e8de7046..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() @@ -232,46 +230,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/exceptions.ts b/src/exceptions.ts index a49327d6..7d000010 100644 --- a/src/exceptions.ts +++ b/src/exceptions.ts @@ -94,12 +94,14 @@ export const enum ERROR_CODE { NO_SUCH_ASSO_MEMBERSHIP = 4416, NO_SUCH_MEDIA = 4417, NO_SUCH_WEEKLY = 4418, + NO_SUCH_LINK = 4419, ANNAL_ALREADY_UPLOADED = 4901, RESOURCE_UNAVAILABLE = 4902, RESOURCE_INVALID_TYPE = 4903, ASSO_ROLE_ALREADY_MOVED = 4904, CREDENTIALS_ALREADY_TAKEN = 5001, SERVER_DISK_ERROR = 8001, + LINK_ALREADY_EXISTS = 5002, HIDDEN_DUCK = 9999, } @@ -425,6 +427,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.NO_SUCH_WEEKLY]: { message: 'No such weekly in asso: %', httpCode: HttpStatus.NOT_FOUND, @@ -445,12 +451,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 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 new file mode 100644 index 00000000..86a7c4d1 --- /dev/null +++ b/src/link/dto/req/link-req.dto.ts @@ -0,0 +1,37 @@ +import { + IsBoolean, + IsInt, + IsNotEmpty, + IsObject, + IsOptional, + IsUrl, + Min, + 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() + hyperlink: string; + + @IsBoolean() + @IsOptional() + public?: boolean = true; + + @IsInt() + @Min(0) + @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 new file mode 100644 index 00000000..4af98b5a --- /dev/null +++ b/src/link/dto/res/link-res.dto.ts @@ -0,0 +1,15 @@ +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; + 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 new file mode 100644 index 00000000..3d1ba0b0 --- /dev/null +++ b/src/link/link.controller.ts @@ -0,0 +1,71 @@ +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'; +import { Link } from './link.interface'; +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 { UUIDParam } from '../app.pipe'; +import { User } from '../users/interfaces/user.interface'; +import { GetPermissions } from '../auth/decorator/get-permissions.decorator'; +import { ApiOperation } from '@nestjs/swagger'; + +@Controller('link') +export class LinkController { + constructor(private readonly linkService: LinkService) {} + + @Get() + @IsPublic() + @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, 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: 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', 'position'), + public_: dto.public, + }); + return this.formatLink(link, true); + } + + @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 new file mode 100644 index 00000000..9680ac53 --- /dev/null +++ b/src/link/link.interface.ts @@ -0,0 +1,19 @@ +import { Prisma, PrismaClient } from '../prisma/types'; +import { translationSelect } from '../utils'; +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; + +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..1cb970af --- /dev/null +++ b/src/link/link.service.ts @@ -0,0 +1,94 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +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; + } + + /** + * 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; + } + + /** + * 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. + * @param public If non-connected people can access the link it. + * @param position 0-based position of the link. + * @returns The new link. + */ + 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_ } }) + } + + /** + * 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. + * @param position 0-based position of the link. + * @param public_ If the link is visible for not-connected users. + * @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) { + 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, 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/src/prisma/prisma.service.ts b/src/prisma/prisma.service.ts index b114bb75..9a542200 100644 --- a/src/prisma/prisma.service.ts +++ b/src/prisma/prisma.service.ts @@ -16,6 +16,7 @@ import { generateCustomAssoMembershipRoleModel } from '../assos/interfaces/membe import { generateCustomCreditCategoryModel } from '../ue/credit/interfaces/credit-category.interface'; import { generateCustomApplicationModel } from '../auth/application/interfaces/application.interface'; import { generateCustomAssoWeeklyModel } from '../assos/interfaces/weekly.interface'; +import { generateCustomLinkModel } from '../link/link.interface'; @Injectable() export class PrismaService extends PrismaClient> implements OnModuleDestroy { @@ -56,6 +57,7 @@ function createNormalizedEntitiesUtility(prisma: PrismaClient) { ueCreditCategory: generateCustomCreditCategoryModel(prisma), apiApplication: generateCustomApplicationModel(prisma), assoWeekly: generateCustomAssoWeeklyModel(prisma), + link: generateCustomLinkModel(prisma), }; } diff --git a/src/prisma/types.ts b/src/prisma/types.ts index d252700f..50b6d159 100644 --- a/src/prisma/types.ts +++ b/src/prisma/types.ts @@ -47,7 +47,7 @@ export { ApiApplication as RawApiApplication, ApiKey as RawApiKey, ImageMedia as RawImageMedia, - + Link as RawLink, } from './build/client'; export { RawTranslation }; diff --git a/src/ue/dto/req/ue-search-req.dto.ts b/src/ue/dto/req/ue-search-req.dto.ts index c296833b..1f1d9921 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/types'; /** * 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 42384666..374ea83c 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/types'; 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 434ccb3f..abeeef96 100644 --- a/test/declarations.d.ts +++ b/test/declarations.d.ts @@ -9,6 +9,7 @@ import { FakeAssoMembershipPermission, FakeAssoMembershipRole, FakeImageMedia, + FakeLink, FakeUeAnnalType, FakeUeof, FakeUeCreditCategory, @@ -108,10 +109,12 @@ declare module './declarations' { expectApplications(applications: FakeApiApplication[]): this; expectApplication(application: FakeApiApplication): this; expectImageMedia(media: JsonLikeVariant): this; - expectPermissions(permissions: PermissionManager): this; 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; language: Language; diff --git a/test/declarations.ts b/test/declarations.ts index 9cc06ca6..928d7dc0 100644 --- a/test/declarations.ts +++ b/test/declarations.ts @@ -17,6 +17,7 @@ import { FakeAssoMembership, FakeImageMedia, FakeAssoWeekly, + FakeLink, } from './utils/fakedb'; import { UeAnnalFile } from 'src/ue/annals/interfaces/annal.interface'; import { ConfigService } from '../src/config/config.service'; @@ -27,6 +28,8 @@ import { Language } from '../src/prisma/types'; 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 ueOverviewExpectation(ue: FakeUeWithOfs, spec: Spec) { return { @@ -349,6 +352,41 @@ Spec.prototype.expectAssoWeeklies = function (this: Spec, app: AppProvider, week itemsPerPage: app().get(ConfigService).PAGINATION_PAGE_SIZE, }); }; +Spec.prototype.expectLinks = function (this: Spec, links: FakeLink[]) { + return this.expectStatus(HttpStatus.OK).expectJson( + [...links] + .mappedSort((link) => link.position) + .map( + (link) => + ({ + ...pick(link as Required, 'id', 'hyperlink'), + name: link.name[this.language], + tooltip: link.tooltip[this.language], + }) satisfies TranslationToString, + ), + ); +}; +Spec.prototype.expectLinksForAdmin = function (this: Spec, links: FakeLink[]) { + return this.expectStatus(HttpStatus.OK).expectJson( + [...links] + .mappedSort((link) => link.position) + .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', 'public'), + name: getTranslation(link.name as Translation, this.language), + tooltip: getTranslation(link.tooltip as Translation, this.language), + } satisfies TranslationToString); +}; Spec.prototype.$expectRegexableJson = $expectRegexableJson; @@ -410,3 +448,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 73916b68..ef128aa5 100644 --- a/test/e2e/app.e2e-spec.ts +++ b/test/e2e/app.e2e-spec.ts @@ -23,6 +23,7 @@ import * as cas from '../external_services/cas'; import * as timetableProvider from '../external_services/timetable'; import { ConfigService } from '../../src/config/config.service'; import AssoE2ESpec from './assos'; +import LinkE2ESpec from './link'; import MediaE2ESpec from './media'; describe('EtuUTT API e2e testing', () => { @@ -63,5 +64,6 @@ describe('EtuUTT API e2e testing', () => { TimetableE2ESpec(() => app); // Deactivated, see function UeE2ESpec(() => app); AssoE2ESpec(() => app); + LinkE2ESpec(() => app); MediaE2ESpec(() => 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..e0ea4825 --- /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 = { + hyperlink: faker.db.link.hyperlink(), + 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, hyperlink: existingLink.hyperlink }) + .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, public: true }); + 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/delete-link.e2e-spec.ts b/test/e2e/link/delete-link.e2e-spec.ts new file mode 100644 index 00000000..a36d0209 --- /dev/null +++ b/test/e2e/link/delete-link.e2e-spec.ts @@ -0,0 +1,45 @@ +import { Dummies, e2eSuite } from '../../utils/test_utils'; +import * as pactum from 'pactum'; +import * as fakedb from '../../utils/fakedb'; +import { ERROR_CODE } from '../../../src/exceptions'; +import { PermissionManager } from '../../../src/utils'; +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.NO_SUCH_LINK, Dummies.UUID)); + + it('should successfully delete the link', async () => { + await pactum + .spec() + .delete(`/link/${link.id}`) + .withBearerToken(user.token) + .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 new file mode 100644 index 00000000..6b6fcfe8 --- /dev/null +++ b/test/e2e/link/get-links.e2e-spec.ts @@ -0,0 +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 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 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/index.ts b/test/e2e/link/index.ts new file mode 100644 index 00000000..f578141c --- /dev/null +++ b/test/e2e/link/index.ts @@ -0,0 +1,14 @@ +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/e2e/link/update-link.e2e-spec.ts b/test/e2e/link/update-link.e2e-spec.ts new file mode 100644 index 00000000..d2f416be --- /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 = () => ({ + hyperlink: faker.db.link.hyperlink(), + 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 469ec8af..9fc52666 100644 --- a/test/utils/fakedb.ts +++ b/test/utils/fakedb.ts @@ -43,9 +43,10 @@ import { AppProvider } from './test_utils'; import { ImageMediaPreset, Permission, Sex, TimetableEntryType, UserType } from '../../src/prisma/types'; 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'; /** * The fake entities can be used like normal entities in the it(string, () => void) functions. @@ -122,6 +123,7 @@ export type FakeApiApplication = Partial> & { }; export type FakeImageMedia = Partial; export type FakeAssoWeekly = Partial>; +export type FakeLink = Partial> & { name?: Partial; tooltip?: Partial }; export interface FakeEntityMap { assoMembership: { @@ -258,6 +260,10 @@ export interface FakeEntityMap { entity: FakeImageMedia; params: CreateImageMediaParameter; }; + link: { + entity: FakeLink; + params: CreateLinkParameter; + }; } export type CreateUserParameters = FakeUser & { password: string }; @@ -786,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 }), @@ -837,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', @@ -1149,6 +1157,28 @@ export const createImageMedia = entityFaker( async (app, params) => app().get(PrismaService).imageMedia.create({ data: params }), ); +export type CreateLinkParameter = FakeLink; +export const createLink = entityFaker( + 'link', + { + 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', '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 757f2b01..3fa223cb 100644 --- a/test/utils/test_utils.ts +++ b/test/utils/test_utils.ts @@ -4,7 +4,7 @@ import { INestApplication } from '@nestjs/common'; import { TestingModule } from '@nestjs/testing'; import { faker } from '@faker-js/faker'; import { ConfigService } from '../../src/config/config.service'; -import { clearUniqueValues, generateDefaultApplication } from '../../prisma/seed/utils'; +import { clearFakerExtension, generateDefaultApplication } from '../../prisma/seed/utils'; /** * Initializes this file. @@ -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);