diff --git a/.env.dist b/.env.dist index 96900002..6b38ec04 100644 --- a/.env.dist +++ b/.env.dist @@ -26,4 +26,8 @@ OLD_DATABASE_URL="mysql://dev:dev@localhost:3306/etuutt_old" ETUUTT_WEBSITE_APPLICATION_ID=52ce644d-183f-49e9-bd21-d2d4f37e2196 -TIMETABLE_URL="monedt.utt.fr" \ No newline at end of file +TIMETABLE_URL="monedt.utt.fr" + +# Weekly +WEEKLY_SEND_DAY=1 +WEEKLY_SEND_HOUR=8 diff --git a/.env.test.dist b/.env.test.dist index 0ee7f187..c79961c2 100644 --- a/.env.test.dist +++ b/.env.test.dist @@ -23,4 +23,8 @@ LDAP_PWD= ETUUTT_WEBSITE_APPLICATION_ID=52ce644d-183f-49e9-bd21-d2d4f37e2196 # Can be changed to localhost for testing -TIMETABLE_URL="monedt.utt.fr" \ No newline at end of file +TIMETABLE_URL="monedt.utt.fr" + +# Weekly +WEEKLY_SEND_DAY=1 +WEEKLY_SEND_HOUR=8 diff --git a/migration/etuutt_old/modules/user.ts b/migration/etuutt_old/modules/user.ts index 42d4582f..95b5a84a 100644 --- a/migration/etuutt_old/modules/user.ts +++ b/migration/etuutt_old/modules/user.ts @@ -72,7 +72,7 @@ export async function migrateUsers( }, preference: { create: { - wantDaymail: user.daymail, + wantWeekly: user.daymail, language: user.language, wantDayNotif: false, wantDiscordUtt: user.wantsJoinUTTDiscord, diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ee6bef46..154e04ad 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -63,7 +63,7 @@ model Asso { descriptionShortTranslation Translation? @relation(name: "descriptionShortTranslation", fields: [descriptionShortTranslationId], references: [id], onDelete: Cascade) descriptionTranslation Translation? @relation(name: "descriptionTranslation", fields: [descriptionTranslationId], references: [id], onDelete: Cascade) assoMemberships AssoMembership[] - assoMessages AssoMessage[] + weeklies AssoWeekly[] events Event[] assoMembershipRoles AssoMembershipRole[] assoAccount User @relation(fields: [assoAccountId], references: [id], onDelete: Cascade) @@ -101,19 +101,19 @@ model AssoMembershipRole { asso Asso @relation(fields: [assoId], references: [id]) } -model AssoMessage { +model AssoWeekly { id String @id @default(uuid()) - date DateTime - sendToMobile Boolean - sendAsDaymail Boolean createdAt DateTime @default(now()) assoId String titleTranslationId String @unique bodyTranslationId String @unique + date DateTime asso Asso @relation(fields: [assoId], references: [id]) titleTranslation Translation @relation(name: "titleTranslation", fields: [titleTranslationId], references: [id], onDelete: Cascade) bodyTranslation Translation @relation(name: "bodyTranslation", fields: [bodyTranslationId], references: [id], onDelete: Cascade) + + @@unique([assoId, date]) } model Event { @@ -231,8 +231,8 @@ model Translation { assoDescription Asso? @relation("descriptionTranslation") assoDescriptionShort Asso? @relation("descriptionShortTranslation") - assoMessageTitle AssoMessage? @relation("titleTranslation") - assoMessageTitleBody AssoMessage? @relation("bodyTranslation") + assoWeeklyTitle AssoWeekly? @relation("titleTranslation") + assoWeeklyTitleBody AssoWeekly? @relation("bodyTranslation") eventDescription Event? @relation("descriptionTranslation") eventTitle Event? @relation("titleTranslation") ueofInfo UeofInfo? @relation("ueofInfoObjectivesTranslation") @@ -732,7 +732,7 @@ model UserHomepageWidget { model UserPreference { id String @id @default(uuid()) language Language @default(fr) - wantDaymail Boolean @default(false) + wantWeekly Boolean @default(false) wantDayNotif Boolean @default(false) wantDiscordUtt Boolean @default(false) diff --git a/src/app.dto.ts b/src/app.dto.ts index 6d731dbb..ee0ec47f 100644 --- a/src/app.dto.ts +++ b/src/app.dto.ts @@ -3,6 +3,9 @@ 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'; +import { HasSomeAmong } from './validation'; +import { languages } from './utils'; // 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 +47,27 @@ export function paginatedResponseDto(Base: TBase) { } return mixin(ResponseDto, `${Base.name}$Paginated`); // This is important otherwise you will get always the same instance } + +@HasSomeAmong(...languages) +export class TranslationReqDto { + @IsString() + @IsOptional() + fr?: string; + + @IsString() + @IsOptional() + en?: string; + + @IsString() + @IsOptional() + es?: string; + + @IsString() + @IsOptional() + de?: string; + + @IsString() + @IsOptional() + zh?: string; +} + diff --git a/src/assos/assos.controller.ts b/src/assos/assos.controller.ts index f64ee5da..bd569b1b 100644 --- a/src/assos/assos.controller.ts +++ b/src/assos/assos.controller.ts @@ -23,6 +23,7 @@ import AssosMemberCreateReqDto from './dto/req/assos-member-create.dto'; import AssosMemberUpdateReqDto from './dto/req/assos-member-update.dto'; import AssoMembershipResDto from './dto/res/assos-membership-res.dto'; import UsersService from '../users/users.service'; +import { ConfigModule } from '../config/config.module'; @Controller('assos') @ApiTags('Assos') @@ -30,6 +31,7 @@ export class AssosController { constructor( readonly assosService: AssosService, readonly userService: UsersService, + readonly config: ConfigModule, ) {} @Get() diff --git a/src/assos/assos.module.ts b/src/assos/assos.module.ts index bd02ca10..a59fc61a 100644 --- a/src/assos/assos.module.ts +++ b/src/assos/assos.module.ts @@ -2,13 +2,16 @@ import { Module } from '@nestjs/common'; import { AssosController } from './assos.controller'; import { AssosService } from './assos.service'; import UsersService from '../users/users.service'; +import WeeklyWithoutAssoidController from './weekly/weekly-without-asso-id.controller'; +import { WeeklyWithAssoIdController } from './weekly/weekly-with-asso-id.controller'; +import WeeklyService from './weekly/weekly.service'; /** * Defines the `Assos` module. This module handles all routes prefixed by `/assos`. * Includes `Assos` listing, details */ @Module({ - controllers: [AssosController], - providers: [AssosService, UsersService], + controllers: [AssosController, WeeklyWithoutAssoidController, WeeklyWithAssoIdController], + providers: [AssosService, WeeklyService, UsersService], }) export class AssosModule {} diff --git a/src/assos/interfaces/weekly.interface.ts b/src/assos/interfaces/weekly.interface.ts new file mode 100644 index 00000000..d04724b1 --- /dev/null +++ b/src/assos/interfaces/weekly.interface.ts @@ -0,0 +1,28 @@ +import { Prisma, PrismaClient } from '@prisma/client'; +import { generateCustomModel } from '../../prisma/prisma.service'; +import { pick, translationSelect } from '../../utils'; +import { Translation } from '../../prisma/types'; + +const ASSO_WEEKLY_SELECT_FILTER = { + select: { + id: true, + assoId: true, + createdAt: true, + titleTranslation: translationSelect, + bodyTranslation: translationSelect, + date: true, + }, + orderBy: { date: 'asc'} +} as const satisfies Prisma.AssoWeeklyFindManyArgs; + +export type UnformattedAssoWeekly = Prisma.AssoWeeklyGetPayload; +export type AssoWeekly = Pick & { title: Translation, message: Translation } + +export const generateCustomAssoWeeklyModel = (prisma: PrismaClient) => + generateCustomModel(prisma, 'assoWeekly', ASSO_WEEKLY_SELECT_FILTER, formatAssoWeekly); + +function formatAssoWeekly(_: PrismaClient, r: UnformattedAssoWeekly): AssoWeekly { + return { + ...pick(r, 'id', 'assoId', 'createdAt', 'date'), title: r.titleTranslation, message: r.bodyTranslation + } +} diff --git a/src/assos/weekly/dto/req/weekly-req.dto.ts b/src/assos/weekly/dto/req/weekly-req.dto.ts new file mode 100644 index 00000000..7e3070b9 --- /dev/null +++ b/src/assos/weekly/dto/req/weekly-req.dto.ts @@ -0,0 +1,22 @@ +import { Type } from 'class-transformer'; +import { IsDate, IsNotEmpty, ValidateNested } from 'class-validator'; +import { TranslationReqDto } from '../../../../app.dto'; +import { IsWeekDate } from '../../../../validation'; + +export default class WeeklyReqDto { + @ValidateNested() + @IsNotEmpty() + @Type(() => TranslationReqDto) + title: TranslationReqDto + + @ValidateNested() + @IsNotEmpty() + @Type(() => TranslationReqDto) + message: TranslationReqDto; + + @IsDate() + @IsNotEmpty() + @Type(() => Date) + @IsWeekDate() + date: Date; +} \ No newline at end of file diff --git a/src/assos/weekly/dto/req/weekly-search-req.dto.ts b/src/assos/weekly/dto/req/weekly-search-req.dto.ts new file mode 100644 index 00000000..169af54f --- /dev/null +++ b/src/assos/weekly/dto/req/weekly-search-req.dto.ts @@ -0,0 +1,17 @@ +import { IsDate, IsInt, IsOptional } from 'class-validator'; +import { Type } from 'class-transformer'; + +export default class WeeklySearchReqDto { + @IsDate() + @Type(() => Date) + from?: Date; + + @IsOptional() + @IsDate() + @Type(() => Date) + to?: Date; + + @IsOptional() + @IsInt() + page: number = 1; +} \ No newline at end of file diff --git a/src/assos/weekly/dto/res/weekly-info-res.dto.ts b/src/assos/weekly/dto/res/weekly-info-res.dto.ts new file mode 100644 index 00000000..e78739e2 --- /dev/null +++ b/src/assos/weekly/dto/res/weekly-info-res.dto.ts @@ -0,0 +1,4 @@ +export default class WeeklyInfoResDto { + sendDay: number; + sendHour: number; +} \ No newline at end of file diff --git a/src/assos/weekly/dto/res/weekly-res.dto.ts b/src/assos/weekly/dto/res/weekly-res.dto.ts new file mode 100644 index 00000000..911a9609 --- /dev/null +++ b/src/assos/weekly/dto/res/weekly-res.dto.ts @@ -0,0 +1,10 @@ +import { Translation } from '../../../../prisma/types'; + +export default class WeeklyResDto { + id: string; + assoId: string; + createdAt: Date; + title: Translation; + message: Translation; + date: Date; +} diff --git a/src/assos/weekly/weekly-with-asso-id.controller.ts b/src/assos/weekly/weekly-with-asso-id.controller.ts new file mode 100644 index 00000000..ee429ce1 --- /dev/null +++ b/src/assos/weekly/weekly-with-asso-id.controller.ts @@ -0,0 +1,127 @@ +import { Body, Controller, Delete, Get, Patch, Post, Query } from '@nestjs/common'; +import { ApiCreatedResponse, ApiOkResponse, ApiOperation, ApiQuery } from '@nestjs/swagger'; +import WeeklyResDto from './dto/res/weekly-res.dto'; +import { ApiAppErrorResponse, paginatedResponseDto } from '../../app.dto'; +import { AppException, ERROR_CODE } from '../../exceptions'; +import { ParamAsso } from '../decorator/get-asso'; +import { Asso } from '../interfaces/asso.interface'; +import AssoGetWeeklyReqDto from './dto/req/weekly-search-req.dto'; +import { GetUser } from '../../auth/decorator'; +import { User } from '../../users/interfaces/user.interface'; +import AssosPostWeeklyReqDto from './dto/req/weekly-req.dto'; +import { UUIDParam } from '../../app.pipe'; +import { AssoWeekly } from '../interfaces/weekly.interface'; +import { ConfigModule } from '../../config/config.module'; +import WeeklyService from './weekly.service'; +import { AssosService } from '../assos.service'; + +@Controller('assos/:assoId/weekly') +export class WeeklyWithAssoIdController { + constructor(readonly weeklyService: WeeklyService, readonly assosService: AssosService, readonly config: ConfigModule) {} + + @Get() + @ApiOperation({ description: 'Get weeklies from query parameter `from` to query parameter `to`.' }) + @ApiQuery({ name: 'from', type: String }) + @ApiQuery({ name: 'to', type: String, default: `\`from\` + env.PAGINATION_PAGE_SIZE days`, required: false }) + @ApiOkResponse({ type: WeeklyResDto }) + @ApiAppErrorResponse(ERROR_CODE.PARAM_DATE_MUST_BE_AFTER, '`to` must come after `from` (or be equal)') + @ApiAppErrorResponse( + ERROR_CODE.FORBIDDEN_ASSOS_PERMISSIONS, + 'The user issuing the request does not have the permission weekly', + ) + async searchWeeklies( + @ParamAsso() asso: Asso, + @Query() { from, to, page }: AssoGetWeeklyReqDto, + @GetUser() user: User, + ): Promise> { + from = from.dropTime(); + to = to?.dropTime(); + if (to && from > to) + throw new AppException(ERROR_CODE.PARAM_DATE_MUST_BE_AFTER, to.toISOString(), from.toISOString()); + if (!(await this.assosService.hasSomeAssoPermission(asso, user.id, 'weekly'))) + throw new AppException(ERROR_CODE.FORBIDDEN_ASSOS_PERMISSIONS, asso.id, 'weekly'); + const { weeklies, count } = await this.weeklyService.searchWeeklies(asso.id, from, to, page); + return { + items: weeklies, + itemCount: count, + itemsPerPage: this.config.PAGINATION_PAGE_SIZE, + }; + } + + @Post() + @ApiOperation({ description: 'Create a weekly for the given association.' }) + @ApiCreatedResponse({ type: WeeklyResDto }) + @ApiAppErrorResponse( + ERROR_CODE.FORBIDDEN_ASSOS_PERMISSIONS, + 'The user issuing the request does not have the permission weekly', + ) + @ApiAppErrorResponse(ERROR_CODE.WEEKLY_ALREADY_SENT_FOR_WEEK, 'The weekly was already sent for the specified week') + @ApiAppErrorResponse(ERROR_CODE.WEEKLY_ALREADY_PLANNED_FOR_WEEK, 'The asso already has a weekly planned for the requested week') + async createWeekly( + @ParamAsso() asso: Asso, + @Body() dto: AssosPostWeeklyReqDto, + @GetUser() user: User, + ): Promise { + if (!(await this.assosService.hasSomeAssoPermission(asso, user.id, 'weekly'))) + throw new AppException(ERROR_CODE.FORBIDDEN_ASSOS_PERMISSIONS, asso.id, 'weekly'); + if (this.weeklyService.getWeeklySendDate(dto.date) < new Date()) + throw new AppException(ERROR_CODE.WEEKLY_ALREADY_SENT_FOR_WEEK, dto.date.toISOString()); + if (await this.weeklyService.hasWeekly(asso.id, dto.date)) + throw new AppException(ERROR_CODE.WEEKLY_ALREADY_PLANNED_FOR_WEEK); + return this.weeklyService.addWeekly(asso.id, dto.title, dto.message, dto.date); + } + + @Patch('/:weeklyId') + @ApiOperation({ description: 'Update a weekly for the given association.' }) + @ApiOkResponse({ type: paginatedResponseDto(WeeklyResDto) }) + @ApiAppErrorResponse(ERROR_CODE.NO_SUCH_ASSO) + @ApiAppErrorResponse( + ERROR_CODE.FORBIDDEN_ASSOS_PERMISSIONS, + 'The user issuing the request does not have the permission weekly', + ) + @ApiAppErrorResponse(ERROR_CODE.NO_SUCH_WEEKLY, 'The weekly does not exist for the specified asso') + @ApiAppErrorResponse(ERROR_CODE.WEEKLY_ALREADY_SENT, 'The weekly that is beeing modified was already sent') + @ApiAppErrorResponse(ERROR_CODE.WEEKLY_ALREADY_SENT_FOR_WEEK, 'The weekly was already sent for the specified week') + @ApiAppErrorResponse(ERROR_CODE.WEEKLY_ALREADY_PLANNED_FOR_WEEK, 'The asso already has a weekly planned for the requested week') + async updateWeekly( + @ParamAsso() asso: Asso, + @UUIDParam('weeklyId') weeklyId: string, + @Body() dto: AssosPostWeeklyReqDto, + @GetUser() user: User, + ): Promise { + if (!(await this.assosService.hasSomeAssoPermission(asso, user.id, 'weekly'))) + throw new AppException(ERROR_CODE.FORBIDDEN_ASSOS_PERMISSIONS, asso.id, 'weekly'); + const weekly: AssoWeekly = await this.weeklyService.getWeekly(weeklyId, asso.id); + if (!weekly) throw new AppException(ERROR_CODE.NO_SUCH_WEEKLY, weeklyId); + if (this.weeklyService.getWeeklySendDate(weekly.date) < new Date()) + throw new AppException(ERROR_CODE.WEEKLY_ALREADY_SENT); + if (this.weeklyService.getWeeklySendDate(dto.date) < new Date()) + throw new AppException(ERROR_CODE.WEEKLY_ALREADY_SENT_FOR_WEEK, dto.date.toISOString()); + if (await this.weeklyService.hasWeekly(asso.id, weekly.date, weekly.id)) + throw new AppException(ERROR_CODE.WEEKLY_ALREADY_PLANNED_FOR_WEEK); + return this.weeklyService.updateWeekly(weeklyId, dto); + } + + @Delete('/:weeklyId') + @ApiOperation({ description: 'Delete a weekly for the given association.' }) + @ApiOkResponse({ type: WeeklyResDto }) + @ApiAppErrorResponse( + ERROR_CODE.FORBIDDEN_ASSOS_PERMISSIONS, + 'The user issuing the request does not have the permission weekly', + ) + @ApiAppErrorResponse(ERROR_CODE.NO_SUCH_WEEKLY) + @ApiAppErrorResponse(ERROR_CODE.WEEKLY_ALREADY_SENT, 'The weekly that is beeing modified was already sent') + async deleteWeekly( + @ParamAsso() asso: Asso, + @UUIDParam('weeklyId') weeklyId: string, + @GetUser() user: User, + ): Promise { + if (!(await this.assosService.hasSomeAssoPermission(asso, user.id, 'weekly'))) + throw new AppException(ERROR_CODE.FORBIDDEN_ASSOS_PERMISSIONS, asso.id, 'weekly'); + const weekly: AssoWeekly = await this.weeklyService.getWeekly(weeklyId, asso.id); + if (!weekly) throw new AppException(ERROR_CODE.NO_SUCH_WEEKLY, weeklyId); + if (this.weeklyService.getWeeklySendDate(weekly.date) < new Date()) + throw new AppException(ERROR_CODE.WEEKLY_ALREADY_SENT); + return this.weeklyService.deleteWeekly(weeklyId); + } +} \ No newline at end of file diff --git a/src/assos/weekly/weekly-without-asso-id.controller.ts b/src/assos/weekly/weekly-without-asso-id.controller.ts new file mode 100644 index 00000000..bf5a5322 --- /dev/null +++ b/src/assos/weekly/weekly-without-asso-id.controller.ts @@ -0,0 +1,20 @@ +import { Controller, Get } from '@nestjs/common'; +import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; +import WeeklyInfoResDto from './dto/res/weekly-info-res.dto'; +import { ConfigModule } from '../../config/config.module'; + +@Controller('assos/weekly') +@ApiTags('Weekly') +export default class WeeklyWithoutAssoIdController { + constructor(readonly config: ConfigModule) {} + + @Get('/info') + @ApiOperation({ description: 'Returns information about weeklies.' }) + @ApiOkResponse({ type: WeeklyInfoResDto }) + getWeeklyInfo(): WeeklyInfoResDto { + return { + sendDay: this.config.WEEKLY_SEND_DAY, + sendHour: this.config.WEEKLY_SEND_HOUR, + }; + } +} diff --git a/src/assos/weekly/weekly.service.ts b/src/assos/weekly/weekly.service.ts new file mode 100644 index 00000000..775a4e8f --- /dev/null +++ b/src/assos/weekly/weekly.service.ts @@ -0,0 +1,75 @@ +import { Injectable } from '@nestjs/common'; +import { AssoWeekly } from '../interfaces/weekly.interface'; +import { Prisma } from '@prisma/client'; +import { Translation } from '../../prisma/types'; +import WeeklyResDto from './dto/res/weekly-res.dto'; +import { PrismaService } from '../../prisma/prisma.service'; +import { ConfigModule } from '../../config/config.module'; + +@Injectable() +export default class WeeklyService { + constructor(readonly prisma: PrismaService, readonly config: ConfigModule) {} + + async searchWeeklies(assoId: string, from: Date, to: Date, page: number): Promise<{ weeklies: AssoWeekly[], count: number }> { + const where = { + assoId, + date: { gte: from, lte: to }, + } satisfies Prisma.AssoWeeklyWhereInput; + const count = await this.prisma.assoWeekly.count({ where }); + const weeklies = await this.prisma.normalize.assoWeekly.findMany({ where, skip: (page - 1) * this.config.PAGINATION_PAGE_SIZE, take: this.config.PAGINATION_PAGE_SIZE }); + return { weeklies, count }; + } + + async addWeekly(assoId: string, title: Translation, message: Translation, date): Promise { + return this.prisma.normalize.assoWeekly.create({ + data: { + asso: { connect: { id: assoId } }, + titleTranslation: { create: title }, + bodyTranslation: { create: message }, + date, + } + }); + } + + getWeeklySendDate(sendWeek: Date): Date { + return new Date( + Date.UTC( + sendWeek.getUTCFullYear(), + sendWeek.getUTCMonth(), + sendWeek.getUTCDate() + this.config.WEEKLY_SEND_DAY, + this.config.WEEKLY_SEND_HOUR, + 0, + -Date.getTimezoneOffset('Europe/Paris') + ) + ); + } + + async getWeekly(weeklyId: string, assoId?: string): Promise { + return this.prisma.normalize.assoWeekly.findUnique({ where: { id: weeklyId, assoId } }); + } + + async hasWeekly(assoId: string, date: Date, exclude: string = undefined): Promise { + return (await this.prisma.assoWeekly.count({ + where: { + assoId, + date, + ...(exclude ? { id: { not: exclude } } : {}) + } + })) > 0; + } + + async updateWeekly(weeklyId: string, fields: { title: Translation, message: Translation, date: Date }): Promise { + return this.prisma.normalize.assoWeekly.update({ + where: { id: weeklyId }, + data: { + titleTranslation: { update: fields.title }, + bodyTranslation: { update: fields.message }, + date: fields.date, + } + }); + } + + async deleteWeekly(weeklyId: string): Promise { + return this.prisma.normalize.assoWeekly.delete({ where: { id: weeklyId } }); + } +} \ No newline at end of file diff --git a/src/config/config.module.ts b/src/config/config.module.ts index 614c5b39..7816928f 100644 --- a/src/config/config.module.ts +++ b/src/config/config.module.ts @@ -32,6 +32,8 @@ export class ConfigModule { public readonly TIMETABLE_URL: string; public readonly ANNAL_UPLOAD_DIR: string; public readonly ETUUTT_WEBSITE_APPLICATION_ID: string; + public readonly WEEKLY_SEND_DAY: number; + public readonly WEEKLY_SEND_HOUR: number; // DEV ENVIRONMENT ONLY // TEST ENVIRONMENT ONLY @@ -51,9 +53,10 @@ export class ConfigModule { this.ANNAL_UPLOAD_DIR = config.get('ANNAL_UPLOAD_DIR'); this.IS_PROD_ENV = isProdEnv; this.TIMETABLE_URL = config.get('TIMETABLE_URL'); - if (this.ANNAL_UPLOAD_DIR.endsWith('/')) this.ANNAL_UPLOAD_DIR = this.ANNAL_UPLOAD_DIR.slice(0, -1); this.ETUUTT_WEBSITE_APPLICATION_ID = config.get('ETUUTT_WEBSITE_APPLICATION_ID'); + this.WEEKLY_SEND_DAY = Number.parseInt(config.get('WEEKLY_SEND_DAY')); + this.WEEKLY_SEND_HOUR = Number.parseInt(config.get('WEEKLY_SEND_HOUR')); this._FAKER_SEED = isTestEnv ? Number(config.get('FAKER_SEED')) : undefined; } diff --git a/src/exceptions.ts b/src/exceptions.ts index 28c17707..fe58bc2e 100644 --- a/src/exceptions.ts +++ b/src/exceptions.ts @@ -40,9 +40,14 @@ export const enum ERROR_CODE { BODY_MISSING = 2022, PARAM_PAST_DATE = 2023, PARAM_MISSING_EITHER = 2024, + PARAM_DATE_MUST_BE_AFTER = 2025, + PARAM_DATE_MUST_BE_A_WEEK_DATE = 2026, PARAM_DOES_NOT_MATCH_REGEX = 2102, NO_FIELD_PROVIDED = 2201, WIDGET_OVERLAPPING = 2301, + WEEKLY_ALREADY_SENT = 2302, + WEEKLY_ALREADY_SENT_FOR_WEEK = 2303, + WEEKLY_ALREADY_PLANNED_FOR_WEEK = 2304, FILE_INVALID_TYPE = 2901, FILE_TOO_HEAVY = 2902, FORBIDDEN_NOT_ENOUGH_API_PERMISSIONS = 3001, @@ -84,6 +89,7 @@ export const enum ERROR_CODE { NO_SUCH_UE_AT_SEMESTER = 4414, NO_SUCH_ASSO_ROLE = 4415, NO_SUCH_ASSO_MEMBERSHIP = 4416, + NO_SUCH_WEEKLY = 4417, ANNAL_ALREADY_UPLOADED = 4901, RESOURCE_UNAVAILABLE = 4902, RESOURCE_INVALID_TYPE = 4903, @@ -206,6 +212,14 @@ export const ErrorData = Object.freeze({ message: 'One of these parameters must be provided: %', httpCode: HttpStatus.BAD_REQUEST, }, + [ERROR_CODE.PARAM_DATE_MUST_BE_AFTER]: { + message: 'Date % come after date %', + httpCode: HttpStatus.BAD_REQUEST, + }, + [ERROR_CODE.PARAM_DATE_MUST_BE_A_WEEK_DATE]: { + message: 'Param `%` is not a week-date. A week-date is a date pointing to any Sunday at 12pm, UTC', + httpCode: HttpStatus.BAD_REQUEST, + }, [ERROR_CODE.PARAM_DOES_NOT_MATCH_REGEX]: { message: 'The following parameters must match the regex "%": %', httpCode: HttpStatus.BAD_REQUEST, @@ -226,6 +240,18 @@ export const ErrorData = Object.freeze({ message: 'Widgets at index % and % are overlapping', httpCode: HttpStatus.BAD_REQUEST, }, + [ERROR_CODE.WEEKLY_ALREADY_SENT]: { + message: 'Weekly is already sent', + httpCode: HttpStatus.BAD_REQUEST, + }, + [ERROR_CODE.WEEKLY_ALREADY_SENT_FOR_WEEK]: { + message: 'Weekly is already sent for the week starting at %', + httpCode: HttpStatus.BAD_REQUEST, + }, + [ERROR_CODE.WEEKLY_ALREADY_PLANNED_FOR_WEEK]: { + message: 'Asso already has a weekly planned for this week', + httpCode: HttpStatus.CONFLICT, + }, [ERROR_CODE.FORBIDDEN_NOT_ENOUGH_API_PERMISSIONS]: { message: 'Missing permission %', httpCode: HttpStatus.FORBIDDEN, @@ -382,6 +408,10 @@ export const ErrorData = Object.freeze({ message: 'No such membership in asso: %', httpCode: HttpStatus.NOT_FOUND, }, + [ERROR_CODE.NO_SUCH_WEEKLY]: { + message: 'No such weekly in asso: %', + httpCode: HttpStatus.NOT_FOUND, + }, [ERROR_CODE.ANNAL_ALREADY_UPLOADED]: { message: 'A file has alreay been uploaded for this annal', httpCode: HttpStatus.CONFLICT, diff --git a/src/prisma/prisma.service.ts b/src/prisma/prisma.service.ts index 1f75e86f..7f37b759 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 { generateCustomAssoWeeklyModel } from '../assos/interfaces/weekly.interface'; @Injectable() export class PrismaService extends PrismaClient> { @@ -54,6 +55,7 @@ function createNormalizedEntitiesUtility(prisma: PrismaClient) { assoMembershipRole: generateCustomAssoMembershipRoleModel(prisma), ueCreditCategory: generateCustomCreditCategoryModel(prisma), apiApplication: generateCustomApplicationModel(prisma), + assoWeekly: generateCustomAssoWeeklyModel(prisma), }; } diff --git a/src/std.type.ts b/src/std.type.ts index 6c5209cd..4a5e44e6 100644 --- a/src/std.type.ts +++ b/src/std.type.ts @@ -43,6 +43,33 @@ declare global { keys(o: O): (keyof O)[]; entries(o: O): Array<[keyof O, O[keyof O]]>; } + + interface Date { + /** + * Removes the UTC time part of the date. + * @returns Date A new date without the UTC time. + */ + dropTime(): Date; + + /** + * Adds the number of UTC years, months, days, etc. to the Date. + * @param years Number of UTC years to add. + * @param months Number of UTC months to add. + * @param days Number of UTC days to add. + * @param hours Number of UTC hours to add. + * @param minutes Number of UTC minutes to add. + * @param seconds Number of UTC seconds to add. + * @param milliseconds Number of UTC milliseconds to add. + * @returns A new Date offset by the specified number of UTC years, months, days, etc. + */ + add({ years, months, days, hours, minutes, seconds, milliseconds }: { years?: number, months?: number, days?: number, hours?: number, minutes?: number, seconds?: number, milliseconds?: number }): Date; + + getWeekDate(): Date; + } + + interface DateConstructor { + getTimezoneOffset(timezone: string): number; + } } Array.prototype.groupyBy = function ( @@ -84,4 +111,24 @@ Array.prototype.unique = function (this: Array) { return this.filter((value, index) => this.indexOf(value) === index); }; +Date.prototype.dropTime = function (this: Date) { + return new Date(Date.UTC(this.getUTCFullYear(), this.getUTCMonth(), this.getUTCDate())); +}; + +Date.prototype.add = function (this: Date, { years = 0, months = 0, days = 0, hours = 0, minutes = 0, seconds = 0, milliseconds = 0 } = {}) { + return new Date(Date.UTC(this.getUTCFullYear() + years, this.getUTCMonth() + months, this.getUTCDate() + days, this.getUTCHours() + hours, this.getUTCMinutes() + minutes, this.getUTCSeconds() + seconds, this.getUTCMilliseconds() + milliseconds)); +} + +Date.prototype.getWeekDate = function (this: Date) { + return new Date(Date.UTC(this.getUTCFullYear(), this.getUTCMonth(), this.getUTCDate() - this.getUTCDay())); +} + +Date.getTimezoneOffset = function (timeZone: string) { + // https://stackoverflow.com/questions/21327371/get-timezone-offset-from-timezone-name-using-javascript#answer-68593283 + const date = new Date(); + const utcDate = new Date(date.toLocaleString('en-US', { timeZone: 'UTC' })); + const tzDate = new Date(date.toLocaleString('en-US', { timeZone })); + return (tzDate.getTime() - utcDate.getTime()) / 1000; +} + export {}; diff --git a/src/ue/annals/dto/req/create-annal-req.dto.ts b/src/ue/annals/dto/req/create-annal-req.dto.ts index bb145b1c..5f72d89b 100644 --- a/src/ue/annals/dto/req/create-annal-req.dto.ts +++ b/src/ue/annals/dto/req/create-annal-req.dto.ts @@ -10,8 +10,8 @@ import { } from 'class-validator'; import { HasSomeAmong } from '../../../../validation'; +@HasSomeAmong('ueCode', 'ueof') export class CreateAnnalReqDto { - @HasSomeAmong('ueCode', 'ueof') @IsString() @IsNotEmpty() @Length(3) diff --git a/src/users/dto/req/users-update-req.dto.ts b/src/users/dto/req/users-update-req.dto.ts index bfb06635..ecc87464 100644 --- a/src/users/dto/req/users-update-req.dto.ts +++ b/src/users/dto/req/users-update-req.dto.ts @@ -68,7 +68,7 @@ export class UserUpdateReqDto { @IsBoolean() @IsOptional() - wantDaymail?: boolean; + wantWeekly?: boolean; @IsBoolean() @IsOptional() diff --git a/src/users/interfaces/user.interface.ts b/src/users/interfaces/user.interface.ts index fedd7b3b..f0f96874 100644 --- a/src/users/interfaces/user.interface.ts +++ b/src/users/interfaces/user.interface.ts @@ -66,7 +66,7 @@ const USER_SELECT_FILTER = { select: { wantDayNotif: true, language: true, - wantDaymail: true, + wantWeekly: true, wantDiscordUtt: true, }, }, diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 8c039e40..ced02ef1 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -173,7 +173,7 @@ export default class UsersService { preference: { update: { language: dto.language, - wantDaymail: dto.wantDaymail, + wantWeekly: dto.wantWeekly, wantDayNotif: dto.wantDayNotif, wantDiscordUtt: dto.wantDiscordUtt, }, diff --git a/src/utils.ts b/src/utils.ts index 8e468be3..2fa257ec 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -57,6 +57,12 @@ export const translationSelect = { }, }; +const exhaustiveLanguage = (array: ( + [Language] extends [CheckArray[number]] + ? CheckArray + : 'Missing some values from Language')) => array; +export const languages = exhaustiveLanguage(['fr', 'en', 'es', 'de', 'zh']); + export class PermissionManager { public readonly hardPermissions: Permission[]; public readonly softPermissions: { diff --git a/src/validation.ts b/src/validation.ts index abb1296c..cf15ea0a 100644 --- a/src/validation.ts +++ b/src/validation.ts @@ -32,10 +32,13 @@ const mappedErrors = { max: ERROR_CODE.PARAM_TOO_HIGH, isUrl: ERROR_CODE.PARAM_NOT_URL, isFutureDate: ERROR_CODE.PARAM_PAST_DATE, + isWeekDate: ERROR_CODE.PARAM_DATE_MUST_BE_A_WEEK_DATE, } satisfies { [constraint: string]: ERROR_CODE; }; +const errorsOnMultipleFields: string[] = ['hasEither'] + export const validationExceptionFactory = (errors: ValidationError[]) => { // Map errors by constraint name const errorsByType: { [constraint: string]: string[] } = {}; @@ -44,8 +47,12 @@ export const validationExceptionFactory = (errors: ValidationError[]) => { return validationExceptionFactory(error.children); } for (const constraint of Object.keys(error.constraints)) { - if (constraint in errorsByType) errorsByType[constraint].push(error.property); - else errorsByType[constraint] = [error.property]; + const field = errorsOnMultipleFields.includes(constraint as string) ? error.constraints[constraint] : error.property + if (constraint in errorsByType) { + errorsByType[constraint].push(field); + } else { + errorsByType[constraint] = [field]; + } } } // Loop on possible errors and throw the first one @@ -69,14 +76,57 @@ class FutureDate implements ValidatorConstraintInterface { return new Date(text).getTime() >= Date.now(); } } +@ValidatorConstraint({ name: 'isWeekDate', async: false }) +class WeekDate implements ValidatorConstraintInterface { + validate(text: string): boolean { + const date = new Date(text); + return date.getWeekDate().getTime() === date.getTime(); + } +} @ValidatorConstraint({ name: 'hasEither', async: false }) class HasEither implements ValidatorConstraintInterface { validate(_: string, args: ValidationArguments) { - args.targetName = args.constraints.join(', '); return args.constraints.some((prop) => args.object[prop]); } + + defaultMessage(validationArguments?: ValidationArguments): string { + return validationArguments.constraints.join(', '); + } } + +/** + * Used internally by HasEither. + * Makes a property invisible to validation. + */ +@ValidatorConstraint({ name: 'ghostProperty', async: false }) +class GhostProperty implements ValidatorConstraintInterface { + validate(_: string, args: ValidationArguments) { + return args.value === undefined; + } +} + /** Equivalent to @MinDate(() => Date.now()) with an error message */ -export const IsFutureDate = () => Validate(FutureDate); +export const IsFutureDate = ({ each = false } = {}) => Validate(FutureDate, { each }); + +export const IsWeekDate = ({ each = false } = {}) => Validate(WeekDate, { each }); + /** Checks whether at least one of the given properties is provided. Use this decorator on any property EXCEPT those contained in the constraint list. */ -export const HasSomeAmong = (...args: string[]) => Validate(HasEither, args); +export function HasSomeAmong(...fields: ((keyof T) & string)[]) { + return (target: { prototype: T }) => { + // Define a new property on the class. + // This property will be used to apply the validator HasEither. + // It will not be possible to fill it in the request with the GhostProperty validator. + const propertyName = "_internalHasSomeAmong"; + const propertySymbol = Symbol(propertyName) + Object.defineProperty(target.prototype, propertyName, { + get() { return this[propertySymbol]; }, + set(value) { this[propertySymbol] = value; }, + enumerable: false, + configurable: true, + }); + // Apply GhostProperty decorator + Validate(GhostProperty)(target.prototype, propertyName); + // Apply HasEither decorator + Validate(HasEither, fields)(target.prototype, propertyName); + }; +} diff --git a/test/declarations.d.ts b/test/declarations.d.ts index bf412f0a..c7e0818a 100644 --- a/test/declarations.d.ts +++ b/test/declarations.d.ts @@ -4,15 +4,16 @@ import { UeCommentReply } from 'src/ue/comments/interfaces/comment-reply.interfa import { UeRating } from 'src/ue/interfaces/rate.interface'; import { FakeApiApplication, + FakeAssoWeekly, FakeAssoMembership, FakeAssoMembershipPermission, FakeAssoMembershipRole, FakeUeAnnalType, FakeUeof, + FakeUeCreditCategory, } from './utils/fakedb'; import { UeAnnalFile } from 'src/ue/annals/interfaces/annal.interface'; import { Criterion } from 'src/ue/interfaces/criterion.interface'; -import { UeRating } from 'src/ue/interfaces/rate.interface'; import { FakeUe, FakeUser, FakeHomepageWidget, FakeAsso } from './utils/fakedb'; import { AppProvider } from './utils/test_utils'; import { Language } from '@prisma/client'; @@ -106,6 +107,8 @@ declare module './declarations' { expectApplication(application: FakeApiApplication): this; expectPermissions(permissions: PermissionManager): this; + expectAssoWeekly(weekly: JsonLikeVariant, created = false): this; + expectAssoWeeklies(app: AppProvider, weeklies: JsonLikeVariant[], count: number): this; withLanguage(language: Language): this; language: Language; diff --git a/test/declarations.ts b/test/declarations.ts index 1d8e1f5f..e91e6e4d 100644 --- a/test/declarations.ts +++ b/test/declarations.ts @@ -14,7 +14,7 @@ import { FakeUeCreditCategory, FakeApiApplication, FakeAssoMembershipRole, - FakeAssoMembership, + FakeAssoMembership, FakeAssoWeekly, } from './utils/fakedb'; import { UeAnnalFile } from 'src/ue/annals/interfaces/annal.interface'; import { ConfigModule } from '../src/config/config.module'; @@ -315,6 +315,24 @@ Spec.prototype.expectPermissions = function (permissions: PermissionManager) { .mappedSort((permission) => permission.permission), } satisfies PermissionsResDto); }; +Spec.prototype.expectAssoWeekly = function (this: Spec, weekly: JsonLikeVariant, created = false) { + return this.expectStatus(created ? HttpStatus.CREATED : HttpStatus.OK).$expectRegexableJson({ + ...pick(weekly, 'id', 'assoId', 'createdAt', 'date'), + title: weekly.title[this.language], + message: weekly.message[this.language] + }); +}; +Spec.prototype.expectAssoWeeklies = function (this: Spec, app: AppProvider, weeklies: JsonLikeVariant[], count: number) { + return this.expectStatus(HttpStatus.OK).$expectRegexableJson({ + items: weeklies.map((weekly) => ({ + ...pick(weekly, 'id', 'assoId', 'createdAt', 'date'), + title: weekly.title[this.language], + message: weekly.message[this.language], + })), + itemCount: count, + itemsPerPage: app().get(ConfigModule).PAGINATION_PAGE_SIZE, + }); +}; export { Spec, JsonLikeVariant, FakeUeWithOfs }; diff --git a/test/e2e/assos/add-member.e2e-spec.ts b/test/e2e/assos/add-member.e2e-spec.ts index 8593c3af..6ccbe4c6 100644 --- a/test/e2e/assos/add-member.e2e-spec.ts +++ b/test/e2e/assos/add-member.e2e-spec.ts @@ -30,7 +30,7 @@ const AddAssoMemberE2ESpec = e2eSuite('POST /assos/:id/members', (app) => { permissions: [manageMembersPermission], }); - it('should return 403 as user is not authenticated', () => + it('should return 401 as user is not authenticated', () => pactum.spec().post(`/assos/${asso.id}/members`).expectAppError(ERROR_CODE.NOT_LOGGED_IN)); it('should return a 400 as the id param is not valid', () => diff --git a/test/e2e/assos/create-role.e2e-spec.ts b/test/e2e/assos/create-role.e2e-spec.ts index 74741b29..7a17ca8a 100644 --- a/test/e2e/assos/create-role.e2e-spec.ts +++ b/test/e2e/assos/create-role.e2e-spec.ts @@ -24,7 +24,7 @@ const CreateAssoRoleE2ESpec = e2eSuite('POST /assos/:id/roles', (app) => { name: 'Bouffeur de carte graphiques', }; - it('should return 403 as user is not authenticated', () => + it('should return 401 as user is not authenticated', () => pactum.spec().post(`/assos/${asso.id}/roles`).expectAppError(ERROR_CODE.NOT_LOGGED_IN)); it('should return a 400 as the id param is not valid', () => diff --git a/test/e2e/assos/create-weekly.e2e-spec.ts b/test/e2e/assos/create-weekly.e2e-spec.ts new file mode 100644 index 00000000..a22115dc --- /dev/null +++ b/test/e2e/assos/create-weekly.e2e-spec.ts @@ -0,0 +1,114 @@ +import { Dummies, e2eSuite, JsonLike } from '../../utils/test_utils'; +import { + createAsso, createAssoWeekly, + createAssoMembership, + createAssoMembershipPermission, + createAssoMembershipRole, + createUser, +} from '../../utils/fakedb'; +import * as pactum from 'pactum'; +import { ERROR_CODE } from '../../../src/exceptions'; +import AssosPostWeeklyReqDto from '../../../src/assos/weekly/dto/req/weekly-req.dto'; +import { PrismaService } from '../../../src/prisma/prisma.service'; + +const CreateWeeklyE2ESpec = e2eSuite('POST /assos/:assoId/weekly', (app) => { + const asso = createAsso(app); + const userWithPermission = createUser(app); + const userWithoutPermission = createUser(app); + const permissionManageAsso = createAssoMembershipPermission(app, { id: 'weekly' }); + const role = createAssoMembershipRole(app, { asso }); + createAssoMembership(app, { asso, user: userWithPermission, role, permissions: [permissionManageAsso] }); + const weekly = createAssoWeekly(app, { asso }, { date: new Date().add({ days: 7 }).getWeekDate() }); + + const body: AssosPostWeeklyReqDto = { + title: { fr: "The title" }, + message: { fr: "The message" }, + date: new Date().add({ days: 14 }).getWeekDate(), + } + + it('should return 401 as user is not authenticated', () => + pactum.spec().post(`/assos/${asso.id}/weekly`).expectAppError(ERROR_CODE.NOT_LOGGED_IN)); + + it('should return a 400 as the assoId param is not valid', () => + pactum + .spec() + .withBearerToken(userWithPermission.token) + .post('/assos/thisisnotavaliduuid/weekly') + .withBody(body) + .expectAppError(ERROR_CODE.PARAM_NOT_UUID, 'assoId')); + + it('should return a 404 as asso is not found', () => + pactum + .spec() + .withBearerToken(userWithPermission.token) + .post(`/assos/${Dummies.UUID}/weekly`) + .withBody(body) + .expectAppError(ERROR_CODE.NO_SUCH_ASSO, Dummies.UUID)); + + it('should return a 403 as user does not have the permission to create a weekly', () => pactum + .spec() + .withBearerToken(userWithoutPermission.token) + .post(`/assos/${asso.id}/weekly`) + .withBody(body) + .expectAppError(ERROR_CODE.FORBIDDEN_ASSOS_PERMISSIONS, asso.id, 'weekly')); + + it('should return a 400 as the title was not provided in any language', () => + pactum + .spec() + .withBearerToken(userWithPermission.token) + .post(`/assos/${asso.id}/weekly`) + .withBody({ ...body, title: {} }) + .expectAppError(ERROR_CODE.PARAM_MISSING_EITHER, 'fr, en, es, de, zh')); + + it('should return a 400 as the message was not provided in any language', () => pactum + .spec() + .withBearerToken(userWithPermission.token) + .post(`/assos/${asso.id}/weekly`) + .withBody({ ...body, message: {} }) + .expectAppError(ERROR_CODE.PARAM_MISSING_EITHER, 'fr, en, es, de, zh')); + + it('should return a 400 as the date is not a week-date', () => pactum + .spec() + .withBearerToken(userWithPermission.token) + .post(`/assos/${asso.id}/weekly`) + .withBody({ ...body, date: new Date(Date.UTC(2024, 10, 9)) }) + .expectAppError(ERROR_CODE.PARAM_DATE_MUST_BE_A_WEEK_DATE, 'date')); + + it('should fail as the weekly was already sent for the requested week', () => pactum + .spec() + .withBearerToken(userWithPermission.token) + .post(`/assos/${asso.id}/weekly`) + .withBody({ ...body, date: new Date(Date.UTC(2024, 10, 10)) }) + .expectAppError(ERROR_CODE.WEEKLY_ALREADY_SENT_FOR_WEEK, new Date(Date.UTC(2024, 10, 10)).toISOString())); + + it('should fail as the asso already has a planned weekly for the requested week', () => + pactum + .spec() + .withBearerToken(userWithPermission.token) + .post(`/assos/${asso.id}/weekly`) + .withBody({ ...body, date: weekly.date }) + .expectAppError(ERROR_CODE.WEEKLY_ALREADY_PLANNED_FOR_WEEK)); + + it('should successfully create a new weekly', async () => { + const id: string = await pactum + .spec() + .withBearerToken(userWithPermission.token) + .post(`/assos/${asso.id}/weekly`) + .withBody(body) + .expectAssoWeekly( + { + id: JsonLike.UUID, + assoId: asso.id, + createdAt: JsonLike.DATE, + title: body.title, + message: body.message, + date: body.date, + }, + true, + ) + .returns('id'); + return app().get(PrismaService).assoWeekly.delete({ where: { id } }); + }); +}); + +export default CreateWeeklyE2ESpec; diff --git a/test/e2e/assos/delete-role.e2e-spec.ts b/test/e2e/assos/delete-role.e2e-spec.ts index cd8d2eb1..28e80004 100644 --- a/test/e2e/assos/delete-role.e2e-spec.ts +++ b/test/e2e/assos/delete-role.e2e-spec.ts @@ -23,7 +23,7 @@ const DeleteAssoRoleE2ESpec = e2eSuite('DELETE /assos/:id/roles/:id', (app) => { const permission = createAssoMembershipPermission(app, { id: 'manage_roles' }); createAssoMembership(app, { asso, role: assoMembershipRole, user: userAllowed, permissions: [permission] }); - it('should return 403 as user is not authenticated', () => + it('should return 401 as user is not authenticated', () => pactum.spec().delete(`/assos/${asso.id}/roles/${assoMembershipRole.id}`).expectAppError(ERROR_CODE.NOT_LOGGED_IN)); it('should return a 400 as the id param is not valid', () => diff --git a/test/e2e/assos/delete-weekly.e2e-spec.ts b/test/e2e/assos/delete-weekly.e2e-spec.ts new file mode 100644 index 00000000..47b46fef --- /dev/null +++ b/test/e2e/assos/delete-weekly.e2e-spec.ts @@ -0,0 +1,71 @@ +import { Dummies, e2eSuite } from '../../utils/test_utils'; +import { + createAsso, createAssoWeekly, + createAssoMembership, + createAssoMembershipPermission, + createAssoMembershipRole, + createUser, +} from '../../utils/fakedb'; +import * as pactum from 'pactum'; +import { ERROR_CODE } from '../../../src/exceptions'; + +const DeleteWeeklyE2ESpec = e2eSuite('DELETE /assos/:assoId/weekly/:weeklyId', (app) => { + const userWithPermission = createUser(app); + const userWithoutPermission = createUser(app); + const permissionManageAsso = createAssoMembershipPermission(app, { id: 'weekly' }); + + const asso = createAsso(app); + const role = createAssoMembershipRole(app, { asso }); + createAssoMembership(app, { asso, user: userWithPermission, role, permissions: [permissionManageAsso] }); + const weekly = createAssoWeekly(app, { asso }, { date: new Date().add({ days: 14 }).getWeekDate() }); + const oldWeekly = createAssoWeekly(app, { asso }, { date: new Date(Date.UTC(2025, 10, 10)) }); + + const otherAsso = createAsso(app); + const otherAssoRole = createAssoMembershipRole(app, { asso }); + createAssoMembership(app, { asso: otherAsso, user: userWithPermission, role: otherAssoRole, permissions: [permissionManageAsso] }); + + it('should return 401 as user is not authenticated', () => + pactum.spec().delete(`/assos/${asso.id}/weekly/${weekly.id}`).expectAppError(ERROR_CODE.NOT_LOGGED_IN)); + + it('should return a 404 as asso is not found', () => + pactum + .spec() + .withBearerToken(userWithPermission.token) + .delete(`/assos/${Dummies.UUID}/weekly/${weekly.id}`) + .expectAppError(ERROR_CODE.NO_SUCH_ASSO, Dummies.UUID)); + + it('should return a 403 as user does not have the permission to update the weeklies', () => pactum + .spec() + .withBearerToken(userWithoutPermission.token) + .delete(`/assos/${asso.id}/weekly/${weekly.id}`) + .expectAppError(ERROR_CODE.FORBIDDEN_ASSOS_PERMISSIONS, asso.id, 'weekly')); + + it('should return a 404 as weekly is not found', () => pactum + .spec() + .withBearerToken(userWithPermission.token) + .delete(`/assos/${asso.id}/weekly/${Dummies.UUID}`) + .expectAppError(ERROR_CODE.NO_SUCH_WEEKLY, Dummies.UUID)) + + it('should return a 404 as the weekly does not belong to the asso', () => pactum + .spec() + .withBearerToken(userWithPermission.token) + .delete(`/assos/${otherAsso.id}/weekly/${weekly.id}`) + .expectAppError(ERROR_CODE.NO_SUCH_WEEKLY, weekly.id)); + + it('should return a 400 as the weekly was already sent', () => pactum + .spec() + .withBearerToken(userWithPermission.token) + .delete(`/assos/${asso.id}/weekly/${oldWeekly.id}`) + .expectAppError(ERROR_CODE.WEEKLY_ALREADY_SENT)); + + it('should delete the weekly', async () => { + await pactum + .spec() + .withBearerToken(userWithPermission.token) + .delete(`/assos/${asso.id}/weekly/${weekly.id}`) + .expectAssoWeekly(weekly); + await createAssoWeekly(app, { asso }, weekly, true); + }); +}); + +export default DeleteWeeklyE2ESpec; diff --git a/test/e2e/assos/get-weekly-info.e2e-spec.ts b/test/e2e/assos/get-weekly-info.e2e-spec.ts new file mode 100644 index 00000000..8303d75d --- /dev/null +++ b/test/e2e/assos/get-weekly-info.e2e-spec.ts @@ -0,0 +1,21 @@ +import { e2eSuite } from '../../utils/test_utils'; +import { createUser } from '../../utils/fakedb'; +import * as pactum from 'pactum'; +import { ERROR_CODE } from '../../../src/exceptions'; +import { ConfigModule } from '../../../src/config/config.module'; + +const GetWeeklyInfoE2ESpec = e2eSuite('GET /assos/weekly/info', (app) => { + const user = createUser(app); + + it('should return 401 as user is not authenticated', () => + pactum.spec().get('/assos/weekly/info').expectAppError(ERROR_CODE.NOT_LOGGED_IN)); + + it('should successfully return information about weeklies', () => + pactum + .spec() + .withBearerToken(user.token) + .get('/assos/weekly/info') + .expectJson({ sendDay: app().get(ConfigModule).WEEKLY_SEND_DAY, sendHour: app().get(ConfigModule).WEEKLY_SEND_HOUR })); +}); + +export default GetWeeklyInfoE2ESpec; diff --git a/test/e2e/assos/index.ts b/test/e2e/assos/index.ts index 5ce31380..e3e9d47f 100644 --- a/test/e2e/assos/index.ts +++ b/test/e2e/assos/index.ts @@ -8,6 +8,11 @@ import UpdateAssoMemberE2ESpec from './update-member.e2e-spec'; import CreateAssoRoleE2ESpec from './create-role.e2e-spec'; import DeleteAssoRoleE2ESpec from './delete-role.e2e-spec'; import UpdateAssoRoleE2ESpec from './update-role.e2e-spec'; +import CreateWeeklyE2ESpec from './create-weekly.e2e-spec'; +import SearchWeekliesE2ESpec from './search-weeklies.e2e-spec'; +import UpdateWeeklyE2ESpec from './update-weekly.e2e-spec'; +import DeleteWeeklyE2ESpec from './delete-weekly.e2e-spec'; +import GetWeeklyInfoE2ESpec from './get-weekly-info.e2e-spec'; export default function AssoE2ESpec(app: () => INestApplication) { describe('Assos', () => { @@ -20,5 +25,10 @@ export default function AssoE2ESpec(app: () => INestApplication) { CreateAssoRoleE2ESpec(app); DeleteAssoRoleE2ESpec(app); UpdateAssoRoleE2ESpec(app); + GetWeeklyInfoE2ESpec(app); + SearchWeekliesE2ESpec(app); + CreateWeeklyE2ESpec(app); + UpdateWeeklyE2ESpec(app); + DeleteWeeklyE2ESpec(app); }); } diff --git a/test/e2e/assos/kick-member.e2e-spec.ts b/test/e2e/assos/kick-member.e2e-spec.ts index ba7fa630..53b9a572 100644 --- a/test/e2e/assos/kick-member.e2e-spec.ts +++ b/test/e2e/assos/kick-member.e2e-spec.ts @@ -35,7 +35,7 @@ const KickAssoMemberE2ESpec = e2eSuite('DELETE /assos/:id/members/:id', (app) => }); createAssoMembership(app, { asso, role: assoMembershipRole, user: userAllowed, permissions: [permission] }); - it('should return 403 as user is not authenticated', () => + it('should return 401 as user is not authenticated', () => pactum .spec() .delete(`/assos/${asso.id}/members/${userInAssoMembershipInAsso.id}`) diff --git a/test/e2e/assos/list-members.e2e-spec.ts b/test/e2e/assos/list-members.e2e-spec.ts index 16239913..e2315562 100644 --- a/test/e2e/assos/list-members.e2e-spec.ts +++ b/test/e2e/assos/list-members.e2e-spec.ts @@ -10,7 +10,7 @@ const GetAssoMembersE2ESpec = e2eSuite('GET /assos/:id/members', (app) => { const permissions = []; createAssoMembership(app, { asso, role: assoMembershipRole, user, permissions }); - it('should return 403 as user is not authenticated', () => + it('should return 401 as user is not authenticated', () => pactum.spec().get(`/assos/${asso.id}/members`).expectAppError(ERROR_CODE.NOT_LOGGED_IN)); it('should return a 400 as the id param is not valid', () => diff --git a/test/e2e/assos/search-weeklies.e2e-spec.ts b/test/e2e/assos/search-weeklies.e2e-spec.ts new file mode 100644 index 00000000..4f1f585c --- /dev/null +++ b/test/e2e/assos/search-weeklies.e2e-spec.ts @@ -0,0 +1,74 @@ +import { Dummies, e2eSuite } from '../../utils/test_utils'; +import { + createAsso, createAssoWeekly, + createAssoMembership, + createAssoMembershipPermission, + createAssoMembershipRole, + createUser, +} from '../../utils/fakedb'; +import * as pactum from 'pactum'; +import { ERROR_CODE } from '../../../src/exceptions'; + +const SearchWeekliesE2ESpec = e2eSuite('GET /assos/:assoId/weekly', (app) => { + const userWithPermission = createUser(app); + const userWithoutPermission = createUser(app); + const permissionManageAsso = createAssoMembershipPermission(app, { id: 'weekly' }); + + const asso = createAsso(app); + const roleAsso = createAssoMembershipRole(app, { asso }); + createAssoMembership(app, { asso, user: userWithPermission, role: roleAsso, permissions: [permissionManageAsso] }); + // Working request will be between the 1st, October and the 10th of October + const weeklyAssoOctober1 = createAssoWeekly(app, { asso }, { date: new Date(Date.UTC(2025, 9, 1)) }); + const weeklyAssoOctober2 = createAssoWeekly(app, { asso }, { date: new Date(Date.UTC(2025, 9, 2)) }); + createAssoWeekly(app, { asso }, { date: new Date(Date.UTC(2025, 8, 1)) }); // September + createAssoWeekly(app, { asso }, { date: new Date(Date.UTC(2025, 10, 1)) }); // November + + // Create a weekly for another asso, that we should not get + const otherAsso = createAsso(app); + const roleOtherAsso = createAssoMembershipRole(app, { asso: otherAsso }); + createAssoMembership(app, { asso: otherAsso, user: userWithPermission, role: roleOtherAsso, permissions: [permissionManageAsso] }); + createAssoWeekly(app, { asso: otherAsso }, { date: new Date(Date.UTC(2025, 9, 6)) }); + + it('should return 401 as user is not authenticated', () => + pactum.spec().get(`/assos/${asso.id}/weekly/`).withQueryParams({ from: '2025-10-01T00:00:00Z', to: '2025-10-10T00:00:00Z' }).expectAppError(ERROR_CODE.NOT_LOGGED_IN)); + + it('should return a 400 as the assoId param is not valid', () => + pactum + .spec() + .withBearerToken(userWithPermission.token) + .get('/assos/thisisnotavaliduuid/weekly') + .withQueryParams({ from: '2025-10-01T00:00:00Z', to: '2025-10-10T00:00:00Z' }) + .expectAppError(ERROR_CODE.PARAM_NOT_UUID, 'assoId')); + + it('should return a 404 as asso is not found', () => + pactum + .spec() + .withBearerToken(userWithPermission.token) + .get(`/assos/${Dummies.UUID}/weekly`) + .withQueryParams({ from: '2025-10-01T00:00:00Z', to: '2025-10-10T00:00:00Z' }) + .expectAppError(ERROR_CODE.NO_SUCH_ASSO, Dummies.UUID)); + + it('should return a 400 as the `to` parameter comes before the `from` parameter', () => + pactum + .spec() + .withBearerToken(userWithPermission.token) + .get(`/assos/${asso.id}/weekly`) + .withQueryParams({ from: '2025-10-10T00:00:00Z', to: '2025-10-01T00:00:00Z' }) + .expectAppError(ERROR_CODE.PARAM_DATE_MUST_BE_AFTER, '2025-10-01T00:00:00.000Z', '2025-10-10T00:00:00.000Z')); + + it('should return a 403 as user does not have the permission to see the weeklies', () => pactum + .spec() + .withBearerToken(userWithoutPermission.token) + .get(`/assos/${asso.id}/weekly`) + .withQueryParams({ from: '2025-10-01T00:00:00Z', to: '2025-10-10T00:00:00Z' }) + .expectAppError(ERROR_CODE.FORBIDDEN_ASSOS_PERMISSIONS, asso.id, 'weekly')); + + it('should return weeklies for `asso` between October, 1st and October, 31st', () => pactum + .spec() + .withBearerToken(userWithPermission.token) + .get(`/assos/${asso.id}/weekly`) + .withQueryParams({ from: '2025-10-01T00:00:00Z', to: '2025-10-31T00:00:00Z' }) + .expectAssoWeeklies(app, [weeklyAssoOctober1, weeklyAssoOctober2], 2)); +}); + +export default SearchWeekliesE2ESpec; diff --git a/test/e2e/assos/update-member.e2e-spec.ts b/test/e2e/assos/update-member.e2e-spec.ts index 3f919d49..9f98627d 100644 --- a/test/e2e/assos/update-member.e2e-spec.ts +++ b/test/e2e/assos/update-member.e2e-spec.ts @@ -49,7 +49,7 @@ const UpdateAssoMemberE2ESpec = e2eSuite('PATCH /assos/:id/members/:id', (app) = const endAt = new Date(Date.now() + 7 * 24 * 3600 * 1000); - it('should return 403 as user is not authenticated', () => + it('should return 401 as user is not authenticated', () => pactum .spec() .patch(`/assos/${asso.id}/members/${userInAssoMembershipInAsso.id}`) diff --git a/test/e2e/assos/update-role.e2e-spec.ts b/test/e2e/assos/update-role.e2e-spec.ts index 02e2df05..3d57bed0 100644 --- a/test/e2e/assos/update-role.e2e-spec.ts +++ b/test/e2e/assos/update-role.e2e-spec.ts @@ -23,7 +23,7 @@ const UpdateAssoRoleE2ESpec = e2eSuite('PUT /assos/:id/roles/:id', (app) => { const permission = createAssoMembershipPermission(app, { id: 'manage_roles' }); createAssoMembership(app, { asso, role: assoMembershipRole, user: userAllowed, permissions: [permission] }); - it('should return 403 as user is not authenticated', () => + it('should return 401 as user is not authenticated', () => pactum.spec().put(`/assos/${asso.id}/roles/${assoMembershipRole.id}`).expectAppError(ERROR_CODE.NOT_LOGGED_IN)); it('should return a 400 as the id param is not valid', () => diff --git a/test/e2e/assos/update-weekly.e2e-spec.ts b/test/e2e/assos/update-weekly.e2e-spec.ts new file mode 100644 index 00000000..866e89c2 --- /dev/null +++ b/test/e2e/assos/update-weekly.e2e-spec.ts @@ -0,0 +1,100 @@ +import { Dummies, e2eSuite } from '../../utils/test_utils'; +import { + createAsso, createAssoWeekly, + createAssoMembership, + createAssoMembershipPermission, + createAssoMembershipRole, + createUser, +} from '../../utils/fakedb'; +import * as pactum from 'pactum'; +import { ERROR_CODE } from '../../../src/exceptions'; +import { faker } from '@faker-js/faker'; +import { pick } from '../../../src/utils'; + +const UpdateWeeklyE2ESpec = e2eSuite('PATCH /assos/:assoId/weekly/:weeklyId', (app) => { + const userWithPermission = createUser(app); + const userWithoutPermission = createUser(app); + const permissionWeekly = createAssoMembershipPermission(app, { id: 'weekly' }); + + const asso = createAsso(app); + const role = createAssoMembershipRole(app, { asso }); + createAssoMembership(app, { asso, user: userWithPermission, role, permissions: [permissionWeekly] }); + const weekly = createAssoWeekly(app, { asso }, { date: new Date().add({ days: 14 }).getWeekDate() }); + const oldWeekly = createAssoWeekly(app, { asso }, { date: new Date(Date.UTC(2024, 10, 10)) }); + + const otherAsso = createAsso(app); + const otherAssoRole = createAssoMembershipRole(app, { asso: otherAsso }); + createAssoMembership(app, { asso: otherAsso, user: userWithPermission, role: otherAssoRole, permissions: [permissionWeekly] }); + + const generateBody = () => ({ + title: pick(faker.db.translation(), 'fr', 'en', 'zh'), + message: pick(faker.db.translation(), 'de', 'es'), + date: weekly.date.add({ days: 7 }), + }); + + it('should return 401 as user is not authenticated', () => + pactum.spec().patch(`/assos/${asso.id}/weekly/${weekly.id}`).withJson(generateBody()).expectAppError(ERROR_CODE.NOT_LOGGED_IN)); + + it('should return a 404 as asso is not found', () => + pactum + .spec() + .withBearerToken(userWithPermission.token) + .patch(`/assos/${Dummies.UUID}/weekly/${weekly.id}`) + .withJson(generateBody()) + .expectAppError(ERROR_CODE.NO_SUCH_ASSO, Dummies.UUID)); + + it('should return a 403 as user does not have the permission to update the weeklies', () => pactum + .spec() + .withBearerToken(userWithoutPermission.token) + .patch(`/assos/${asso.id}/weekly/${weekly.id}`) + .withJson(generateBody()) + .expectAppError(ERROR_CODE.FORBIDDEN_ASSOS_PERMISSIONS, asso.id, 'weekly')); + + it('should return a 404 as weekly is not found', () => pactum + .spec() + .withBearerToken(userWithPermission.token) + .patch(`/assos/${asso.id}/weekly/${Dummies.UUID}`) + .withJson(generateBody()) + .expectAppError(ERROR_CODE.NO_SUCH_WEEKLY, Dummies.UUID)) + + it('should return a 404 as the weekly does not belong to the asso', () => pactum + .spec() + .withBearerToken(userWithPermission.token) + .patch(`/assos/${otherAsso.id}/weekly/${weekly.id}`) + .withJson(generateBody()) + .expectAppError(ERROR_CODE.NO_SUCH_WEEKLY, weekly.id)); + + it('should return a 400 as the weekly was already sent', () => pactum + .spec() + .withBearerToken(userWithPermission.token) + .patch(`/assos/${asso.id}/weekly/${oldWeekly.id}`) + .withJson(generateBody()) + .expectAppError(ERROR_CODE.WEEKLY_ALREADY_SENT)); + + it('should return a 400 as the weeklies planned at the new date were already sent', () => pactum + .spec() + .withBearerToken(userWithPermission.token) + .patch(`/assos/${asso.id}/weekly/${weekly.id}`) + .withJson({ ...generateBody(), date: new Date(Date.UTC(2024, 10, 10)) }) + .expectAppError(ERROR_CODE.WEEKLY_ALREADY_SENT_FOR_WEEK, new Date(Date.UTC(2024, 10, 10)).toISOString())); + + it('should update the weekly', async () => { + const body = generateBody(); + await pactum + .spec() + .withBearerToken(userWithPermission.token) + .patch(`/assos/${asso.id}/weekly/${weekly.id}`) + .withJson(body) + .expectAssoWeekly({ + ...weekly, + title: {...weekly.title, ...body.title}, + message: {...weekly.message, ...body.message}, + date: body.date, + }); + weekly.title = {...weekly.title, ...body.title}; + weekly.message = {...weekly.message, ...body.message}; + weekly.date = body.date; + }); +}); + +export default UpdateWeeklyE2ESpec; diff --git a/test/e2e/users/get-todays-birthdays.e2e-spec.ts b/test/e2e/users/get-todays-birthdays.e2e-spec.ts index 3801753f..6c719d5c 100644 --- a/test/e2e/users/get-todays-birthdays.e2e-spec.ts +++ b/test/e2e/users/get-todays-birthdays.e2e-spec.ts @@ -3,7 +3,7 @@ import * as pactum from 'pactum'; import { createUser } from '../../utils/fakedb'; import { ERROR_CODE } from '../../../src/exceptions'; -const GetTodaysBirthdaysE2ESpec = e2eSuite('GET /users/birthday/today', (app) => { +const GetTodaysBirthdaysE2ESpec = e2eSuite('GET /users/birthdays/today', (app) => { const now = new Date(); const user = createUser(app, { infos: { birthday: new Date(now.getTime() - 3_600_000 * 24) } }); const otherUser = createUser(app, { diff --git a/test/utils/fakedb.ts b/test/utils/fakedb.ts index c368ac9b..6833759f 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 { AssoWeekly } from '../../src/assos/interfaces/weekly.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 FakeAssoWeekly = Partial>; export interface FakeEntityMap { assoMembership: { @@ -143,6 +145,11 @@ export interface FakeEntityMap { entity: FakeAsso; params: CreateAssoParameters; }; + assoWeekly: { + entity: FakeAssoWeekly; + params: CreateAssoWeeklyParameters; + deps: { asso: FakeAsso }; + } timetableEntryOverride: { entity: Partial; params: CreateTimetableEntryOverrideParameters; @@ -543,6 +550,39 @@ export const createAsso = entityFaker( }, ); +export type CreateAssoWeeklyParameters = FakeAssoWeekly; +export const createAssoWeekly = entityFaker( + 'assoWeekly', + { + date: new Date, + title: { + fr: faker.company.catchPhrase, + en: faker.company.catchPhrase, + es: faker.company.catchPhrase, + de: faker.company.catchPhrase, + zh: faker.company.catchPhrase, + }, + message: { + fr: faker.company.catchPhrase, + en: faker.company.catchPhrase, + es: faker.company.catchPhrase, + de: faker.company.catchPhrase, + zh: faker.company.catchPhrase, + } + }, + async (app, deps, params) => { + return app().get(PrismaService).normalize.assoWeekly.create({ + data: { + id: params.id, + titleTranslation: { create: params.title }, + bodyTranslation: { create: params.message }, + asso: { connect: { id: deps.asso.id } }, + date: params.date, + }, + }) + } +); + export type CreateTimetableGroupParams = { users?: Array<{ user: FakeUser; priority: number }> }; /** * Creates a timetableGroup in the database. @@ -1146,7 +1186,7 @@ function deeplyCallFunctions(params: T) { for (const key in params) { if (typeof params[key] === 'function') { params[key] = (params[key] as () => T[Extract])(); - } else if (typeof params[key] === 'object') { + } else if (typeof params[key] === 'object' && !(params[key] instanceof Date)) { deeplyCallFunctions(params[key]); } }