Skip to content
23 changes: 18 additions & 5 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,16 @@ model ImageMedia {
descriptionForAssos Asso[] @relation("descriptionImages")
}

model Link {
id String @id @default(uuid())
Comment thread
TeddyRoncin marked this conversation as resolved.
Outdated
nameId String @unique
tooltipId String @unique
hyperlink 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
Expand Down Expand Up @@ -263,6 +273,8 @@ model Translation {
formationFollowingMethodDescriptions UTTFormationFollowingMethod?
starCriterionDescriptions UeStarCriterion?
ueNames Ueof?
linkName Link? @relation("linkNameTranslation")
linkDescription Link? @relation("linkTooltipTranslation")
}

model Ue {
Expand Down Expand Up @@ -510,11 +522,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)
Expand Down Expand Up @@ -932,6 +944,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
Expand Down
6 changes: 6 additions & 0 deletions prisma/seed/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,9 @@ declare module '@faker-js/faker' {
association: {
name: () => string;
};
link: {
hyperlink: () => string;
},
};
}
}
Expand Down Expand Up @@ -191,6 +194,9 @@ Faker.prototype.db = {
association: {
name: () => fakeSafeUniqueData('association', 'name', faker.person.firstName),
},
link: {
hyperlink: () => fakeSafeUniqueData('link', 'hyperlink', faker.internet.url),
}
};

export { Faker };
Expand Down
18 changes: 18 additions & 0 deletions src/app.decorator.ts
Original file line number Diff line number Diff line change
@@ -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<Request>();
return request.headers['x-language'] as Language;
});
17 changes: 11 additions & 6 deletions src/app.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import { IsOptional, IsString } from 'class-validator';
import { HasSomeAmong } from './validation';
import { languages } from './utils';
import { Language } from '@prisma/client';

Check failure on line 9 in src/app.dto.ts

View workflow job for this annotation

GitHub Actions / lint (22, 10)

'Language' is defined but never used

// Redefine the mixin function in node_modules/.pnpm/@nestjs+common@<version>_class-transformer@<version>_class-validator@<version>_reflect-metadata@<version>_rxjs@<version>/node_modules/@nestjs/common/decorators/core/injectable.decorator.js
// This implementation allows to give a name to the class
Expand Down Expand Up @@ -50,24 +51,28 @@

@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;
}

4 changes: 2 additions & 2 deletions src/app.interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ import { getTranslation } from './utils';
@Injectable()
export class TranslationInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const headerLanguage = context.switchToHttp().getRequest<Request>().header('language');
const headerLanguage = context.switchToHttp().getRequest<Request>().header('x-language');
const language = headerLanguage in Language ? (headerLanguage as Language) : 'fr';
context.switchToHttp().getRequest<Request>().headers['language'] = language;
context.switchToHttp().getRequest<Request>().headers['x-language'] = language;
return next.handle().pipe(map((item) => this.transform(item, language)));
}

Expand Down
2 changes: 2 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand All @@ -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.
Expand Down
14 changes: 12 additions & 2 deletions src/exceptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}

Expand Down Expand Up @@ -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,
Expand All @@ -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]: {
Expand Down
19 changes: 19 additions & 0 deletions src/link/dto/req/link-req.dto.ts
Original file line number Diff line number Diff line change
@@ -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()
hyperlink: string;
}
11 changes: 11 additions & 0 deletions src/link/dto/res/link-res.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
hyperlink: string;
}
45 changes: 45 additions & 0 deletions src/link/link.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { Body, Controller, Get, Patch, Post } from '@nestjs/common';
import { LinkService } from './link.service';
import { LinkResDto } from './dto/res/link-res.dto';
import { getTranslation, pick } from '../utils';
import { Link } from './link.interface';
import { 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';

@Controller('link')
export class LinkController {
constructor(private readonly linkService: LinkService) {}

@Get()
@IsPublic()
public async get(@GetLanguage() language: Language) {
return (await this.linkService.getLinks()).mappedSort((link) => getTranslation(link.name, language)).map(this.formatLink);
Comment thread
TeddyRoncin marked this conversation as resolved.
Outdated
}

@Post()
@RequireApiPermission('API_MODIFY_LINKS')
@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);
}

@Patch('/:id')
@RequireApiPermission('API_MODIFY_LINKS')
@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', 'hyperlink'));
return this.formatLink(link);
}

formatLink(link: Link): LinkResDto {
return pick(link, 'id', 'name', 'tooltip', 'hyperlink');
}
}
17 changes: 17 additions & 0 deletions src/link/link.interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
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,
hyperlink: true,
},
} as const satisfies Prisma.LinkFindManyArgs;

export type Link = Prisma.LinkGetPayload<typeof LINK_SELECT_FILTER>;

export const generateCustomLinkModel = (prisma: PrismaClient) =>
generateCustomModel(prisma, 'link', LINK_SELECT_FILTER, (_, e: Link) => e);
9 changes: 9 additions & 0 deletions src/link/link.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
55 changes: 55 additions & 0 deletions src/link/link.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
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<Link[]> {
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<boolean> {
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<boolean> {
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.
* @returns The new link.
*/
public create(name: Translation, tooltip: Translation, hyperlink: string): Promise<Link> {
return this.prisma.normalize.link.create({ data: { name: {create: name}, tooltip: {create: tooltip}, hyperlink } })
}

/**
* 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<Link> {
return this.prisma.normalize.link.update({ where: { id }, data: { name: {create: name}, tooltip: {create: tooltip}, hyperlink } });
}
}
2 changes: 2 additions & 0 deletions src/prisma/prisma.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,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<ReturnType<typeof prismaOptions>> {
Expand Down Expand Up @@ -56,6 +57,7 @@ function createNormalizedEntitiesUtility(prisma: PrismaClient) {
ueCreditCategory: generateCustomCreditCategoryModel(prisma),
apiApplication: generateCustomApplicationModel(prisma),
assoWeekly: generateCustomAssoWeeklyModel(prisma),
link: generateCustomLinkModel(prisma),
};
}

Expand Down
1 change: 1 addition & 0 deletions src/prisma/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export {
ApiApplication as RawApiApplication,
ApiKey as RawApiKey,
ImageMedia as RawImageMedia,
Link as RawLink,
} from '@prisma/client';

export { RawTranslation };
Expand Down
Loading
Loading