Skip to content
1 change: 1 addition & 0 deletions prisma/models/application.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -57,6 +69,8 @@ model Translation {
formationFollowingMethodDescriptions UTTFormationFollowingMethod?
starCriterionDescriptions UeStarCriterion?
ueNames Ueof?
linkName Link? @relation("linkNameTranslation")
linkDescription Link? @relation("linkTooltipTranslation")
}

enum AttributeType {
Expand Down
62 changes: 46 additions & 16 deletions prisma/seed/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]?: {
Expand Down Expand Up @@ -58,17 +58,9 @@ export const registerUniqueValue = <T extends keyof FakeEntityMap, K extends key
return value;
};

/**
* 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 clearUniqueValues = () => {
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.
Expand All @@ -91,6 +83,41 @@ function fakeSafeUniqueData<T extends keyof FakeEntityMap, K extends keyof Entit
return registerUniqueValue(table, column, data);
}

/**
* For each field of each FakeEntity, stores the next number to yield.
* That number is increased by one each time, and can be used as a counter (for example, to store a position).
*/
const registeredCounters: {
[Type in keyof FakeEntityMap]?: {
[property in keyof Entity<Type> & 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<T extends keyof FakeEntityMap, K extends keyof Entity<T> & 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.
Expand Down Expand Up @@ -128,6 +155,10 @@ declare module '@faker-js/faker' {
association: {
name: () => string;
};
link: {
hyperlink: () => string;
position: () => number;
},
};
}
}
Expand Down Expand Up @@ -178,19 +209,18 @@ 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),
},
association: {
name: () => fakeSafeUniqueData('association', 'name', faker.person.firstName),
},
link: {
hyperlink: () => fakeSafeUniqueData('link', 'hyperlink', faker.internet.url),
position: () => fakeCounterData('link', 'position'),
}
};

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/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<Request>();
return request.headers['x-language'] as Language;
});
16 changes: 10 additions & 6 deletions src/app.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,24 +50,28 @@ export function paginatedResponseDto<TBase extends Constructor>(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;
}

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
61 changes: 20 additions & 41 deletions src/assos/assos.service.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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()
Expand Down Expand Up @@ -232,46 +230,27 @@ export class AssosService {
assoId: string,
newData: Partial<Pick<AssoMembershipRole, 'name' | 'position'>>,
): Promise<RawAssoMembershipRole[]> {
// 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' },
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
37 changes: 37 additions & 0 deletions src/link/dto/req/link-req.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
Comment thread
TeddyRoncin marked this conversation as resolved.
}
Loading
Loading