From 7ff1fea2146176b955752e5d2fda81d7a2517857 Mon Sep 17 00:00:00 2001 From: Teddy Roncin Date: Sun, 19 Oct 2025 03:37:47 +0200 Subject: [PATCH 1/7] New route POST /assos/:assoId/daymail and decorator HasSomeAmong is now applied to classes instead of properties --- prisma/schema.prisma | 21 +++-- src/app.dto.ts | 46 ++++++++++ src/assos/assos.controller.ts | 10 +++ src/assos/assos.service.ts | 15 +++- .../dto/req/assos-post-daymail-req.dto.ts | 21 +++++ src/assos/dto/res/daymail-res.dto.ts | 10 +++ src/assos/interfaces/daymail.interface.ts | 34 ++++++++ src/prisma/prisma.service.ts | 4 +- src/prisma/types.ts | 1 + src/ue/annals/dto/req/create-annal-req.dto.ts | 2 +- src/validation.ts | 45 ++++++++-- test/declarations.d.ts | 3 +- test/declarations.ts | 5 +- test/e2e/assos/create-daymail.e2e-spec.ts | 83 +++++++++++++++++++ test/e2e/assos/index.ts | 2 + test/utils/fakedb.ts | 2 + 16 files changed, 287 insertions(+), 17 deletions(-) create mode 100644 src/assos/dto/req/assos-post-daymail-req.dto.ts create mode 100644 src/assos/dto/res/daymail-res.dto.ts create mode 100644 src/assos/interfaces/daymail.interface.ts create mode 100644 test/e2e/assos/create-daymail.e2e-spec.ts diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ee6bef46..f8c5d283 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[] + daymails AssoDaymail[] events Event[] assoMembershipRoles AssoMembershipRole[] assoAccount User @relation(fields: [assoAccountId], references: [id], onDelete: Cascade) @@ -101,11 +101,8 @@ model AssoMembershipRole { asso Asso @relation(fields: [assoId], references: [id]) } -model AssoMessage { +model AssoDaymail { id String @id @default(uuid()) - date DateTime - sendToMobile Boolean - sendAsDaymail Boolean createdAt DateTime @default(now()) assoId String titleTranslationId String @unique @@ -114,6 +111,16 @@ model AssoMessage { 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) + sendDates AssoDaymailSendDate[] +} + +model AssoDaymailSendDate { + daymailId String + date DateTime + + daymail AssoDaymail @relation(fields: [daymailId], references: [id], onDelete: Cascade) + + @@id([daymailId, date]) } model Event { @@ -231,8 +238,8 @@ model Translation { assoDescription Asso? @relation("descriptionTranslation") assoDescriptionShort Asso? @relation("descriptionShortTranslation") - assoMessageTitle AssoMessage? @relation("titleTranslation") - assoMessageTitleBody AssoMessage? @relation("bodyTranslation") + assoDaymailTitle AssoDaymail? @relation("titleTranslation") + assoDaymailTitleBody AssoDaymail? @relation("bodyTranslation") eventDescription Event? @relation("descriptionTranslation") eventTitle Event? @relation("titleTranslation") ueofInfo UeofInfo? @relation("ueofInfoObjectivesTranslation") diff --git a/src/app.dto.ts b/src/app.dto.ts index 6d731dbb..8803b583 100644 --- a/src/app.dto.ts +++ b/src/app.dto.ts @@ -3,6 +3,8 @@ 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, registerDecorator, ValidationArguments, ValidationOptions } from 'class-validator'; +import { HasSomeAmong } from './validation'; // 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 +46,47 @@ export function paginatedResponseDto(Base: TBase) { } return mixin(ResponseDto, `${Base.name}$Paginated`); // This is important otherwise you will get always the same instance } + +/*export function AtLeastOneTranslation(validationOptions?: ValidationOptions) { + return function (constructor: Function) { + registerDecorator({ + name: 'atLeastOneTranslation', + target: constructor, + propertyName: propertyName, + options: validationOptions, + validator: { + validate(value: any, args: ValidationArguments) { + if (!value || typeof value !== 'object') return false; + + return Object.values(value).some(v => typeof v === 'string' && v.trim().length > 0); + }, + defaultMessage(args: ValidationArguments) { + return 'At least one translation must be provided'; + }, + }, + }); + }; +}*/ +@HasSomeAmong('fr', 'en', 'es', 'de', 'zh') +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..061179a9 100644 --- a/src/assos/assos.controller.ts +++ b/src/assos/assos.controller.ts @@ -23,6 +23,8 @@ 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 AssosPostDaymailReqDto from './dto/req/assos-post-daymail-req.dto'; +import DaymailResDto from './dto/res/daymail-res.dto'; @Controller('assos') @ApiTags('Assos') @@ -216,6 +218,14 @@ export class AssosController { return { roles: updatedRoles.map(this.formatPartialAssoMembershipRole) }; } + @Post('/:assoId/daymail') + @ApiOperation({ description: 'Create a message for the given association' }) + async createDaymail(@ParamAsso() asso: Asso, @Body() dto: AssosPostDaymailReqDto, @GetUser() user: User): Promise { + if (!(await this.assosService.hasSomeAssoPermission(asso, user.id, 'manage_asso'))) + throw new AppException(ERROR_CODE.FORBIDDEN_ASSOS_PERMISSIONS, asso.id, 'manage_asso'); + return this.assosService.addDaymail(asso.id, dto.title, dto.message, dto.dates); + } + formatAssoOverview(asso: Asso): AssoOverviewResDto { return { ...pick(asso, 'id', 'name', 'logo', 'president'), diff --git a/src/assos/assos.service.ts b/src/assos/assos.service.ts index e21b02e0..8b340446 100644 --- a/src/assos/assos.service.ts +++ b/src/assos/assos.service.ts @@ -2,13 +2,15 @@ import { Injectable } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { ConfigModule } from '../config/config.module'; import { PrismaService } from '../prisma/prisma.service'; -import { RawAssoMembershipRole } from '../prisma/types'; +import { RawAssoMembershipRole, Translation } from '../prisma/types'; import { Asso } from './interfaces/asso.interface'; 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 { pick } from '../utils'; +import { AssoDaymail } from './interfaces/daymail.interface'; @Injectable() export class AssosService { @@ -285,4 +287,15 @@ export class AssosService { }, }); } + + async addDaymail(assoId: string, title: Translation, message: Translation, dates: Date[]): Promise { + return this.prisma.normalize.assoDaymail.create({ + data: { + asso: { connect: { id: assoId } }, + titleTranslation: { create: pick(title, 'fr', 'en', 'es', 'de', 'zh') }, + bodyTranslation: { create: pick(message, 'fr', 'en', 'es', 'de', 'zh') }, + sendDates: { createMany: { data: dates.map((date) => ({ date })) } }, + } + }); + } } diff --git a/src/assos/dto/req/assos-post-daymail-req.dto.ts b/src/assos/dto/req/assos-post-daymail-req.dto.ts new file mode 100644 index 00000000..116fe7ac --- /dev/null +++ b/src/assos/dto/req/assos-post-daymail-req.dto.ts @@ -0,0 +1,21 @@ +import { Type } from 'class-transformer'; +import { IsDate, IsNotEmpty, ValidateNested } from 'class-validator'; +import { TranslationReqDto } from '../../../app.dto'; +import { IsFutureDate } from '../../../validation'; + +export default class AssosPostDaymailReqDto { + @ValidateNested() + @Type(() => TranslationReqDto) + title: TranslationReqDto + + @ValidateNested() + @IsNotEmpty() + @Type(() => TranslationReqDto) + message: TranslationReqDto; + + @IsDate({ each: true }) + @IsNotEmpty() + @Type(() => Date) + @IsFutureDate({ each: true }) + dates: Date[]; +} \ No newline at end of file diff --git a/src/assos/dto/res/daymail-res.dto.ts b/src/assos/dto/res/daymail-res.dto.ts new file mode 100644 index 00000000..312b39b0 --- /dev/null +++ b/src/assos/dto/res/daymail-res.dto.ts @@ -0,0 +1,10 @@ +import { Translation } from '../../../prisma/types'; + +export default class DaymailResDto { + id: string; + assoId: string; + createdAt: Date; + title: Translation; + message: Translation; + sendDates: Date[]; +} diff --git a/src/assos/interfaces/daymail.interface.ts b/src/assos/interfaces/daymail.interface.ts new file mode 100644 index 00000000..d22f8ee6 --- /dev/null +++ b/src/assos/interfaces/daymail.interface.ts @@ -0,0 +1,34 @@ +import { Prisma, PrismaClient } from '@prisma/client'; +import { generateCustomModel } from '../../prisma/prisma.service'; +import { pick, translationSelect } from '../../utils'; +import { RawAssoDaymail, Translation } from '../../prisma/types'; + +const ASSO_DAYMAIL_SELECT_FILTER = { + select: { + id: true, + assoId: true, + createdAt: true, + titleTranslation: translationSelect, + bodyTranslation: translationSelect, + sendDates: { + select: { + date: true, + }, + orderBy: { + date: 'asc', + } + } + }, +} as const satisfies Prisma.AssoDaymailFindManyArgs; + +export type UnformattedAssoDaymail = Prisma.AssoDaymailGetPayload; +export type AssoDaymail = Pick & { title: Translation, message: Translation, sendDates: Date[] } + +export const generateCustomAssoDaymailModel = (prisma: PrismaClient) => + generateCustomModel(prisma, 'assoDaymail', ASSO_DAYMAIL_SELECT_FILTER, formatAssoDaymail); + +function formatAssoDaymail(_: PrismaClient, r: UnformattedAssoDaymail): AssoDaymail { + return { + ...pick(r, 'id', 'assoId', 'createdAt'), title: r.titleTranslation, message: r.bodyTranslation, sendDates: r.sendDates.map((s) => s.date) + } +} diff --git a/src/prisma/prisma.service.ts b/src/prisma/prisma.service.ts index 1f75e86f..18dffe7d 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 { generateCustomAssoDaymailModel } from '../assos/interfaces/daymail.interface'; @Injectable() export class PrismaService extends PrismaClient> { @@ -54,6 +55,7 @@ function createNormalizedEntitiesUtility(prisma: PrismaClient) { assoMembershipRole: generateCustomAssoMembershipRoleModel(prisma), ueCreditCategory: generateCustomCreditCategoryModel(prisma), apiApplication: generateCustomApplicationModel(prisma), + assoDaymail: generateCustomAssoDaymailModel(prisma), }; } @@ -117,7 +119,7 @@ export function generateCustomModel< ModelName extends ModelNameType, Raw, Formatted, - QueryArgs extends Record, + QueryArgs extends Record = object, >( prisma: PrismaClient, modelName: ModelName, diff --git a/src/prisma/types.ts b/src/prisma/types.ts index c29ba04c..565c2977 100644 --- a/src/prisma/types.ts +++ b/src/prisma/types.ts @@ -37,6 +37,7 @@ export { UserPrivacy as RawUserPrivacy, ApiApplication as RawApiApplication, ApiKey as RawApiKey, + AssoDaymail as RawAssoDaymail, } from '@prisma/client'; export { RawTranslation }; 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/validation.ts b/src/validation.ts index abb1296c..6d75902c 100644 --- a/src/validation.ts +++ b/src/validation.ts @@ -36,6 +36,8 @@ const mappedErrors = { [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 +46,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 @@ -72,11 +78,40 @@ class FutureDate implements ValidatorConstraintInterface { @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(', '); + } } +@ValidatorConstraint({ name: 'whitelistValidation', 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 }); + /** 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..6b72fbe1 100644 --- a/test/declarations.d.ts +++ b/test/declarations.d.ts @@ -3,7 +3,7 @@ import { UeComment } from 'src/ue/comments/interfaces/comment.interface'; import { UeCommentReply } from 'src/ue/comments/interfaces/comment-reply.interface'; import { UeRating } from 'src/ue/interfaces/rate.interface'; import { - FakeApiApplication, + FakeApiApplication, FakeAssoDaymail, FakeAssoMembership, FakeAssoMembershipPermission, FakeAssoMembershipRole, @@ -106,6 +106,7 @@ declare module './declarations' { expectApplication(application: FakeApiApplication): this; expectPermissions(permissions: PermissionManager): this; + expectAssoDaymail(daymail: JsonLikeVariant, created = false): this; withLanguage(language: Language): this; language: Language; diff --git a/test/declarations.ts b/test/declarations.ts index 1d8e1f5f..dc651ca2 100644 --- a/test/declarations.ts +++ b/test/declarations.ts @@ -14,7 +14,7 @@ import { FakeUeCreditCategory, FakeApiApplication, FakeAssoMembershipRole, - FakeAssoMembership, + FakeAssoMembership, FakeAssoDaymail, } from './utils/fakedb'; import { UeAnnalFile } from 'src/ue/annals/interfaces/annal.interface'; import { ConfigModule } from '../src/config/config.module'; @@ -315,6 +315,9 @@ Spec.prototype.expectPermissions = function (permissions: PermissionManager) { .mappedSort((permission) => permission.permission), } satisfies PermissionsResDto); }; +Spec.prototype.expectAssoDaymail = function (this: Spec, daymail: JsonLikeVariant, created = false) { + return this.expectStatus(created ? HttpStatus.CREATED : HttpStatus.OK).$expectRegexableJson({...pick(daymail, 'id', 'assoId', 'createdAt', 'title', 'message', 'sendDates')}) +}; export { Spec, JsonLikeVariant, FakeUeWithOfs }; diff --git a/test/e2e/assos/create-daymail.e2e-spec.ts b/test/e2e/assos/create-daymail.e2e-spec.ts new file mode 100644 index 00000000..d6faec7c --- /dev/null +++ b/test/e2e/assos/create-daymail.e2e-spec.ts @@ -0,0 +1,83 @@ +import { Dummies, e2eSuite, JsonLike } from '../../utils/test_utils'; +import { + createAsso, + createAssoMembership, + createAssoMembershipPermission, + createAssoMembershipRole, + createUser, +} from '../../utils/fakedb'; +import * as pactum from 'pactum'; +import { ERROR_CODE } from '../../../src/exceptions'; +import AssosPostDaymailReqDto from '../../../src/assos/dto/req/assos-post-daymail-req.dto'; + +const CreateDaymailE2ESpec = e2eSuite('POST /assos/:assoId/daymail', (app) => { + const asso = createAsso(app); + const userWithPermission = createUser(app); + const userWithoutPermission = createUser(app); + const permissionManageAsso = createAssoMembershipPermission(app, {id: 'manage_asso'}); + const role = createAssoMembershipRole(app, {asso}); + createAssoMembership(app, {asso, user: userWithPermission, role, permissions: [permissionManageAsso]}); + + const body: AssosPostDaymailReqDto = {title: {fr: "The title"}, message: {fr: "The message"}, dates: [new Date(Date.now() + 1e6), new Date(Date.now() + 2e6)]} + + it('should return 403 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 assoId param is not valid', () => + pactum + .spec() + .withBearerToken(userWithPermission.token) + .post('/assos/thisisnotavaliduuid/daymail') + .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}/daymail`) + .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 daymail', () => pactum + .spec() + .withBearerToken(userWithoutPermission.token) + .post(`/assos/${asso.id}/daymail`) + .withBody(body) + .expectAppError(ERROR_CODE.FORBIDDEN_ASSOS_PERMISSIONS, asso.id, 'manage_asso')); + + it('should return a 400 as the title was not provided in any language', () => + pactum + .spec() + .withBearerToken(userWithPermission.token) + .post(`/assos/${asso.id}/daymail`) + .withBody({ ...body, title: {} }) + .expectAppError(ERROR_CODE.PARAM_MISSING_EITHER, 'fr, en, es, de, zh')); + + it('should return a 400 as the body was not provided in any language', () => pactum + .spec() + .withBearerToken(userWithPermission.token) + .post(`/assos/${asso.id}/daymail`) + .withBody({ ...body, message: {} }) + .expectAppError(ERROR_CODE.PARAM_MISSING_EITHER, 'fr, en, es, de, zh')); + + it('should successfully create a new daymail', () => + pactum + .spec() + .withBearerToken(userWithPermission.token) + .post(`/assos/${asso.id}/daymail`) + .withBody(body) + .expectAssoDaymail( + { + id: JsonLike.UUID, + assoId: asso.id, + createdAt: JsonLike.DATE, + title: body.title.fr, + message: body.message.fr, + sendDates: body.dates, + }, + true, + )); +}); + +export default CreateDaymailE2ESpec; diff --git a/test/e2e/assos/index.ts b/test/e2e/assos/index.ts index 5ce31380..36bba06e 100644 --- a/test/e2e/assos/index.ts +++ b/test/e2e/assos/index.ts @@ -8,6 +8,7 @@ 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 CreateDaymailE2ESpec from './create-daymail.e2e-spec'; export default function AssoE2ESpec(app: () => INestApplication) { describe('Assos', () => { @@ -20,5 +21,6 @@ export default function AssoE2ESpec(app: () => INestApplication) { CreateAssoRoleE2ESpec(app); DeleteAssoRoleE2ESpec(app); UpdateAssoRoleE2ESpec(app); + CreateDaymailE2ESpec(app); }); } diff --git a/test/utils/fakedb.ts b/test/utils/fakedb.ts index c368ac9b..abc02ab6 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 { AssoDaymail } from '../../src/assos/interfaces/daymail.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 FakeAssoDaymail = Partial & { title: string, message: string }>; export interface FakeEntityMap { assoMembership: { From 7d76f37bbabad19283657a108560c4e17e8fb85f Mon Sep 17 00:00:00 2001 From: Teddy Roncin Date: Wed, 5 Nov 2025 22:48:13 +0100 Subject: [PATCH 2/7] Added routes GET, PATCH and DELETE for daymails, and tests for GET route --- src/assos/assos.controller.ts | 83 +++++++++++++++++- src/assos/assos.service.ts | 42 +++++++++ src/exceptions.ts | 10 +++ src/std.type.ts | 16 ++++ test/declarations.d.ts | 1 + test/declarations.ts | 9 +- test/e2e/assos/create-daymail.e2e-spec.ts | 6 +- test/e2e/assos/get-daymails.e2e-spec.ts | 85 +++++++++++++++++++ test/e2e/assos/index.ts | 2 + .../users/get-todays-birthdays.e2e-spec.ts | 2 +- test/utils/fakedb.ts | 47 +++++++++- 11 files changed, 292 insertions(+), 11 deletions(-) create mode 100644 test/e2e/assos/get-daymails.e2e-spec.ts diff --git a/src/assos/assos.controller.ts b/src/assos/assos.controller.ts index 061179a9..3008fa00 100644 --- a/src/assos/assos.controller.ts +++ b/src/assos/assos.controller.ts @@ -1,5 +1,5 @@ -import { Body, Controller, Delete, Get, Patch, Post, Put, Query } from '@nestjs/common'; -import { ApiCreatedResponse, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { Body, Controller, Delete, Get, ParseDatePipe, Patch, Post, Put, Query } from '@nestjs/common'; +import { ApiCreatedResponse, ApiOkResponse, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger'; import { ApiAppErrorResponse, paginatedResponseDto } from '../app.dto'; import { AssoMembershipRole } from './interfaces/membership-role.interface'; import { AssoMembership } from './interfaces/membership.interface'; @@ -25,6 +25,7 @@ import AssoMembershipResDto from './dto/res/assos-membership-res.dto'; import UsersService from '../users/users.service'; import AssosPostDaymailReqDto from './dto/req/assos-post-daymail-req.dto'; import DaymailResDto from './dto/res/daymail-res.dto'; +import { ConfigModule } from '../config/config.module'; @Controller('assos') @ApiTags('Assos') @@ -32,6 +33,7 @@ export class AssosController { constructor( readonly assosService: AssosService, readonly userService: UsersService, + readonly config: ConfigModule, ) {} @Get() @@ -218,14 +220,87 @@ export class AssosController { return { roles: updatedRoles.map(this.formatPartialAssoMembershipRole) }; } + @Get('/:assoId/daymail') + @ApiOperation({ description: 'Get daymails from query parameter `from` to query parameter `to`.' }) + @ApiQuery({ name: 'from', type: String, default: 'Today' }) + @ApiQuery({ name: 'to', type: String, default: `\`from\` + env.PAGINATION_PAGE_SIZE days` }) + @ApiOkResponse({ type: DaymailResDto, isArray: true }) + @ApiAppErrorResponse(ERROR_CODE.TOO_MANY_DAYS, "API can't return more than env.PAGINATION_PAGE_SIZE days of daymail") + @ApiAppErrorResponse( + ERROR_CODE.FORBIDDEN_ASSOS_PERMISSIONS, + 'The user issuing the request does not have the permission manage_asso', + ) + async getPlannedDaymails( + @ParamAsso() asso: Asso, + @Query('from', new ParseDatePipe({ optional: true })) from: Date, + @Query('to', new ParseDatePipe({ optional: true })) to: Date, + @GetUser() user: User, + ): Promise { + if (!from) from = new Date(); + from = from.dropTime(); + to = to ? to.dropTime() : from.add({ days: this.config.PAGINATION_PAGE_SIZE - 1 }); + const daysCount = Math.floor((to.getTime() - from.getTime()) / (1000 * 3600 * 24)) + 1; // Add 1 to include both `from` and `to` + if (daysCount > this.config.PAGINATION_PAGE_SIZE) + throw new AppException(ERROR_CODE.TOO_MANY_DAYS, `${daysCount}`, `${this.config.PAGINATION_PAGE_SIZE}`); + if (!(await this.assosService.hasSomeAssoPermission(asso, user.id, 'manage_asso'))) + throw new AppException(ERROR_CODE.FORBIDDEN_ASSOS_PERMISSIONS, asso.id, 'manage_asso'); + return (await this.assosService.getDaymails(asso.id, from, to)).mappedSort((daymail) => [daymail.sendDates[0]]); + } + @Post('/:assoId/daymail') - @ApiOperation({ description: 'Create a message for the given association' }) - async createDaymail(@ParamAsso() asso: Asso, @Body() dto: AssosPostDaymailReqDto, @GetUser() user: User): Promise { + @ApiOperation({ description: 'Create a daymail for the given association.' }) + @ApiCreatedResponse({ type: DaymailResDto }) + @ApiAppErrorResponse( + ERROR_CODE.FORBIDDEN_ASSOS_PERMISSIONS, + 'The user issuing the request does not have the permission manage_asso', + ) + async createDaymail( + @ParamAsso() asso: Asso, + @Body() dto: AssosPostDaymailReqDto, + @GetUser() user: User, + ): Promise { if (!(await this.assosService.hasSomeAssoPermission(asso, user.id, 'manage_asso'))) throw new AppException(ERROR_CODE.FORBIDDEN_ASSOS_PERMISSIONS, asso.id, 'manage_asso'); return this.assosService.addDaymail(asso.id, dto.title, dto.message, dto.dates); } + @Patch('/:assoId/daymail/:daymailId') + @ApiOperation({ description: 'Update a daymail for the given association.' }) + @ApiOkResponse({ type: DaymailResDto }) + @ApiAppErrorResponse(ERROR_CODE.NO_SUCH_DAYMAIL) + @ApiAppErrorResponse( + ERROR_CODE.FORBIDDEN_ASSOS_PERMISSIONS, + 'The user issuing the request does not have the permission manage_asso', + ) + async updateDaymail( + @ParamAsso() asso: Asso, + @UUIDParam('daymailId') daymailId, + @Body() dto: AssosPostDaymailReqDto, + @GetUser() user: User, + ): Promise { + if (!(await this.assosService.doesDaymailExist(asso.id, daymailId))) + throw new AppException(ERROR_CODE.NO_SUCH_DAYMAIL, 'daymailId'); + if (!(await this.assosService.hasSomeAssoPermission(asso, user.id, 'manage_asso'))) + throw new AppException(ERROR_CODE.FORBIDDEN_ASSOS_PERMISSIONS, asso.id, 'manage_asso'); + return this.assosService.updateDaymail(daymailId, pick(dto, 'title', 'message', 'dates')); + } + + @Delete('/:assoId/daymail/:daymailId') + @ApiOperation({ description: 'Delete a daymail for the given association.' }) + @ApiOkResponse({ type: DaymailResDto }) + @ApiAppErrorResponse(ERROR_CODE.NO_SUCH_DAYMAIL) + @ApiAppErrorResponse( + ERROR_CODE.FORBIDDEN_ASSOS_PERMISSIONS, + 'The user issuing the request does not have the permission manage_asso', + ) + async deleteDaymail(@ParamAsso() asso: Asso, @UUIDParam('daymailId') daymailId, @GetUser() user: User): Promise { + if (!(await this.assosService.doesDaymailExist(asso.id, daymailId))) + throw new AppException(ERROR_CODE.NO_SUCH_DAYMAIL, 'daymailId'); + if (!(await this.assosService.hasSomeAssoPermission(asso, user.id, 'manage_asso'))) + throw new AppException(ERROR_CODE.FORBIDDEN_ASSOS_PERMISSIONS, asso.id, 'manage_asso'); + return this.assosService.deleteDaymail(daymailId); + } + formatAssoOverview(asso: Asso): AssoOverviewResDto { return { ...pick(asso, 'id', 'name', 'logo', 'president'), diff --git a/src/assos/assos.service.ts b/src/assos/assos.service.ts index 8b340446..407e9f81 100644 --- a/src/assos/assos.service.ts +++ b/src/assos/assos.service.ts @@ -288,6 +288,26 @@ export class AssosService { }); } + async getDaymails(assoId: string, from: Date, to: Date): Promise { + const daymails = await this.prisma.normalize.assoDaymail.findMany({ + where: { + assoId, + sendDates: { + some: { + date: { + gte: from, + lte: to, + } + } + } + } + }); + for (const daymail of daymails) { + daymail.sendDates = daymail.sendDates.filter((date) => from <= date && date <= to); + } + return daymails; + } + async addDaymail(assoId: string, title: Translation, message: Translation, dates: Date[]): Promise { return this.prisma.normalize.assoDaymail.create({ data: { @@ -298,4 +318,26 @@ export class AssosService { } }); } + + async doesDaymailExist(daymailId: string, assoId?: string): Promise { + return (await this.prisma.assoDaymail.count({ where: { id: daymailId, assoId } })) > 0; + } + + async updateDaymail(daymailId: string, fields: { title: Translation, message: Translation, dates: Date[] }): Promise { + return this.prisma.normalize.assoDaymail.update({ + where: { id: daymailId }, + data: { + titleTranslation: { update: fields.title }, + bodyTranslation: { update: fields.message }, + sendDates: fields.dates ? { + deleteMany: {}, + createMany: { data: fields.dates.map((date) => ({ date })) } + } : {}, + }, + }); + } + + async deleteDaymail(daymailId: string): Promise { + return this.prisma.normalize.assoDaymail.delete({ where: { id: daymailId } }); + } } diff --git a/src/exceptions.ts b/src/exceptions.ts index 28c17707..482af23b 100644 --- a/src/exceptions.ts +++ b/src/exceptions.ts @@ -43,6 +43,7 @@ export const enum ERROR_CODE { PARAM_DOES_NOT_MATCH_REGEX = 2102, NO_FIELD_PROVIDED = 2201, WIDGET_OVERLAPPING = 2301, + TOO_MANY_DAYS = 2302, FILE_INVALID_TYPE = 2901, FILE_TOO_HEAVY = 2902, FORBIDDEN_NOT_ENOUGH_API_PERMISSIONS = 3001, @@ -84,6 +85,7 @@ export const enum ERROR_CODE { NO_SUCH_UE_AT_SEMESTER = 4414, NO_SUCH_ASSO_ROLE = 4415, NO_SUCH_ASSO_MEMBERSHIP = 4416, + NO_SUCH_DAYMAIL = 4417, ANNAL_ALREADY_UPLOADED = 4901, RESOURCE_UNAVAILABLE = 4902, RESOURCE_INVALID_TYPE = 4903, @@ -226,6 +228,10 @@ export const ErrorData = Object.freeze({ message: 'Widgets at index % and % are overlapping', httpCode: HttpStatus.BAD_REQUEST, }, + [ERROR_CODE.TOO_MANY_DAYS]: { + message: 'Too many days in the interval, requested % days, maximum authorized is %', + httpCode: HttpStatus.BAD_REQUEST, + }, [ERROR_CODE.FORBIDDEN_NOT_ENOUGH_API_PERMISSIONS]: { message: 'Missing permission %', httpCode: HttpStatus.FORBIDDEN, @@ -382,6 +388,10 @@ export const ErrorData = Object.freeze({ message: 'No such membership in asso: %', httpCode: HttpStatus.NOT_FOUND, }, + [ERROR_CODE.NO_SUCH_DAYMAIL]: { + message: 'No such daymail 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/std.type.ts b/src/std.type.ts index 6c5209cd..80bf0d0e 100644 --- a/src/std.type.ts +++ b/src/std.type.ts @@ -1,3 +1,6 @@ +import { Reflector } from '@nestjs/core'; +import { AppException } from './exceptions'; + declare global { interface Array { /** @@ -43,6 +46,11 @@ declare global { keys(o: O): (keyof O)[]; entries(o: O): Array<[keyof O, O[keyof O]]>; } + + interface Date { + dropTime(): Date; + add({ years, months, days }: { years?: number, months?: number, days?: number }): Date; + } } Array.prototype.groupyBy = function ( @@ -84,4 +92,12 @@ 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 } = {}) { + return new Date(Date.UTC(this.getUTCFullYear() + years, this.getUTCMonth() + months, this.getUTCDate() + days)); +} + export {}; diff --git a/test/declarations.d.ts b/test/declarations.d.ts index 6b72fbe1..ecc4dcfd 100644 --- a/test/declarations.d.ts +++ b/test/declarations.d.ts @@ -107,6 +107,7 @@ declare module './declarations' { expectPermissions(permissions: PermissionManager): this; expectAssoDaymail(daymail: JsonLikeVariant, created = false): this; + expectAssoDaymails(daymails: JsonLikeVariant[]): this; withLanguage(language: Language): this; language: Language; diff --git a/test/declarations.ts b/test/declarations.ts index dc651ca2..7b791c1c 100644 --- a/test/declarations.ts +++ b/test/declarations.ts @@ -316,7 +316,14 @@ Spec.prototype.expectPermissions = function (permissions: PermissionManager) { } satisfies PermissionsResDto); }; Spec.prototype.expectAssoDaymail = function (this: Spec, daymail: JsonLikeVariant, created = false) { - return this.expectStatus(created ? HttpStatus.CREATED : HttpStatus.OK).$expectRegexableJson({...pick(daymail, 'id', 'assoId', 'createdAt', 'title', 'message', 'sendDates')}) + return this.expectStatus(created ? HttpStatus.CREATED : HttpStatus.OK).$expectRegexableJson({...pick(daymail, 'id', 'assoId', 'createdAt', 'sendDates'), title: daymail.title[this.language], message: daymail.message[this.language]}); +}; +Spec.prototype.expectAssoDaymails = function (this: Spec, daymails: JsonLikeVariant[]) { + return this.expectStatus(HttpStatus.OK).$expectRegexableJson(daymails.map((daymail) => ({ + ...pick(daymail, 'id', 'assoId', 'createdAt', 'sendDates'), + title: daymail.title[this.language], + message: daymail.message[this.language] + }))); }; export { Spec, JsonLikeVariant, FakeUeWithOfs }; diff --git a/test/e2e/assos/create-daymail.e2e-spec.ts b/test/e2e/assos/create-daymail.e2e-spec.ts index d6faec7c..855e4640 100644 --- a/test/e2e/assos/create-daymail.e2e-spec.ts +++ b/test/e2e/assos/create-daymail.e2e-spec.ts @@ -21,7 +21,7 @@ const CreateDaymailE2ESpec = e2eSuite('POST /assos/:assoId/daymail', (app) => { const body: AssosPostDaymailReqDto = {title: {fr: "The title"}, message: {fr: "The message"}, dates: [new Date(Date.now() + 1e6), new Date(Date.now() + 2e6)]} it('should return 403 as user is not authenticated', () => - pactum.spec().post(`/assos/${asso.id}/members`).expectAppError(ERROR_CODE.NOT_LOGGED_IN)); + pactum.spec().post(`/assos/${asso.id}/daymail`).expectAppError(ERROR_CODE.NOT_LOGGED_IN)); it('should return a 400 as the assoId param is not valid', () => pactum @@ -72,8 +72,8 @@ const CreateDaymailE2ESpec = e2eSuite('POST /assos/:assoId/daymail', (app) => { id: JsonLike.UUID, assoId: asso.id, createdAt: JsonLike.DATE, - title: body.title.fr, - message: body.message.fr, + title: body.title, + message: body.message, sendDates: body.dates, }, true, diff --git a/test/e2e/assos/get-daymails.e2e-spec.ts b/test/e2e/assos/get-daymails.e2e-spec.ts new file mode 100644 index 00000000..9a293551 --- /dev/null +++ b/test/e2e/assos/get-daymails.e2e-spec.ts @@ -0,0 +1,85 @@ +import { Dummies, e2eSuite } from '../../utils/test_utils'; +import { + createAsso, createAssoDaymail, + createAssoMembership, + createAssoMembershipPermission, + createAssoMembershipRole, + createUser, +} from '../../utils/fakedb'; +import * as pactum from 'pactum'; +import { ERROR_CODE } from '../../../src/exceptions'; +import { ConfigModule } from '../../../src/config/config.module'; + +const GetDaymailsE2ESpec = e2eSuite('GET /assos/:assoId/daymail', (app) => { + const userWithPermission = createUser(app); + const userWithoutPermission = createUser(app); + const permissionManageAsso = createAssoMembershipPermission(app, { id: 'manage_asso' }); + + 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 daymailAsso1 = createAssoDaymail(app, { asso }, { + sendDates: [ + new Date(Date.UTC(2025, 8, 30)), + new Date(Date.UTC(2025, 9, 1)), + new Date(Date.UTC(2025, 9, 2)), + new Date(Date.UTC(2025, 9, 10)), + new Date(Date.UTC(2025, 9, 11))] + }); + const daymailAsso2 = createAssoDaymail(app, { asso }, { sendDates: [new Date(Date.UTC(2025, 9, 5))] }); + + // Create a daymail 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] }); + createAssoDaymail(app, { asso: otherAsso }, { sendDates: [new Date(Date.UTC(2025, 9, 6))] }); + + it('should return 403 as user is not authenticated', () => + pactum.spec().get(`/assos/${asso.id}/daymail/`).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/daymail') + .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}/daymail`) + .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 403 as user does not have the permission to see the daymails', () => pactum + .spec() + .withBearerToken(userWithoutPermission.token) + .get(`/assos/${asso.id}/daymail`) + .withQueryParams({ from: '2025-10-01T00:00:00Z', to: '2025-10-10T00:00:00Z' }) + .expectAppError(ERROR_CODE.FORBIDDEN_ASSOS_PERMISSIONS, asso.id, 'manage_asso')); + + it('should fail as there are too many days requested', () => + pactum + .spec() + .withBearerToken(userWithPermission.token) + .get(`/assos/${asso.id}/daymail`) + .withQueryParams({ from: '2024-10-01T00:00:00Z', to: '2026-10-01T00:00:00Z' }) + .expectAppError(ERROR_CODE.TOO_MANY_DAYS, `${2 * 365 + 1}`, `${app().get(ConfigModule).PAGINATION_PAGE_SIZE}`)); + + it('should return daymails for `asso` between October, 1st and October, 10th', () => { + return pactum + .spec() + .withBearerToken(userWithPermission.token) + .get(`/assos/${asso.id}/daymail`) + .withQueryParams({ from: '2025-10-01T00:00:00Z', to: '2025-10-10T00:00:00Z' }) + .expectAssoDaymails([ + { ...daymailAsso1, sendDates: [new Date(Date.UTC(2025, 9, 1)), new Date(Date.UTC(2025, 9, 2)), new Date(Date.UTC(2025, 9, 10))] }, + { ...daymailAsso2, sendDates: [new Date(Date.UTC(2025, 9, 5))] }, + ]); + }); +}); + +export default GetDaymailsE2ESpec; diff --git a/test/e2e/assos/index.ts b/test/e2e/assos/index.ts index 36bba06e..037a54b8 100644 --- a/test/e2e/assos/index.ts +++ b/test/e2e/assos/index.ts @@ -9,6 +9,7 @@ import CreateAssoRoleE2ESpec from './create-role.e2e-spec'; import DeleteAssoRoleE2ESpec from './delete-role.e2e-spec'; import UpdateAssoRoleE2ESpec from './update-role.e2e-spec'; import CreateDaymailE2ESpec from './create-daymail.e2e-spec'; +import GetDaymailsE2ESpec from './get-daymails.e2e-spec'; export default function AssoE2ESpec(app: () => INestApplication) { describe('Assos', () => { @@ -21,6 +22,7 @@ export default function AssoE2ESpec(app: () => INestApplication) { CreateAssoRoleE2ESpec(app); DeleteAssoRoleE2ESpec(app); UpdateAssoRoleE2ESpec(app); + GetDaymailsE2ESpec(app); CreateDaymailE2ESpec(app); }); } 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 abc02ab6..4e9aa7fc 100644 --- a/test/utils/fakedb.ts +++ b/test/utils/fakedb.ts @@ -45,6 +45,7 @@ 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 { AssoDaymail } from '../../src/assos/interfaces/daymail.interface'; +import { isDate } from 'node:util/types'; /** * The fake entities can be used like normal entities in the it(string, () => void) functions. @@ -119,7 +120,7 @@ export type FakeHomepageWidget = Partial; export type FakeApiApplication = Partial> & { owner: { id: string; firstName: string; lastName: string }; }; -export type FakeAssoDaymail = Partial & { title: string, message: string }>; +export type FakeAssoDaymail = Partial>; export interface FakeEntityMap { assoMembership: { @@ -145,6 +146,11 @@ export interface FakeEntityMap { entity: FakeAsso; params: CreateAssoParameters; }; + assoDaymail: { + entity: FakeAssoDaymail; + params: CreateAssoDaymailParameters; + deps: { asso: FakeAsso }; + } timetableEntryOverride: { entity: Partial; params: CreateTimetableEntryOverrideParameters; @@ -545,6 +551,43 @@ export const createAsso = entityFaker( }, ); +export type CreateAssoDaymailParameters = FakeAssoDaymail; +export const createAssoDaymail = entityFaker( + 'assoDaymail', + { + sendDates: [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.assoDaymail.create({ + data: { + id: params.id, + titleTranslation: { create: params.title }, + bodyTranslation: { create: params.message }, + asso: { connect: { id: deps.asso.id } }, + sendDates: { + createMany: { + data: params.sendDates.map((date) => ({ date: date.toISOString() })) + }, + }, + }, + }) + } +); + export type CreateTimetableGroupParams = { users?: Array<{ user: FakeUser; priority: number }> }; /** * Creates a timetableGroup in the database. @@ -1148,7 +1191,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') && !isDate(params[key])) { deeplyCallFunctions(params[key]); } } From 3f2a56ff9a0cd80fa36a5024b00a527c29a1c480 Mon Sep 17 00:00:00 2001 From: Teddy Roncin Date: Thu, 6 Nov 2025 23:15:10 +0100 Subject: [PATCH 3/7] Tests for PATCH and DELETE route + added another check to the GET route (from <= to) --- src/app.dto.ts | 2 +- src/assos/assos.controller.ts | 10 +-- src/assos/interfaces/daymail.interface.ts | 2 +- src/exceptions.ts | 5 ++ src/std.type.ts | 15 +++- test/e2e/assos/delete-daymail.e2e-spec.ts | 64 +++++++++++++++++ test/e2e/assos/get-daymails.e2e-spec.ts | 14 ++-- test/e2e/assos/index.ts | 4 ++ test/e2e/assos/update-daymail.e2e-spec.ts | 86 +++++++++++++++++++++++ 9 files changed, 189 insertions(+), 13 deletions(-) create mode 100644 test/e2e/assos/delete-daymail.e2e-spec.ts create mode 100644 test/e2e/assos/update-daymail.e2e-spec.ts diff --git a/src/app.dto.ts b/src/app.dto.ts index 8803b583..20502ed7 100644 --- a/src/app.dto.ts +++ b/src/app.dto.ts @@ -3,7 +3,7 @@ import { applyDecorators, HttpStatus, Injectable } from '@nestjs/common'; import * as ApiResponses from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger'; import { Type } from '@nestjs/common/interfaces/type.interface'; -import { IsOptional, IsString, registerDecorator, ValidationArguments, ValidationOptions } from 'class-validator'; +import { IsOptional, IsString } from 'class-validator'; import { HasSomeAmong } from './validation'; // 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 diff --git a/src/assos/assos.controller.ts b/src/assos/assos.controller.ts index 3008fa00..592ed337 100644 --- a/src/assos/assos.controller.ts +++ b/src/assos/assos.controller.ts @@ -239,6 +239,7 @@ export class AssosController { if (!from) from = new Date(); from = from.dropTime(); to = to ? to.dropTime() : from.add({ days: this.config.PAGINATION_PAGE_SIZE - 1 }); + if (from > to) throw new AppException(ERROR_CODE.PARAM_DATE_MUST_BE_AFTER, to.toISOString(), from.toISOString()); const daysCount = Math.floor((to.getTime() - from.getTime()) / (1000 * 3600 * 24)) + 1; // Add 1 to include both `from` and `to` if (daysCount > this.config.PAGINATION_PAGE_SIZE) throw new AppException(ERROR_CODE.TOO_MANY_DAYS, `${daysCount}`, `${this.config.PAGINATION_PAGE_SIZE}`); @@ -267,6 +268,7 @@ export class AssosController { @Patch('/:assoId/daymail/:daymailId') @ApiOperation({ description: 'Update a daymail for the given association.' }) @ApiOkResponse({ type: DaymailResDto }) + @ApiAppErrorResponse(ERROR_CODE.NO_SUCH_ASSO) @ApiAppErrorResponse(ERROR_CODE.NO_SUCH_DAYMAIL) @ApiAppErrorResponse( ERROR_CODE.FORBIDDEN_ASSOS_PERMISSIONS, @@ -278,8 +280,8 @@ export class AssosController { @Body() dto: AssosPostDaymailReqDto, @GetUser() user: User, ): Promise { - if (!(await this.assosService.doesDaymailExist(asso.id, daymailId))) - throw new AppException(ERROR_CODE.NO_SUCH_DAYMAIL, 'daymailId'); + if (!(await this.assosService.doesDaymailExist(daymailId, asso.id))) + throw new AppException(ERROR_CODE.NO_SUCH_DAYMAIL, daymailId); if (!(await this.assosService.hasSomeAssoPermission(asso, user.id, 'manage_asso'))) throw new AppException(ERROR_CODE.FORBIDDEN_ASSOS_PERMISSIONS, asso.id, 'manage_asso'); return this.assosService.updateDaymail(daymailId, pick(dto, 'title', 'message', 'dates')); @@ -294,8 +296,8 @@ export class AssosController { 'The user issuing the request does not have the permission manage_asso', ) async deleteDaymail(@ParamAsso() asso: Asso, @UUIDParam('daymailId') daymailId, @GetUser() user: User): Promise { - if (!(await this.assosService.doesDaymailExist(asso.id, daymailId))) - throw new AppException(ERROR_CODE.NO_SUCH_DAYMAIL, 'daymailId'); + if (!(await this.assosService.doesDaymailExist(daymailId, asso.id))) + throw new AppException(ERROR_CODE.NO_SUCH_DAYMAIL, daymailId); if (!(await this.assosService.hasSomeAssoPermission(asso, user.id, 'manage_asso'))) throw new AppException(ERROR_CODE.FORBIDDEN_ASSOS_PERMISSIONS, asso.id, 'manage_asso'); return this.assosService.deleteDaymail(daymailId); diff --git a/src/assos/interfaces/daymail.interface.ts b/src/assos/interfaces/daymail.interface.ts index d22f8ee6..555157ed 100644 --- a/src/assos/interfaces/daymail.interface.ts +++ b/src/assos/interfaces/daymail.interface.ts @@ -1,7 +1,7 @@ import { Prisma, PrismaClient } from '@prisma/client'; import { generateCustomModel } from '../../prisma/prisma.service'; import { pick, translationSelect } from '../../utils'; -import { RawAssoDaymail, Translation } from '../../prisma/types'; +import { Translation } from '../../prisma/types'; const ASSO_DAYMAIL_SELECT_FILTER = { select: { diff --git a/src/exceptions.ts b/src/exceptions.ts index 482af23b..189217be 100644 --- a/src/exceptions.ts +++ b/src/exceptions.ts @@ -40,6 +40,7 @@ export const enum ERROR_CODE { BODY_MISSING = 2022, PARAM_PAST_DATE = 2023, PARAM_MISSING_EITHER = 2024, + PARAM_DATE_MUST_BE_AFTER = 2025, PARAM_DOES_NOT_MATCH_REGEX = 2102, NO_FIELD_PROVIDED = 2201, WIDGET_OVERLAPPING = 2301, @@ -208,6 +209,10 @@ 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_DOES_NOT_MATCH_REGEX]: { message: 'The following parameters must match the regex "%": %', httpCode: HttpStatus.BAD_REQUEST, diff --git a/src/std.type.ts b/src/std.type.ts index 80bf0d0e..fd726bdf 100644 --- a/src/std.type.ts +++ b/src/std.type.ts @@ -1,6 +1,3 @@ -import { Reflector } from '@nestjs/core'; -import { AppException } from './exceptions'; - declare global { interface Array { /** @@ -48,7 +45,19 @@ declare global { } 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 and days 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. + * @returns A new Date offset by the specified number of UTC years, months and days. + */ add({ years, months, days }: { years?: number, months?: number, days?: number }): Date; } } diff --git a/test/e2e/assos/delete-daymail.e2e-spec.ts b/test/e2e/assos/delete-daymail.e2e-spec.ts new file mode 100644 index 00000000..6e9db295 --- /dev/null +++ b/test/e2e/assos/delete-daymail.e2e-spec.ts @@ -0,0 +1,64 @@ +import { Dummies, e2eSuite } from '../../utils/test_utils'; +import { + createAsso, createAssoDaymail, + createAssoMembership, + createAssoMembershipPermission, + createAssoMembershipRole, + createUser, +} from '../../utils/fakedb'; +import * as pactum from 'pactum'; +import { ERROR_CODE } from '../../../src/exceptions'; + +const DeleteDaymailE2ESpec = e2eSuite('DELETE /assos/:assoId/daymail/:daymailId', (app) => { + const userWithPermission = createUser(app); + const userWithoutPermission = createUser(app); + const permissionManageAsso = createAssoMembershipPermission(app, { id: 'manage_asso' }); + + const asso = createAsso(app); + const role = createAssoMembershipRole(app, { asso }); + createAssoMembership(app, { asso, user: userWithPermission, role, permissions: [permissionManageAsso] }); + const daymail = createAssoDaymail(app, { asso }, { sendDates: [new Date(Date.UTC(2025, 9, 5))] }); + + const otherAsso = createAsso(app); + const otherAssoRole = createAssoMembershipRole(app, { asso }); + createAssoMembership(app, { asso: otherAsso, user: userWithPermission, role: otherAssoRole, permissions: [permissionManageAsso] }); + + it('should return 403 as user is not authenticated', () => + pactum.spec().delete(`/assos/${asso.id}/daymail/${daymail.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}/daymail/${daymail.id}`) + .expectAppError(ERROR_CODE.NO_SUCH_ASSO, Dummies.UUID)); + + it('should return a 404 as daymail is not found', () => pactum + .spec() + .withBearerToken(userWithPermission.token) + .delete(`/assos/${asso.id}/daymail/${Dummies.UUID}`) + .expectAppError(ERROR_CODE.NO_SUCH_DAYMAIL, Dummies.UUID)) + + it('should return a 404 as the daymail does not belong to the asso', () => pactum + .spec() + .withBearerToken(userWithPermission.token) + .delete(`/assos/${otherAsso.id}/daymail/${daymail.id}`) + .expectAppError(ERROR_CODE.NO_SUCH_DAYMAIL, daymail.id)); + + it('should return a 403 as user does not have the permission to update the daymails', () => pactum + .spec() + .withBearerToken(userWithoutPermission.token) + .delete(`/assos/${asso.id}/daymail/${daymail.id}`) + .expectAppError(ERROR_CODE.FORBIDDEN_ASSOS_PERMISSIONS, asso.id, 'manage_asso')); + + it('should delete the daymail', async () => { + await pactum + .spec() + .withBearerToken(userWithPermission.token) + .delete(`/assos/${asso.id}/daymail/${daymail.id}`) + .expectAssoDaymail(daymail); + await createAssoDaymail(app, { asso }, daymail, true); + }); +}); + +export default DeleteDaymailE2ESpec; diff --git a/test/e2e/assos/get-daymails.e2e-spec.ts b/test/e2e/assos/get-daymails.e2e-spec.ts index 9a293551..3248a1bf 100644 --- a/test/e2e/assos/get-daymails.e2e-spec.ts +++ b/test/e2e/assos/get-daymails.e2e-spec.ts @@ -54,6 +54,14 @@ const GetDaymailsE2ESpec = e2eSuite('GET /assos/:assoId/daymail', (app) => { .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}/daymail`) + .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 daymails', () => pactum .spec() .withBearerToken(userWithoutPermission.token) @@ -69,8 +77,7 @@ const GetDaymailsE2ESpec = e2eSuite('GET /assos/:assoId/daymail', (app) => { .withQueryParams({ from: '2024-10-01T00:00:00Z', to: '2026-10-01T00:00:00Z' }) .expectAppError(ERROR_CODE.TOO_MANY_DAYS, `${2 * 365 + 1}`, `${app().get(ConfigModule).PAGINATION_PAGE_SIZE}`)); - it('should return daymails for `asso` between October, 1st and October, 10th', () => { - return pactum + it('should return daymails for `asso` between October, 1st and October, 10th', () => pactum .spec() .withBearerToken(userWithPermission.token) .get(`/assos/${asso.id}/daymail`) @@ -78,8 +85,7 @@ const GetDaymailsE2ESpec = e2eSuite('GET /assos/:assoId/daymail', (app) => { .expectAssoDaymails([ { ...daymailAsso1, sendDates: [new Date(Date.UTC(2025, 9, 1)), new Date(Date.UTC(2025, 9, 2)), new Date(Date.UTC(2025, 9, 10))] }, { ...daymailAsso2, sendDates: [new Date(Date.UTC(2025, 9, 5))] }, - ]); - }); + ])); }); export default GetDaymailsE2ESpec; diff --git a/test/e2e/assos/index.ts b/test/e2e/assos/index.ts index 037a54b8..cc0fa94d 100644 --- a/test/e2e/assos/index.ts +++ b/test/e2e/assos/index.ts @@ -10,6 +10,8 @@ import DeleteAssoRoleE2ESpec from './delete-role.e2e-spec'; import UpdateAssoRoleE2ESpec from './update-role.e2e-spec'; import CreateDaymailE2ESpec from './create-daymail.e2e-spec'; import GetDaymailsE2ESpec from './get-daymails.e2e-spec'; +import UpdateDaymailE2ESpec from './update-daymail.e2e-spec'; +import DeleteDaymailE2ESpec from './delete-daymail.e2e-spec'; export default function AssoE2ESpec(app: () => INestApplication) { describe('Assos', () => { @@ -24,5 +26,7 @@ export default function AssoE2ESpec(app: () => INestApplication) { UpdateAssoRoleE2ESpec(app); GetDaymailsE2ESpec(app); CreateDaymailE2ESpec(app); + UpdateDaymailE2ESpec(app); + DeleteDaymailE2ESpec(app); }); } diff --git a/test/e2e/assos/update-daymail.e2e-spec.ts b/test/e2e/assos/update-daymail.e2e-spec.ts new file mode 100644 index 00000000..f83cbedf --- /dev/null +++ b/test/e2e/assos/update-daymail.e2e-spec.ts @@ -0,0 +1,86 @@ +import { Dummies, e2eSuite } from '../../utils/test_utils'; +import { + createAsso, createAssoDaymail, + 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 UpdateDaymailE2ESpec = e2eSuite('PATCH /assos/:assoId/daymail/:daymailId', (app) => { + const userWithPermission = createUser(app); + const userWithoutPermission = createUser(app); + const permissionManageAsso = createAssoMembershipPermission(app, { id: 'manage_asso' }); + + const asso = createAsso(app); + const role = createAssoMembershipRole(app, { asso }); + createAssoMembership(app, { asso, user: userWithPermission, role, permissions: [permissionManageAsso] }); + const initialSendDate = new Date().dropTime().add({ days: 2 }); + const daymail = createAssoDaymail(app, { asso }, { sendDates: [initialSendDate] }); + + const otherAsso = createAsso(app); + const otherAssoRole = createAssoMembershipRole(app, { asso }); + createAssoMembership(app, { asso: otherAsso, user: userWithPermission, role: otherAssoRole, permissions: [permissionManageAsso] }); + + const generateBody = () => ({ + title: pick(faker.db.translation(), 'fr', 'en', 'zh'), + message: pick(faker.db.translation(), 'de', 'es'), + dates: [daymail.sendDates[0].add({ days: 1 })] + }) + + it('should return 403 as user is not authenticated', () => + pactum.spec().patch(`/assos/${asso.id}/daymail/${daymail.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}/daymail/${daymail.id}`) + .withJson(generateBody()) + .expectAppError(ERROR_CODE.NO_SUCH_ASSO, Dummies.UUID)); + + it('should return a 404 as daymail is not found', () => pactum + .spec() + .withBearerToken(userWithPermission.token) + .patch(`/assos/${asso.id}/daymail/${Dummies.UUID}`) + .withJson(generateBody()) + .expectAppError(ERROR_CODE.NO_SUCH_DAYMAIL, Dummies.UUID)) + + it('should return a 404 as the daymail does not belong to the asso', () => pactum + .spec() + .withBearerToken(userWithPermission.token) + .patch(`/assos/${otherAsso.id}/daymail/${daymail.id}`) + .withJson(generateBody()) + .expectAppError(ERROR_CODE.NO_SUCH_DAYMAIL, daymail.id)); + + it('should return a 403 as user does not have the permission to update the daymails', () => pactum + .spec() + .withBearerToken(userWithoutPermission.token) + .patch(`/assos/${asso.id}/daymail/${daymail.id}`) + .withJson(generateBody()) + .expectAppError(ERROR_CODE.FORBIDDEN_ASSOS_PERMISSIONS, asso.id, 'manage_asso')); + + it('should update the daymail', async () => { + const body = generateBody(); + await pactum + .spec() + .withBearerToken(userWithPermission.token) + .patch(`/assos/${asso.id}/daymail/${daymail.id}`) + .withJson(body) + .expectAssoDaymail({ + ...daymail, + title: {...daymail.title, ...body.title}, + message: {...daymail.message, ...body.message}, + sendDates: body.dates, + }); + daymail.title = {...daymail.title, ...body.title}; + daymail.message = {...daymail.message, ...body.message}; + daymail.sendDates = body.dates; + }); +}); + +export default UpdateDaymailE2ESpec; From 398571610a7b17588bfe5040448784b66f2c113c Mon Sep 17 00:00:00 2001 From: Teddy Roncin Date: Fri, 21 Nov 2025 13:59:46 +0100 Subject: [PATCH 4/7] Review, daymails are now weeklies --- .env.dist | 6 +- .env.test.dist | 6 +- prisma/schema.prisma | 11 +- src/app.dto.ts | 23 +--- src/assos/assos.controller.ts | 114 ++++++++++++------ src/assos/assos.service.ts | 72 ++++++----- src/assos/dto/req/asso-get-daymail-req.dto.ts | 17 +++ .../dto/req/assos-post-daymail-req.dto.ts | 9 +- src/assos/dto/res/daymail-info-res.dto.ts | 4 + src/assos/dto/res/daymail-res.dto.ts | 2 +- src/assos/interfaces/daymail.interface.ts | 14 +-- src/config/config.module.ts | 5 +- src/exceptions.ts | 21 +++- src/std.type.ts | 32 ++++- src/utils.ts | 6 + src/validation.ts | 10 ++ test/declarations.d.ts | 2 +- test/declarations.ts | 20 ++- test/e2e/assos/create-daymail.e2e-spec.ts | 51 ++++++-- test/e2e/assos/delete-daymail.e2e-spec.ts | 19 ++- test/e2e/assos/get-daymail-info.e2e-spec.ts | 21 ++++ test/e2e/assos/get-daymails.e2e-spec.ts | 41 ++----- test/e2e/assos/index.ts | 2 + test/e2e/assos/update-daymail.e2e-spec.ts | 42 ++++--- test/utils/fakedb.ts | 10 +- 25 files changed, 361 insertions(+), 199 deletions(-) create mode 100644 src/assos/dto/req/asso-get-daymail-req.dto.ts create mode 100644 src/assos/dto/res/daymail-info-res.dto.ts create mode 100644 test/e2e/assos/get-daymail-info.e2e-spec.ts diff --git a/.env.dist b/.env.dist index 96900002..3d9bae34 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" + +# Daymail +DAYMAIL_SEND_DAY=1 +DAYMAIL_SEND_HOUR=8 diff --git a/.env.test.dist b/.env.test.dist index 0ee7f187..e460860a 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" + +# Daymail +DAYMAIL_SEND_DAY=1 +DAYMAIL_SEND_HOUR=8 diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f8c5d283..30360f13 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -107,20 +107,13 @@ model AssoDaymail { 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) - sendDates AssoDaymailSendDate[] -} - -model AssoDaymailSendDate { - daymailId String - date DateTime - - daymail AssoDaymail @relation(fields: [daymailId], references: [id], onDelete: Cascade) - @@id([daymailId, date]) + @@unique([assoId, date]) } model Event { diff --git a/src/app.dto.ts b/src/app.dto.ts index 20502ed7..ee0ec47f 100644 --- a/src/app.dto.ts +++ b/src/app.dto.ts @@ -5,6 +5,7 @@ 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 @@ -47,27 +48,7 @@ export function paginatedResponseDto(Base: TBase) { return mixin(ResponseDto, `${Base.name}$Paginated`); // This is important otherwise you will get always the same instance } -/*export function AtLeastOneTranslation(validationOptions?: ValidationOptions) { - return function (constructor: Function) { - registerDecorator({ - name: 'atLeastOneTranslation', - target: constructor, - propertyName: propertyName, - options: validationOptions, - validator: { - validate(value: any, args: ValidationArguments) { - if (!value || typeof value !== 'object') return false; - - return Object.values(value).some(v => typeof v === 'string' && v.trim().length > 0); - }, - defaultMessage(args: ValidationArguments) { - return 'At least one translation must be provided'; - }, - }, - }); - }; -}*/ -@HasSomeAmong('fr', 'en', 'es', 'de', 'zh') +@HasSomeAmong(...languages) export class TranslationReqDto { @IsString() @IsOptional() diff --git a/src/assos/assos.controller.ts b/src/assos/assos.controller.ts index 592ed337..9d3bd223 100644 --- a/src/assos/assos.controller.ts +++ b/src/assos/assos.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Delete, Get, ParseDatePipe, Patch, Post, Put, Query } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Patch, Post, Put, Query } from '@nestjs/common'; import { ApiCreatedResponse, ApiOkResponse, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger'; import { ApiAppErrorResponse, paginatedResponseDto } from '../app.dto'; import { AssoMembershipRole } from './interfaces/membership-role.interface'; @@ -26,6 +26,9 @@ import UsersService from '../users/users.service'; import AssosPostDaymailReqDto from './dto/req/assos-post-daymail-req.dto'; import DaymailResDto from './dto/res/daymail-res.dto'; import { ConfigModule } from '../config/config.module'; +import AssoGetDaymailReqDto from './dto/req/asso-get-daymail-req.dto'; +import { AssoDaymail } from './interfaces/daymail.interface'; +import DaymailInfoResDto from './dto/res/daymail-info-res.dto'; @Controller('assos') @ApiTags('Assos') @@ -220,32 +223,43 @@ export class AssosController { return { roles: updatedRoles.map(this.formatPartialAssoMembershipRole) }; } + @Get('/daymail/info') + @ApiOperation({ description: 'Returns information about daymails.' }) + @ApiOkResponse({ type: DaymailInfoResDto }) + getDaymailInfo(): DaymailInfoResDto { + return { + sendDay: this.config.DAYMAIL_SEND_DAY, + sendHour: this.config.DAYMAIL_SEND_HOUR, + }; + } + @Get('/:assoId/daymail') @ApiOperation({ description: 'Get daymails from query parameter `from` to query parameter `to`.' }) - @ApiQuery({ name: 'from', type: String, default: 'Today' }) - @ApiQuery({ name: 'to', type: String, default: `\`from\` + env.PAGINATION_PAGE_SIZE days` }) - @ApiOkResponse({ type: DaymailResDto, isArray: true }) - @ApiAppErrorResponse(ERROR_CODE.TOO_MANY_DAYS, "API can't return more than env.PAGINATION_PAGE_SIZE days of daymail") + @ApiQuery({ name: 'from', type: String }) + @ApiQuery({ name: 'to', type: String, default: `\`from\` + env.PAGINATION_PAGE_SIZE days`, required: false }) + @ApiOkResponse({ type: DaymailResDto }) + @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 manage_asso', + 'The user issuing the request does not have the permission daymail', ) - async getPlannedDaymails( + async searchDaymails( @ParamAsso() asso: Asso, - @Query('from', new ParseDatePipe({ optional: true })) from: Date, - @Query('to', new ParseDatePipe({ optional: true })) to: Date, + @Query() { from, to, page }: AssoGetDaymailReqDto, @GetUser() user: User, - ): Promise { - if (!from) from = new Date(); + ): Promise> { from = from.dropTime(); - to = to ? to.dropTime() : from.add({ days: this.config.PAGINATION_PAGE_SIZE - 1 }); - if (from > to) throw new AppException(ERROR_CODE.PARAM_DATE_MUST_BE_AFTER, to.toISOString(), from.toISOString()); - const daysCount = Math.floor((to.getTime() - from.getTime()) / (1000 * 3600 * 24)) + 1; // Add 1 to include both `from` and `to` - if (daysCount > this.config.PAGINATION_PAGE_SIZE) - throw new AppException(ERROR_CODE.TOO_MANY_DAYS, `${daysCount}`, `${this.config.PAGINATION_PAGE_SIZE}`); - if (!(await this.assosService.hasSomeAssoPermission(asso, user.id, 'manage_asso'))) - throw new AppException(ERROR_CODE.FORBIDDEN_ASSOS_PERMISSIONS, asso.id, 'manage_asso'); - return (await this.assosService.getDaymails(asso.id, from, to)).mappedSort((daymail) => [daymail.sendDates[0]]); + 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, 'daymail'))) + throw new AppException(ERROR_CODE.FORBIDDEN_ASSOS_PERMISSIONS, asso.id, 'daymail'); + const { daymails, count } = await this.assosService.searchDaymails(asso.id, from, to, page); + return { + items: daymails, + itemCount: count, + itemsPerPage: this.config.PAGINATION_PAGE_SIZE, + }; } @Post('/:assoId/daymail') @@ -253,53 +267,75 @@ export class AssosController { @ApiCreatedResponse({ type: DaymailResDto }) @ApiAppErrorResponse( ERROR_CODE.FORBIDDEN_ASSOS_PERMISSIONS, - 'The user issuing the request does not have the permission manage_asso', + 'The user issuing the request does not have the permission daymail', ) + @ApiAppErrorResponse(ERROR_CODE.DAYMAIL_ALREADY_SENT_FOR_WEEK, 'The daymail was already sent for the specified week') + @ApiAppErrorResponse(ERROR_CODE.DAYMAIL_ALREADY_PLANNED_FOR_WEEK, 'The asso already has a daymailed planned for the requested new week') async createDaymail( @ParamAsso() asso: Asso, @Body() dto: AssosPostDaymailReqDto, @GetUser() user: User, ): Promise { - if (!(await this.assosService.hasSomeAssoPermission(asso, user.id, 'manage_asso'))) - throw new AppException(ERROR_CODE.FORBIDDEN_ASSOS_PERMISSIONS, asso.id, 'manage_asso'); - return this.assosService.addDaymail(asso.id, dto.title, dto.message, dto.dates); + if (!(await this.assosService.hasSomeAssoPermission(asso, user.id, 'daymail'))) + throw new AppException(ERROR_CODE.FORBIDDEN_ASSOS_PERMISSIONS, asso.id, 'daymail'); + if (this.assosService.getSendDate(dto.date) < new Date()) + throw new AppException(ERROR_CODE.DAYMAIL_ALREADY_SENT_FOR_WEEK, dto.date.toISOString()); + if (await this.assosService.hasDaymailForWeek(asso.id, dto.date)) + throw new AppException(ERROR_CODE.DAYMAIL_ALREADY_PLANNED_FOR_WEEK); + return this.assosService.addDaymail(asso.id, dto.title, dto.message, dto.date); } @Patch('/:assoId/daymail/:daymailId') @ApiOperation({ description: 'Update a daymail for the given association.' }) - @ApiOkResponse({ type: DaymailResDto }) + @ApiOkResponse({ type: paginatedResponseDto(DaymailResDto) }) @ApiAppErrorResponse(ERROR_CODE.NO_SUCH_ASSO) - @ApiAppErrorResponse(ERROR_CODE.NO_SUCH_DAYMAIL) @ApiAppErrorResponse( ERROR_CODE.FORBIDDEN_ASSOS_PERMISSIONS, - 'The user issuing the request does not have the permission manage_asso', + 'The user issuing the request does not have the permission daymail', ) + @ApiAppErrorResponse(ERROR_CODE.NO_SUCH_DAYMAIL, 'The daymail does not exist for the specified asso') + @ApiAppErrorResponse(ERROR_CODE.DAYMAIL_ALREADY_SENT, 'The daymail that is beeing modified was already sent') + @ApiAppErrorResponse(ERROR_CODE.DAYMAIL_ALREADY_SENT_FOR_WEEK, 'The daymail was already sent for the specified week') + @ApiAppErrorResponse(ERROR_CODE.DAYMAIL_ALREADY_PLANNED_FOR_WEEK, 'The asso already has a daymailed planned for the requested new week') async updateDaymail( @ParamAsso() asso: Asso, - @UUIDParam('daymailId') daymailId, + @UUIDParam('daymailId') daymailId: string, @Body() dto: AssosPostDaymailReqDto, @GetUser() user: User, ): Promise { - if (!(await this.assosService.doesDaymailExist(daymailId, asso.id))) - throw new AppException(ERROR_CODE.NO_SUCH_DAYMAIL, daymailId); - if (!(await this.assosService.hasSomeAssoPermission(asso, user.id, 'manage_asso'))) - throw new AppException(ERROR_CODE.FORBIDDEN_ASSOS_PERMISSIONS, asso.id, 'manage_asso'); - return this.assosService.updateDaymail(daymailId, pick(dto, 'title', 'message', 'dates')); + if (!(await this.assosService.hasSomeAssoPermission(asso, user.id, 'daymail'))) + throw new AppException(ERROR_CODE.FORBIDDEN_ASSOS_PERMISSIONS, asso.id, 'daymail'); + const daymail: AssoDaymail = await this.assosService.getDaymail(daymailId, asso.id); + if (!daymail) throw new AppException(ERROR_CODE.NO_SUCH_DAYMAIL, daymailId); + if (this.assosService.getSendDate(daymail.date) < new Date()) + throw new AppException(ERROR_CODE.DAYMAIL_ALREADY_SENT); + if (this.assosService.getSendDate(dto.date) < new Date()) + throw new AppException(ERROR_CODE.DAYMAIL_ALREADY_SENT_FOR_WEEK, dto.date.toISOString()); + if (await this.assosService.hasDaymailForWeek(asso.id, daymail.date, daymail.id)) + throw new AppException(ERROR_CODE.DAYMAIL_ALREADY_PLANNED_FOR_WEEK); + return this.assosService.updateDaymail(daymailId, dto); } @Delete('/:assoId/daymail/:daymailId') @ApiOperation({ description: 'Delete a daymail for the given association.' }) @ApiOkResponse({ type: DaymailResDto }) - @ApiAppErrorResponse(ERROR_CODE.NO_SUCH_DAYMAIL) @ApiAppErrorResponse( ERROR_CODE.FORBIDDEN_ASSOS_PERMISSIONS, - 'The user issuing the request does not have the permission manage_asso', + 'The user issuing the request does not have the permission daymail', ) - async deleteDaymail(@ParamAsso() asso: Asso, @UUIDParam('daymailId') daymailId, @GetUser() user: User): Promise { - if (!(await this.assosService.doesDaymailExist(daymailId, asso.id))) - throw new AppException(ERROR_CODE.NO_SUCH_DAYMAIL, daymailId); - if (!(await this.assosService.hasSomeAssoPermission(asso, user.id, 'manage_asso'))) - throw new AppException(ERROR_CODE.FORBIDDEN_ASSOS_PERMISSIONS, asso.id, 'manage_asso'); + @ApiAppErrorResponse(ERROR_CODE.NO_SUCH_DAYMAIL) + @ApiAppErrorResponse(ERROR_CODE.DAYMAIL_ALREADY_SENT, 'The daymail that is beeing modified was already sent') + async deleteDaymail( + @ParamAsso() asso: Asso, + @UUIDParam('daymailId') daymailId: string, + @GetUser() user: User, + ): Promise { + if (!(await this.assosService.hasSomeAssoPermission(asso, user.id, 'daymail'))) + throw new AppException(ERROR_CODE.FORBIDDEN_ASSOS_PERMISSIONS, asso.id, 'daymail'); + const daymail: AssoDaymail = await this.assosService.getDaymail(daymailId, asso.id); + if (!daymail) throw new AppException(ERROR_CODE.NO_SUCH_DAYMAIL, daymailId); + if (this.assosService.getSendDate(daymail.date) < new Date()) + throw new AppException(ERROR_CODE.DAYMAIL_ALREADY_SENT); return this.assosService.deleteDaymail(daymailId); } diff --git a/src/assos/assos.service.ts b/src/assos/assos.service.ts index 407e9f81..91c62f83 100644 --- a/src/assos/assos.service.ts +++ b/src/assos/assos.service.ts @@ -9,8 +9,8 @@ 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 { pick } from '../utils'; import { AssoDaymail } from './interfaces/daymail.interface'; +import DaymailResDto from './dto/res/daymail-res.dto'; @Injectable() export class AssosService { @@ -288,52 +288,62 @@ export class AssosService { }); } - async getDaymails(assoId: string, from: Date, to: Date): Promise { - const daymails = await this.prisma.normalize.assoDaymail.findMany({ - where: { - assoId, - sendDates: { - some: { - date: { - gte: from, - lte: to, - } - } - } - } - }); - for (const daymail of daymails) { - daymail.sendDates = daymail.sendDates.filter((date) => from <= date && date <= to); - } - return daymails; + async searchDaymails(assoId: string, from: Date, to: Date, page: number): Promise<{ daymails: AssoDaymail[], count: number }> { + const where = { + assoId, + date: { gte: from, lte: to }, + } satisfies Prisma.AssoDaymailWhereInput; + const count = await this.prisma.assoDaymail.count({ where }); + const daymails = await this.prisma.normalize.assoDaymail.findMany({ where, skip: (page - 1) * this.config.PAGINATION_PAGE_SIZE, take: this.config.PAGINATION_PAGE_SIZE }); + return { daymails, count }; } - async addDaymail(assoId: string, title: Translation, message: Translation, dates: Date[]): Promise { + async addDaymail(assoId: string, title: Translation, message: Translation, date): Promise { return this.prisma.normalize.assoDaymail.create({ data: { asso: { connect: { id: assoId } }, - titleTranslation: { create: pick(title, 'fr', 'en', 'es', 'de', 'zh') }, - bodyTranslation: { create: pick(message, 'fr', 'en', 'es', 'de', 'zh') }, - sendDates: { createMany: { data: dates.map((date) => ({ date })) } }, + titleTranslation: { create: title }, + bodyTranslation: { create: message }, + date, } }); } - async doesDaymailExist(daymailId: string, assoId?: string): Promise { - return (await this.prisma.assoDaymail.count({ where: { id: daymailId, assoId } })) > 0; + getSendDate(sendWeek: Date): Date { + return new Date( + Date.UTC( + sendWeek.getUTCFullYear(), + sendWeek.getUTCMonth(), + sendWeek.getUTCDate() + this.config.DAYMAIL_SEND_DAY, + this.config.DAYMAIL_SEND_HOUR, + 0, + -Date.getTimezoneOffset('Europe/Paris') + ) + ); + } + + async getDaymail(daymailId: string, assoId?: string): Promise { + return this.prisma.normalize.assoDaymail.findUnique({ where: { id: daymailId, assoId } }); } - async updateDaymail(daymailId: string, fields: { title: Translation, message: Translation, dates: Date[] }): Promise { + async hasDaymailForWeek(assoId: string, date: Date, excludeDaymail: string = undefined): Promise { + return (await this.prisma.assoDaymail.count({ + where: { + assoId, + date, + ...(excludeDaymail ? { id: { not: excludeDaymail } } : {}) + } + })) > 0; + } + + async updateDaymail(daymailId: string, fields: { title: Translation, message: Translation, date: Date }): Promise { return this.prisma.normalize.assoDaymail.update({ where: { id: daymailId }, data: { titleTranslation: { update: fields.title }, bodyTranslation: { update: fields.message }, - sendDates: fields.dates ? { - deleteMany: {}, - createMany: { data: fields.dates.map((date) => ({ date })) } - } : {}, - }, + date: fields.date, + } }); } diff --git a/src/assos/dto/req/asso-get-daymail-req.dto.ts b/src/assos/dto/req/asso-get-daymail-req.dto.ts new file mode 100644 index 00000000..4e191ccf --- /dev/null +++ b/src/assos/dto/req/asso-get-daymail-req.dto.ts @@ -0,0 +1,17 @@ +import { IsDate, IsInt, IsOptional } from 'class-validator'; +import { Type } from 'class-transformer'; + +export default class AssoGetDaymailReqDto { + @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/dto/req/assos-post-daymail-req.dto.ts b/src/assos/dto/req/assos-post-daymail-req.dto.ts index 116fe7ac..6de9bddc 100644 --- a/src/assos/dto/req/assos-post-daymail-req.dto.ts +++ b/src/assos/dto/req/assos-post-daymail-req.dto.ts @@ -1,10 +1,11 @@ import { Type } from 'class-transformer'; import { IsDate, IsNotEmpty, ValidateNested } from 'class-validator'; import { TranslationReqDto } from '../../../app.dto'; -import { IsFutureDate } from '../../../validation'; +import { IsWeekDate } from '../../../validation'; export default class AssosPostDaymailReqDto { @ValidateNested() + @IsNotEmpty() @Type(() => TranslationReqDto) title: TranslationReqDto @@ -13,9 +14,9 @@ export default class AssosPostDaymailReqDto { @Type(() => TranslationReqDto) message: TranslationReqDto; - @IsDate({ each: true }) + @IsDate() @IsNotEmpty() @Type(() => Date) - @IsFutureDate({ each: true }) - dates: Date[]; + @IsWeekDate() + date: Date; } \ No newline at end of file diff --git a/src/assos/dto/res/daymail-info-res.dto.ts b/src/assos/dto/res/daymail-info-res.dto.ts new file mode 100644 index 00000000..595dfd06 --- /dev/null +++ b/src/assos/dto/res/daymail-info-res.dto.ts @@ -0,0 +1,4 @@ +export default class DaymailInfoResDto { + sendDay: number; + sendHour: number; +} \ No newline at end of file diff --git a/src/assos/dto/res/daymail-res.dto.ts b/src/assos/dto/res/daymail-res.dto.ts index 312b39b0..452c9d68 100644 --- a/src/assos/dto/res/daymail-res.dto.ts +++ b/src/assos/dto/res/daymail-res.dto.ts @@ -6,5 +6,5 @@ export default class DaymailResDto { createdAt: Date; title: Translation; message: Translation; - sendDates: Date[]; + date: Date; } diff --git a/src/assos/interfaces/daymail.interface.ts b/src/assos/interfaces/daymail.interface.ts index 555157ed..6595dbf2 100644 --- a/src/assos/interfaces/daymail.interface.ts +++ b/src/assos/interfaces/daymail.interface.ts @@ -10,25 +10,19 @@ const ASSO_DAYMAIL_SELECT_FILTER = { createdAt: true, titleTranslation: translationSelect, bodyTranslation: translationSelect, - sendDates: { - select: { - date: true, - }, - orderBy: { - date: 'asc', - } - } + date: true, }, + orderBy: { date: 'asc'} } as const satisfies Prisma.AssoDaymailFindManyArgs; export type UnformattedAssoDaymail = Prisma.AssoDaymailGetPayload; -export type AssoDaymail = Pick & { title: Translation, message: Translation, sendDates: Date[] } +export type AssoDaymail = Pick & { title: Translation, message: Translation } export const generateCustomAssoDaymailModel = (prisma: PrismaClient) => generateCustomModel(prisma, 'assoDaymail', ASSO_DAYMAIL_SELECT_FILTER, formatAssoDaymail); function formatAssoDaymail(_: PrismaClient, r: UnformattedAssoDaymail): AssoDaymail { return { - ...pick(r, 'id', 'assoId', 'createdAt'), title: r.titleTranslation, message: r.bodyTranslation, sendDates: r.sendDates.map((s) => s.date) + ...pick(r, 'id', 'assoId', 'createdAt', 'date'), title: r.titleTranslation, message: r.bodyTranslation } } diff --git a/src/config/config.module.ts b/src/config/config.module.ts index 614c5b39..fd113ac2 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 DAYMAIL_SEND_DAY: number; + public readonly DAYMAIL_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.DAYMAIL_SEND_DAY = Number.parseInt(config.get('DAYMAIL_SEND_DAY')); + this.DAYMAIL_SEND_HOUR = Number.parseInt(config.get('DAYMAIL_SEND_HOUR')); this._FAKER_SEED = isTestEnv ? Number(config.get('FAKER_SEED')) : undefined; } diff --git a/src/exceptions.ts b/src/exceptions.ts index 189217be..5e4da192 100644 --- a/src/exceptions.ts +++ b/src/exceptions.ts @@ -41,10 +41,13 @@ export const enum ERROR_CODE { 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, - TOO_MANY_DAYS = 2302, + DAYMAIL_ALREADY_SENT = 2302, + DAYMAIL_ALREADY_SENT_FOR_WEEK = 2303, + DAYMAIL_ALREADY_PLANNED_FOR_WEEK = 2304, FILE_INVALID_TYPE = 2901, FILE_TOO_HEAVY = 2902, FORBIDDEN_NOT_ENOUGH_API_PERMISSIONS = 3001, @@ -213,6 +216,10 @@ export const ErrorData = Object.freeze({ 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, @@ -233,10 +240,18 @@ export const ErrorData = Object.freeze({ message: 'Widgets at index % and % are overlapping', httpCode: HttpStatus.BAD_REQUEST, }, - [ERROR_CODE.TOO_MANY_DAYS]: { - message: 'Too many days in the interval, requested % days, maximum authorized is %', + [ERROR_CODE.DAYMAIL_ALREADY_SENT]: { + message: 'Daymail is already sent', + httpCode: HttpStatus.BAD_REQUEST, + }, + [ERROR_CODE.DAYMAIL_ALREADY_SENT_FOR_WEEK]: { + message: 'Daymail is already sent for the week starting at %', httpCode: HttpStatus.BAD_REQUEST, }, + [ERROR_CODE.DAYMAIL_ALREADY_PLANNED_FOR_WEEK]: { + message: 'Asso already has a daymail planned for this week', + httpCode: HttpStatus.CONFLICT, + }, [ERROR_CODE.FORBIDDEN_NOT_ENOUGH_API_PERMISSIONS]: { message: 'Missing permission %', httpCode: HttpStatus.FORBIDDEN, diff --git a/src/std.type.ts b/src/std.type.ts index fd726bdf..4a5e44e6 100644 --- a/src/std.type.ts +++ b/src/std.type.ts @@ -52,13 +52,23 @@ declare global { dropTime(): Date; /** - * Adds the number of UTC years, months and days to the 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. - * @returns A new Date offset by the specified number of UTC years, months and days. + * @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 }: { years?: number, months?: number, days?: number }): Date; + 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; } } @@ -105,8 +115,20 @@ 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 } = {}) { - return new Date(Date.UTC(this.getUTCFullYear() + years, this.getUTCMonth() + months, this.getUTCDate() + days)); +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/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 6d75902c..07e9ffbd 100644 --- a/src/validation.ts +++ b/src/validation.ts @@ -32,6 +32,7 @@ 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; }; @@ -75,6 +76,13 @@ 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) { @@ -95,6 +103,8 @@ class GhostProperty implements ValidatorConstraintInterface { /** Equivalent to @MinDate(() => Date.now()) with an error message */ 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 function HasSomeAmong(...fields: ((keyof T) & string)[]) { return (target: { prototype: T }) => { diff --git a/test/declarations.d.ts b/test/declarations.d.ts index ecc4dcfd..d36918e4 100644 --- a/test/declarations.d.ts +++ b/test/declarations.d.ts @@ -107,7 +107,7 @@ declare module './declarations' { expectPermissions(permissions: PermissionManager): this; expectAssoDaymail(daymail: JsonLikeVariant, created = false): this; - expectAssoDaymails(daymails: JsonLikeVariant[]): this; + expectAssoDaymails(app: AppProvider, daymails: JsonLikeVariant[], count: number): this; withLanguage(language: Language): this; language: Language; diff --git a/test/declarations.ts b/test/declarations.ts index 7b791c1c..928bd650 100644 --- a/test/declarations.ts +++ b/test/declarations.ts @@ -316,14 +316,22 @@ Spec.prototype.expectPermissions = function (permissions: PermissionManager) { } satisfies PermissionsResDto); }; Spec.prototype.expectAssoDaymail = function (this: Spec, daymail: JsonLikeVariant, created = false) { - return this.expectStatus(created ? HttpStatus.CREATED : HttpStatus.OK).$expectRegexableJson({...pick(daymail, 'id', 'assoId', 'createdAt', 'sendDates'), title: daymail.title[this.language], message: daymail.message[this.language]}); -}; -Spec.prototype.expectAssoDaymails = function (this: Spec, daymails: JsonLikeVariant[]) { - return this.expectStatus(HttpStatus.OK).$expectRegexableJson(daymails.map((daymail) => ({ - ...pick(daymail, 'id', 'assoId', 'createdAt', 'sendDates'), + return this.expectStatus(created ? HttpStatus.CREATED : HttpStatus.OK).$expectRegexableJson({ + ...pick(daymail, 'id', 'assoId', 'createdAt', 'date'), title: daymail.title[this.language], message: daymail.message[this.language] - }))); + }); +}; +Spec.prototype.expectAssoDaymails = function (this: Spec, app: AppProvider, daymails: JsonLikeVariant[], count: number) { + return this.expectStatus(HttpStatus.OK).$expectRegexableJson({ + items: daymails.map((daymail) => ({ + ...pick(daymail, 'id', 'assoId', 'createdAt', 'date'), + title: daymail.title[this.language], + message: daymail.message[this.language] + })), + itemCount: count, + itemsPerPage: app().get(ConfigModule).PAGINATION_PAGE_SIZE, + }); }; export { Spec, JsonLikeVariant, FakeUeWithOfs }; diff --git a/test/e2e/assos/create-daymail.e2e-spec.ts b/test/e2e/assos/create-daymail.e2e-spec.ts index 855e4640..5c71d5a4 100644 --- a/test/e2e/assos/create-daymail.e2e-spec.ts +++ b/test/e2e/assos/create-daymail.e2e-spec.ts @@ -1,6 +1,6 @@ import { Dummies, e2eSuite, JsonLike } from '../../utils/test_utils'; import { - createAsso, + createAsso, createAssoDaymail, createAssoMembership, createAssoMembershipPermission, createAssoMembershipRole, @@ -9,16 +9,22 @@ import { import * as pactum from 'pactum'; import { ERROR_CODE } from '../../../src/exceptions'; import AssosPostDaymailReqDto from '../../../src/assos/dto/req/assos-post-daymail-req.dto'; +import { PrismaService } from '../../../src/prisma/prisma.service'; const CreateDaymailE2ESpec = e2eSuite('POST /assos/:assoId/daymail', (app) => { const asso = createAsso(app); const userWithPermission = createUser(app); const userWithoutPermission = createUser(app); - const permissionManageAsso = createAssoMembershipPermission(app, {id: 'manage_asso'}); - const role = createAssoMembershipRole(app, {asso}); - createAssoMembership(app, {asso, user: userWithPermission, role, permissions: [permissionManageAsso]}); + const permissionManageAsso = createAssoMembershipPermission(app, { id: 'daymail' }); + const role = createAssoMembershipRole(app, { asso }); + createAssoMembership(app, { asso, user: userWithPermission, role, permissions: [permissionManageAsso] }); + const daymail = createAssoDaymail(app, { asso }, { date: new Date().add({ days: 7 }).getWeekDate() }); - const body: AssosPostDaymailReqDto = {title: {fr: "The title"}, message: {fr: "The message"}, dates: [new Date(Date.now() + 1e6), new Date(Date.now() + 2e6)]} + const body: AssosPostDaymailReqDto = { + title: { fr: "The title" }, + message: { fr: "The message" }, + date: new Date().add({ days: 14 }).getWeekDate(), + } it('should return 403 as user is not authenticated', () => pactum.spec().post(`/assos/${asso.id}/daymail`).expectAppError(ERROR_CODE.NOT_LOGGED_IN)); @@ -44,7 +50,7 @@ const CreateDaymailE2ESpec = e2eSuite('POST /assos/:assoId/daymail', (app) => { .withBearerToken(userWithoutPermission.token) .post(`/assos/${asso.id}/daymail`) .withBody(body) - .expectAppError(ERROR_CODE.FORBIDDEN_ASSOS_PERMISSIONS, asso.id, 'manage_asso')); + .expectAppError(ERROR_CODE.FORBIDDEN_ASSOS_PERMISSIONS, asso.id, 'daymail')); it('should return a 400 as the title was not provided in any language', () => pactum @@ -54,15 +60,37 @@ const CreateDaymailE2ESpec = e2eSuite('POST /assos/:assoId/daymail', (app) => { .withBody({ ...body, title: {} }) .expectAppError(ERROR_CODE.PARAM_MISSING_EITHER, 'fr, en, es, de, zh')); - it('should return a 400 as the body was not provided in any language', () => pactum + it('should return a 400 as the message was not provided in any language', () => pactum .spec() .withBearerToken(userWithPermission.token) .post(`/assos/${asso.id}/daymail`) .withBody({ ...body, message: {} }) .expectAppError(ERROR_CODE.PARAM_MISSING_EITHER, 'fr, en, es, de, zh')); - it('should successfully create a new daymail', () => + it('should return a 400 as the date is not a week-date', () => pactum + .spec() + .withBearerToken(userWithPermission.token) + .post(`/assos/${asso.id}/daymail`) + .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 daymail was already sent for the requested week', () => pactum + .spec() + .withBearerToken(userWithPermission.token) + .post(`/assos/${asso.id}/daymail`) + .withBody({ ...body, date: new Date(Date.UTC(2024, 10, 10)) }) + .expectAppError(ERROR_CODE.DAYMAIL_ALREADY_SENT_FOR_WEEK, new Date(Date.UTC(2024, 10, 10)).toISOString())); + + it('should fail as the asso already has a planned daymail for the requested week', () => pactum + .spec() + .withBearerToken(userWithPermission.token) + .post(`/assos/${asso.id}/daymail`) + .withBody({ ...body, date: daymail.date }) + .expectAppError(ERROR_CODE.DAYMAIL_ALREADY_PLANNED_FOR_WEEK)); + + it('should successfully create a new daymail', async () => { + const id: string = await pactum .spec() .withBearerToken(userWithPermission.token) .post(`/assos/${asso.id}/daymail`) @@ -74,10 +102,13 @@ const CreateDaymailE2ESpec = e2eSuite('POST /assos/:assoId/daymail', (app) => { createdAt: JsonLike.DATE, title: body.title, message: body.message, - sendDates: body.dates, + date: body.date, }, true, - )); + ) + .returns('id'); + return app().get(PrismaService).assoDaymail.delete({ where: { id } }); + }); }); export default CreateDaymailE2ESpec; diff --git a/test/e2e/assos/delete-daymail.e2e-spec.ts b/test/e2e/assos/delete-daymail.e2e-spec.ts index 6e9db295..fda73e68 100644 --- a/test/e2e/assos/delete-daymail.e2e-spec.ts +++ b/test/e2e/assos/delete-daymail.e2e-spec.ts @@ -12,12 +12,13 @@ import { ERROR_CODE } from '../../../src/exceptions'; const DeleteDaymailE2ESpec = e2eSuite('DELETE /assos/:assoId/daymail/:daymailId', (app) => { const userWithPermission = createUser(app); const userWithoutPermission = createUser(app); - const permissionManageAsso = createAssoMembershipPermission(app, { id: 'manage_asso' }); + const permissionManageAsso = createAssoMembershipPermission(app, { id: 'daymail' }); const asso = createAsso(app); const role = createAssoMembershipRole(app, { asso }); createAssoMembership(app, { asso, user: userWithPermission, role, permissions: [permissionManageAsso] }); - const daymail = createAssoDaymail(app, { asso }, { sendDates: [new Date(Date.UTC(2025, 9, 5))] }); + const daymail = createAssoDaymail(app, { asso }, { date: new Date().add({ days: 14 }).getWeekDate() }); + const oldDaymail = createAssoDaymail(app, { asso }, { date: new Date(Date.UTC(2025, 10, 10)) }); const otherAsso = createAsso(app); const otherAssoRole = createAssoMembershipRole(app, { asso }); @@ -33,6 +34,12 @@ const DeleteDaymailE2ESpec = e2eSuite('DELETE /assos/:assoId/daymail/:daymailId' .delete(`/assos/${Dummies.UUID}/daymail/${daymail.id}`) .expectAppError(ERROR_CODE.NO_SUCH_ASSO, Dummies.UUID)); + it('should return a 403 as user does not have the permission to update the daymails', () => pactum + .spec() + .withBearerToken(userWithoutPermission.token) + .delete(`/assos/${asso.id}/daymail/${daymail.id}`) + .expectAppError(ERROR_CODE.FORBIDDEN_ASSOS_PERMISSIONS, asso.id, 'daymail')); + it('should return a 404 as daymail is not found', () => pactum .spec() .withBearerToken(userWithPermission.token) @@ -45,11 +52,11 @@ const DeleteDaymailE2ESpec = e2eSuite('DELETE /assos/:assoId/daymail/:daymailId' .delete(`/assos/${otherAsso.id}/daymail/${daymail.id}`) .expectAppError(ERROR_CODE.NO_SUCH_DAYMAIL, daymail.id)); - it('should return a 403 as user does not have the permission to update the daymails', () => pactum + it('should return a 400 as the daymail was already sent', () => pactum .spec() - .withBearerToken(userWithoutPermission.token) - .delete(`/assos/${asso.id}/daymail/${daymail.id}`) - .expectAppError(ERROR_CODE.FORBIDDEN_ASSOS_PERMISSIONS, asso.id, 'manage_asso')); + .withBearerToken(userWithPermission.token) + .delete(`/assos/${asso.id}/daymail/${oldDaymail.id}`) + .expectAppError(ERROR_CODE.DAYMAIL_ALREADY_SENT)); it('should delete the daymail', async () => { await pactum diff --git a/test/e2e/assos/get-daymail-info.e2e-spec.ts b/test/e2e/assos/get-daymail-info.e2e-spec.ts new file mode 100644 index 00000000..5c45bfc3 --- /dev/null +++ b/test/e2e/assos/get-daymail-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 GetDaymailInfoE2ESpec = e2eSuite('GET /assos/daymail/info', (app) => { + const user = createUser(app); + + it('should return 403 as user is not authenticated', () => + pactum.spec().get('/assos/daymail/info').expectAppError(ERROR_CODE.NOT_LOGGED_IN)); + + it('should successfully create a new daymail', () => + pactum + .spec() + .withBearerToken(user.token) + .get('/assos/daymail/info') + .expectJson({ sendDay: app().get(ConfigModule).DAYMAIL_SEND_DAY, sendHour: app().get(ConfigModule).DAYMAIL_SEND_HOUR })); +}); + +export default GetDaymailInfoE2ESpec; diff --git a/test/e2e/assos/get-daymails.e2e-spec.ts b/test/e2e/assos/get-daymails.e2e-spec.ts index 3248a1bf..d0227bd0 100644 --- a/test/e2e/assos/get-daymails.e2e-spec.ts +++ b/test/e2e/assos/get-daymails.e2e-spec.ts @@ -8,32 +8,26 @@ import { } from '../../utils/fakedb'; import * as pactum from 'pactum'; import { ERROR_CODE } from '../../../src/exceptions'; -import { ConfigModule } from '../../../src/config/config.module'; -const GetDaymailsE2ESpec = e2eSuite('GET /assos/:assoId/daymail', (app) => { +const SearchDaymailsE2ESpec = e2eSuite('GET /assos/:assoId/daymail', (app) => { const userWithPermission = createUser(app); const userWithoutPermission = createUser(app); - const permissionManageAsso = createAssoMembershipPermission(app, { id: 'manage_asso' }); + const permissionManageAsso = createAssoMembershipPermission(app, { id: 'daymail' }); 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 daymailAsso1 = createAssoDaymail(app, { asso }, { - sendDates: [ - new Date(Date.UTC(2025, 8, 30)), - new Date(Date.UTC(2025, 9, 1)), - new Date(Date.UTC(2025, 9, 2)), - new Date(Date.UTC(2025, 9, 10)), - new Date(Date.UTC(2025, 9, 11))] - }); - const daymailAsso2 = createAssoDaymail(app, { asso }, { sendDates: [new Date(Date.UTC(2025, 9, 5))] }); + const daymailAssoOctober1 = createAssoDaymail(app, { asso }, { date: new Date(Date.UTC(2025, 9, 1)) }); + const daymailAssoOctober2 = createAssoDaymail(app, { asso }, { date: new Date(Date.UTC(2025, 9, 2)) }); + createAssoDaymail(app, { asso }, { date: new Date(Date.UTC(2025, 8, 1)) }); // September + createAssoDaymail(app, { asso }, { date: new Date(Date.UTC(2025, 10, 1)) }); // November // Create a daymail 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] }); - createAssoDaymail(app, { asso: otherAsso }, { sendDates: [new Date(Date.UTC(2025, 9, 6))] }); + createAssoDaymail(app, { asso: otherAsso }, { date: new Date(Date.UTC(2025, 9, 6)) }); it('should return 403 as user is not authenticated', () => pactum.spec().get(`/assos/${asso.id}/daymail/`).withQueryParams({ from: '2025-10-01T00:00:00Z', to: '2025-10-10T00:00:00Z' }).expectAppError(ERROR_CODE.NOT_LOGGED_IN)); @@ -67,25 +61,14 @@ const GetDaymailsE2ESpec = e2eSuite('GET /assos/:assoId/daymail', (app) => { .withBearerToken(userWithoutPermission.token) .get(`/assos/${asso.id}/daymail`) .withQueryParams({ from: '2025-10-01T00:00:00Z', to: '2025-10-10T00:00:00Z' }) - .expectAppError(ERROR_CODE.FORBIDDEN_ASSOS_PERMISSIONS, asso.id, 'manage_asso')); + .expectAppError(ERROR_CODE.FORBIDDEN_ASSOS_PERMISSIONS, asso.id, 'daymail')); - it('should fail as there are too many days requested', () => - pactum - .spec() - .withBearerToken(userWithPermission.token) - .get(`/assos/${asso.id}/daymail`) - .withQueryParams({ from: '2024-10-01T00:00:00Z', to: '2026-10-01T00:00:00Z' }) - .expectAppError(ERROR_CODE.TOO_MANY_DAYS, `${2 * 365 + 1}`, `${app().get(ConfigModule).PAGINATION_PAGE_SIZE}`)); - - it('should return daymails for `asso` between October, 1st and October, 10th', () => pactum + it('should return daymails for `asso` between October, 1st and October, 31st', () => pactum .spec() .withBearerToken(userWithPermission.token) .get(`/assos/${asso.id}/daymail`) - .withQueryParams({ from: '2025-10-01T00:00:00Z', to: '2025-10-10T00:00:00Z' }) - .expectAssoDaymails([ - { ...daymailAsso1, sendDates: [new Date(Date.UTC(2025, 9, 1)), new Date(Date.UTC(2025, 9, 2)), new Date(Date.UTC(2025, 9, 10))] }, - { ...daymailAsso2, sendDates: [new Date(Date.UTC(2025, 9, 5))] }, - ])); + .withQueryParams({ from: '2025-10-01T00:00:00Z', to: '2025-10-31T00:00:00Z' }) + .expectAssoDaymails(app, [daymailAssoOctober1, daymailAssoOctober2], 2)); }); -export default GetDaymailsE2ESpec; +export default SearchDaymailsE2ESpec; diff --git a/test/e2e/assos/index.ts b/test/e2e/assos/index.ts index cc0fa94d..7f786046 100644 --- a/test/e2e/assos/index.ts +++ b/test/e2e/assos/index.ts @@ -12,6 +12,7 @@ import CreateDaymailE2ESpec from './create-daymail.e2e-spec'; import GetDaymailsE2ESpec from './get-daymails.e2e-spec'; import UpdateDaymailE2ESpec from './update-daymail.e2e-spec'; import DeleteDaymailE2ESpec from './delete-daymail.e2e-spec'; +import GetDaymailInfoE2ESpec from './get-daymail-info.e2e-spec'; export default function AssoE2ESpec(app: () => INestApplication) { describe('Assos', () => { @@ -24,6 +25,7 @@ export default function AssoE2ESpec(app: () => INestApplication) { CreateAssoRoleE2ESpec(app); DeleteAssoRoleE2ESpec(app); UpdateAssoRoleE2ESpec(app); + GetDaymailInfoE2ESpec(app); GetDaymailsE2ESpec(app); CreateDaymailE2ESpec(app); UpdateDaymailE2ESpec(app); diff --git a/test/e2e/assos/update-daymail.e2e-spec.ts b/test/e2e/assos/update-daymail.e2e-spec.ts index f83cbedf..745eeaa2 100644 --- a/test/e2e/assos/update-daymail.e2e-spec.ts +++ b/test/e2e/assos/update-daymail.e2e-spec.ts @@ -14,23 +14,23 @@ import { pick } from '../../../src/utils'; const UpdateDaymailE2ESpec = e2eSuite('PATCH /assos/:assoId/daymail/:daymailId', (app) => { const userWithPermission = createUser(app); const userWithoutPermission = createUser(app); - const permissionManageAsso = createAssoMembershipPermission(app, { id: 'manage_asso' }); + const permissionDaymail = createAssoMembershipPermission(app, { id: 'daymail' }); const asso = createAsso(app); const role = createAssoMembershipRole(app, { asso }); - createAssoMembership(app, { asso, user: userWithPermission, role, permissions: [permissionManageAsso] }); - const initialSendDate = new Date().dropTime().add({ days: 2 }); - const daymail = createAssoDaymail(app, { asso }, { sendDates: [initialSendDate] }); + createAssoMembership(app, { asso, user: userWithPermission, role, permissions: [permissionDaymail] }); + const daymail = createAssoDaymail(app, { asso }, { date: new Date().add({ days: 14 }).getWeekDate() }); + const oldDaymail = createAssoDaymail(app, { asso }, { date: new Date(Date.UTC(2024, 10, 10)) }); const otherAsso = createAsso(app); - const otherAssoRole = createAssoMembershipRole(app, { asso }); - createAssoMembership(app, { asso: otherAsso, user: userWithPermission, role: otherAssoRole, permissions: [permissionManageAsso] }); + const otherAssoRole = createAssoMembershipRole(app, { asso: otherAsso }); + createAssoMembership(app, { asso: otherAsso, user: userWithPermission, role: otherAssoRole, permissions: [permissionDaymail] }); const generateBody = () => ({ title: pick(faker.db.translation(), 'fr', 'en', 'zh'), message: pick(faker.db.translation(), 'de', 'es'), - dates: [daymail.sendDates[0].add({ days: 1 })] - }) + date: daymail.date.add({ days: 7 }), + }); it('should return 403 as user is not authenticated', () => pactum.spec().patch(`/assos/${asso.id}/daymail/${daymail.id}`).withJson(generateBody()).expectAppError(ERROR_CODE.NOT_LOGGED_IN)); @@ -43,6 +43,13 @@ const UpdateDaymailE2ESpec = e2eSuite('PATCH /assos/:assoId/daymail/:daymailId', .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 daymails', () => pactum + .spec() + .withBearerToken(userWithoutPermission.token) + .patch(`/assos/${asso.id}/daymail/${daymail.id}`) + .withJson(generateBody()) + .expectAppError(ERROR_CODE.FORBIDDEN_ASSOS_PERMISSIONS, asso.id, 'daymail')); + it('should return a 404 as daymail is not found', () => pactum .spec() .withBearerToken(userWithPermission.token) @@ -57,12 +64,19 @@ const UpdateDaymailE2ESpec = e2eSuite('PATCH /assos/:assoId/daymail/:daymailId', .withJson(generateBody()) .expectAppError(ERROR_CODE.NO_SUCH_DAYMAIL, daymail.id)); - it('should return a 403 as user does not have the permission to update the daymails', () => pactum + it('should return a 400 as the daymail was already sent', () => pactum .spec() - .withBearerToken(userWithoutPermission.token) - .patch(`/assos/${asso.id}/daymail/${daymail.id}`) + .withBearerToken(userWithPermission.token) + .patch(`/assos/${asso.id}/daymail/${oldDaymail.id}`) .withJson(generateBody()) - .expectAppError(ERROR_CODE.FORBIDDEN_ASSOS_PERMISSIONS, asso.id, 'manage_asso')); + .expectAppError(ERROR_CODE.DAYMAIL_ALREADY_SENT)); + + it('should return a 400 as the daymails planned at the new date were already sent', () => pactum + .spec() + .withBearerToken(userWithPermission.token) + .patch(`/assos/${asso.id}/daymail/${daymail.id}`) + .withJson({ ...generateBody(), date: new Date(Date.UTC(2024, 10, 10)) }) + .expectAppError(ERROR_CODE.DAYMAIL_ALREADY_SENT_FOR_WEEK, new Date(Date.UTC(2024, 10, 10)).toISOString())); it('should update the daymail', async () => { const body = generateBody(); @@ -75,11 +89,11 @@ const UpdateDaymailE2ESpec = e2eSuite('PATCH /assos/:assoId/daymail/:daymailId', ...daymail, title: {...daymail.title, ...body.title}, message: {...daymail.message, ...body.message}, - sendDates: body.dates, + date: body.date, }); daymail.title = {...daymail.title, ...body.title}; daymail.message = {...daymail.message, ...body.message}; - daymail.sendDates = body.dates; + daymail.date = body.date; }); }); diff --git a/test/utils/fakedb.ts b/test/utils/fakedb.ts index 4e9aa7fc..a175dc03 100644 --- a/test/utils/fakedb.ts +++ b/test/utils/fakedb.ts @@ -120,7 +120,7 @@ export type FakeHomepageWidget = Partial; export type FakeApiApplication = Partial> & { owner: { id: string; firstName: string; lastName: string }; }; -export type FakeAssoDaymail = Partial>; +export type FakeAssoDaymail = Partial>; export interface FakeEntityMap { assoMembership: { @@ -555,7 +555,7 @@ export type CreateAssoDaymailParameters = FakeAssoDaymail; export const createAssoDaymail = entityFaker( 'assoDaymail', { - sendDates: [new Date()], + date: new Date, title: { fr: faker.company.catchPhrase, en: faker.company.catchPhrase, @@ -578,11 +578,7 @@ export const createAssoDaymail = entityFaker( titleTranslation: { create: params.title }, bodyTranslation: { create: params.message }, asso: { connect: { id: deps.asso.id } }, - sendDates: { - createMany: { - data: params.sendDates.map((date) => ({ date: date.toISOString() })) - }, - }, + date: params.date, }, }) } From 3b646277b13c5bbd25c51c714b5753438bb3e4d1 Mon Sep 17 00:00:00 2001 From: Teddy Roncin Date: Fri, 21 Nov 2025 14:33:55 +0100 Subject: [PATCH 5/7] Renamed daymail to weekly --- .env.dist | 6 +- .env.test.dist | 6 +- migration/etuutt_old/modules/user.ts | 2 +- prisma/schema.prisma | 10 +- src/assos/assos.controller.ts | 154 +++++++++--------- src/assos/assos.service.ts | 44 ++--- ...-req.dto.ts => asso-get-weekly-req.dto.ts} | 2 +- ...eq.dto.ts => assos-post-weekly-req.dto.ts} | 2 +- src/assos/dto/res/daymail-info-res.dto.ts | 4 - src/assos/dto/res/weekly-info-res.dto.ts | 4 + .../{daymail-res.dto.ts => weekly-res.dto.ts} | 2 +- src/assos/interfaces/daymail.interface.ts | 28 ---- src/assos/interfaces/weekly.interface.ts | 28 ++++ src/config/config.module.ts | 8 +- src/exceptions.ts | 24 +-- src/prisma/prisma.service.ts | 4 +- src/prisma/types.ts | 1 - src/users/dto/req/users-update-req.dto.ts | 2 +- src/users/interfaces/user.interface.ts | 2 +- src/users/users.service.ts | 2 +- test/declarations.d.ts | 9 +- test/declarations.ts | 20 +-- ....e2e-spec.ts => create-weekly.e2e-spec.ts} | 54 +++--- ....e2e-spec.ts => delete-weekly.e2e-spec.ts} | 48 +++--- test/e2e/assos/get-daymail-info.e2e-spec.ts | 21 --- test/e2e/assos/get-weekly-info.e2e-spec.ts | 21 +++ test/e2e/assos/index.ts | 20 +-- ...2e-spec.ts => search-weeklies.e2e-spec.ts} | 40 ++--- test/e2e/assos/update-daymail.e2e-spec.ts | 100 ------------ test/e2e/assos/update-weekly.e2e-spec.ts | 100 ++++++++++++ test/utils/fakedb.ts | 18 +- 31 files changed, 393 insertions(+), 393 deletions(-) rename src/assos/dto/req/{asso-get-daymail-req.dto.ts => asso-get-weekly-req.dto.ts} (85%) rename src/assos/dto/req/{assos-post-daymail-req.dto.ts => assos-post-weekly-req.dto.ts} (91%) delete mode 100644 src/assos/dto/res/daymail-info-res.dto.ts create mode 100644 src/assos/dto/res/weekly-info-res.dto.ts rename src/assos/dto/res/{daymail-res.dto.ts => weekly-res.dto.ts} (81%) delete mode 100644 src/assos/interfaces/daymail.interface.ts create mode 100644 src/assos/interfaces/weekly.interface.ts rename test/e2e/assos/{create-daymail.e2e-spec.ts => create-weekly.e2e-spec.ts} (66%) rename test/e2e/assos/{delete-daymail.e2e-spec.ts => delete-weekly.e2e-spec.ts} (52%) delete mode 100644 test/e2e/assos/get-daymail-info.e2e-spec.ts create mode 100644 test/e2e/assos/get-weekly-info.e2e-spec.ts rename test/e2e/assos/{get-daymails.e2e-spec.ts => search-weeklies.e2e-spec.ts} (65%) delete mode 100644 test/e2e/assos/update-daymail.e2e-spec.ts create mode 100644 test/e2e/assos/update-weekly.e2e-spec.ts diff --git a/.env.dist b/.env.dist index 3d9bae34..6b38ec04 100644 --- a/.env.dist +++ b/.env.dist @@ -28,6 +28,6 @@ ETUUTT_WEBSITE_APPLICATION_ID=52ce644d-183f-49e9-bd21-d2d4f37e2196 TIMETABLE_URL="monedt.utt.fr" -# Daymail -DAYMAIL_SEND_DAY=1 -DAYMAIL_SEND_HOUR=8 +# Weekly +WEEKLY_SEND_DAY=1 +WEEKLY_SEND_HOUR=8 diff --git a/.env.test.dist b/.env.test.dist index e460860a..c79961c2 100644 --- a/.env.test.dist +++ b/.env.test.dist @@ -25,6 +25,6 @@ ETUUTT_WEBSITE_APPLICATION_ID=52ce644d-183f-49e9-bd21-d2d4f37e2196 # Can be changed to localhost for testing TIMETABLE_URL="monedt.utt.fr" -# Daymail -DAYMAIL_SEND_DAY=1 -DAYMAIL_SEND_HOUR=8 +# 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 30360f13..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[] - daymails AssoDaymail[] + weeklies AssoWeekly[] events Event[] assoMembershipRoles AssoMembershipRole[] assoAccount User @relation(fields: [assoAccountId], references: [id], onDelete: Cascade) @@ -101,7 +101,7 @@ model AssoMembershipRole { asso Asso @relation(fields: [assoId], references: [id]) } -model AssoDaymail { +model AssoWeekly { id String @id @default(uuid()) createdAt DateTime @default(now()) assoId String @@ -231,8 +231,8 @@ model Translation { assoDescription Asso? @relation("descriptionTranslation") assoDescriptionShort Asso? @relation("descriptionShortTranslation") - assoDaymailTitle AssoDaymail? @relation("titleTranslation") - assoDaymailTitleBody AssoDaymail? @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/assos/assos.controller.ts b/src/assos/assos.controller.ts index 9d3bd223..8e284f41 100644 --- a/src/assos/assos.controller.ts +++ b/src/assos/assos.controller.ts @@ -23,12 +23,12 @@ 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 AssosPostDaymailReqDto from './dto/req/assos-post-daymail-req.dto'; -import DaymailResDto from './dto/res/daymail-res.dto'; +import AssosPostWeeklyReqDto from './dto/req/assos-post-weekly-req.dto'; +import WeeklyResDto from './dto/res/weekly-res.dto'; import { ConfigModule } from '../config/config.module'; -import AssoGetDaymailReqDto from './dto/req/asso-get-daymail-req.dto'; -import { AssoDaymail } from './interfaces/daymail.interface'; -import DaymailInfoResDto from './dto/res/daymail-info-res.dto'; +import AssoGetWeeklyReqDto from './dto/req/asso-get-weekly-req.dto'; +import { AssoWeekly } from './interfaces/weekly.interface'; +import WeeklyInfoResDto from './dto/res/weekly-info-res.dto'; @Controller('assos') @ApiTags('Assos') @@ -223,120 +223,120 @@ export class AssosController { return { roles: updatedRoles.map(this.formatPartialAssoMembershipRole) }; } - @Get('/daymail/info') - @ApiOperation({ description: 'Returns information about daymails.' }) - @ApiOkResponse({ type: DaymailInfoResDto }) - getDaymailInfo(): DaymailInfoResDto { + @Get('/weekly/info') + @ApiOperation({ description: 'Returns information about weeklies.' }) + @ApiOkResponse({ type: WeeklyInfoResDto }) + getWeeklyInfo(): WeeklyInfoResDto { return { - sendDay: this.config.DAYMAIL_SEND_DAY, - sendHour: this.config.DAYMAIL_SEND_HOUR, + sendDay: this.config.WEEKLY_SEND_DAY, + sendHour: this.config.WEEKLY_SEND_HOUR, }; } - @Get('/:assoId/daymail') - @ApiOperation({ description: 'Get daymails from query parameter `from` to query parameter `to`.' }) + @Get('/:assoId/weekly') + @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: DaymailResDto }) + @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 daymail', + 'The user issuing the request does not have the permission weekly', ) - async searchDaymails( + async searchWeeklies( @ParamAsso() asso: Asso, - @Query() { from, to, page }: AssoGetDaymailReqDto, + @Query() { from, to, page }: AssoGetWeeklyReqDto, @GetUser() user: User, - ): Promise> { + ): 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, 'daymail'))) - throw new AppException(ERROR_CODE.FORBIDDEN_ASSOS_PERMISSIONS, asso.id, 'daymail'); - const { daymails, count } = await this.assosService.searchDaymails(asso.id, from, to, page); + 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.assosService.searchWeeklies(asso.id, from, to, page); return { - items: daymails, + items: weeklies, itemCount: count, itemsPerPage: this.config.PAGINATION_PAGE_SIZE, }; } - @Post('/:assoId/daymail') - @ApiOperation({ description: 'Create a daymail for the given association.' }) - @ApiCreatedResponse({ type: DaymailResDto }) + @Post('/:assoId/weekly') + @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 daymail', + 'The user issuing the request does not have the permission weekly', ) - @ApiAppErrorResponse(ERROR_CODE.DAYMAIL_ALREADY_SENT_FOR_WEEK, 'The daymail was already sent for the specified week') - @ApiAppErrorResponse(ERROR_CODE.DAYMAIL_ALREADY_PLANNED_FOR_WEEK, 'The asso already has a daymailed planned for the requested new week') - async createDaymail( + @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: AssosPostDaymailReqDto, + @Body() dto: AssosPostWeeklyReqDto, @GetUser() user: User, - ): Promise { - if (!(await this.assosService.hasSomeAssoPermission(asso, user.id, 'daymail'))) - throw new AppException(ERROR_CODE.FORBIDDEN_ASSOS_PERMISSIONS, asso.id, 'daymail'); - if (this.assosService.getSendDate(dto.date) < new Date()) - throw new AppException(ERROR_CODE.DAYMAIL_ALREADY_SENT_FOR_WEEK, dto.date.toISOString()); - if (await this.assosService.hasDaymailForWeek(asso.id, dto.date)) - throw new AppException(ERROR_CODE.DAYMAIL_ALREADY_PLANNED_FOR_WEEK); - return this.assosService.addDaymail(asso.id, dto.title, dto.message, dto.date); + ): Promise { + if (!(await this.assosService.hasSomeAssoPermission(asso, user.id, 'weekly'))) + throw new AppException(ERROR_CODE.FORBIDDEN_ASSOS_PERMISSIONS, asso.id, 'weekly'); + if (this.assosService.getWeeklySendDate(dto.date) < new Date()) + throw new AppException(ERROR_CODE.WEEKLY_ALREADY_SENT_FOR_WEEK, dto.date.toISOString()); + if (await this.assosService.hasWeekly(asso.id, dto.date)) + throw new AppException(ERROR_CODE.WEEKLY_ALREADY_PLANNED_FOR_WEEK); + return this.assosService.addWeekly(asso.id, dto.title, dto.message, dto.date); } - @Patch('/:assoId/daymail/:daymailId') - @ApiOperation({ description: 'Update a daymail for the given association.' }) - @ApiOkResponse({ type: paginatedResponseDto(DaymailResDto) }) + @Patch('/:assoId/weekly/: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 daymail', + 'The user issuing the request does not have the permission weekly', ) - @ApiAppErrorResponse(ERROR_CODE.NO_SUCH_DAYMAIL, 'The daymail does not exist for the specified asso') - @ApiAppErrorResponse(ERROR_CODE.DAYMAIL_ALREADY_SENT, 'The daymail that is beeing modified was already sent') - @ApiAppErrorResponse(ERROR_CODE.DAYMAIL_ALREADY_SENT_FOR_WEEK, 'The daymail was already sent for the specified week') - @ApiAppErrorResponse(ERROR_CODE.DAYMAIL_ALREADY_PLANNED_FOR_WEEK, 'The asso already has a daymailed planned for the requested new week') - async updateDaymail( + @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('daymailId') daymailId: string, - @Body() dto: AssosPostDaymailReqDto, + @UUIDParam('weeklyId') weeklyId: string, + @Body() dto: AssosPostWeeklyReqDto, @GetUser() user: User, - ): Promise { - if (!(await this.assosService.hasSomeAssoPermission(asso, user.id, 'daymail'))) - throw new AppException(ERROR_CODE.FORBIDDEN_ASSOS_PERMISSIONS, asso.id, 'daymail'); - const daymail: AssoDaymail = await this.assosService.getDaymail(daymailId, asso.id); - if (!daymail) throw new AppException(ERROR_CODE.NO_SUCH_DAYMAIL, daymailId); - if (this.assosService.getSendDate(daymail.date) < new Date()) - throw new AppException(ERROR_CODE.DAYMAIL_ALREADY_SENT); - if (this.assosService.getSendDate(dto.date) < new Date()) - throw new AppException(ERROR_CODE.DAYMAIL_ALREADY_SENT_FOR_WEEK, dto.date.toISOString()); - if (await this.assosService.hasDaymailForWeek(asso.id, daymail.date, daymail.id)) - throw new AppException(ERROR_CODE.DAYMAIL_ALREADY_PLANNED_FOR_WEEK); - return this.assosService.updateDaymail(daymailId, dto); + ): 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.assosService.getWeekly(weeklyId, asso.id); + if (!weekly) throw new AppException(ERROR_CODE.NO_SUCH_WEEKLY, weeklyId); + if (this.assosService.getWeeklySendDate(weekly.date) < new Date()) + throw new AppException(ERROR_CODE.WEEKLY_ALREADY_SENT); + if (this.assosService.getWeeklySendDate(dto.date) < new Date()) + throw new AppException(ERROR_CODE.WEEKLY_ALREADY_SENT_FOR_WEEK, dto.date.toISOString()); + if (await this.assosService.hasWeekly(asso.id, weekly.date, weekly.id)) + throw new AppException(ERROR_CODE.WEEKLY_ALREADY_PLANNED_FOR_WEEK); + return this.assosService.updateWeekly(weeklyId, dto); } - @Delete('/:assoId/daymail/:daymailId') - @ApiOperation({ description: 'Delete a daymail for the given association.' }) - @ApiOkResponse({ type: DaymailResDto }) + @Delete('/:assoId/weekly/: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 daymail', + 'The user issuing the request does not have the permission weekly', ) - @ApiAppErrorResponse(ERROR_CODE.NO_SUCH_DAYMAIL) - @ApiAppErrorResponse(ERROR_CODE.DAYMAIL_ALREADY_SENT, 'The daymail that is beeing modified was already sent') - async deleteDaymail( + @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('daymailId') daymailId: string, + @UUIDParam('weeklyId') weeklyId: string, @GetUser() user: User, - ): Promise { - if (!(await this.assosService.hasSomeAssoPermission(asso, user.id, 'daymail'))) - throw new AppException(ERROR_CODE.FORBIDDEN_ASSOS_PERMISSIONS, asso.id, 'daymail'); - const daymail: AssoDaymail = await this.assosService.getDaymail(daymailId, asso.id); - if (!daymail) throw new AppException(ERROR_CODE.NO_SUCH_DAYMAIL, daymailId); - if (this.assosService.getSendDate(daymail.date) < new Date()) - throw new AppException(ERROR_CODE.DAYMAIL_ALREADY_SENT); - return this.assosService.deleteDaymail(daymailId); + ): 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.assosService.getWeekly(weeklyId, asso.id); + if (!weekly) throw new AppException(ERROR_CODE.NO_SUCH_WEEKLY, weeklyId); + if (this.assosService.getWeeklySendDate(weekly.date) < new Date()) + throw new AppException(ERROR_CODE.WEEKLY_ALREADY_SENT); + return this.assosService.deleteWeekly(weeklyId); } formatAssoOverview(asso: Asso): AssoOverviewResDto { diff --git a/src/assos/assos.service.ts b/src/assos/assos.service.ts index 91c62f83..39dee7d9 100644 --- a/src/assos/assos.service.ts +++ b/src/assos/assos.service.ts @@ -9,8 +9,8 @@ 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 { AssoDaymail } from './interfaces/daymail.interface'; -import DaymailResDto from './dto/res/daymail-res.dto'; +import { AssoWeekly } from './interfaces/weekly.interface'; +import WeeklyResDto from './dto/res/weekly-res.dto'; @Injectable() export class AssosService { @@ -288,18 +288,18 @@ export class AssosService { }); } - async searchDaymails(assoId: string, from: Date, to: Date, page: number): Promise<{ daymails: AssoDaymail[], count: number }> { + 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.AssoDaymailWhereInput; - const count = await this.prisma.assoDaymail.count({ where }); - const daymails = await this.prisma.normalize.assoDaymail.findMany({ where, skip: (page - 1) * this.config.PAGINATION_PAGE_SIZE, take: this.config.PAGINATION_PAGE_SIZE }); - return { daymails, count }; + } 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 addDaymail(assoId: string, title: Translation, message: Translation, date): Promise { - return this.prisma.normalize.assoDaymail.create({ + async addWeekly(assoId: string, title: Translation, message: Translation, date): Promise { + return this.prisma.normalize.assoWeekly.create({ data: { asso: { connect: { id: assoId } }, titleTranslation: { create: title }, @@ -309,36 +309,36 @@ export class AssosService { }); } - getSendDate(sendWeek: Date): Date { + getWeeklySendDate(sendWeek: Date): Date { return new Date( Date.UTC( sendWeek.getUTCFullYear(), sendWeek.getUTCMonth(), - sendWeek.getUTCDate() + this.config.DAYMAIL_SEND_DAY, - this.config.DAYMAIL_SEND_HOUR, + sendWeek.getUTCDate() + this.config.WEEKLY_SEND_DAY, + this.config.WEEKLY_SEND_HOUR, 0, -Date.getTimezoneOffset('Europe/Paris') ) ); } - async getDaymail(daymailId: string, assoId?: string): Promise { - return this.prisma.normalize.assoDaymail.findUnique({ where: { id: daymailId, assoId } }); + async getWeekly(weeklyId: string, assoId?: string): Promise { + return this.prisma.normalize.assoWeekly.findUnique({ where: { id: weeklyId, assoId } }); } - async hasDaymailForWeek(assoId: string, date: Date, excludeDaymail: string = undefined): Promise { - return (await this.prisma.assoDaymail.count({ + async hasWeekly(assoId: string, date: Date, exclude: string = undefined): Promise { + return (await this.prisma.assoWeekly.count({ where: { assoId, date, - ...(excludeDaymail ? { id: { not: excludeDaymail } } : {}) + ...(exclude ? { id: { not: exclude } } : {}) } })) > 0; } - async updateDaymail(daymailId: string, fields: { title: Translation, message: Translation, date: Date }): Promise { - return this.prisma.normalize.assoDaymail.update({ - where: { id: daymailId }, + 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 }, @@ -347,7 +347,7 @@ export class AssosService { }); } - async deleteDaymail(daymailId: string): Promise { - return this.prisma.normalize.assoDaymail.delete({ where: { id: daymailId } }); + async deleteWeekly(weeklyId: string): Promise { + return this.prisma.normalize.assoWeekly.delete({ where: { id: weeklyId } }); } } diff --git a/src/assos/dto/req/asso-get-daymail-req.dto.ts b/src/assos/dto/req/asso-get-weekly-req.dto.ts similarity index 85% rename from src/assos/dto/req/asso-get-daymail-req.dto.ts rename to src/assos/dto/req/asso-get-weekly-req.dto.ts index 4e191ccf..7b5557cb 100644 --- a/src/assos/dto/req/asso-get-daymail-req.dto.ts +++ b/src/assos/dto/req/asso-get-weekly-req.dto.ts @@ -1,7 +1,7 @@ import { IsDate, IsInt, IsOptional } from 'class-validator'; import { Type } from 'class-transformer'; -export default class AssoGetDaymailReqDto { +export default class AssoGetWeeklyReqDto { @IsDate() @Type(() => Date) from?: Date; diff --git a/src/assos/dto/req/assos-post-daymail-req.dto.ts b/src/assos/dto/req/assos-post-weekly-req.dto.ts similarity index 91% rename from src/assos/dto/req/assos-post-daymail-req.dto.ts rename to src/assos/dto/req/assos-post-weekly-req.dto.ts index 6de9bddc..2f6b069e 100644 --- a/src/assos/dto/req/assos-post-daymail-req.dto.ts +++ b/src/assos/dto/req/assos-post-weekly-req.dto.ts @@ -3,7 +3,7 @@ import { IsDate, IsNotEmpty, ValidateNested } from 'class-validator'; import { TranslationReqDto } from '../../../app.dto'; import { IsWeekDate } from '../../../validation'; -export default class AssosPostDaymailReqDto { +export default class AssosPostWeeklyReqDto { @ValidateNested() @IsNotEmpty() @Type(() => TranslationReqDto) diff --git a/src/assos/dto/res/daymail-info-res.dto.ts b/src/assos/dto/res/daymail-info-res.dto.ts deleted file mode 100644 index 595dfd06..00000000 --- a/src/assos/dto/res/daymail-info-res.dto.ts +++ /dev/null @@ -1,4 +0,0 @@ -export default class DaymailInfoResDto { - sendDay: number; - sendHour: number; -} \ No newline at end of file diff --git a/src/assos/dto/res/weekly-info-res.dto.ts b/src/assos/dto/res/weekly-info-res.dto.ts new file mode 100644 index 00000000..e78739e2 --- /dev/null +++ b/src/assos/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/dto/res/daymail-res.dto.ts b/src/assos/dto/res/weekly-res.dto.ts similarity index 81% rename from src/assos/dto/res/daymail-res.dto.ts rename to src/assos/dto/res/weekly-res.dto.ts index 452c9d68..3c65a2ab 100644 --- a/src/assos/dto/res/daymail-res.dto.ts +++ b/src/assos/dto/res/weekly-res.dto.ts @@ -1,6 +1,6 @@ import { Translation } from '../../../prisma/types'; -export default class DaymailResDto { +export default class WeeklyResDto { id: string; assoId: string; createdAt: Date; diff --git a/src/assos/interfaces/daymail.interface.ts b/src/assos/interfaces/daymail.interface.ts deleted file mode 100644 index 6595dbf2..00000000 --- a/src/assos/interfaces/daymail.interface.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { Prisma, PrismaClient } from '@prisma/client'; -import { generateCustomModel } from '../../prisma/prisma.service'; -import { pick, translationSelect } from '../../utils'; -import { Translation } from '../../prisma/types'; - -const ASSO_DAYMAIL_SELECT_FILTER = { - select: { - id: true, - assoId: true, - createdAt: true, - titleTranslation: translationSelect, - bodyTranslation: translationSelect, - date: true, - }, - orderBy: { date: 'asc'} -} as const satisfies Prisma.AssoDaymailFindManyArgs; - -export type UnformattedAssoDaymail = Prisma.AssoDaymailGetPayload; -export type AssoDaymail = Pick & { title: Translation, message: Translation } - -export const generateCustomAssoDaymailModel = (prisma: PrismaClient) => - generateCustomModel(prisma, 'assoDaymail', ASSO_DAYMAIL_SELECT_FILTER, formatAssoDaymail); - -function formatAssoDaymail(_: PrismaClient, r: UnformattedAssoDaymail): AssoDaymail { - return { - ...pick(r, 'id', 'assoId', 'createdAt', 'date'), title: r.titleTranslation, message: r.bodyTranslation - } -} 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/config/config.module.ts b/src/config/config.module.ts index fd113ac2..7816928f 100644 --- a/src/config/config.module.ts +++ b/src/config/config.module.ts @@ -32,8 +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 DAYMAIL_SEND_DAY: number; - public readonly DAYMAIL_SEND_HOUR: number; + public readonly WEEKLY_SEND_DAY: number; + public readonly WEEKLY_SEND_HOUR: number; // DEV ENVIRONMENT ONLY // TEST ENVIRONMENT ONLY @@ -55,8 +55,8 @@ export class ConfigModule { 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.DAYMAIL_SEND_DAY = Number.parseInt(config.get('DAYMAIL_SEND_DAY')); - this.DAYMAIL_SEND_HOUR = Number.parseInt(config.get('DAYMAIL_SEND_HOUR')); + 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 5e4da192..fe58bc2e 100644 --- a/src/exceptions.ts +++ b/src/exceptions.ts @@ -45,9 +45,9 @@ export const enum ERROR_CODE { PARAM_DOES_NOT_MATCH_REGEX = 2102, NO_FIELD_PROVIDED = 2201, WIDGET_OVERLAPPING = 2301, - DAYMAIL_ALREADY_SENT = 2302, - DAYMAIL_ALREADY_SENT_FOR_WEEK = 2303, - DAYMAIL_ALREADY_PLANNED_FOR_WEEK = 2304, + 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, @@ -89,7 +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_DAYMAIL = 4417, + NO_SUCH_WEEKLY = 4417, ANNAL_ALREADY_UPLOADED = 4901, RESOURCE_UNAVAILABLE = 4902, RESOURCE_INVALID_TYPE = 4903, @@ -240,16 +240,16 @@ export const ErrorData = Object.freeze({ message: 'Widgets at index % and % are overlapping', httpCode: HttpStatus.BAD_REQUEST, }, - [ERROR_CODE.DAYMAIL_ALREADY_SENT]: { - message: 'Daymail is already sent', + [ERROR_CODE.WEEKLY_ALREADY_SENT]: { + message: 'Weekly is already sent', httpCode: HttpStatus.BAD_REQUEST, }, - [ERROR_CODE.DAYMAIL_ALREADY_SENT_FOR_WEEK]: { - message: 'Daymail is already sent for the week starting at %', + [ERROR_CODE.WEEKLY_ALREADY_SENT_FOR_WEEK]: { + message: 'Weekly is already sent for the week starting at %', httpCode: HttpStatus.BAD_REQUEST, }, - [ERROR_CODE.DAYMAIL_ALREADY_PLANNED_FOR_WEEK]: { - message: 'Asso already has a daymail planned for this week', + [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]: { @@ -408,8 +408,8 @@ export const ErrorData = Object.freeze({ message: 'No such membership in asso: %', httpCode: HttpStatus.NOT_FOUND, }, - [ERROR_CODE.NO_SUCH_DAYMAIL]: { - message: 'No such daymail in asso: %', + [ERROR_CODE.NO_SUCH_WEEKLY]: { + message: 'No such weekly in asso: %', httpCode: HttpStatus.NOT_FOUND, }, [ERROR_CODE.ANNAL_ALREADY_UPLOADED]: { diff --git a/src/prisma/prisma.service.ts b/src/prisma/prisma.service.ts index 18dffe7d..0484736c 100644 --- a/src/prisma/prisma.service.ts +++ b/src/prisma/prisma.service.ts @@ -14,7 +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 { generateCustomAssoDaymailModel } from '../assos/interfaces/daymail.interface'; +import { generateCustomAssoWeeklyModel } from '../assos/interfaces/weekly.interface'; @Injectable() export class PrismaService extends PrismaClient> { @@ -55,7 +55,7 @@ function createNormalizedEntitiesUtility(prisma: PrismaClient) { assoMembershipRole: generateCustomAssoMembershipRoleModel(prisma), ueCreditCategory: generateCustomCreditCategoryModel(prisma), apiApplication: generateCustomApplicationModel(prisma), - assoDaymail: generateCustomAssoDaymailModel(prisma), + assoWeekly: generateCustomAssoWeeklyModel(prisma), }; } diff --git a/src/prisma/types.ts b/src/prisma/types.ts index 565c2977..c29ba04c 100644 --- a/src/prisma/types.ts +++ b/src/prisma/types.ts @@ -37,7 +37,6 @@ export { UserPrivacy as RawUserPrivacy, ApiApplication as RawApiApplication, ApiKey as RawApiKey, - AssoDaymail as RawAssoDaymail, } from '@prisma/client'; export { RawTranslation }; 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/test/declarations.d.ts b/test/declarations.d.ts index d36918e4..c7e0818a 100644 --- a/test/declarations.d.ts +++ b/test/declarations.d.ts @@ -3,16 +3,17 @@ import { UeComment } from 'src/ue/comments/interfaces/comment.interface'; import { UeCommentReply } from 'src/ue/comments/interfaces/comment-reply.interface'; import { UeRating } from 'src/ue/interfaces/rate.interface'; import { - FakeApiApplication, FakeAssoDaymail, + 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,8 +107,8 @@ declare module './declarations' { expectApplication(application: FakeApiApplication): this; expectPermissions(permissions: PermissionManager): this; - expectAssoDaymail(daymail: JsonLikeVariant, created = false): this; - expectAssoDaymails(app: AppProvider, daymails: JsonLikeVariant[], count: number): 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 928bd650..e91e6e4d 100644 --- a/test/declarations.ts +++ b/test/declarations.ts @@ -14,7 +14,7 @@ import { FakeUeCreditCategory, FakeApiApplication, FakeAssoMembershipRole, - FakeAssoMembership, FakeAssoDaymail, + FakeAssoMembership, FakeAssoWeekly, } from './utils/fakedb'; import { UeAnnalFile } from 'src/ue/annals/interfaces/annal.interface'; import { ConfigModule } from '../src/config/config.module'; @@ -315,19 +315,19 @@ Spec.prototype.expectPermissions = function (permissions: PermissionManager) { .mappedSort((permission) => permission.permission), } satisfies PermissionsResDto); }; -Spec.prototype.expectAssoDaymail = function (this: Spec, daymail: JsonLikeVariant, created = false) { +Spec.prototype.expectAssoWeekly = function (this: Spec, weekly: JsonLikeVariant, created = false) { return this.expectStatus(created ? HttpStatus.CREATED : HttpStatus.OK).$expectRegexableJson({ - ...pick(daymail, 'id', 'assoId', 'createdAt', 'date'), - title: daymail.title[this.language], - message: daymail.message[this.language] + ...pick(weekly, 'id', 'assoId', 'createdAt', 'date'), + title: weekly.title[this.language], + message: weekly.message[this.language] }); }; -Spec.prototype.expectAssoDaymails = function (this: Spec, app: AppProvider, daymails: JsonLikeVariant[], count: number) { +Spec.prototype.expectAssoWeeklies = function (this: Spec, app: AppProvider, weeklies: JsonLikeVariant[], count: number) { return this.expectStatus(HttpStatus.OK).$expectRegexableJson({ - items: daymails.map((daymail) => ({ - ...pick(daymail, 'id', 'assoId', 'createdAt', 'date'), - title: daymail.title[this.language], - message: daymail.message[this.language] + 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, diff --git a/test/e2e/assos/create-daymail.e2e-spec.ts b/test/e2e/assos/create-weekly.e2e-spec.ts similarity index 66% rename from test/e2e/assos/create-daymail.e2e-spec.ts rename to test/e2e/assos/create-weekly.e2e-spec.ts index 5c71d5a4..b7039b05 100644 --- a/test/e2e/assos/create-daymail.e2e-spec.ts +++ b/test/e2e/assos/create-weekly.e2e-spec.ts @@ -1,6 +1,6 @@ import { Dummies, e2eSuite, JsonLike } from '../../utils/test_utils'; import { - createAsso, createAssoDaymail, + createAsso, createAssoWeekly, createAssoMembership, createAssoMembershipPermission, createAssoMembershipRole, @@ -8,32 +8,32 @@ import { } from '../../utils/fakedb'; import * as pactum from 'pactum'; import { ERROR_CODE } from '../../../src/exceptions'; -import AssosPostDaymailReqDto from '../../../src/assos/dto/req/assos-post-daymail-req.dto'; +import AssosPostWeeklyReqDto from '../../../src/assos/dto/req/assos-post-weekly-req.dto'; import { PrismaService } from '../../../src/prisma/prisma.service'; -const CreateDaymailE2ESpec = e2eSuite('POST /assos/:assoId/daymail', (app) => { +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: 'daymail' }); + const permissionManageAsso = createAssoMembershipPermission(app, { id: 'weekly' }); const role = createAssoMembershipRole(app, { asso }); createAssoMembership(app, { asso, user: userWithPermission, role, permissions: [permissionManageAsso] }); - const daymail = createAssoDaymail(app, { asso }, { date: new Date().add({ days: 7 }).getWeekDate() }); + const weekly = createAssoWeekly(app, { asso }, { date: new Date().add({ days: 7 }).getWeekDate() }); - const body: AssosPostDaymailReqDto = { + const body: AssosPostWeeklyReqDto = { title: { fr: "The title" }, message: { fr: "The message" }, date: new Date().add({ days: 14 }).getWeekDate(), } it('should return 403 as user is not authenticated', () => - pactum.spec().post(`/assos/${asso.id}/daymail`).expectAppError(ERROR_CODE.NOT_LOGGED_IN)); + 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/daymail') + .post('/assos/thisisnotavaliduuid/weekly') .withBody(body) .expectAppError(ERROR_CODE.PARAM_NOT_UUID, 'assoId')); @@ -41,61 +41,61 @@ const CreateDaymailE2ESpec = e2eSuite('POST /assos/:assoId/daymail', (app) => { pactum .spec() .withBearerToken(userWithPermission.token) - .post(`/assos/${Dummies.UUID}/daymail`) + .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 daymail', () => pactum + 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}/daymail`) + .post(`/assos/${asso.id}/weekly`) .withBody(body) - .expectAppError(ERROR_CODE.FORBIDDEN_ASSOS_PERMISSIONS, asso.id, 'daymail')); + .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}/daymail`) + .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}/daymail`) + .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}/daymail`) + .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 daymail was already sent for the requested week', () => pactum + it('should fail as the weekly was already sent for the requested week', () => pactum .spec() .withBearerToken(userWithPermission.token) - .post(`/assos/${asso.id}/daymail`) + .post(`/assos/${asso.id}/weekly`) .withBody({ ...body, date: new Date(Date.UTC(2024, 10, 10)) }) - .expectAppError(ERROR_CODE.DAYMAIL_ALREADY_SENT_FOR_WEEK, new Date(Date.UTC(2024, 10, 10)).toISOString())); + .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 daymail for the requested week', () => + it('should fail as the asso already has a planned weekly for the requested week', () => pactum .spec() .withBearerToken(userWithPermission.token) - .post(`/assos/${asso.id}/daymail`) - .withBody({ ...body, date: daymail.date }) - .expectAppError(ERROR_CODE.DAYMAIL_ALREADY_PLANNED_FOR_WEEK)); + .post(`/assos/${asso.id}/weekly`) + .withBody({ ...body, date: weekly.date }) + .expectAppError(ERROR_CODE.WEEKLY_ALREADY_PLANNED_FOR_WEEK)); - it('should successfully create a new daymail', async () => { + it('should successfully create a new weekly', async () => { const id: string = await pactum .spec() .withBearerToken(userWithPermission.token) - .post(`/assos/${asso.id}/daymail`) + .post(`/assos/${asso.id}/weekly`) .withBody(body) - .expectAssoDaymail( + .expectAssoWeekly( { id: JsonLike.UUID, assoId: asso.id, @@ -107,8 +107,8 @@ const CreateDaymailE2ESpec = e2eSuite('POST /assos/:assoId/daymail', (app) => { true, ) .returns('id'); - return app().get(PrismaService).assoDaymail.delete({ where: { id } }); + return app().get(PrismaService).assoWeekly.delete({ where: { id } }); }); }); -export default CreateDaymailE2ESpec; +export default CreateWeeklyE2ESpec; diff --git a/test/e2e/assos/delete-daymail.e2e-spec.ts b/test/e2e/assos/delete-weekly.e2e-spec.ts similarity index 52% rename from test/e2e/assos/delete-daymail.e2e-spec.ts rename to test/e2e/assos/delete-weekly.e2e-spec.ts index fda73e68..d18b1f6d 100644 --- a/test/e2e/assos/delete-daymail.e2e-spec.ts +++ b/test/e2e/assos/delete-weekly.e2e-spec.ts @@ -1,6 +1,6 @@ import { Dummies, e2eSuite } from '../../utils/test_utils'; import { - createAsso, createAssoDaymail, + createAsso, createAssoWeekly, createAssoMembership, createAssoMembershipPermission, createAssoMembershipRole, @@ -9,63 +9,63 @@ import { import * as pactum from 'pactum'; import { ERROR_CODE } from '../../../src/exceptions'; -const DeleteDaymailE2ESpec = e2eSuite('DELETE /assos/:assoId/daymail/:daymailId', (app) => { +const DeleteWeeklyE2ESpec = e2eSuite('DELETE /assos/:assoId/weekly/:weeklyId', (app) => { const userWithPermission = createUser(app); const userWithoutPermission = createUser(app); - const permissionManageAsso = createAssoMembershipPermission(app, { id: 'daymail' }); + const permissionManageAsso = createAssoMembershipPermission(app, { id: 'weekly' }); const asso = createAsso(app); const role = createAssoMembershipRole(app, { asso }); createAssoMembership(app, { asso, user: userWithPermission, role, permissions: [permissionManageAsso] }); - const daymail = createAssoDaymail(app, { asso }, { date: new Date().add({ days: 14 }).getWeekDate() }); - const oldDaymail = createAssoDaymail(app, { asso }, { date: new Date(Date.UTC(2025, 10, 10)) }); + 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 403 as user is not authenticated', () => - pactum.spec().delete(`/assos/${asso.id}/daymail/${daymail.id}`).expectAppError(ERROR_CODE.NOT_LOGGED_IN)); + 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}/daymail/${daymail.id}`) + .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 daymails', () => pactum + 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}/daymail/${daymail.id}`) - .expectAppError(ERROR_CODE.FORBIDDEN_ASSOS_PERMISSIONS, asso.id, 'daymail')); + .delete(`/assos/${asso.id}/weekly/${weekly.id}`) + .expectAppError(ERROR_CODE.FORBIDDEN_ASSOS_PERMISSIONS, asso.id, 'weekly')); - it('should return a 404 as daymail is not found', () => pactum + it('should return a 404 as weekly is not found', () => pactum .spec() .withBearerToken(userWithPermission.token) - .delete(`/assos/${asso.id}/daymail/${Dummies.UUID}`) - .expectAppError(ERROR_CODE.NO_SUCH_DAYMAIL, Dummies.UUID)) + .delete(`/assos/${asso.id}/weekly/${Dummies.UUID}`) + .expectAppError(ERROR_CODE.NO_SUCH_WEEKLY, Dummies.UUID)) - it('should return a 404 as the daymail does not belong to the asso', () => pactum + it('should return a 404 as the weekly does not belong to the asso', () => pactum .spec() .withBearerToken(userWithPermission.token) - .delete(`/assos/${otherAsso.id}/daymail/${daymail.id}`) - .expectAppError(ERROR_CODE.NO_SUCH_DAYMAIL, daymail.id)); + .delete(`/assos/${otherAsso.id}/weekly/${weekly.id}`) + .expectAppError(ERROR_CODE.NO_SUCH_WEEKLY, weekly.id)); - it('should return a 400 as the daymail was already sent', () => pactum + it('should return a 400 as the weekly was already sent', () => pactum .spec() .withBearerToken(userWithPermission.token) - .delete(`/assos/${asso.id}/daymail/${oldDaymail.id}`) - .expectAppError(ERROR_CODE.DAYMAIL_ALREADY_SENT)); + .delete(`/assos/${asso.id}/weekly/${oldWeekly.id}`) + .expectAppError(ERROR_CODE.WEEKLY_ALREADY_SENT)); - it('should delete the daymail', async () => { + it('should delete the weekly', async () => { await pactum .spec() .withBearerToken(userWithPermission.token) - .delete(`/assos/${asso.id}/daymail/${daymail.id}`) - .expectAssoDaymail(daymail); - await createAssoDaymail(app, { asso }, daymail, true); + .delete(`/assos/${asso.id}/weekly/${weekly.id}`) + .expectAssoWeekly(weekly); + await createAssoWeekly(app, { asso }, weekly, true); }); }); -export default DeleteDaymailE2ESpec; +export default DeleteWeeklyE2ESpec; diff --git a/test/e2e/assos/get-daymail-info.e2e-spec.ts b/test/e2e/assos/get-daymail-info.e2e-spec.ts deleted file mode 100644 index 5c45bfc3..00000000 --- a/test/e2e/assos/get-daymail-info.e2e-spec.ts +++ /dev/null @@ -1,21 +0,0 @@ -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 GetDaymailInfoE2ESpec = e2eSuite('GET /assos/daymail/info', (app) => { - const user = createUser(app); - - it('should return 403 as user is not authenticated', () => - pactum.spec().get('/assos/daymail/info').expectAppError(ERROR_CODE.NOT_LOGGED_IN)); - - it('should successfully create a new daymail', () => - pactum - .spec() - .withBearerToken(user.token) - .get('/assos/daymail/info') - .expectJson({ sendDay: app().get(ConfigModule).DAYMAIL_SEND_DAY, sendHour: app().get(ConfigModule).DAYMAIL_SEND_HOUR })); -}); - -export default GetDaymailInfoE2ESpec; 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..c4f810d7 --- /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 403 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 7f786046..e3e9d47f 100644 --- a/test/e2e/assos/index.ts +++ b/test/e2e/assos/index.ts @@ -8,11 +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 CreateDaymailE2ESpec from './create-daymail.e2e-spec'; -import GetDaymailsE2ESpec from './get-daymails.e2e-spec'; -import UpdateDaymailE2ESpec from './update-daymail.e2e-spec'; -import DeleteDaymailE2ESpec from './delete-daymail.e2e-spec'; -import GetDaymailInfoE2ESpec from './get-daymail-info.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', () => { @@ -25,10 +25,10 @@ export default function AssoE2ESpec(app: () => INestApplication) { CreateAssoRoleE2ESpec(app); DeleteAssoRoleE2ESpec(app); UpdateAssoRoleE2ESpec(app); - GetDaymailInfoE2ESpec(app); - GetDaymailsE2ESpec(app); - CreateDaymailE2ESpec(app); - UpdateDaymailE2ESpec(app); - DeleteDaymailE2ESpec(app); + GetWeeklyInfoE2ESpec(app); + SearchWeekliesE2ESpec(app); + CreateWeeklyE2ESpec(app); + UpdateWeeklyE2ESpec(app); + DeleteWeeklyE2ESpec(app); }); } diff --git a/test/e2e/assos/get-daymails.e2e-spec.ts b/test/e2e/assos/search-weeklies.e2e-spec.ts similarity index 65% rename from test/e2e/assos/get-daymails.e2e-spec.ts rename to test/e2e/assos/search-weeklies.e2e-spec.ts index d0227bd0..75294db9 100644 --- a/test/e2e/assos/get-daymails.e2e-spec.ts +++ b/test/e2e/assos/search-weeklies.e2e-spec.ts @@ -1,6 +1,6 @@ import { Dummies, e2eSuite } from '../../utils/test_utils'; import { - createAsso, createAssoDaymail, + createAsso, createAssoWeekly, createAssoMembership, createAssoMembershipPermission, createAssoMembershipRole, @@ -9,34 +9,34 @@ import { import * as pactum from 'pactum'; import { ERROR_CODE } from '../../../src/exceptions'; -const SearchDaymailsE2ESpec = e2eSuite('GET /assos/:assoId/daymail', (app) => { +const SearchWeekliesE2ESpec = e2eSuite('GET /assos/:assoId/weekly', (app) => { const userWithPermission = createUser(app); const userWithoutPermission = createUser(app); - const permissionManageAsso = createAssoMembershipPermission(app, { id: 'daymail' }); + 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 daymailAssoOctober1 = createAssoDaymail(app, { asso }, { date: new Date(Date.UTC(2025, 9, 1)) }); - const daymailAssoOctober2 = createAssoDaymail(app, { asso }, { date: new Date(Date.UTC(2025, 9, 2)) }); - createAssoDaymail(app, { asso }, { date: new Date(Date.UTC(2025, 8, 1)) }); // September - createAssoDaymail(app, { asso }, { date: new Date(Date.UTC(2025, 10, 1)) }); // November + 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 daymail for another asso, that we should not get + // 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] }); - createAssoDaymail(app, { asso: otherAsso }, { date: new Date(Date.UTC(2025, 9, 6)) }); + createAssoWeekly(app, { asso: otherAsso }, { date: new Date(Date.UTC(2025, 9, 6)) }); it('should return 403 as user is not authenticated', () => - pactum.spec().get(`/assos/${asso.id}/daymail/`).withQueryParams({ from: '2025-10-01T00:00:00Z', to: '2025-10-10T00:00:00Z' }).expectAppError(ERROR_CODE.NOT_LOGGED_IN)); + 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/daymail') + .get('/assos/thisisnotavaliduuid/weekly') .withQueryParams({ from: '2025-10-01T00:00:00Z', to: '2025-10-10T00:00:00Z' }) .expectAppError(ERROR_CODE.PARAM_NOT_UUID, 'assoId')); @@ -44,7 +44,7 @@ const SearchDaymailsE2ESpec = e2eSuite('GET /assos/:assoId/daymail', (app) => { pactum .spec() .withBearerToken(userWithPermission.token) - .get(`/assos/${Dummies.UUID}/daymail`) + .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)); @@ -52,23 +52,23 @@ const SearchDaymailsE2ESpec = e2eSuite('GET /assos/:assoId/daymail', (app) => { pactum .spec() .withBearerToken(userWithPermission.token) - .get(`/assos/${asso.id}/daymail`) + .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 daymails', () => pactum + 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}/daymail`) + .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, 'daymail')); + .expectAppError(ERROR_CODE.FORBIDDEN_ASSOS_PERMISSIONS, asso.id, 'weekly')); - it('should return daymails for `asso` between October, 1st and October, 31st', () => pactum + it('should return weeklies for `asso` between October, 1st and October, 31st', () => pactum .spec() .withBearerToken(userWithPermission.token) - .get(`/assos/${asso.id}/daymail`) + .get(`/assos/${asso.id}/weekly`) .withQueryParams({ from: '2025-10-01T00:00:00Z', to: '2025-10-31T00:00:00Z' }) - .expectAssoDaymails(app, [daymailAssoOctober1, daymailAssoOctober2], 2)); + .expectAssoWeeklies(app, [weeklyAssoOctober1, weeklyAssoOctober2], 2)); }); -export default SearchDaymailsE2ESpec; +export default SearchWeekliesE2ESpec; diff --git a/test/e2e/assos/update-daymail.e2e-spec.ts b/test/e2e/assos/update-daymail.e2e-spec.ts deleted file mode 100644 index 745eeaa2..00000000 --- a/test/e2e/assos/update-daymail.e2e-spec.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { Dummies, e2eSuite } from '../../utils/test_utils'; -import { - createAsso, createAssoDaymail, - 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 UpdateDaymailE2ESpec = e2eSuite('PATCH /assos/:assoId/daymail/:daymailId', (app) => { - const userWithPermission = createUser(app); - const userWithoutPermission = createUser(app); - const permissionDaymail = createAssoMembershipPermission(app, { id: 'daymail' }); - - const asso = createAsso(app); - const role = createAssoMembershipRole(app, { asso }); - createAssoMembership(app, { asso, user: userWithPermission, role, permissions: [permissionDaymail] }); - const daymail = createAssoDaymail(app, { asso }, { date: new Date().add({ days: 14 }).getWeekDate() }); - const oldDaymail = createAssoDaymail(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: [permissionDaymail] }); - - const generateBody = () => ({ - title: pick(faker.db.translation(), 'fr', 'en', 'zh'), - message: pick(faker.db.translation(), 'de', 'es'), - date: daymail.date.add({ days: 7 }), - }); - - it('should return 403 as user is not authenticated', () => - pactum.spec().patch(`/assos/${asso.id}/daymail/${daymail.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}/daymail/${daymail.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 daymails', () => pactum - .spec() - .withBearerToken(userWithoutPermission.token) - .patch(`/assos/${asso.id}/daymail/${daymail.id}`) - .withJson(generateBody()) - .expectAppError(ERROR_CODE.FORBIDDEN_ASSOS_PERMISSIONS, asso.id, 'daymail')); - - it('should return a 404 as daymail is not found', () => pactum - .spec() - .withBearerToken(userWithPermission.token) - .patch(`/assos/${asso.id}/daymail/${Dummies.UUID}`) - .withJson(generateBody()) - .expectAppError(ERROR_CODE.NO_SUCH_DAYMAIL, Dummies.UUID)) - - it('should return a 404 as the daymail does not belong to the asso', () => pactum - .spec() - .withBearerToken(userWithPermission.token) - .patch(`/assos/${otherAsso.id}/daymail/${daymail.id}`) - .withJson(generateBody()) - .expectAppError(ERROR_CODE.NO_SUCH_DAYMAIL, daymail.id)); - - it('should return a 400 as the daymail was already sent', () => pactum - .spec() - .withBearerToken(userWithPermission.token) - .patch(`/assos/${asso.id}/daymail/${oldDaymail.id}`) - .withJson(generateBody()) - .expectAppError(ERROR_CODE.DAYMAIL_ALREADY_SENT)); - - it('should return a 400 as the daymails planned at the new date were already sent', () => pactum - .spec() - .withBearerToken(userWithPermission.token) - .patch(`/assos/${asso.id}/daymail/${daymail.id}`) - .withJson({ ...generateBody(), date: new Date(Date.UTC(2024, 10, 10)) }) - .expectAppError(ERROR_CODE.DAYMAIL_ALREADY_SENT_FOR_WEEK, new Date(Date.UTC(2024, 10, 10)).toISOString())); - - it('should update the daymail', async () => { - const body = generateBody(); - await pactum - .spec() - .withBearerToken(userWithPermission.token) - .patch(`/assos/${asso.id}/daymail/${daymail.id}`) - .withJson(body) - .expectAssoDaymail({ - ...daymail, - title: {...daymail.title, ...body.title}, - message: {...daymail.message, ...body.message}, - date: body.date, - }); - daymail.title = {...daymail.title, ...body.title}; - daymail.message = {...daymail.message, ...body.message}; - daymail.date = body.date; - }); -}); - -export default UpdateDaymailE2ESpec; 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..e08dfeb3 --- /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 403 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/utils/fakedb.ts b/test/utils/fakedb.ts index a175dc03..b7829690 100644 --- a/test/utils/fakedb.ts +++ b/test/utils/fakedb.ts @@ -44,7 +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 { AssoDaymail } from '../../src/assos/interfaces/daymail.interface'; +import { AssoWeekly } from '../../src/assos/interfaces/weekly.interface'; import { isDate } from 'node:util/types'; /** @@ -120,7 +120,7 @@ export type FakeHomepageWidget = Partial; export type FakeApiApplication = Partial> & { owner: { id: string; firstName: string; lastName: string }; }; -export type FakeAssoDaymail = Partial>; +export type FakeAssoWeekly = Partial>; export interface FakeEntityMap { assoMembership: { @@ -146,9 +146,9 @@ export interface FakeEntityMap { entity: FakeAsso; params: CreateAssoParameters; }; - assoDaymail: { - entity: FakeAssoDaymail; - params: CreateAssoDaymailParameters; + assoWeekly: { + entity: FakeAssoWeekly; + params: CreateAssoWeeklyParameters; deps: { asso: FakeAsso }; } timetableEntryOverride: { @@ -551,9 +551,9 @@ export const createAsso = entityFaker( }, ); -export type CreateAssoDaymailParameters = FakeAssoDaymail; -export const createAssoDaymail = entityFaker( - 'assoDaymail', +export type CreateAssoWeeklyParameters = FakeAssoWeekly; +export const createAssoWeekly = entityFaker( + 'assoWeekly', { date: new Date, title: { @@ -572,7 +572,7 @@ export const createAssoDaymail = entityFaker( } }, async (app, deps, params) => { - return app().get(PrismaService).normalize.assoDaymail.create({ + return app().get(PrismaService).normalize.assoWeekly.create({ data: { id: params.id, titleTranslation: { create: params.title }, From b394c9de200d705bc86442b0dba574018d5e2e72 Mon Sep 17 00:00:00 2001 From: Teddy Roncin Date: Fri, 21 Nov 2025 15:16:20 +0100 Subject: [PATCH 6/7] Put weeklies into their own controllers / services --- src/assos/assos.controller.ts | 123 +---------------- src/assos/assos.module.ts | 7 +- src/assos/assos.service.ts | 67 +-------- .../dto/req/weekly-req.dto.ts} | 6 +- .../dto/req/weekly-search-req.dto.ts} | 2 +- .../dto/res/weekly-info-res.dto.ts | 0 .../{ => weekly}/dto/res/weekly-res.dto.ts | 2 +- .../weekly/weekly-with-asso-id.controller.ts | 127 ++++++++++++++++++ .../weekly-without-asso-id.controller.ts | 20 +++ src/assos/weekly/weekly.service.ts | 75 +++++++++++ test/e2e/assos/create-weekly.e2e-spec.ts | 2 +- 11 files changed, 235 insertions(+), 196 deletions(-) rename src/assos/{dto/req/assos-post-weekly-req.dto.ts => weekly/dto/req/weekly-req.dto.ts} (72%) rename src/assos/{dto/req/asso-get-weekly-req.dto.ts => weekly/dto/req/weekly-search-req.dto.ts} (85%) rename src/assos/{ => weekly}/dto/res/weekly-info-res.dto.ts (100%) rename src/assos/{ => weekly}/dto/res/weekly-res.dto.ts (72%) create mode 100644 src/assos/weekly/weekly-with-asso-id.controller.ts create mode 100644 src/assos/weekly/weekly-without-asso-id.controller.ts create mode 100644 src/assos/weekly/weekly.service.ts diff --git a/src/assos/assos.controller.ts b/src/assos/assos.controller.ts index 8e284f41..bd569b1b 100644 --- a/src/assos/assos.controller.ts +++ b/src/assos/assos.controller.ts @@ -1,5 +1,5 @@ import { Body, Controller, Delete, Get, Patch, Post, Put, Query } from '@nestjs/common'; -import { ApiCreatedResponse, ApiOkResponse, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger'; +import { ApiCreatedResponse, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiAppErrorResponse, paginatedResponseDto } from '../app.dto'; import { AssoMembershipRole } from './interfaces/membership-role.interface'; import { AssoMembership } from './interfaces/membership.interface'; @@ -23,12 +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 AssosPostWeeklyReqDto from './dto/req/assos-post-weekly-req.dto'; -import WeeklyResDto from './dto/res/weekly-res.dto'; import { ConfigModule } from '../config/config.module'; -import AssoGetWeeklyReqDto from './dto/req/asso-get-weekly-req.dto'; -import { AssoWeekly } from './interfaces/weekly.interface'; -import WeeklyInfoResDto from './dto/res/weekly-info-res.dto'; @Controller('assos') @ApiTags('Assos') @@ -223,122 +218,6 @@ export class AssosController { return { roles: updatedRoles.map(this.formatPartialAssoMembershipRole) }; } - @Get('/weekly/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, - }; - } - - @Get('/:assoId/weekly') - @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.assosService.searchWeeklies(asso.id, from, to, page); - return { - items: weeklies, - itemCount: count, - itemsPerPage: this.config.PAGINATION_PAGE_SIZE, - }; - } - - @Post('/:assoId/weekly') - @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.assosService.getWeeklySendDate(dto.date) < new Date()) - throw new AppException(ERROR_CODE.WEEKLY_ALREADY_SENT_FOR_WEEK, dto.date.toISOString()); - if (await this.assosService.hasWeekly(asso.id, dto.date)) - throw new AppException(ERROR_CODE.WEEKLY_ALREADY_PLANNED_FOR_WEEK); - return this.assosService.addWeekly(asso.id, dto.title, dto.message, dto.date); - } - - @Patch('/:assoId/weekly/: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.assosService.getWeekly(weeklyId, asso.id); - if (!weekly) throw new AppException(ERROR_CODE.NO_SUCH_WEEKLY, weeklyId); - if (this.assosService.getWeeklySendDate(weekly.date) < new Date()) - throw new AppException(ERROR_CODE.WEEKLY_ALREADY_SENT); - if (this.assosService.getWeeklySendDate(dto.date) < new Date()) - throw new AppException(ERROR_CODE.WEEKLY_ALREADY_SENT_FOR_WEEK, dto.date.toISOString()); - if (await this.assosService.hasWeekly(asso.id, weekly.date, weekly.id)) - throw new AppException(ERROR_CODE.WEEKLY_ALREADY_PLANNED_FOR_WEEK); - return this.assosService.updateWeekly(weeklyId, dto); - } - - @Delete('/:assoId/weekly/: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.assosService.getWeekly(weeklyId, asso.id); - if (!weekly) throw new AppException(ERROR_CODE.NO_SUCH_WEEKLY, weeklyId); - if (this.assosService.getWeeklySendDate(weekly.date) < new Date()) - throw new AppException(ERROR_CODE.WEEKLY_ALREADY_SENT); - return this.assosService.deleteWeekly(weeklyId); - } - formatAssoOverview(asso: Asso): AssoOverviewResDto { return { ...pick(asso, 'id', 'name', 'logo', 'president'), 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/assos.service.ts b/src/assos/assos.service.ts index 39dee7d9..e21b02e0 100644 --- a/src/assos/assos.service.ts +++ b/src/assos/assos.service.ts @@ -2,15 +2,13 @@ import { Injectable } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { ConfigModule } from '../config/config.module'; import { PrismaService } from '../prisma/prisma.service'; -import { RawAssoMembershipRole, Translation } from '../prisma/types'; +import { RawAssoMembershipRole } from '../prisma/types'; import { Asso } from './interfaces/asso.interface'; 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 { AssoWeekly } from './interfaces/weekly.interface'; -import WeeklyResDto from './dto/res/weekly-res.dto'; @Injectable() export class AssosService { @@ -287,67 +285,4 @@ export class AssosService { }, }); } - - 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 } }); - } } diff --git a/src/assos/dto/req/assos-post-weekly-req.dto.ts b/src/assos/weekly/dto/req/weekly-req.dto.ts similarity index 72% rename from src/assos/dto/req/assos-post-weekly-req.dto.ts rename to src/assos/weekly/dto/req/weekly-req.dto.ts index 2f6b069e..7e3070b9 100644 --- a/src/assos/dto/req/assos-post-weekly-req.dto.ts +++ b/src/assos/weekly/dto/req/weekly-req.dto.ts @@ -1,9 +1,9 @@ import { Type } from 'class-transformer'; import { IsDate, IsNotEmpty, ValidateNested } from 'class-validator'; -import { TranslationReqDto } from '../../../app.dto'; -import { IsWeekDate } from '../../../validation'; +import { TranslationReqDto } from '../../../../app.dto'; +import { IsWeekDate } from '../../../../validation'; -export default class AssosPostWeeklyReqDto { +export default class WeeklyReqDto { @ValidateNested() @IsNotEmpty() @Type(() => TranslationReqDto) diff --git a/src/assos/dto/req/asso-get-weekly-req.dto.ts b/src/assos/weekly/dto/req/weekly-search-req.dto.ts similarity index 85% rename from src/assos/dto/req/asso-get-weekly-req.dto.ts rename to src/assos/weekly/dto/req/weekly-search-req.dto.ts index 7b5557cb..169af54f 100644 --- a/src/assos/dto/req/asso-get-weekly-req.dto.ts +++ b/src/assos/weekly/dto/req/weekly-search-req.dto.ts @@ -1,7 +1,7 @@ import { IsDate, IsInt, IsOptional } from 'class-validator'; import { Type } from 'class-transformer'; -export default class AssoGetWeeklyReqDto { +export default class WeeklySearchReqDto { @IsDate() @Type(() => Date) from?: Date; diff --git a/src/assos/dto/res/weekly-info-res.dto.ts b/src/assos/weekly/dto/res/weekly-info-res.dto.ts similarity index 100% rename from src/assos/dto/res/weekly-info-res.dto.ts rename to src/assos/weekly/dto/res/weekly-info-res.dto.ts diff --git a/src/assos/dto/res/weekly-res.dto.ts b/src/assos/weekly/dto/res/weekly-res.dto.ts similarity index 72% rename from src/assos/dto/res/weekly-res.dto.ts rename to src/assos/weekly/dto/res/weekly-res.dto.ts index 3c65a2ab..911a9609 100644 --- a/src/assos/dto/res/weekly-res.dto.ts +++ b/src/assos/weekly/dto/res/weekly-res.dto.ts @@ -1,4 +1,4 @@ -import { Translation } from '../../../prisma/types'; +import { Translation } from '../../../../prisma/types'; export default class WeeklyResDto { id: string; 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/test/e2e/assos/create-weekly.e2e-spec.ts b/test/e2e/assos/create-weekly.e2e-spec.ts index b7039b05..0d433a5a 100644 --- a/test/e2e/assos/create-weekly.e2e-spec.ts +++ b/test/e2e/assos/create-weekly.e2e-spec.ts @@ -8,7 +8,7 @@ import { } from '../../utils/fakedb'; import * as pactum from 'pactum'; import { ERROR_CODE } from '../../../src/exceptions'; -import AssosPostWeeklyReqDto from '../../../src/assos/dto/req/assos-post-weekly-req.dto'; +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) => { From abf5327b41a3af4790b71df40bd658b15442dd1b Mon Sep 17 00:00:00 2001 From: Teddy Roncin Date: Fri, 21 Nov 2025 15:45:33 +0100 Subject: [PATCH 7/7] Review part 2 (I forgot to finish it ^^') --- src/prisma/prisma.service.ts | 2 +- src/validation.ts | 7 ++++++- test/e2e/assos/add-member.e2e-spec.ts | 2 +- test/e2e/assos/create-role.e2e-spec.ts | 2 +- test/e2e/assos/create-weekly.e2e-spec.ts | 2 +- test/e2e/assos/delete-role.e2e-spec.ts | 2 +- test/e2e/assos/delete-weekly.e2e-spec.ts | 2 +- test/e2e/assos/get-weekly-info.e2e-spec.ts | 2 +- test/e2e/assos/kick-member.e2e-spec.ts | 2 +- test/e2e/assos/list-members.e2e-spec.ts | 2 +- test/e2e/assos/search-weeklies.e2e-spec.ts | 2 +- test/e2e/assos/update-member.e2e-spec.ts | 2 +- test/e2e/assos/update-role.e2e-spec.ts | 2 +- test/e2e/assos/update-weekly.e2e-spec.ts | 2 +- test/utils/fakedb.ts | 3 +-- 15 files changed, 20 insertions(+), 16 deletions(-) diff --git a/src/prisma/prisma.service.ts b/src/prisma/prisma.service.ts index 0484736c..7f37b759 100644 --- a/src/prisma/prisma.service.ts +++ b/src/prisma/prisma.service.ts @@ -119,7 +119,7 @@ export function generateCustomModel< ModelName extends ModelNameType, Raw, Formatted, - QueryArgs extends Record = object, + QueryArgs extends Record, >( prisma: PrismaClient, modelName: ModelName, diff --git a/src/validation.ts b/src/validation.ts index 07e9ffbd..cf15ea0a 100644 --- a/src/validation.ts +++ b/src/validation.ts @@ -93,7 +93,12 @@ class HasEither implements ValidatorConstraintInterface { return validationArguments.constraints.join(', '); } } -@ValidatorConstraint({ name: 'whitelistValidation', async: false }) + +/** + * 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; 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 index 0d433a5a..a22115dc 100644 --- a/test/e2e/assos/create-weekly.e2e-spec.ts +++ b/test/e2e/assos/create-weekly.e2e-spec.ts @@ -26,7 +26,7 @@ const CreateWeeklyE2ESpec = e2eSuite('POST /assos/:assoId/weekly', (app) => { date: new Date().add({ days: 14 }).getWeekDate(), } - it('should return 403 as user is not authenticated', () => + 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', () => 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 index d18b1f6d..47b46fef 100644 --- a/test/e2e/assos/delete-weekly.e2e-spec.ts +++ b/test/e2e/assos/delete-weekly.e2e-spec.ts @@ -24,7 +24,7 @@ const DeleteWeeklyE2ESpec = e2eSuite('DELETE /assos/:assoId/weekly/:weeklyId', ( const otherAssoRole = createAssoMembershipRole(app, { asso }); createAssoMembership(app, { asso: otherAsso, user: userWithPermission, role: otherAssoRole, permissions: [permissionManageAsso] }); - it('should return 403 as user is not authenticated', () => + 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', () => diff --git a/test/e2e/assos/get-weekly-info.e2e-spec.ts b/test/e2e/assos/get-weekly-info.e2e-spec.ts index c4f810d7..8303d75d 100644 --- a/test/e2e/assos/get-weekly-info.e2e-spec.ts +++ b/test/e2e/assos/get-weekly-info.e2e-spec.ts @@ -7,7 +7,7 @@ import { ConfigModule } from '../../../src/config/config.module'; const GetWeeklyInfoE2ESpec = e2eSuite('GET /assos/weekly/info', (app) => { const user = createUser(app); - it('should return 403 as user is not authenticated', () => + 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', () => 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 index 75294db9..4f1f585c 100644 --- a/test/e2e/assos/search-weeklies.e2e-spec.ts +++ b/test/e2e/assos/search-weeklies.e2e-spec.ts @@ -29,7 +29,7 @@ const SearchWeekliesE2ESpec = e2eSuite('GET /assos/:assoId/weekly', (app) => { 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 403 as user is not authenticated', () => + 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', () => 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 index e08dfeb3..866e89c2 100644 --- a/test/e2e/assos/update-weekly.e2e-spec.ts +++ b/test/e2e/assos/update-weekly.e2e-spec.ts @@ -32,7 +32,7 @@ const UpdateWeeklyE2ESpec = e2eSuite('PATCH /assos/:assoId/weekly/:weeklyId', (a date: weekly.date.add({ days: 7 }), }); - it('should return 403 as user is not authenticated', () => + 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', () => diff --git a/test/utils/fakedb.ts b/test/utils/fakedb.ts index b7829690..6833759f 100644 --- a/test/utils/fakedb.ts +++ b/test/utils/fakedb.ts @@ -45,7 +45,6 @@ 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'; -import { isDate } from 'node:util/types'; /** * The fake entities can be used like normal entities in the it(string, () => void) functions. @@ -1187,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') && !isDate(params[key])) { + } else if (typeof params[key] === 'object' && !(params[key] instanceof Date)) { deeplyCallFunctions(params[key]); } }