diff --git a/src/configuration/config.dev.ts b/src/configuration/config.dev.ts new file mode 100644 index 0000000000000000000000000000000000000000..e9763084fa20e80748ce3f82c33fe8f3517bc528 --- /dev/null +++ b/src/configuration/config.dev.ts @@ -0,0 +1,37 @@ +export const configDev = { + url: process.env.MAIL_URL, + token: process.env.MAIL_TOKEN, + host: 'ram-dev.grandlyon.com', + protocol: 'https', + port: '443', + from: 'inclusionnumerique@grandlyon.com', + from_name: 'Réseau des acteurs de la médiation numérique', + replyTo: 'inclusionnumerique@grandlyon.com', + templates: { + directory: './src/mailer/mail-templates', + verify: { + ejs: 'verify.ejs', + json: 'verify.json', + }, + changeEmail: { + ejs: 'changeEmail.ejs', + json: 'changeEmail.json', + }, + resetPassword: { + ejs: 'resetPassword.ejs', + json: 'resetPassword.json', + }, + adminStructureClaim: { + ejs: 'adminStructureClaim.ejs', + json: 'adminStructureClaim.json', + }, + structureClaimValidation: { + ejs: 'structureClaimValidation.ejs', + json: 'structureClaimValidation.json', + }, + structureOutdatedInfo: { + ejs: 'structureOutdatedInfo.ejs', + json: 'structureOutdatedInfo.json', + }, + }, +}; diff --git a/src/configuration/config.prod.ts b/src/configuration/config.prod.ts index ca8ee3cac295af740ee203d0c50eca9907878425..7c1b236c9b2d50c67e4769354b0d3dcded212fa8 100644 --- a/src/configuration/config.prod.ts +++ b/src/configuration/config.prod.ts @@ -17,5 +17,21 @@ export const configProd = { ejs: 'changeEmail.ejs', json: 'changeEmail.json', }, + resetPassword: { + ejs: 'resetPassword.ejs', + json: 'resetPassword.json', + }, + adminStructureClaim: { + ejs: 'adminStructureClaim.ejs', + json: 'adminStructureClaim.json', + }, + structureClaimValidation: { + ejs: 'structureClaimValidation.ejs', + json: 'structureClaimValidation.json', + }, + structureOutdatedInfo: { + ejs: 'structureOutdatedInfo.ejs', + json: 'structureOutdatedInfo.json', + }, }, }; diff --git a/src/configuration/config.ts b/src/configuration/config.ts index 00bbfff6d98d45181cad4ed3e58d048e1467a3a5..7bb9d6f5a8b02c9fc654caa84f5cb74a223c99e9 100644 --- a/src/configuration/config.ts +++ b/src/configuration/config.ts @@ -29,5 +29,13 @@ export const config = { ejs: 'structureClaimValidation.ejs', json: 'structureClaimValidation.json', }, + structureOutdatedInfo: { + ejs: 'structureOutdatedInfo.ejs', + json: 'structureOutdatedInfo.json', + }, + apticStructureDuplication: { + ejs: 'apticStructureDuplication.ejs', + json: 'apticStructureDuplication.json', + }, }, }; diff --git a/src/configuration/configuration.service.ts b/src/configuration/configuration.service.ts index e14d7002478a2759464c8d7a221741f57976d71b..f78dfe08193f7773563f487b94a8af33c4c12fa8 100644 --- a/src/configuration/configuration.service.ts +++ b/src/configuration/configuration.service.ts @@ -2,17 +2,21 @@ import { Logger } from '@nestjs/common'; import * as dotenv from 'dotenv'; import { config } from './config'; import { configProd } from './config.prod'; +import { configDev } from './config.dev'; export class ConfigurationService { private readonly _config; constructor() { // Initializing conf with values from var env - if (process.env.NODE_ENV === 'production') { + if (process.env.NODE_ENV && process.env.NODE_ENV === 'production') { this._config = configProd; Logger.log('App started with production conf', 'ConfigurationService'); - } else { - this._config = config; + } else if (process.env.NODE_ENV && process.env.NODE_ENV === 'dev') { + this._config = configDev; Logger.log('App started with dev conf', 'ConfigurationService'); + } else if (process.env.NODE_ENV) { + this._config = config; + Logger.log('App started with local conf', 'ConfigurationService'); } dotenv.config(); } diff --git a/src/mailer/mail-templates/apticStructureDuplication.ejs b/src/mailer/mail-templates/apticStructureDuplication.ejs new file mode 100644 index 0000000000000000000000000000000000000000..5ac784a9c9e172e1d90179224c78319e5c46fb25 --- /dev/null +++ b/src/mailer/mail-templates/apticStructureDuplication.ejs @@ -0,0 +1,13 @@ +Bonjour,<br /> +<br /> +La fiche structure: <strong><%= name %></strong> a été créée après récupération des données aptic. Elle correspond +potientiellement a la structure existante : <strong><%= duplicatedStructureName %></strong>. +<br /> +<br /> +Cordialement, +<br /> +<br /> +L'équipe RES'in +<br /> +<br /> +Ce mail est un mail automatique. Merci de ne pas y répondre. diff --git a/src/mailer/mail-templates/apticStructureDuplication.json b/src/mailer/mail-templates/apticStructureDuplication.json new file mode 100644 index 0000000000000000000000000000000000000000..ec9e77ef58fb1e582b39f4aac4e7a8fef148ecff --- /dev/null +++ b/src/mailer/mail-templates/apticStructureDuplication.json @@ -0,0 +1,3 @@ +{ + "subject": "Doublon Aptic" +} diff --git a/src/mailer/mail-templates/structureOutdatedInfo.ejs b/src/mailer/mail-templates/structureOutdatedInfo.ejs new file mode 100644 index 0000000000000000000000000000000000000000..49e0fa1eef8672da8248b137ee0cb578cce41c06 --- /dev/null +++ b/src/mailer/mail-templates/structureOutdatedInfo.ejs @@ -0,0 +1,15 @@ +Bonjour<br /> +<br /> +Vous recevez ce message, parce que votre structure <strong><%= name %></strong> est référencée sur RES'in, le réseau des +acteurs de l'inclusion numérique de la Métropole de Lyon. Pouvez-vous nous aider en vérifiant que vos données sont bien +à jour en +<a href="<%= config.protocol %>://<%= config.host %><%= config.port ? ':' + config.port : '' %>/home?id=<%= id %>" + >cliquant ici</a +>. +<br /> +Cordialement, +<br /> +L'équipe RES'in +<br /> +<br /> +Ce mail est un mail automatique. Merci de ne pas y répondre. diff --git a/src/mailer/mail-templates/structureOutdatedInfo.json b/src/mailer/mail-templates/structureOutdatedInfo.json new file mode 100644 index 0000000000000000000000000000000000000000..1782ea483e33fb07f29c92e7a12f758f23f2e395 --- /dev/null +++ b/src/mailer/mail-templates/structureOutdatedInfo.json @@ -0,0 +1,3 @@ +{ + "subject": "Votre fiche structure n'est plus a jour, Réseau des Acteurs de la Médiation Numérique de la Métropole de Lyon" +} diff --git a/src/mailer/mailer.service.ts b/src/mailer/mailer.service.ts index 83b2a4ba76ba7684f86caf3998402f22e70fee24..04027e30e550dde3f5b8bb793a89609f4299d87b 100644 --- a/src/mailer/mailer.service.ts +++ b/src/mailer/mailer.service.ts @@ -47,7 +47,7 @@ export class MailerService { }) .subscribe( (body) => { - Logger.log(`Send mail : ${subject} success`, 'Mailer'); + Logger.log(`Send mail - success : ${subject}`, 'Mailer'); return resolve(body); }, (err) => { diff --git a/src/structures/schemas/structure.schema.ts b/src/structures/schemas/structure.schema.ts index 9383f1fb2dfc919a09c55947a57784e59e01bb0e..43aae7c3704927f664c002c4b050ac5530066dd0 100644 --- a/src/structures/schemas/structure.schema.ts +++ b/src/structures/schemas/structure.schema.ts @@ -116,6 +116,9 @@ export class Structure { @Prop() coord: number[]; + + @Prop() + deletedAt: Date; } export const StructureSchema = SchemaFactory.createForClass(Structure); diff --git a/src/structures/services/aptic-structures.service.ts b/src/structures/services/aptic-structures.service.ts index 38941c0d725cfdc094378a791ce483cf2e86e8c5..f2bd9fdc20670bad14a289eb1a29ddaff6f7cc26 100644 --- a/src/structures/services/aptic-structures.service.ts +++ b/src/structures/services/aptic-structures.service.ts @@ -10,11 +10,13 @@ import { Model } from 'mongoose'; import { Structure, StructureDocument } from '../schemas/structure.schema'; import { ApticStructure } from '../schemas/aptic-structure.schema'; import { Address } from '../schemas/address.schema'; +import { UsersService } from '../../users/users.service'; @Injectable() export class ApticStructuresService { constructor( private readonly httpService: HttpService, + private readonly userService: UsersService, @InjectModel(Structure.name) private structureModel: Model<StructureDocument> ) {} @@ -45,32 +47,33 @@ export class ApticStructuresService { private async createApticStructures(structure: ApticStructure): Promise<any> { this.structureAlreadyExist(structure).then((exist) => { if (!exist) { + Logger.log(`Create structure : ${structure.presence_name}`, 'ApticStructuresService - createApticStructures'); const createdStructure = new this.structureModel(); createdStructure.coord = [structure.gps_lng, structure.gps_lat]; createdStructure.structureName = structure.presence_name; createdStructure.contactPhone = structure.presence_phone; createdStructure.labelsQualifications = ['passNumerique']; // Address - const address = new Address(); - address.street = structure.presence_address; - address.commune = structure.city; - createdStructure.address = address; + createdStructure.address = this.formatAddress(structure); createdStructure.save(); + // Send admin weird structure mail + this.verifyDuplication(createdStructure); } }); } private async structureAlreadyExist(structure: ApticStructure): Promise<boolean> { - const existingStructure = await this.structureModel + let existingStructure = await this.structureModel .findOne({ structureName: { $regex: structure.presence_name, $options: 'i' }, }) .exec(); + // Check without regex for case like 'TINEBRA*DANIEL/DANIEL/' + if (!existingStructure) { + existingStructure = await this.structureModel.findOne({ structureName: structure.presence_name }).exec(); + } + if (existingStructure) { - Logger.log( - `Cannot create structure, it already exist : ${existingStructure.structureName}`, - 'ApticStructuresService - structureAlreadyExist' - ); // Add aptic label if it's not the case if (!existingStructure.labelsQualifications.includes('passNumerique')) { existingStructure.labelsQualifications.push('passNumerique'); @@ -124,4 +127,33 @@ export class ApticStructuresService { }, }); } + + private async verifyDuplication(createdStructure: StructureDocument): Promise<void> { + const sameAddrStructure = await this.structureModel + .findOne({ + _id: { $ne: createdStructure._id }, + address: createdStructure.address, + }) + .exec(); + if (sameAddrStructure) { + this.userService.sendAdminApticStructureMail(createdStructure.structureName, sameAddrStructure.structureName); + } + } + + /** + * Format aptic structure address + */ + private formatAddress(structure: ApticStructure): Address { + const address = new Address(); + const regexWithSpace = /\d+\s/g; // NOSONAR + const regex = /\d+/g; // NOSONAR + if (structure.presence_address.match(regex)) { + address.numero = structure.presence_address.match(regex)[0]; + address.street = structure.presence_address.replace(regexWithSpace, ''); + } else { + address.street = structure.presence_address; + } + address.commune = structure.city; + return address; + } } diff --git a/src/structures/services/structures.service.ts b/src/structures/services/structures.service.ts index d8c077ce4b8174d58c72ee4fcc4e0f5aa3899989..fd99c04e10ae183df7c1833b0d87292ce8a92a7c 100644 --- a/src/structures/services/structures.service.ts +++ b/src/structures/services/structures.service.ts @@ -1,18 +1,23 @@ -import { HttpException, HttpService, Injectable, HttpStatus } from '@nestjs/common'; +import { HttpException, HttpService, Injectable, HttpStatus, Logger } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; import { Types, Model } from 'mongoose'; import { Observable } from 'rxjs'; import { AxiosResponse } from 'axios'; import { Structure, StructureDocument } from '../schemas/structure.schema'; -import { Logger } from '@nestjs/common'; +import * as ejs from 'ejs'; import { structureDto } from '../dto/structure.dto'; import { UsersService } from '../../users/users.service'; +import { User } from '../../users/schemas/user.schema'; +import { MailerService } from '../../mailer/mailer.service'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { DateTime } from 'luxon'; @Injectable() export class StructuresService { constructor( private readonly httpService: HttpService, private readonly userService: UsersService, + private readonly mailerService: MailerService, @InjectModel(Structure.name) private structureModel: Model<StructureDocument> ) {} @@ -33,12 +38,18 @@ export class StructuresService { public async search(searchString: string, filters?: Array<any>): Promise<Structure[]> { if (searchString && filters) { return this.structureModel - .find({ $and: [...this.parseFilter(filters), { $text: { $search: searchString } }] }) + .find({ + $and: [...this.parseFilter(filters), { $text: { $search: searchString }, deletedAt: { $exists: false } }], + }) .exec(); } else if (filters) { - return this.structureModel.find({ $or: this.parseFilter(filters) }).exec(); + return this.structureModel + .find({ $and: [{ $or: this.parseFilter(filters), deletedAt: { $exists: false } }] }) + .exec(); } else { - return this.structureModel.find({ $or: [{ $text: { $search: searchString } }] }).exec(); + return this.structureModel + .find({ $and: [{ $or: [{ $text: { $search: searchString }, deletedAt: { $exists: false } }] }] }) + .exec(); } } @@ -58,7 +69,7 @@ export class StructuresService { } public async findAll(): Promise<StructureDocument[]> { - const structures = await this.structureModel.find().exec(); + const structures = await this.structureModel.find({ deletedAt: { $exists: false } }).exec(); // Update structures coord and address before sending them await Promise.all( structures.map((structure: StructureDocument) => { @@ -84,13 +95,15 @@ export class StructuresService { } }) ); - return this.structureModel.find().exec(); + return this.structureModel.find({ deletedAt: { $exists: false } }).exec(); } public async update(idStructure: string, structure: structureDto): Promise<Structure> { const result = await this.structureModel.findByIdAndUpdate(Types.ObjectId(idStructure), structure).exec(); if (!result) { throw new HttpException('Invalid structure id', HttpStatus.BAD_REQUEST); + } else { + this.userService.removeOutdatedStructureFromArray(idStructure); } return this.findOne(idStructure); } @@ -117,9 +130,10 @@ export class StructuresService { }); } - public async isClaimed(structureId: string): Promise<boolean> { + public async isClaimed(structureId: string, user: User): Promise<boolean> { const isStructureClaimed = await this.userService.isStructureClaimed(structureId.toString()); - if (isStructureClaimed) { + const isUserAlreadyClaimed = await this.userService.isUserAlreadyClaimedStructure(structureId, user.email); + if (isStructureClaimed || isUserAlreadyClaimed) { return true; } return false; @@ -187,7 +201,9 @@ export class StructuresService { uniqueElements.map(async (value) => { return { id: value, - count: await this.structureModel.countDocuments({ [key]: { $elemMatch: { $eq: value } } }).exec(), + count: await this.structureModel + .countDocuments({ $and: [{ [key]: { $elemMatch: { $eq: value } }, deletedAt: { $exists: false } }] }) + .exec(), }; }) ); @@ -199,4 +215,85 @@ export class StructuresService { Logger.log(`Request : ${req}`, 'StructureService - getCoord'); return this.httpService.get(encodeURI(req)); } + + public async deleteOne(id: string): Promise<Structure> { + const structure = await this.structureModel.findById(Types.ObjectId(id)).exec(); + if (!structure) { + throw new HttpException('Invalid structure id', HttpStatus.BAD_REQUEST); + } + structure.deletedAt = DateTime.local().setZone('Europe/Paris').toString(); + this.anonymizeStructure(structure).save(); + return structure; + } + + private anonymizeStructure(structure: StructureDocument): StructureDocument { + structure.contactName = ''; + structure.contactSurname = ''; + structure.contactPhone = ''; + structure.contactMail = ''; + structure.facebook = ''; + structure.twitter = ''; + structure.instagram = ''; + structure.website = ''; + structure.fonction = ''; + structure.gender = ''; + return structure; + } + + @Cron(CronExpression.EVERY_DAY_AT_4AM) + public async checkOutdatedStructuresInfo(): Promise<void> { + const OUTDATED_MONT_TO_CHECK = 6; + const structureList = await this.findAll(); + const local = DateTime.local().setZone('Europe/Paris'); + // Get outdated structures + const filteredList = structureList.filter((structure) => { + const updateDate = DateTime.fromFormat(structure.updatedAt.split(' GMT')[0], 'EEE MMM dd yyyy HH:mm:ss'); + const diff = local.diff(updateDate, ['months']); + if (diff.values.months > OUTDATED_MONT_TO_CHECK) { + return true; + } + }); + // Get owners of outdated structures + const ownerList = await Promise.all( + filteredList.map(async (structure) => { + const owner = await this.userService.isStructureClaimed(structure._id.toString()); + if (owner) { + return { structure: structure, owner: owner }; + } + }) + ); + // Send email if possible and update user + ownerList + .filter((x) => x != undefined) + .forEach(async (data) => { + this.userService.findOne(data.owner.email).then((user) => { + // If mail is already sent, do not resend + if (user.structureOutdatedMailSent.includes(data.structure._id)) { + return; + } else { + this.sendOutdatedEmailToUser(data.owner.email, data.structure.structureName, data.structure._id); + user.structureOutdatedMailSent.push(data.structure._id); + user.save(); + } + }); + }); + } + + /** + * Generate activation token and send it to user by email, in order to validate + * a new account. + * @param user User + */ + private async sendOutdatedEmailToUser(userEmail: string, structureName: string, id: string): Promise<any> { + const config = this.mailerService.config; + const ejsPath = this.mailerService.getTemplateLocation(config.templates.structureOutdatedInfo.ejs); + const jsonConfig = this.mailerService.loadJsonConfig(config.templates.structureOutdatedInfo.json); + + const html = await ejs.renderFile(ejsPath, { + config, + name: structureName, + id: id, + }); + this.mailerService.send(userEmail, jsonConfig.subject, html); + } } diff --git a/src/structures/structures.controller.ts b/src/structures/structures.controller.ts index 94a6dca7760be4679d2376414728abdc21033e80..b301a463f40c505140410986d1d17e69e12e345b 100644 --- a/src/structures/structures.controller.ts +++ b/src/structures/structures.controller.ts @@ -1,7 +1,9 @@ -import { Body, Controller, Get, Param, ParseIntPipe, Post, Put, Query, UseGuards } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, ParseIntPipe, Post, Put, Query, UseGuards } from '@nestjs/common'; +import { ApiParam } from '@nestjs/swagger'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { Roles } from '../users/decorators/roles.decorator'; import { IsStructureOwnerGuard } from '../users/guards/isStructureOwner.guard'; +import { RolesGuard } from '../users/guards/roles.guard'; import { User } from '../users/schemas/user.schema'; import { UsersService } from '../users/users.service'; import { CreateStructureDto } from './dto/create-structure.dto'; @@ -36,9 +38,9 @@ export class StructuresController { return this.structureService.findAll(); } - @Get(':id/isClaimed') - public async isClaimed(@Param('id') id: string): Promise<boolean> { - return this.structureService.isClaimed(id); + @Post(':id/isClaimed') + public async isClaimed(@Param('id') id: string, @Body() user?: User): Promise<boolean> { + return this.structureService.isClaimed(id, user); } @Post(':id/claim') @@ -76,4 +78,12 @@ export class StructuresController { public async find(@Param('id') id: string) { return this.structureService.findOne(id); } + + @Delete(':id') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('admin') + @ApiParam({ name: 'id', type: String, required: true }) + public async delete(@Param('id') id: string) { + return this.structureService.deleteOne(id); + } } diff --git a/src/users/interfaces/user.interface.ts b/src/users/interfaces/user.interface.ts index a348aacb00c6e783094341748b27b628bd1ac195..312a312561258df14bbb3930506f8070f94feba1 100644 --- a/src/users/interfaces/user.interface.ts +++ b/src/users/interfaces/user.interface.ts @@ -1,4 +1,4 @@ -import { Document } from 'mongoose'; +import { Document, Types } from 'mongoose'; export interface IUser extends Document { readonly _id: string; @@ -15,4 +15,5 @@ export interface IUser extends Document { newEmail: string; structuresLink: string[]; pendingStructuresLink: string[]; + structureOutdatedMailSent: Types.ObjectId[]; } diff --git a/src/users/schemas/user.schema.ts b/src/users/schemas/user.schema.ts index 15261937b0258c939eb01d8cc41e0d8569a957bb..deadfe5a0ca1b1bb0a616a4fe5fed566e8ca3d2b 100644 --- a/src/users/schemas/user.schema.ts +++ b/src/users/schemas/user.schema.ts @@ -1,4 +1,5 @@ import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { Types } from 'mongoose'; import { UserRole } from '../enum/user-role.enum'; @Schema() export class User { @@ -40,6 +41,9 @@ export class User { @Prop({ default: null }) pendingStructuresLink: string[]; + + @Prop({ default: null }) + structureOutdatedMailSent: Types.ObjectId[]; } export const UserSchema = SchemaFactory.createForClass(User); diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 8acbeb4f25429981843ff49870278f61ee63ff4a..920a233da69bc25e390303b463de08a8445b668c 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -3,7 +3,7 @@ import { InjectModel } from '@nestjs/mongoose'; import * as bcrypt from 'bcrypt'; import * as ejs from 'ejs'; import * as crypto from 'crypto'; -import { Model } from 'mongoose'; +import { Model, Types } from 'mongoose'; import { LoginDto } from '../auth/login-dto'; import { CreateUserDto } from './dto/create-user.dto'; import { User } from './schemas/user.schema'; @@ -37,7 +37,6 @@ export class UsersService { // Send verification email createUser = await this.verifyUserMail(createUser); createUser.save(); - this.sendAdminStructureValidationMail(); return await this.findOne(createUserDto.email); } @@ -129,7 +128,6 @@ export class UsersService { /** * Send to all admins validation email for structures * a new account. - * @param user User */ private async sendAdminStructureValidationMail(): Promise<any> { const config = this.mailerService.config; @@ -145,6 +143,25 @@ export class UsersService { }); } + /** + * Send to all admins mail for aptic duplicated data + */ + public async sendAdminApticStructureMail(structureName: string, duplicatedStructureName: string): Promise<any> { + const config = this.mailerService.config; + const ejsPath = this.mailerService.getTemplateLocation(config.templates.apticStructureDuplication.ejs); + const jsonConfig = this.mailerService.loadJsonConfig(config.templates.apticStructureDuplication.json); + + const html = await ejs.renderFile(ejsPath, { + config, + name: structureName, + duplicatedStructureName: duplicatedStructureName, + }); + const admins = await this.getAdmins(); + admins.forEach((admin) => { + this.mailerService.send(admin.email, jsonConfig.subject, html); + }); + } + /** * Send approval email for user * a new account. @@ -288,6 +305,14 @@ export class UsersService { return this.userModel.findOne({ structuresLink: structureId }).exec(); } + public async isUserAlreadyClaimedStructure(structureId: string, userEmail: string): Promise<boolean> { + const user = await this.findOne(userEmail, true); + if (user) { + return user.pendingStructuresLink.includes(structureId); + } + return false; + } + public async updateStructureLinked(userEmail: string, idStructure: string): Promise<string[]> { const user = await this.findOne(userEmail, true); if (user) { @@ -364,4 +389,14 @@ export class UsersService { ); } } + + public async removeOutdatedStructureFromArray(structureId: string): Promise<void> { + const users = await this.userModel.find({ structureOutdatedMailSent: Types.ObjectId(structureId) }).exec(); + users.forEach((user) => { + user.structureOutdatedMailSent = user.structureOutdatedMailSent.filter((item) => + Types.ObjectId(structureId).equals(item) + ); + user.save(); + }); + } }