Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .env.dist
Original file line number Diff line number Diff line change
Expand Up @@ -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"
TIMETABLE_URL="monedt.utt.fr"

# Weekly
WEEKLY_SEND_DAY=1
WEEKLY_SEND_HOUR=8
6 changes: 5 additions & 1 deletion .env.test.dist
Original file line number Diff line number Diff line change
Expand Up @@ -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"
TIMETABLE_URL="monedt.utt.fr"

# Weekly
WEEKLY_SEND_DAY=1
WEEKLY_SEND_HOUR=8
2 changes: 1 addition & 1 deletion migration/etuutt_old/modules/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ export async function migrateUsers(
},
preference: {
create: {
wantDaymail: user.daymail,
wantWeekly: user.daymail,
language: user.language,
wantDayNotif: false,
wantDiscordUtt: user.wantsJoinUTTDiscord,
Expand Down
16 changes: 8 additions & 8 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ model Asso {
descriptionShortTranslation Translation? @relation(name: "descriptionShortTranslation", fields: [descriptionShortTranslationId], references: [id], onDelete: Cascade)
descriptionTranslation Translation? @relation(name: "descriptionTranslation", fields: [descriptionTranslationId], references: [id], onDelete: Cascade)
assoMemberships AssoMembership[]
assoMessages AssoMessage[]
weeklies AssoWeekly[]
events Event[]
assoMembershipRoles AssoMembershipRole[]
assoAccount User @relation(fields: [assoAccountId], references: [id], onDelete: Cascade)
Expand Down Expand Up @@ -101,19 +101,19 @@ model AssoMembershipRole {
asso Asso @relation(fields: [assoId], references: [id])
}

model AssoMessage {
model AssoWeekly {
id String @id @default(uuid())
date DateTime
sendToMobile Boolean
sendAsDaymail Boolean
createdAt DateTime @default(now())
assoId String
titleTranslationId String @unique
bodyTranslationId String @unique
date DateTime

asso Asso @relation(fields: [assoId], references: [id])
titleTranslation Translation @relation(name: "titleTranslation", fields: [titleTranslationId], references: [id], onDelete: Cascade)
bodyTranslation Translation @relation(name: "bodyTranslation", fields: [bodyTranslationId], references: [id], onDelete: Cascade)

@@unique([assoId, date])
}

model Event {
Expand Down Expand Up @@ -231,8 +231,8 @@ model Translation {

assoDescription Asso? @relation("descriptionTranslation")
assoDescriptionShort Asso? @relation("descriptionShortTranslation")
assoMessageTitle AssoMessage? @relation("titleTranslation")
assoMessageTitleBody AssoMessage? @relation("bodyTranslation")
assoWeeklyTitle AssoWeekly? @relation("titleTranslation")
assoWeeklyTitleBody AssoWeekly? @relation("bodyTranslation")
eventDescription Event? @relation("descriptionTranslation")
eventTitle Event? @relation("titleTranslation")
ueofInfo UeofInfo? @relation("ueofInfoObjectivesTranslation")
Expand Down Expand Up @@ -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)

Expand Down
27 changes: 27 additions & 0 deletions src/app.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ import { applyDecorators, HttpStatus, Injectable } from '@nestjs/common';
import * as ApiResponses from '@nestjs/swagger';
import { ApiProperty } from '@nestjs/swagger';
import { Type } from '@nestjs/common/interfaces/type.interface';
import { IsOptional, IsString } from 'class-validator';
import { HasSomeAmong } from './validation';
import { languages } from './utils';

// Redefine the mixin function in node_modules/.pnpm/@nestjs+common@<version>_class-transformer@<version>_class-validator@<version>_reflect-metadata@<version>_rxjs@<version>/node_modules/@nestjs/common/decorators/core/injectable.decorator.js
// This implementation allows to give a name to the class
Expand Down Expand Up @@ -44,3 +47,27 @@ export function paginatedResponseDto<TBase extends Constructor>(Base: TBase) {
}
return mixin(ResponseDto, `${Base.name}$Paginated`); // This is important otherwise you will get always the same instance
}

@HasSomeAmong(...languages)
export class TranslationReqDto {
@IsString()
@IsOptional()
fr?: string;

@IsString()
@IsOptional()
en?: string;

@IsString()
@IsOptional()
es?: string;

@IsString()
@IsOptional()
de?: string;

@IsString()
@IsOptional()
zh?: string;
}

2 changes: 2 additions & 0 deletions src/assos/assos.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,15 @@ import AssosMemberCreateReqDto from './dto/req/assos-member-create.dto';
import AssosMemberUpdateReqDto from './dto/req/assos-member-update.dto';
import AssoMembershipResDto from './dto/res/assos-membership-res.dto';
import UsersService from '../users/users.service';
import { ConfigModule } from '../config/config.module';

@Controller('assos')
@ApiTags('Assos')
export class AssosController {
constructor(
readonly assosService: AssosService,
readonly userService: UsersService,
readonly config: ConfigModule,
) {}

@Get()
Expand Down
7 changes: 5 additions & 2 deletions src/assos/assos.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
28 changes: 28 additions & 0 deletions src/assos/interfaces/weekly.interface.ts
Original file line number Diff line number Diff line change
@@ -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<typeof ASSO_WEEKLY_SELECT_FILTER>;
export type AssoWeekly = Pick<UnformattedAssoWeekly, 'id' | 'assoId' | 'createdAt' | 'date'> & { 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
}
}
22 changes: 22 additions & 0 deletions src/assos/weekly/dto/req/weekly-req.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Type } from 'class-transformer';
import { IsDate, IsNotEmpty, ValidateNested } from 'class-validator';
import { TranslationReqDto } from '../../../../app.dto';
import { IsWeekDate } from '../../../../validation';

export default class WeeklyReqDto {
@ValidateNested()
@IsNotEmpty()
@Type(() => TranslationReqDto)
title: TranslationReqDto

@ValidateNested()
@IsNotEmpty()
@Type(() => TranslationReqDto)
message: TranslationReqDto;

@IsDate()
@IsNotEmpty()
@Type(() => Date)
@IsWeekDate()
date: Date;
}
17 changes: 17 additions & 0 deletions src/assos/weekly/dto/req/weekly-search-req.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { IsDate, IsInt, IsOptional } from 'class-validator';
import { Type } from 'class-transformer';

export default class WeeklySearchReqDto {
@IsDate()
@Type(() => Date)
from?: Date;

@IsOptional()
@IsDate()
@Type(() => Date)
to?: Date;

@IsOptional()
@IsInt()
page: number = 1;
}
4 changes: 4 additions & 0 deletions src/assos/weekly/dto/res/weekly-info-res.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export default class WeeklyInfoResDto {
sendDay: number;
sendHour: number;
}
10 changes: 10 additions & 0 deletions src/assos/weekly/dto/res/weekly-res.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Translation } from '../../../../prisma/types';

export default class WeeklyResDto {
id: string;
assoId: string;
createdAt: Date;
title: Translation;
message: Translation;
date: Date;
}
127 changes: 127 additions & 0 deletions src/assos/weekly/weekly-with-asso-id.controller.ts
Original file line number Diff line number Diff line change
@@ -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<Pagination<WeeklyResDto>> {
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<WeeklyResDto> {
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<WeeklyResDto> {
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<WeeklyResDto> {
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);
}
}
20 changes: 20 additions & 0 deletions src/assos/weekly/weekly-without-asso-id.controller.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
}
Loading
Loading