diff --git a/src/admin/admin.controller.spec.ts b/src/admin/admin.controller.spec.ts index 056f80ed46a68a678ffd27e8c8c1de2813e66fc7..525d002151ff9ebc80e45f4eecf0c4923217f9fb 100644 --- a/src/admin/admin.controller.spec.ts +++ b/src/admin/admin.controller.spec.ts @@ -77,7 +77,6 @@ describe('AdminController', () => { const mockAdminService = { isDateOutdated: jest.fn(), - getLastUpdateDate: jest.fn(), }; const pendingStructureTest: PendingStructureDto = { @@ -158,7 +157,7 @@ describe('AdminController', () => { it('should get pending attachments', async () => { mockStructureService.findOne.mockResolvedValue({ structureName: 'structure', updatedAt: '' }); expect((await adminController.getPendingAttachments()).length).toBe(2); - expect(Object.keys((await adminController.getPendingAttachments())[0]).length).toBe(5); + expect(Object.keys((await adminController.getPendingAttachments())[0]).length).toBe(6); }); describe('Pending structures validation', () => { @@ -362,7 +361,6 @@ describe('AdminController', () => { it('should get pending structure list for admin', async () => { mockAdminService.isDateOutdated.mockReturnValue(false); - mockAdminService.getLastUpdateDate.mockReturnValue(''); mockStructureService.findOne.mockResolvedValue({ structureName: 'structure' }); // using _id for unclaimed mockStructureService.findAllUnclaimed.mockResolvedValueOnce([ diff --git a/src/admin/admin.controller.ts b/src/admin/admin.controller.ts index 760c2b0ffa9d93a7257c322683afa23d06d80e8f..e9724bd236908ffe9b92003bb9b7154b6a784e33 100644 --- a/src/admin/admin.controller.ts +++ b/src/admin/admin.controller.ts @@ -16,7 +16,6 @@ import { validate } from 'class-validator'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { EspaceCoopCNFS } from '../espaceCoop/schemas/espaceCoopCNFS.schema'; import { EspaceCoopService } from '../espaceCoop/services/espaceCoop.service'; -import { NewsletterService } from '../newsletter/newsletter.service'; import { Structure, StructureDocument } from '../structures/schemas/structure.schema'; import { StructuresService } from '../structures/services/structures.service'; import { Roles } from '../users/decorators/roles.decorator'; @@ -30,6 +29,15 @@ import { PendingStructureDto } from './dto/pending-structure.dto'; import { SetUserEmployerDto } from './dto/set-user-employer.dto'; import { SetUserJobDto } from './dto/set-user-job.dto'; +type PendingStructure = { + userEmail: string; + structureId: string; + createdAt: string; + structureName: string; + updatedAt: string; + permalink: string; +}; + @ApiTags('admin') @Controller('admin') export class AdminController { @@ -39,7 +47,6 @@ export class AdminController { private structuresService: StructuresService, private jobsService: JobsService, private employerService: EmployerService, - private newsletterService: NewsletterService, private adminService: AdminService, private espaceCoopCNFSService: EspaceCoopService ) {} @@ -48,13 +55,14 @@ export class AdminController { @Roles('admin') @Get('pendingStructures') @ApiOperation({ description: 'Get pending structures for validation' }) - public async getPendingAttachments(): Promise<PendingStructureDto[]> { + public async getPendingAttachments(): Promise<PendingStructure[]> { const pendingStructure = await this.usersService.getPendingStructures(); return Promise.all( pendingStructure.map(async (structure) => { const structureDocument = await this.structuresService.findOne(structure.structureId); structure.structureName = structureDocument.structureName; structure.updatedAt = structureDocument.updatedAt; + structure.permalink = structureDocument.permalink; return structure; }) ); @@ -68,22 +76,26 @@ export class AdminController { this.logger.debug('getAdminStructuresList'); const structuresList = { claimed: [], inClaim: [], toClaim: [], incomplete: [] }; const inClaimStructures = await this.getPendingAttachments(); - structuresList.inClaim = inClaimStructures.map((structure) => { - const lastUpdateDate = this.adminService.getLastUpdateDate(structure); - return { - structureId: structure.structureId, - structureName: structure.structureName, - updatedAt: lastUpdateDate, - isOutdated: this.adminService.isDateOutdated(lastUpdateDate, 6), - }; - }); + structuresList.inClaim = await Promise.all( + inClaimStructures.map(async (structure) => { + const lastUpdateDate = structure.updatedAt ? structure.updatedAt : structure.createdAt; + return { + structureId: structure.structureId, + permalink: structure.permalink, + structureName: structure.structureName, + updatedAt: structure.updatedAt ? structure.updatedAt : structure.createdAt, + isOutdated: this.adminService.isDateOutdated(lastUpdateDate, 6), + }; + }) + ); const toClaimStructures = await this.structuresService.findAllUnclaimed(); structuresList.toClaim = toClaimStructures .filter((demand) => !structuresList.inClaim.find((elem) => elem.structureId == demand.id)) .map((structure) => { - const lastUpdateDate = this.adminService.getLastUpdateDate(structure); + const lastUpdateDate = structure.updatedAt ? structure.updatedAt : structure.createdAt; return { structureId: structure._id, + permalink: structure.permalink, structureName: structure.structureName, updatedAt: lastUpdateDate, isOutdated: this.adminService.isDateOutdated(lastUpdateDate, 6), @@ -97,9 +109,10 @@ export class AdminController { !structuresList.toClaim.find((elem) => elem.structureId == demand.id) ) .map((structure) => { - const lastUpdateDate = this.adminService.getLastUpdateDate(structure); + const lastUpdateDate = structure.updatedAt ? structure.updatedAt : structure.createdAt; return { structureId: structure.id, + permalink: structure.permalink, structureName: structure.structureName, updatedAt: lastUpdateDate, isOutdated: this.adminService.isDateOutdated(lastUpdateDate, 6), @@ -110,9 +123,10 @@ export class AdminController { const validity = await validate(new Structure(struct)); if (validity.length > 0) { this.logger.debug(`getAdminStructuresList - validation failed. errors: ${validity.toString()}`); - const lastUpdateDate = this.adminService.getLastUpdateDate(struct); + const lastUpdateDate = struct.updatedAt ? struct.updatedAt : struct.createdAt; return { structureId: struct.id, + permalink: struct.permalink, structureName: struct.structureName, updatedAt: lastUpdateDate, isOutdated: this.adminService.isDateOutdated(lastUpdateDate, 6), diff --git a/src/admin/admin.service.ts b/src/admin/admin.service.ts index 62ea4ad0473e2f679d99ffb9de32d66d3fcb7f7a..4169f47ede800dc511fed227de6a0256adf7e236 100644 --- a/src/admin/admin.service.ts +++ b/src/admin/admin.service.ts @@ -1,8 +1,5 @@ import { Injectable } from '@nestjs/common'; import { DateTime, Interval } from 'luxon'; -import { Structure } from '../structures/schemas/structure.schema'; -import { PendingStructureDto } from './dto/pending-structure.dto'; -import { UnclaimedStructureDto } from './dto/unclaimed-structure-dto'; @Injectable() export class AdminService { @@ -10,7 +7,4 @@ export class AdminService { const today = DateTime.local().setZone('utc', { keepLocalTime: true }); return Interval.fromDateTimes(date, today).length('months') > nbMonths; } - public getLastUpdateDate(structure: Structure | UnclaimedStructureDto | PendingStructureDto): DateTime { - return structure.updatedAt ? structure.updatedAt : structure.createdAt; - } } diff --git a/src/auth/auth.controller.spec.ts b/src/auth/auth.controller.spec.ts index 27a4f2fa5a6be0719141a7080bed6b7add616f7f..ce44fe09d9736b2b9d5c9341f27ceedd3b6ba3fc 100644 --- a/src/auth/auth.controller.spec.ts +++ b/src/auth/auth.controller.spec.ts @@ -4,10 +4,12 @@ import { getModelToken } from '@nestjs/mongoose'; import { PassportModule } from '@nestjs/passport'; import { Test } from '@nestjs/testing'; import { AuthServiceMock } from '../../test/mock/services/auth.mock.service'; +import { NewsletterServiceMock } from '../../test/mock/services/newsletter.mock.service'; import { StructuresServiceMock } from '../../test/mock/services/structures.mock.service'; import { CategoriesService } from '../categories/services/categories.service'; import { ConfigurationModule } from '../configuration/configuration.module'; import { MailerModule } from '../mailer/mailer.module'; +import { NewsletterService } from '../newsletter/newsletter.service'; import { StructuresSearchService } from '../structures/services/structures-search.service'; import { StructuresService } from '../structures/services/structures.service'; import { Job } from '../users/schemas/job.schema'; @@ -18,8 +20,6 @@ import { UsersService } from '../users/services/users.service'; import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; import { LoginDto } from './login-dto'; -import { NewsletterService } from '../newsletter/newsletter.service'; -import { NewsletterServiceMock } from '../../test/mock/services/newsletter.mock.service'; describe('AuthController', () => { let authController: AuthController; @@ -34,6 +34,8 @@ describe('AuthController', () => { get: jest.fn(), }; + const mockJobsService = {}; + beforeEach(async () => { const module = await Test.createTestingModule({ imports: [ @@ -86,6 +88,10 @@ describe('AuthController', () => { provide: NewsletterService, useClass: NewsletterServiceMock, }, + { + provide: JobsService, + useValue: mockJobsService, + }, ], }).compile(); diff --git a/src/mailer/mail-templates/adminStructureCreate.ejs b/src/mailer/mail-templates/adminStructureCreate.ejs index e86a52d2f1ffc688b8232a347cfe8bc0254ae2d6..1bbdd5967271085d34bfad0ca2b05941639d8692 100644 --- a/src/mailer/mail-templates/adminStructureCreate.ejs +++ b/src/mailer/mail-templates/adminStructureCreate.ejs @@ -1,6 +1,7 @@ Bonjour,<br /> <br /> Une nouvelle structure a été créée: -<a href="<%= config.protocol %>://<%= config.host %><%= config.port ? ':' + config.port : '' %>/acteurs?id=<%= id %>" +<a + href="<%= config.protocol %>://<%= config.host %><%= config.port ? ':' + config.port : '' %>/acteurs?structure=<%= permalink %>" ><strong><%= structureName %></strong></a > diff --git a/src/mailer/mail-templates/adminUserCreate.ejs b/src/mailer/mail-templates/adminUserCreate.ejs index c64fa949200363953c2df71a900961297f657195..d9a57e54874c3b9cf1377230252f9c22e9b2a66f 100644 --- a/src/mailer/mail-templates/adminUserCreate.ejs +++ b/src/mailer/mail-templates/adminUserCreate.ejs @@ -2,7 +2,9 @@ Bonjour,<br /> <br /> Un nouvel utilisateur a été créé (<strong><%= name %> <%= surname %></strong>) <br /> -<a href="<%= config.protocol %>://<%= config.host %><%= config.port ? ':' + config.port : '' %>/profile/<%= userId %>"> +<a + href="<%= config.protocol %>://<%= config.host %><%= config.port ? ':' + config.port : '' %>/profil/<%= userPermalink %>" +> Accédez à son profil </a> <br /> diff --git a/src/mailer/mail-templates/resetPassword.ejs b/src/mailer/mail-templates/resetPassword.ejs index 233966c0e062c056cb73c5afcbdd2461448c941b..389a748ee0878cac4e0bfb9d22b1229190f5b360 100644 --- a/src/mailer/mail-templates/resetPassword.ejs +++ b/src/mailer/mail-templates/resetPassword.ejs @@ -4,7 +4,7 @@ Vous avez demandé une réinitialisation de votre mot de passe pour le <em>Réseau des Acteurs de la Médiation Numérique de la Métropole de Lyon</em>. Pour changer de mot de passe, merci de cliquer sur le lien suivant : <a - href="<%= config.protocol %>://<%= config.host %><%= config.port ? ':' + config.port : '' %>/reset-password?token=<%= token %>" + href="<%= config.protocol %>://<%= config.host %><%= config.port ? ':' + config.port : '' %>/mot-de-passe-oublie?token=<%= token %>" >ce lien</a ><br /> Si vous n'avez pas demandé de réinitiallisation de votre mot de passe, merci d'ignorer cet email. diff --git a/src/mailer/mail-templates/structureErrorReport.ejs b/src/mailer/mail-templates/structureErrorReport.ejs index 361e823023251a76fd9d3fe584b6ed9ce0d623ef..52b8e35453adf00cc1e1f7d1adbaba970ed86835 100644 --- a/src/mailer/mail-templates/structureErrorReport.ejs +++ b/src/mailer/mail-templates/structureErrorReport.ejs @@ -6,6 +6,7 @@ Voici le message:<br /> <br /> <strong><%= content %></strong><br /> <br /> -<a href="<%= config.protocol %>://<%= config.host %><%= config.port ? ':' + config.port : '' %>/acteurs?id=<%= id %>" +<a + href="<%= config.protocol %>://<%= config.host %><%= config.port ? ':' + config.port : '' %>/acteurs?structure=<%= permalink %>" >Accéder à votre structure</a >. diff --git a/src/mailer/mail-templates/structureModificationNotification.ejs b/src/mailer/mail-templates/structureModificationNotification.ejs index f4153b552a2ee8587a713e7a4a79201bfc351a2b..50fda658928c5667fa1f560d45c2926202f9077e 100644 --- a/src/mailer/mail-templates/structureModificationNotification.ejs +++ b/src/mailer/mail-templates/structureModificationNotification.ejs @@ -2,6 +2,7 @@ Bonjour,<br /> <br /> Un utilisateur a modifié une ou plusieurs informations sur la fiche de sa structure (<%= structureName %>). <br /> -<a href="<%= config.protocol %>://<%= config.host %><%= config.port ? ':' + config.port : '' %>/acteurs?id=<%= id %>" +<a + href="<%= config.protocol %>://<%= config.host %><%= config.port ? ':' + config.port : '' %>/acteurs?structure=<%= permalink %>" >Accéder à cette structure</a >. diff --git a/src/mailer/mail-templates/structureOutdatedInfo.ejs b/src/mailer/mail-templates/structureOutdatedInfo.ejs index 72e04b9f8e45c191faf2c2a574421179577a1f02..7b8b94e48710ea2d2a12643c8912350d13409cf8 100644 --- a/src/mailer/mail-templates/structureOutdatedInfo.ejs +++ b/src/mailer/mail-templates/structureOutdatedInfo.ejs @@ -4,6 +4,6 @@ Vous recevez ce message car votre structure <strong><%= name %></strong> est ré 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 : '' %>/profile/edit-structure/<%= id %>" + href="<%= config.protocol %>://<%= config.host %><%= config.port ? ':' + config.port : '' %>/profil/edition-structure/<%= id %>" >cliquant ici</a >. diff --git a/src/mailer/mail-templates/structureToBeDeleted.ejs b/src/mailer/mail-templates/structureToBeDeleted.ejs index 05e4659acb9cd5def4180fd568a5a27880bc77f9..d60782a27a2840c85a7173aefb840146b2ef4143 100644 --- a/src/mailer/mail-templates/structureToBeDeleted.ejs +++ b/src/mailer/mail-templates/structureToBeDeleted.ejs @@ -9,7 +9,7 @@ suppression en cliquant sur le lien ci-dessous. <div style="text-align: center"> <a - href="<%= config.protocol %>://<%= config.host %><%= config.port ? ':' + config.port : '' %>/profile/structures-management" + href="<%= config.protocol %>://<%= config.host %><%= config.port ? ':' + config.port : '' %>/profil/gestion-structures" >Gérer mes structures</a > </div> diff --git a/src/mailer/mail-templates/tempUserRegistration.ejs b/src/mailer/mail-templates/tempUserRegistration.ejs index 0b318c49926d53013ab226c4d0cdb5c61c9d16a9..641a7107e8133576393e884ad52005a0a2a47d6b 100644 --- a/src/mailer/mail-templates/tempUserRegistration.ejs +++ b/src/mailer/mail-templates/tempUserRegistration.ejs @@ -3,6 +3,7 @@ Bonjour,<br /> Vous recevez ce message car vous avez été relié à la stucture <strong><%= name %></strong> sur RES'in, le réseau des acteurs de l'inclusion numérique de la Métropole de Lyon. Vous pouvez dès maitenant vous créer un compte sur la plateforme pour accéder à votre structure en -<a href="<%= config.protocol %>://<%= config.host %><%= config.port ? ':' + config.port : '' %>/form/register/<%= id %>" +<a + href="<%= config.protocol %>://<%= config.host %><%= config.port ? ':' + config.port : '' %>/formulaire/inscription/<%= id %>" >cliquant ici</a >. diff --git a/src/mailer/mail-templates/userAddedToStructure.ejs b/src/mailer/mail-templates/userAddedToStructure.ejs index 03aa6b961add1fb36074a7a3a878ec161d16a8a4..b3394b36c8f5d6c27f1a7eff38b33d96215b275f 100644 --- a/src/mailer/mail-templates/userAddedToStructure.ejs +++ b/src/mailer/mail-templates/userAddedToStructure.ejs @@ -2,5 +2,5 @@ Bonjour,<br /> <br /> Vous recevez ce message car vous avez été relié à la stucture <strong><%= name %></strong> sur RES'in, le réseau des acteurs de l'inclusion numérique de la Métropole de Lyon. Vous pouvez dès maintenant la consulter sur -<a href="<%= config.protocol %>://<%= config.host %><%= config.port ? ':' + config.port : '' %>/profile">votre profil</a +<a href="<%= config.protocol %>://<%= config.host %><%= config.port ? ':' + config.port : '' %>/profil">votre profil</a >. diff --git a/src/migrations/scripts/1713961381613-add-user-and-structure-permalink.ts b/src/migrations/scripts/1713961381613-add-user-and-structure-permalink.ts new file mode 100644 index 0000000000000000000000000000000000000000..0d914d7ee7489076d520bb6f05196b7d78e84c9e --- /dev/null +++ b/src/migrations/scripts/1713961381613-add-user-and-structure-permalink.ts @@ -0,0 +1,50 @@ +import { Db } from 'mongodb'; +import { sanitize } from '../../shared/utils'; +import { getDb } from '../migrations-utils/db'; + +export const up = async () => { + const db: Db = await getDb(); + + const usersCursor = db.collection('users').find({}); + let document; + while ((document = await usersCursor.next())) { + let permalink = `${sanitize(document.name)}-${sanitize(document.surname)}`; + + // If already exists, add a number to the end of the permalink + const exists = await db.collection('users').findOne({ permalink }); + if (exists) { + let count = 1; + while (await db.collection('users').findOne({ permalink: `${permalink}-${count}` })) { + count++; + } + permalink = `${permalink}-${count}`; + } + await db.collection('users').updateOne({ _id: document._id }, [{ $set: { permalink } }]); + } + + const structuresCursor = db.collection('structures').find({}); + while ((document = await structuresCursor.next())) { + let permalink = sanitize(document.structureName); + + // If already exists, add a number to the end of the permalink + const exists = await db.collection('structures').findOne({ permalink }); + if (exists) { + let count = 1; + while (await db.collection('structures').findOne({ permalink: `${permalink}-${count}` })) { + count++; + } + permalink = `${permalink}-${count}`; + } + await db.collection('structures').updateOne({ _id: document._id }, [{ $set: { permalink } }]); + } + console.log(`Update done : added permalink to users and structures`); +}; + +export const down = async () => { + const db: Db = await getDb(); + + await db.collection('users').updateMany({}, [{ $unset: 'permalink' }]); + await db.collection('structures').updateMany({}, [{ $unset: 'permalink' }]); + + console.log(`Downgrade done : removed permalink from users and structures`); +}; diff --git a/src/posts/posts.controller.spec.ts b/src/posts/posts.controller.spec.ts index 5738c687845e13658691414a283b49b258b86837..72d7f927c4d76a0adb42e5e43f9ef3d02b5cb850 100644 --- a/src/posts/posts.controller.spec.ts +++ b/src/posts/posts.controller.spec.ts @@ -356,70 +356,4 @@ describe('PostsController', () => { expect(result.public.length).toBe(2); }); }); - - describe('getPostbyId', () => { - it('should get post Hello by id 61c4847b0ff4550001505090', async () => { - const data = [ - { - id: '61c4847b0ff4550001505090', - uuid: 'f4ee5a37-a343-4cad-8a32-3f6cf87f9569', - title: 'Hello', - slug: 'hello', - html: '<p>Test</p>', - comment_id: '61c4847b0ff4550001505090', - feature_image: 'http://localhost:2368/content/images/2021/12/dacc-4.png', - featured: false, - visibility: 'public', - email_recipient_filter: 'none', - created_at: '2021-12-23T14:15:23.000+00:00', - updated_at: '2021-12-23T14:15:45.000+00:00', - published_at: '2021-12-23T14:15:45.000+00:00', - custom_excerpt: null, - codeinjection_head: null, - codeinjection_foot: null, - custom_template: null, - canonical_url: null, - tags: [Array], - authors: [Array], - primary_author: [Object], - primary_tag: [Object], - url: 'http://localhost:2368/hello/', - excerpt: - '« Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed non risus.\n' + - 'Suspendisse lectus tortor, dignissim sit amet, adipiscing nec, ultricies sed,\n' + - 'dolor. Cras elementum ultrices diam. Maecenas ligula massa, varius a, semper\n' + - 'congue, euismod non, mi. Proin porttitor, orci nec nonummy molestie, enim est\n' + - 'eleifend mi, non fermentum diam nisl sit amet erat. Duis semper. Duis arcu\n' + - 'massa, scelerisque vitae, consequat in, pretium a, enim. Pellentesque congue. Ut\n' + - 'in risus volutpat libero pharetra tem', - reading_time: 2, - access: true, - send_email_when_published: false, - og_image: null, - og_title: null, - og_description: null, - twitter_image: null, - twitter_title: null, - twitter_description: null, - meta_title: null, - meta_description: null, - email_subject: null, - frontmatter: null, - }, - ]; - const axiosResult: AxiosResponse = { - data: { - posts: data, - }, - status: 200, - statusText: 'OK', - headers: {}, - config: {}, - }; - httpServiceMock.get.mockImplementationOnce(() => of(axiosResult)); - postServiceMock.formatPosts.mockImplementationOnce(() => data); - const result = await (await postsController.getPostbyId('61c4847b0ff4550001505090')).toPromise(); - expect(result).toStrictEqual({ posts: [data] }); - }); - }); }); diff --git a/src/posts/posts.controller.ts b/src/posts/posts.controller.ts index f11f49a8cdb279c45cd1c1292848fcd786b7e03a..008700f8b1ea4751fa6b73ad962d8393be09784e 100644 --- a/src/posts/posts.controller.ts +++ b/src/posts/posts.controller.ts @@ -1,12 +1,12 @@ +import { HttpService } from '@nestjs/axios'; import { Controller, Get, HttpException, HttpStatus, Logger, Param, Query } from '@nestjs/common'; +import { ApiQuery, ApiTags } from '@nestjs/swagger'; import { Observable } from 'rxjs'; import { catchError, map } from 'rxjs/operators'; -import { ApiQuery, ApiTags } from '@nestjs/swagger'; -import { Post } from './schemas/post.schema'; import { PostsService } from './posts.service'; -import { Tag } from './schemas/tag.schema'; +import { Post } from './schemas/post.schema'; import { PostWithMeta } from './schemas/postWithMeta.schema'; -import { HttpService } from '@nestjs/axios'; +import { Tag } from './schemas/tag.schema'; @ApiTags('posts') @Controller('posts') @@ -64,10 +64,10 @@ export class PostsController { }); } - @Get(':id') - public async getPostbyId(@Param('id') id: string): Promise<Observable<{ posts: Post[] }>> { + @Get(':slug') + public async getPostBySlug(@Param('slug') slug: string): Promise<Observable<{ posts: Post[] }>> { return this.httpService - .get(`${process.env.GHOST_HOST_AND_PORT}/ghost/api/v3/content/posts/` + id, { + .get(`${process.env.GHOST_HOST_AND_PORT}/ghost/api/v3/content/posts/slug/` + slug, { params: { key: process.env.GHOST_CONTENT_API_KEY, include: 'tags,authors', diff --git a/src/shared/utils.spec.ts b/src/shared/utils.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..6cc3526af4f35dfe278734bc73bdb29219f6e14b --- /dev/null +++ b/src/shared/utils.spec.ts @@ -0,0 +1,41 @@ +import { sanitize } from './utils'; + +describe('sanitize', () => { + it('should do nothing if the string is already sanitized', () => { + expect(sanitize('abcdefghijklmnopqrstuvwxyz1234567890')).toBe('abcdefghijklmnopqrstuvwxyz1234567890'); + }); + + it('should remove trailing spaces', () => { + expect(sanitize(' abc ')).toBe('abc'); + }); + + it('should be lowercase', () => { + expect(sanitize('ABC')).toBe('abc'); + }); + + it('should remove accents', () => { + expect(sanitize('à èìòùÀÈÌÒÙáéÃóúýÃÉÃÓÚÃâêîôûÂÊÎÔÛãñõÃÑÕäëïöüÿÄËÃÖÜŸçÇÅå')).toBe( + 'aeiouaeiouaeiouyaeiouyaeiouaeiouanoanoaeiouyaeiouyccaa' + ); + }); + + it('should replace " - " by "-"', () => { + expect(sanitize('Mairie - Lyon 8')).toBe('mairie-lyon-8'); + }); + + it('should replace " / " by "-"', () => { + expect(sanitize('Mairie / Lyon 8')).toBe('mairie-lyon-8'); + }); + + it('should replace spaces by "-"', () => { + expect(sanitize('a b c d')).toBe('a-b-c-d'); + }); + + it('should remove parenthesis', () => { + expect(sanitize('Mairie (Lyon 8)')).toBe('mairie-lyon-8'); + }); + + it('should remove all non-alphanumeric characters', () => { + expect(sanitize('@%#$.!?;:+=[]{}~\'"')).toBe(''); + }); +}); diff --git a/src/shared/utils.ts b/src/shared/utils.ts index 9235c3513c24c12ec5b801ff7ccec9c47f6220ba..c3b78793be8b363b9606ebde330cc68df73e836e 100644 --- a/src/shared/utils.ts +++ b/src/shared/utils.ts @@ -63,3 +63,20 @@ export const es_settings_homemade_french: IndicesIndexSettings = { export function escapeElasticsearchQuery(query: string): string { return query.replace(/(\+|\-|\=|&&|\|\||\>|\<|\!|\(|\)|\{|\}|\[|\]|\^|"|~|\?|\:|\\|\/)/g, '\\$&'); } + +/** + * Remove accents and trailing spaces from a string, replace non-alphanumeric characters with dashes and return the result in lowercase + * @example + * sanitize(' Métropole de Lyon ') => 'metropole-de-lyon' + */ +export const sanitize = (str: string) => { + return str + .trim() + .toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') // remove accents + .replace(/\s-\s/g, '-') // replace " - " by "-" + .replace(/\s\/\s/g, '-') // replace " / " by "-" + .replace(/\s/g, '-') // replace spaces by "-" + .replace(/[^\w\s-]/gi, ''); // remove all non alphanumeric characters except spaces and dashes +}; diff --git a/src/structures/schemas/structure.schema.ts b/src/structures/schemas/structure.schema.ts index 37c5b34ebe6507f9f86473c721a0d92c2d9b256a..c55c3c2ba5e3253d5a81a8b7e393a95df6cb94cc 100644 --- a/src/structures/schemas/structure.schema.ts +++ b/src/structures/schemas/structure.schema.ts @@ -42,6 +42,7 @@ export class Structure { this.idDataGouv = data.idDataGouv; this.hasNoUserDN = data.hasNoUserDN; this.hasUserWithAppointmentDN = data.hasUserWithAppointmentDN; + this.permalink = data.permalink; } @Prop() @@ -150,6 +151,9 @@ export class Structure { // No @Prop decorator because this property must not be stored in database categoriesWithPersonalOffers: StructureCategories; + + @Prop() + permalink: string; } export const StructureSchema = SchemaFactory.createForClass(Structure); diff --git a/src/structures/services/structures.service.spec.ts b/src/structures/services/structures.service.spec.ts index 1e055739441c0054433a27b2492f7086c41b549d..d5f40c4883c8065f2af9803575f2957dd305b3ca 100644 --- a/src/structures/services/structures.service.spec.ts +++ b/src/structures/services/structures.service.spec.ts @@ -615,4 +615,28 @@ describe('StructuresService', () => { expect(result).toEqual(expectedResult); }); }); + describe('getAvailablePermalink', () => { + it('should return a permalink', async () => { + jest.spyOn(structureService, 'findByPermalink').mockResolvedValueOnce(null); + const permalink = await structureService.getAvailablePermalink('test'); + expect(permalink).toBe('test'); + }); + it('should return a permalink with "-1" because structure already exists', async () => { + jest + .spyOn(structureService, 'findByPermalink') + .mockResolvedValueOnce(mockResinStructures[0] as StructureDocument) + .mockResolvedValueOnce(null); + const permalink = await structureService.getAvailablePermalink('test'); + expect(permalink).toBe('test-1'); + }); + it('should return a permalink with "-2" because structure already exists twice', async () => { + jest + .spyOn(structureService, 'findByPermalink') + .mockResolvedValueOnce(mockResinStructures[0] as StructureDocument) + .mockResolvedValueOnce(mockResinStructures[0] as StructureDocument) + .mockResolvedValueOnce(null); + const permalink = await structureService.getAvailablePermalink('test'); + expect(permalink).toBe('test-2'); + }); + }); }); diff --git a/src/structures/services/structures.service.ts b/src/structures/services/structures.service.ts index 019fef247999b7a88af191552f29f251b4083eaf..64c304a7f10e65a7ecd60e6afe5ac9fda0cebb79 100644 --- a/src/structures/services/structures.service.ts +++ b/src/structures/services/structures.service.ts @@ -14,8 +14,12 @@ import { Module } from '../../categories/schemas/module.class'; import { CategoriesService } from '../../categories/services/categories.service'; import { MailerService } from '../../mailer/mailer.service'; import { PersonalOfferDocument } from '../../personal-offers/schemas/personal-offer.schema'; +import { sanitize } from '../../shared/utils'; import { IUser } from '../../users/interfaces/user.interface'; +import { User } from '../../users/schemas/user.schema'; +import { JobsService } from '../../users/services/jobs.service'; import { UsersService } from '../../users/services/users.service'; +import { PhotonAddress } from '../common/photonAddress.type'; import { PhotonResponse } from '../common/photonResponse.type'; import { depRegex } from '../common/regex'; import { StructureDto } from '../dto/structure.dto'; @@ -26,9 +30,6 @@ import { PhotonPoints } from '../interfaces/photon-response.interface'; import { Address } from '../schemas/address.schema'; import { Structure, StructureDocument } from '../schemas/structure.schema'; import { StructuresSearchService } from './structures-search.service'; -import { JobsService } from '../../users/services/jobs.service'; -import { User } from '../../users/schemas/user.schema'; -import { PhotonAddress } from '../common/photonAddress.type'; @Injectable() export class StructuresService { @@ -244,6 +245,7 @@ export class StructuresService { const createdStructure = new this.structureModel(structure); createdStructure._id = new Types.ObjectId(); createdStructure.structureName = createdStructure.structureName.trim(); + createdStructure.permalink = await this.getAvailablePermalink(createdStructure.structureName); createdStructure.categories.selfServiceMaterial = this.getSelfServiceMaterial(createdStructure); await createdStructure.save(); await this.setStructurePosition(createdStructure).then(async (structureWithPosition: StructureDocument) => { @@ -275,6 +277,22 @@ export class StructuresService { return updatedStructure; } + public async getAvailablePermalink(structureName: string): Promise<string> { + let permalink = sanitize(structureName); + + // If already exists, add a number to the end of the permalink + const exists = await this.findByPermalink(permalink); + if (exists) { + let count = 1; + while (await this.findByPermalink(`${permalink}-${count}`)) { + count++; + } + permalink = `${permalink}-${count}`; + } + + return permalink; + } + public async search(searchString: string, filters?: Array<any>): Promise<Structure[]> { if (searchString && filters) { return this.structureModel @@ -456,13 +474,20 @@ export class StructuresService { return responseStructure; } + private async findQueryExec(query): Promise<StructureDocument> { + return query.populate('personalOffers').populate('structureType').exec(); + } + public async findOne(idParam: string): Promise<StructureDocument> { - this.logger.debug('findOne'); - return this.structureModel - .findById(new Types.ObjectId(idParam)) - .populate('personalOffers') - .populate('structureType') - .exec(); + this.logger.debug(`findOne: ${idParam}`); + const structureQuery = this.structureModel.findById(new Types.ObjectId(idParam)); + return this.findQueryExec(structureQuery); + } + + public async findByPermalink(permalink: string): Promise<StructureDocument> { + this.logger.debug(`findByPermalink: ${permalink}`); + const structureQuery = this.structureModel.findOne({ permalink }); + return this.findQueryExec(structureQuery); } /** @@ -914,7 +939,7 @@ export class StructuresService { const jsonConfig = this.mailerService.loadJsonConfig(jsonConfigLocation); const html = await ejs.renderFile(ejsPath, { config, - id: structure ? structure._id : 0, + permalink: structure ? structure.permalink : '', structureName: structure ? structure.structureName : '', structureAddress: structure ? `${structure.address.numero || ''} ${structure.address.street} ${structure.address.commune}` @@ -1089,7 +1114,7 @@ export class StructuresService { const html = await ejs.renderFile(ejsPath, { config, content: content, - id: structure._id, + permalink: structure.permalink, structureName: structure.structureName, }); this.mailerService.send(emailsObject, jsonConfig.subject, html); diff --git a/src/structures/structures.controller.spec.ts b/src/structures/structures.controller.spec.ts index 2ca77c731f722ae6783a603a44d2a677f9a6ad8d..d480497671181fdb5a1a93260a1c54bf6dd84efc 100644 --- a/src/structures/structures.controller.spec.ts +++ b/src/structures/structures.controller.spec.ts @@ -1,30 +1,30 @@ import { HttpService } from '@nestjs/axios'; import { HttpStatus } from '@nestjs/common'; +import { getModelToken } from '@nestjs/mongoose'; import { Test } from '@nestjs/testing'; import { of } from 'rxjs'; import { PhotonResponseMock } from '../../test/mock/data/dataPhoton.mock.data'; import { structureDtoMock } from '../../test/mock/data/structure.mock.dto'; +import { mockDeletedStructure, mockStructure } from '../../test/mock/data/structures.mock.data'; +import { mockUser } from '../../test/mock/data/users.mock.data'; import { CategoriesServiceMock } from '../../test/mock/services/categories.mock.service'; +import { + StructuresExportServiceMock, + mockFormattedStructures, +} from '../../test/mock/services/structures-export.mock.service'; import { UsersServiceMock } from '../../test/mock/services/user.mock.service'; import { CategoriesService } from '../categories/services/categories.service'; import { PersonalOffersService } from '../personal-offers/personal-offers.service'; import { TempUserService } from '../temp-user/temp-user.service'; import { UserRole } from '../users/enum/user-role.enum'; +import { JobDocument } from '../users/schemas/job.schema'; import { UsersService } from '../users/services/users.service'; import { CreateStructureDto } from './dto/create-structure.dto'; -import { StructuresService } from './services/structures.service'; -import { StructuresController } from './structures.controller'; -import { mockDeletedStructure, mockStructure } from '../../test/mock/data/structures.mock.data'; -import { mockUser } from '../../test/mock/data/users.mock.data'; -import { StructuresExportService } from './services/structures-export.service'; -import { - StructuresExportServiceMock, - mockFormattedStructures, -} from '../../test/mock/services/structures-export.mock.service'; import { StructureFormatted } from './interfaces/structure-formatted.interface'; -import { JobDocument } from '../users/schemas/job.schema'; -import { getModelToken } from '@nestjs/mongoose'; import { Structure } from './schemas/structure.schema'; +import { StructuresExportService } from './services/structures-export.service'; +import { StructuresService } from './services/structures.service'; +import { StructuresController } from './structures.controller'; describe('StructuresController', () => { let structuresController: StructuresController; @@ -49,6 +49,7 @@ describe('StructuresController', () => { searchForStructures: jest.fn(), update: jest.fn(), updateAllDenormalizedFields: jest.fn(), + findByPermalink: jest.fn(), }; const mockTempUserService = { @@ -200,6 +201,7 @@ describe('StructuresController', () => { } as JobDocument, unattachedSince: null, lastLoginDate: null, + permalink: '', }); expect(res).toBeTruthy(); }); @@ -327,6 +329,24 @@ describe('StructuresController', () => { // test personal offers delete }); + describe('permalink', () => { + it('should get structure by permalink', async () => { + mockStructureService.findByPermalink.mockResolvedValueOnce(mockStructure); + await structuresController.findByPermalink({ user: { _id: 'test' } }, 'test'); + expect(mockStructureService.findByPermalink).toHaveBeenCalled(); + }); + it('should throw error if no structure found', async () => { + mockStructureService.findByPermalink.mockResolvedValueOnce(null); + try { + await structuresController.findByPermalink({ user: { _id: 'test' } }, 'test'); + expect(true).toBe(false); + } catch (error) { + expect(error.message).toBe('Structure does not exist'); + expect(error.status).toBe(HttpStatus.NOT_FOUND); + } + }); + }); + it('should remove temp user', async () => { mockStructureService.findOne.mockResolvedValue(mockStructure); mockTempUserService.findById.mockResolvedValue(mockUser); diff --git a/src/structures/structures.controller.ts b/src/structures/structures.controller.ts index 7ba449e7479cbbd21193cfb94f4719e5b04c3458..fe62766f74a30f95c36ff8c3bea30f4a40461d1e 100644 --- a/src/structures/structures.controller.ts +++ b/src/structures/structures.controller.ts @@ -38,10 +38,10 @@ import { CreateStructureDto } from './dto/create-structure.dto'; import { QueryStructure } from './dto/query-structure.dto'; import { UpdateStructureDto } from './dto/update-structure.dto'; import { PhotonPoints } from './interfaces/photon-response.interface'; +import { StructureFormatted } from './interfaces/structure-formatted.interface'; import { Structure, StructureDocument } from './schemas/structure.schema'; import { StructuresExportService } from './services/structures-export.service'; import { StructuresService } from './services/structures.service'; -import { StructureFormatted } from './interfaces/structure-formatted.interface'; @ApiTags('structures') @Controller('structures') @@ -171,6 +171,18 @@ export class StructuresController { return structure; } + @Get('permalink/:permalink') + @UseGuards(AuthGuard(['jwt', 'anonymous'])) // first success wins (allow anonymous call, req.user is an empty object if user is not authenticated) + public async findByPermalink(@Request() req, @Param('permalink') permalink: string) { + this.logger.debug(`findByPermalink with ${permalink}`); + const structure = await this.structureService.findByPermalink(permalink); + if (!structure || (structure.deletedAt && !hasAdminRole(req.user))) { + this.logger.log(`structure with permalink ${permalink} does not exist`); + throw new HttpException('Structure does not exist', HttpStatus.NOT_FOUND); + } + return structure; + } + @Get(':id/withOwners') public async findWithOwners(@Param('id') id: string) { return this.structureService.findWithOwners(id); diff --git a/src/users/controllers/users.controller.spec.ts b/src/users/controllers/users.controller.spec.ts index 8b2e72bebcaea572671ab4c4605aab1fddacd93f..9df155d9b561b69733aa6a89788609b1258b283c 100644 --- a/src/users/controllers/users.controller.spec.ts +++ b/src/users/controllers/users.controller.spec.ts @@ -58,12 +58,12 @@ describe('UsersController', () => { validateUser: jest.fn(), verifyAndUpdateUserEmail: jest.fn(), verifyUserExist: jest.fn(), + findByPermalink: jest.fn(), checkPasswordResetToken: jest.fn(), updatePendingStructureLinked: jest.fn(), updateStructureLinked: jest.fn(), removeFromPendingStructureLinked: jest.fn(), sendStructureClaimApproval: jest.fn(), - sendAdminStructureNotification: jest.fn(), }; const structureServiceMock = { @@ -443,7 +443,7 @@ describe('UsersController', () => { } }); - it('should accept and return id & name', async () => { + it('should accept and return structure', async () => { mockJwtService.decode.mockReturnValue({ expiresAt: '2999-12-31T00:00:00.000Z', idStructure: '620e5236f25755550cb86dfd', @@ -465,10 +465,10 @@ describe('UsersController', () => { const result = await usersController.joinValidation('token', 'true'); expect(userServiceMock.updateStructureLinked).toHaveBeenCalledTimes(1); expect(userServiceMock.removeFromPendingStructureLinked).toHaveBeenCalledTimes(1); - expect(result).toEqual({ id: '620e5236f25755550cb86dfd', name: 'a' }); + expect(result).toEqual(mockStructure); }); - it('should refuse and return id & name', async () => { + it('should refuse and return structure', async () => { mockJwtService.decode.mockReturnValue({ expiresAt: '2999-12-31T00:00:00.000Z', idStructure: '620e5236f25755550cb86dfd', @@ -490,7 +490,7 @@ describe('UsersController', () => { const result = await usersController.joinValidation('token', 'false'); expect(userServiceMock.updateStructureLinked).toHaveBeenCalledTimes(0); expect(userServiceMock.removeFromPendingStructureLinked).toHaveBeenCalledTimes(1); - expect(result).toEqual({ id: '620e5236f25755550cb86dfd', name: 'a' }); + expect(result).toEqual(mockStructure); }); }); @@ -548,4 +548,23 @@ describe('UsersController', () => { expect(userServiceMock.removeFromPendingStructureLinked).toHaveBeenCalledTimes(1); }); }); + + describe('permalink', () => { + it('should get user by permalink', async () => { + userServiceMock.findByPermalink.mockResolvedValueOnce(usersMockData[0]); + const result = await usersController.getUserByPermalink('test'); + expect(userServiceMock.findByPermalink).toHaveBeenCalled(); + expect(result).toEqual(usersMockData[0]); + }); + it('should throw error if no user found', async () => { + userServiceMock.findByPermalink.mockResolvedValueOnce(null); + try { + await usersController.getUserByPermalink('test'); + expect(true).toBe(false); + } catch (error) { + expect(error.message).toBe('User does not exist'); + expect(error.status).toBe(HttpStatus.NOT_FOUND); + } + }); + }); }); diff --git a/src/users/controllers/users.controller.ts b/src/users/controllers/users.controller.ts index 2be10cb18e70d2da5f0acd00ab0ced00c030447a..359fe25f59968fb60ea77dfde93a574bc369085e 100644 --- a/src/users/controllers/users.controller.ts +++ b/src/users/controllers/users.controller.ts @@ -207,6 +207,17 @@ export class UsersController { return user; } + @UseGuards(JwtAuthGuard) + @Get('permalink/:permalink') + @ApiParam({ name: 'permalink', type: String, required: true }) + public async getUserByPermalink(@Param('permalink') permalink: string): Promise<IUser> { + const user = await this.usersService.findByPermalink(permalink); + if (!user) { + throw new HttpException('User does not exist', HttpStatus.NOT_FOUND); + } + return user; + } + @ApiResponse({ status: HttpStatus.CREATED, description: 'Description updated' }) @ApiResponse({ status: HttpStatus.UNAUTHORIZED, description: 'Unauthorized' }) @UseGuards(JwtAuthGuard) @@ -249,13 +260,7 @@ export class UsersController { @Get('join-validate/:token/:status') @ApiParam({ name: 'token', type: String, required: true }) @ApiParam({ name: 'status', type: String, required: true }) - public async joinValidation( - @Param('token') token: string, - @Param('status') status: string - ): Promise<{ - id: string; - name: string; - }> { + public async joinValidation(@Param('token') token: string, @Param('status') status: string): Promise<Structure> { const decoded = this.jwtService.decode(token) as IPendingStructureToken; const today = DateTime.local().setZone('utc', { keepLocalTime: true }); @@ -292,7 +297,7 @@ export class UsersController { } await this.usersService.sendStructureClaimApproval(userFromDb.email, structure.structureName, status == 'true'); - return { id: decoded.idStructure, name: structure.structureName }; + return structure; } /** Cancel a user's join request */ diff --git a/src/users/schemas/user.schema.ts b/src/users/schemas/user.schema.ts index c850621c819deb256743bb8edaa1a4d18115c7a5..cafd214ba94e6ee1826e95884c4301f63c744788 100644 --- a/src/users/schemas/user.schema.ts +++ b/src/users/schemas/user.schema.ts @@ -1,10 +1,10 @@ import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; import { Types } from 'mongoose'; import { PersonalOfferDocument } from '../../personal-offers/schemas/personal-offer.schema'; -import { Employer } from './employer.schema'; -import { JobDocument } from './job.schema'; import { UserRole } from '../enum/user-role.enum'; import { pendingStructuresLink } from '../interfaces/pendingStructure'; +import { Employer } from './employer.schema'; +import { JobDocument } from './job.schema'; @Schema({ timestamps: true }) export class User { @@ -74,6 +74,9 @@ export class User { @Prop({ default: null }) lastLoginDate: Date; + @Prop() + permalink: string; + // Document methods // static because object method would need to create a constructor and get an actual User instance (cf. https://stackoverflow.com/a/42899705 ) diff --git a/src/users/services/userRegistry.service.ts b/src/users/services/userRegistry.service.ts index be12c62b11eb08a8c18c39ef570af648ab7095ff..2577369ed616d1d25892205911429a1530edd535 100644 --- a/src/users/services/userRegistry.service.ts +++ b/src/users/services/userRegistry.service.ts @@ -4,9 +4,9 @@ import { Model } from 'mongoose'; import { IUser } from '../interfaces/user.interface'; import { IUserRegistry, UserRegistryPaginatedResponse } from '../interfaces/userRegistry.interface'; import { Employer } from '../schemas/employer.schema'; +import { JobsGroupsDocument } from '../schemas/jobsGroups.schema'; import { User } from '../schemas/user.schema'; import { EmployerService } from './employer.service'; -import { JobsGroupsDocument } from '../schemas/jobsGroups.schema'; import { JobsGroupsService } from './jobsGroups.service'; import { UserRegistrySearchService } from './userRegistry-search.service'; @@ -93,7 +93,7 @@ export class UserRegistryService { }) .where('emailVerified') .equals(true) - .select('name surname employer job _id withAppointment') + .select('name surname employer job _id withAppointment permalink') .populate('employer job') .collation({ locale: 'fr' }) .sort({ surname: 1 }) diff --git a/src/users/services/users.service.spec.ts b/src/users/services/users.service.spec.ts index 2a84af964132d8b0d9e6a7afb4d407b33331dd4a..6241aacde355ea80bb3dfba8b7051e43fa722c19 100644 --- a/src/users/services/users.service.spec.ts +++ b/src/users/services/users.service.spec.ts @@ -9,11 +9,13 @@ import { employersMockData } from '../../../test/mock/data/employers.mock.data'; import { personalOffersDataMock } from '../../../test/mock/data/personalOffers.mock.data'; import { userDetails, userRegistryMockData, usersMockData } from '../../../test/mock/data/users.mock.data'; import { MailerMockService } from '../../../test/mock/services/mailer.mock.service'; +import { NewsletterServiceMock } from '../../../test/mock/services/newsletter.mock.service'; import { StructuresServiceMock } from '../../../test/mock/services/structures.mock.service'; import { LoginDto } from '../../auth/login-dto'; import { ConfigurationModule } from '../../configuration/configuration.module'; import { MailerModule } from '../../mailer/mailer.module'; import { MailerService } from '../../mailer/mailer.service'; +import { NewsletterService } from '../../newsletter/newsletter.service'; import { PersonalOfferDocument } from '../../personal-offers/schemas/personal-offer.schema'; import { StructuresService } from '../../structures/services/structures.service'; import { CreateUserDto } from '../dto/create-user.dto'; @@ -26,8 +28,6 @@ import { User } from '../schemas/user.schema'; import { JobsService } from './jobs.service'; import { UserRegistrySearchService } from './userRegistry-search.service'; import { UsersService } from './users.service'; -import { NewsletterService } from '../../newsletter/newsletter.service'; -import { NewsletterServiceMock } from '../../../test/mock/services/newsletter.mock.service'; function hashPassword() { return bcrypt.hashSync(process.env.USER_PWD, process.env.SALT); @@ -121,6 +121,7 @@ const mockUser: User = { } as JobDocument, unattachedSince: null, lastLoginDate: null, + permalink: 'jacques-dupont', }; const mockJobsService = { @@ -204,8 +205,6 @@ describe('UsersService', () => { jest.spyOn(usersService, 'isStrongPassword').mockImplementationOnce(() => true); //TODO mock new userModal(createUserDto) return; - jest.spyOn(usersService, 'findOne').mockImplementationOnce(async (): Promise<any> => mockUser); - expect(await usersService.create(createUserDto)).toBe(mockUser); }); }); @@ -261,13 +260,6 @@ describe('UsersService', () => { jest.spyOn(usersService, 'findOne').mockResolvedValue(mockUser as IUser); //TODO mock private function comparePassword ? -> false return true; - try { - await usersService.checkLogin(loginDto); - expect(true).toBe(false); - } catch (error) { - expect(error.status).toBe(HttpStatus.UNAUTHORIZED); - expect(error.message).toBe('Invalid credentials'); - } }); it('should find', async () => { @@ -275,7 +267,6 @@ describe('UsersService', () => { jest.spyOn(usersService, 'findOne').mockResolvedValue(mockUser as IUser); //TODO mock private function comparePassword ? -> true return true; - expect(await usersService.checkLogin(loginDto)).toBe(mockUser); }); it('wrong password, should be unauthorized issue', async () => { @@ -499,4 +490,26 @@ describe('UsersService', () => { expect(res.length).toBe(1); }); }); + + describe('getAvailablePermalink', () => { + it('should return a permalink', async () => { + jest.spyOn(usersService, 'findByPermalink').mockResolvedValueOnce(null); + const permalink = await usersService.getAvailablePermalink('prenom', 'nom'); + expect(permalink).toBe('prenom-nom'); + }); + it('should return a permalink with "-1" because structure already exists', async () => { + jest.spyOn(usersService, 'findByPermalink').mockResolvedValueOnce(usersMockData[0]).mockResolvedValueOnce(null); + const permalink = await usersService.getAvailablePermalink('prenom', 'nom'); + expect(permalink).toBe('prenom-nom-1'); + }); + it('should return a permalink with "-2" because structure already exists twice', async () => { + jest + .spyOn(usersService, 'findByPermalink') + .mockResolvedValueOnce(usersMockData[0]) + .mockResolvedValueOnce(usersMockData[0]) + .mockResolvedValueOnce(null); + const permalink = await usersService.getAvailablePermalink('prenom', 'nom'); + expect(permalink).toBe('prenom-nom-2'); + }); + }); }); diff --git a/src/users/services/users.service.ts b/src/users/services/users.service.ts index fcd3e2d10bcbbb6833bc318b64d27a03673c73d0..abdbc5f4ca472453329351dead90509bb02c62a4 100644 --- a/src/users/services/users.service.ts +++ b/src/users/services/users.service.ts @@ -14,6 +14,7 @@ import { LoginDto } from '../../auth/login-dto'; import { MailerService } from '../../mailer/mailer.service'; import { NewsletterService } from '../../newsletter/newsletter.service'; import { PersonalOfferDocument } from '../../personal-offers/schemas/personal-offer.schema'; +import { sanitize } from '../../shared/utils'; import { Structure, StructureDocument } from '../../structures/schemas/structure.schema'; import { StructuresService } from '../../structures/services/structures.service'; import { EmailChangeDto } from '../dto/change-email.dto'; @@ -60,6 +61,7 @@ export class UsersService { } let createUser = new this.userModel(createUserDto) as IUser; createUser.surname = createUser.surname.toUpperCase(); + createUser.permalink = await this.getAvailablePermalink(createUser.name, createUser.surname); createUser.structuresLink = []; if (createUserDto.structuresLink) { createUserDto.structuresLink.forEach((structureId) => { @@ -77,6 +79,21 @@ export class UsersService { return this.findOne(createUserDto.email); } + public async getAvailablePermalink(name: string, surname: string): Promise<string> { + let permalink = sanitize(`${name}-${surname}`); + + // If already exists, add a number to the end of the permalink + const exists = await this.findByPermalink(permalink); + if (exists) { + let count = 1; + while (await this.findByPermalink(`${permalink}-${count}`)) { + count++; + } + permalink = `${permalink}-${count}`; + } + + return permalink; + } /** * Verify password strength with the following rule: * - The string must contain at least 1 lowercase alphabetical character @@ -105,6 +122,10 @@ export class UsersService { return this.userModel.findOne({ email: mail }).populate('employer').populate('job').select('-password').exec(); } + public async findByPermalink(permalink: string): Promise<IUser | undefined> { + return this.userModel.findOne({ permalink }).exec(); + } + public findAll(): Promise<User[]> { return this.userModel.find().populate('employer').populate('job').select('-password').exec(); } @@ -288,7 +309,7 @@ export class UsersService { config, name: user.name, surname: user.surname, - userId: user._id, + userPermalink: user.permalink, }); this.logger.debug(html); const admins = await this.getAdmins(); @@ -749,7 +770,7 @@ export class UsersService { .populate('job') .populate('employer') .populate('personalOffers') - .select('name surname job employer email withAppointment personalOffers') + .select('name surname permalink job employer email withAppointment personalOffers') .exec(); } diff --git a/test/mock/data/structures.mock.data.ts b/test/mock/data/structures.mock.data.ts index 18d7efcdb7858edd82182532386c129de4c4fee4..a830061d6d7a3254e0350143c0b2baa4ee6cc200 100644 --- a/test/mock/data/structures.mock.data.ts +++ b/test/mock/data/structures.mock.data.ts @@ -422,6 +422,7 @@ export const mockStructure: Structure = { freeWorkShop: 'Non', accountVerified: true, categoriesWithPersonalOffers: {}, + permalink: 'a', }; export const mockDeletedStructure: Structure = { @@ -527,4 +528,5 @@ export const mockDeletedStructure: Structure = { createdAt: new Date('2020-11-16T09:30:00.000Z'), updatedAt: new Date('2020-11-16T09:30:00.000Z'), deletedAt: new Date('2020-11-16T09:30:00.000Z'), + permalink: 'latelier-numerique', }; diff --git a/test/mock/data/users.mock.data.ts b/test/mock/data/users.mock.data.ts index 2dfe3fde3a243ed607c6c78c5a45fb049fe63cfc..b779604ede304d59c609a154dc984eb023c5acca 100644 --- a/test/mock/data/users.mock.data.ts +++ b/test/mock/data/users.mock.data.ts @@ -244,4 +244,5 @@ export const mockUser: User = { structureOutdatedMailSent: [], unattachedSince: new Date(2024, 1, 1), lastLoginDate: new Date(2024, 1, 1), + permalink: 'pauline-dupont', }; diff --git a/test/mock/services/structures.mock.service.ts b/test/mock/services/structures.mock.service.ts index 63bb0cf7cc1f3204d0d98a1c6f4eeaa29c1b06e6..8ee9c3c3575229205228d9bc0dfb5c674df5fea8 100644 --- a/test/mock/services/structures.mock.service.ts +++ b/test/mock/services/structures.mock.service.ts @@ -1547,6 +1547,7 @@ export const mockResinStructures: Array<Structure> = [ dataShareConsentDate: new Date('2021-05-06T09:42:38.000Z'), personalOffers: [], categoriesWithPersonalOffers: null, + permalink: 'a', }, { contactMail: 'matchin@email.com', @@ -1649,6 +1650,7 @@ export const mockResinStructures: Array<Structure> = [ deletedAt: new Date('2121-05-06T09:42:38.000Z'), dataShareConsentDate: new Date('2021-05-06T09:42:38.000Z'), categoriesWithPersonalOffers: null, + permalink: 'a-1', }, { contactMail: 'nomatch@email.com', @@ -1751,5 +1753,6 @@ export const mockResinStructures: Array<Structure> = [ deletedAt: new Date('2121-05-06T09:42:38.000Z'), dataShareConsentDate: new Date('2021-05-06T09:42:38.000Z'), categoriesWithPersonalOffers: null, + permalink: 'a-2', }, ];