diff --git a/.gitignore b/.gitignore index 3f3a096..4802c92 100644 --- a/.gitignore +++ b/.gitignore @@ -135,3 +135,6 @@ logs/ *.secrets .act.secrets .env + +.vscode + diff --git a/.vscode/extensions.json b/.vscode/extensions.json deleted file mode 100644 index 77b3745..0000000 --- a/.vscode/extensions.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - // For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846 - "recommendations": ["angular.ng-template"] -} diff --git a/.vscode/launch.json b/.vscode/launch.json deleted file mode 100644 index 925af83..0000000 --- a/.vscode/launch.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 - "version": "0.2.0", - "configurations": [ - { - "name": "ng serve", - "type": "chrome", - "request": "launch", - "preLaunchTask": "npm: start", - "url": "http://localhost:4200/" - }, - { - "name": "ng test", - "type": "chrome", - "request": "launch", - "preLaunchTask": "npm: test", - "url": "http://localhost:9876/debug.html" - } - ] -} diff --git a/.vscode/tasks.json b/.vscode/tasks.json deleted file mode 100644 index a298b5b..0000000 --- a/.vscode/tasks.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - // For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558 - "version": "2.0.0", - "tasks": [ - { - "type": "npm", - "script": "start", - "isBackground": true, - "problemMatcher": { - "owner": "typescript", - "pattern": "$tsc", - "background": { - "activeOnStart": true, - "beginsPattern": { - "regexp": "(.*?)" - }, - "endsPattern": { - "regexp": "bundle generation complete" - } - } - } - }, - { - "type": "npm", - "script": "test", - "isBackground": true, - "problemMatcher": { - "owner": "typescript", - "pattern": "$tsc", - "background": { - "activeOnStart": true, - "beginsPattern": { - "regexp": "(.*?)" - }, - "endsPattern": { - "regexp": "bundle generation complete" - } - } - } - } - ] -} diff --git a/package.json b/package.json index abcf445..dc1b2e7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "trouve-ton-profile", - "version": "1.3.1", + "version": "1.3.2", "scripts": { "ng": "ng", "start": "bash replace-prod-env.sh src/environments/environment.development.ts $ENV_URL && ng serve", diff --git a/src/app/adapters/profiles/profile.presenter.model.ts b/src/app/adapters/profiles/profile.presenter.model.ts index 8e01e55..da66e50 100644 --- a/src/app/adapters/profiles/profile.presenter.model.ts +++ b/src/app/adapters/profiles/profile.presenter.model.ts @@ -23,6 +23,8 @@ export interface ProfileViewModel { settings?: SettingsProfileDto; slug?: string; userViewModel?: UserViewModel; + mail?: string; + phone?: string; } export interface ProfileViewModelPaginated { diff --git a/src/app/adapters/profiles/profile.presenter.ts b/src/app/adapters/profiles/profile.presenter.ts index 3e1d0a9..73d4d2e 100644 --- a/src/app/adapters/profiles/profile.presenter.ts +++ b/src/app/adapters/profiles/profile.presenter.ts @@ -46,6 +46,7 @@ export class ProfilePresenter { settings, isProfileVisible, missingFields, + phone: this.formatPhoneNumberForDisplay(profile.phone ? `${profile.phone.toString()}` : ''), }; const profileExpand = (profile as any) ? (profile as any).expand : { utilisateur: {} as User }; @@ -58,7 +59,7 @@ export class ProfilePresenter { const slug = userSlug === '' ? profileId : userSlug.concat('-', profileId); const avatarUrl = userExpand.avatar ? `${environment.baseUrl}/api/files/users/${profile.utilisateur}/${userExpand.avatar}?thumb=320x240` - : `https://api.dicebear.com/9.x/initials/svg?seed=${userExpand.name}}`; + : `https://api.dicebear.com/9.x/initials/svg?seed=${userExpand.name}`; profileViewModel = { ...profileViewModel, @@ -66,6 +67,7 @@ export class ProfilePresenter { slug, fullName: userExpand.name, avatarUrl, + mail: userExpand.email, }; } @@ -104,4 +106,31 @@ export class ProfilePresenter { return missing; } + + private cleanPhoneNumber(phone: string): string { + if (!phone) return ''; + return phone.replace(/\D/g, ''); + } + + private formatPhoneNumberForDisplay(phone: string): string { + if (!phone) return ''; + + const cleaned = this.cleanPhoneNumber(`0${phone}`); + + // 9 chiffres : 06 xxx xx xx (3 + 2 + 2 + 2) + if (cleaned.length === 9) { + return `${cleaned.slice(0, 2)} ${cleaned.slice(2, 5)} ${cleaned.slice(5, 7)} ${cleaned.slice(7, 9)}`; + } + + // 10 chiffres : 06 xx xx xx xx (5 groupes de 2) + if (cleaned.length === 10) { + const groups: string[] = []; + for (let i = 0; i < cleaned.length; i += 2) { + groups.push(cleaned.slice(i, i + 2)); + } + return groups.join(' '); + } + + return cleaned; + } } diff --git a/src/app/domain/profiles/profile.model.ts b/src/app/domain/profiles/profile.model.ts index f8b7df6..2728b21 100644 --- a/src/app/domain/profiles/profile.model.ts +++ b/src/app/domain/profiles/profile.model.ts @@ -16,6 +16,7 @@ export interface Profile { estGeolocaliser?: boolean; partageMail?: boolean; partagePhone?: boolean; + phone?: number; } export interface ProfilePaginated { diff --git a/src/app/infrastructure/profiles/pb-profile.repository.ts b/src/app/infrastructure/profiles/pb-profile.repository.ts index 22e14be..77d8263 100644 --- a/src/app/infrastructure/profiles/pb-profile.repository.ts +++ b/src/app/infrastructure/profiles/pb-profile.repository.ts @@ -1,8 +1,8 @@ import { ProfileRepository } from '@app/domain/profiles/profile.repository'; -import { from, map, Observable } from 'rxjs'; +import { from, Observable } from 'rxjs'; import { Profile, ProfilePaginated } from '@app/domain/profiles/profile.model'; import { Injectable } from '@angular/core'; -import PocketBase, { ListResult } from 'pocketbase'; +import PocketBase from 'pocketbase'; import { environment } from '@env/environment'; import { ProfileDTO } from '@app/domain/profiles/dto/create-profile.dto'; import { SearchFilters } from '@app/domain/search/search-filters'; diff --git a/src/app/shared/components/my-profile-update-form/my-profile-update-form.component.html b/src/app/shared/components/my-profile-update-form/my-profile-update-form.component.html index 8898e80..ba25023 100644 --- a/src/app/shared/components/my-profile-update-form/my-profile-update-form.component.html +++ b/src/app/shared/components/my-profile-update-form/my-profile-update-form.component.html @@ -42,6 +42,135 @@ + +
+
+
+ + + +
+

Mes coordonnées

+
+ +
+ +
+ +
+
+ + + + +
+ +
+ @if (isEmailInvalid) { +

+ + + + {{ emailErrorMessage }} +

+ } +
+ + +
+ +
+
+ + + +
+ +
+ @if (isPhoneInvalid) { +

+ + + + {{ phoneErrorMessage }} +

+ } +
+
+
+
{ bio: '', apropos: '', reseaux: { facebook: '', github: '', instagram: '', linkedIn: '', web: '', x: '', youTube: '' }, + phone: '123', + mail: 'test@mail.com', }; beforeEach(async () => { @@ -69,6 +71,8 @@ describe('MyProfileUpdateFormComponent', () => { mockProfileData.secteur = 'technology'; mockProfileData.bio = 'A passionate developer'; mockProfileData.apropos = 'About me'; + mockProfileData.mail = 'test@mail.com'; + mockProfileData.phone = '123'; component.profileForm.setValue(mockProfileData); const spyUpdateProfile = jest.spyOn(component, 'onSubmit'); diff --git a/src/app/shared/components/my-profile-update-form/my-profile-update-form.component.ts b/src/app/shared/components/my-profile-update-form/my-profile-update-form.component.ts index e73a55b..8463301 100644 --- a/src/app/shared/components/my-profile-update-form/my-profile-update-form.component.ts +++ b/src/app/shared/components/my-profile-update-form/my-profile-update-form.component.ts @@ -78,6 +78,11 @@ export class MyProfileUpdateFormComponent implements OnInit { ), bio: new FormControl(this.profile.bio ? this.profile.bio.toLowerCase() : ''), apropos: new FormControl(this.profile.apropos ? this.profile.apropos.toLowerCase() : ''), + mail: new FormControl( + { value: this.profile.mail ? this.profile.mail.toLowerCase() : '', disabled: true }, + [Validators.email] + ), + phone: new FormControl(this.profile.phone ? this.profile.phone.toString().toLowerCase() : ''), reseaux: new FormGroup({ facebook: new FormControl( this.profile.reseaux ? (this.profile.reseaux as any)['facebook'] : '' @@ -111,14 +116,36 @@ export class MyProfileUpdateFormComponent implements OnInit { return; } - const data: Profile = { + const data: Partial = { profession: this.profileForm.getRawValue().profession, secteur: this.profileForm.getRawValue().secteur, apropos: this.profileForm.getRawValue().apropos, bio: this.profileForm.getRawValue().bio, + phone: parseInt(this.profileForm.getRawValue().phone.replace(/\D/g, '')), reseaux: this.profileForm.getRawValue().reseaux, - } as Profile; + } as Partial; this.profileFacade.update(this.profile.id, data); } + + get isEmailInvalid(): boolean { + const mailControl = this.profileForm.get('mail'); + return !!(mailControl?.invalid && mailControl?.touched && mailControl?.value); + } + + get isPhoneInvalid(): boolean { + const phoneControl = this.profileForm.get('phone'); + if (!phoneControl?.value) return false; + const phone = phoneControl.value.replace(/\D/g, ''); + + return phoneControl.touched && (phone.length < 9 || !phone.startsWith('0')); + } + + get emailErrorMessage(): string { + return 'Veuillez entrer une adresse email valide'; + } + + get phoneErrorMessage(): string { + return 'Le numéro doit contenir minimum 9 chiffres (ex: 06 xxx xx xx)'; + } } diff --git a/src/app/testing/adapters/profiles/profile.presenter.spec.ts b/src/app/testing/adapters/profiles/profile.presenter.spec.ts index f5cc9a1..acbfb4f 100644 --- a/src/app/testing/adapters/profiles/profile.presenter.spec.ts +++ b/src/app/testing/adapters/profiles/profile.presenter.spec.ts @@ -1,56 +1,332 @@ import { ProfilePresenter } from '@app/adapters/profiles/profile.presenter'; -import { Profile } from '@app/domain/profiles/profile.model'; +import { Profile, ProfilePaginated } from '@app/domain/profiles/profile.model'; import { mockProfiles } from '@app/testing/profile.mock'; -import { Coordinates } from '@app/domain/localisation/coordinates.model'; +import { UserViewModel } from '@app/adapters/users/user.presenter.model'; +import { mockUsers } from '@app/testing/user.mock'; describe('ProfilePresenter', () => { - it('devrait transformer un Profile en ProfileViewModel', () => { - const profile: Profile = mockProfiles[0]; + let presenter: ProfilePresenter; - const profilePresenter = new ProfilePresenter(); - const viewModel = profilePresenter.toViewModel(profile); + const mockProfile: Profile = mockProfiles[0] as Profile; - expect(viewModel).toEqual({ - id: profile.id, - fullName: '', // transformation OK - isVerifiedLabel: '✅ Vérifié', - estVerifier: true, - coordonnees: { latitude: 0, longitude: 0 } as Coordinates, - profession: 'Développeur Web'.toUpperCase(), - reseaux: { linkedin: 'https://linkedin.com/in/test' }, - secteur: 'Informatique', - utilisateur: 'user_abc', - createdAtFormatted: new Date(profile.created).toLocaleDateString(), - avatarUrl: '', - apropos: 'Développeur Angular & Node.js', - bio: 'Passionné de code.', - cv: 'http://localhost:8090/api/files/profiles/1/cv.pdf', - isProfileVisible: true, - missingFields: [], - projets: ['p1', 'p2'], - settings: { - showEmail: false, - showPhone: false, - allowGeolocation: false, - isProfilePublic: false, - }, + const mockUser: UserViewModel = mockUsers[0]; + + beforeEach(() => { + presenter = new ProfilePresenter(); + }); + + describe('toViewModel', () => { + it('devrait convertir un profil en ProfileViewModel', () => { + const viewModel = presenter.toViewModel(mockProfile); + + expect(viewModel).toBeDefined(); + expect(viewModel.id).toBe('1'); + expect(viewModel.profession).toBe('DÉVELOPPEUR WEB'); + expect(viewModel.secteur).toBe('Informatique'); + expect(viewModel.bio).toBe('Passionné de code.'); + expect(viewModel.apropos).toBe('Développeur Angular & Node.js'); + expect(viewModel.estVerifier).toBe(true); + expect(viewModel.isVerifiedLabel).toBe('✅ Vérifié'); + }); + + it('devrait formater le numéro de téléphone à 10 chiffres', () => { + const profileWithPhone = { + ...mockProfile, + phone: 612345678, + } as Profile; + + const viewModel = presenter.toViewModel(profileWithPhone); + + expect(viewModel.phone).toBe('06 12 34 56 78'); + }); + + it('devrait formater le numéro de téléphone à 9 chiffres', () => { + const profileWithPhone = { + ...mockProfile, + phone: 123456789, + } as Profile; + + const viewModel = presenter.toViewModel(profileWithPhone); + + expect(viewModel.phone).toBe('01 23 45 67 89'); + }); + + it('devrait gérer un téléphone vide', () => { + const profileWithoutPhone = { + ...mockProfile, + phone: undefined, + } as Profile; + + const viewModel = presenter.toViewModel(profileWithoutPhone); + + expect(viewModel.phone).toBe(''); + }); + + it('devrait utiliser la bio par défaut si non fournie', () => { + const profileWithoutBio = { + ...mockProfile, + bio: '', + } as Profile; + + const viewModel = presenter.toViewModel(profileWithoutBio); + + expect(viewModel.bio).toContain('Trouve Ton Profil'); + expect(viewModel.bio).toContain('expertise'); + }); + + it("devrait générer l'URL du CV correctement", () => { + const viewModel = presenter.toViewModel(mockProfile); + + expect(viewModel.cv).toContain('/api/files/profiles/1/cv.pdf'); + }); + + it('devrait retourner une chaîne vide si pas de CV', () => { + const profileWithoutCv = { + ...mockProfile, + cv: '', + } as Profile; + + const viewModel = presenter.toViewModel(profileWithoutCv); + + expect(viewModel.cv).toBe(''); + }); + + it('devrait définir les settings correctement', () => { + const viewModel = presenter.toViewModel(mockProfile); + + expect(viewModel.settings).toBeDefined(); + expect(viewModel.settings?.showEmail).toBe(false); + expect(viewModel.settings?.showPhone).toBe(false); + expect(viewModel.settings?.allowGeolocation).toBe(false); + expect(viewModel.settings?.isProfilePublic).toBe(false); + }); + + it('devrait définir les coordonnées correctement', () => { + const viewModel = presenter.toViewModel(mockProfile); + + expect(viewModel.coordonnees).toBeDefined(); + expect(viewModel.coordonnees?.latitude).toBe(0); + expect(viewModel.coordonnees?.longitude).toBe(0); + }); + + it("devrait gérer l'absence de coordonnées", () => { + const profileWithoutCoords = { + ...mockProfile, + coordonnees: undefined, + } as Profile; + + const viewModel = presenter.toViewModel(profileWithoutCoords); + + expect(viewModel.coordonnees).toBeUndefined(); + }); + + it('devrait formater la date de création', () => { + const viewModel = presenter.toViewModel(mockProfile); + + expect(viewModel.createdAtFormatted).toBeDefined(); + expect(typeof viewModel.createdAtFormatted).toBe('string'); + }); + + it('devrait définir le label de vérification pour un profil non vérifié', () => { + const unverifiedProfile = { + ...mockProfile, + estVerifier: false, + } as Profile; + + const viewModel = presenter.toViewModel(unverifiedProfile); + + expect(viewModel.isVerifiedLabel).toBe('❌ Non vérifié'); + }); + + describe('avec données utilisateur (expand)', () => { + it('devrait inclure les informations utilisateur si disponibles', () => { + const profileWithExpand = { + ...mockProfile, + expand: { + utilisateur: mockUser, + }, + } as any; + + const viewModel = presenter.toViewModel(profileWithExpand); + + expect(viewModel.fullName).toBe('foo'); + expect(viewModel.mail).toBe('foo@bar.com'); + expect(viewModel.avatarUrl).toContain( + 'http://localhost:8090/api/files/users/user_abc/foo.png?thumb=320x240' + ); + expect(viewModel.userViewModel).toBeDefined(); + }); + + it("devrait générer un slug à partir du username et de l'ID", () => { + const profileWithExpand = { + ...mockProfile, + expand: { + utilisateur: { + ...mockUser, + slug: 'test-user', + }, + }, + } as any; + + const viewModel = presenter.toViewModel(profileWithExpand); + + expect(viewModel.slug).toBe('foo-user001-1'); + }); + + it("devrait utiliser l'ID du profil si pas de slug utilisateur", () => { + const profileWithExpand = { + ...mockProfile, + expand: { + utilisateur: { + ...mockUser, + slug: undefined, + }, + }, + } as any; + + const viewModel = presenter.toViewModel(profileWithExpand); + + expect(viewModel.slug).toBe('foo-user001-1'); + }); + + it("devrait générer une URL Dicebear si pas d'avatar", () => { + const profileWithExpand = { + ...mockProfile, + expand: { + utilisateur: { + ...mockUser, + avatar: undefined, + }, + }, + } as any; + + const viewModel = presenter.toViewModel(profileWithExpand); + + expect(viewModel.avatarUrl).toContain('dicebear.com'); + expect(viewModel.avatarUrl).toContain('foo'); + }); }); }); - it('devrait retourner ❌ Non vérifié si estVerifier = false', () => { - const profile = { ...mockProfiles[1], estVerifier: false }; + describe('toViewModels', () => { + it('devrait convertir un tableau de profils', () => { + const profiles = [mockProfile, { ...mockProfile, id: '456' }]; + const viewModels = presenter.toViewModels(profiles); - const profilePresenter = new ProfilePresenter(); - const viewModel = profilePresenter.toViewModel(profile); + expect(viewModels).toHaveLength(2); + expect(viewModels[0].id).toBe('1'); + expect(viewModels[1].id).toBe('456'); + }); - expect(viewModel.isVerifiedLabel).toBe('❌ Non vérifié'); + it('devrait gérer un tableau vide', () => { + const viewModels = presenter.toViewModels([]); + + expect(viewModels).toEqual([]); + }); }); - it('devrait transformer un tableau complet', () => { - const profilePresenter = new ProfilePresenter(); - const result = profilePresenter.toViewModels(mockProfiles); + describe('toViewModelPaginated', () => { + it('devrait convertir un ProfilePaginated en ProfileViewModelPaginated', () => { + const paginatedProfiles: ProfilePaginated = { + page: 1, + perPage: 10, + totalPages: 5, + totalItems: 50, + items: [mockProfile], + }; - expect(result.length).toBe(2); - expect(result[0].fullName).toBe(''); + const viewModel = presenter.toViewModelPaginated(paginatedProfiles); + + expect(viewModel.page).toBe(1); + expect(viewModel.perPage).toBe(10); + expect(viewModel.totalPages).toBe(5); + expect(viewModel.totalItems).toBe(50); + expect(viewModel.items).toHaveLength(1); + expect(viewModel.items[0].id).toBe('1'); + }); + }); + + describe('isProfileVisible (méthode privée via toViewModel)', () => { + it('devrait marquer le profil comme visible si profession et secteur sont renseignés', () => { + const viewModel = presenter.toViewModel(mockProfile); + + expect(viewModel.isProfileVisible).toBe(true); + expect(viewModel.missingFields).toEqual([]); + }); + + it("devrait marquer le profil comme non visible si la profession n'est pas renseignée", () => { + const profileWithoutProfession = { + ...mockProfile, + profession: 'profession non renseignée', + } as Profile; + + const viewModel = presenter.toViewModel(profileWithoutProfession); + + expect(viewModel.isProfileVisible).toBe(false); + expect(viewModel.missingFields).toContain('profession'); + }); + + it("devrait marquer le profil comme non visible si le secteur n'est pas renseigné", () => { + const profileWithoutSector = { + ...mockProfile, + secteur: '', + } as Profile; + + const viewModel = presenter.toViewModel(profileWithoutSector); + + expect(viewModel.isProfileVisible).toBe(false); + expect(viewModel.missingFields).toContain("secteur d'activité"); + }); + + it('devrait lister tous les champs manquants', () => { + const incompleteProfile = { + ...mockProfile, + profession: 'profession non renseignée', + secteur: '', + } as Profile; + + const viewModel = presenter.toViewModel(incompleteProfile); + + expect(viewModel.isProfileVisible).toBe(false); + expect(viewModel.missingFields).toContain('profession'); + expect(viewModel.missingFields).toContain("secteur d'activité"); + }); + }); + + describe('formatPhoneNumberForDisplay (méthode privée)', () => { + it('devrait formater un numéro à 10 chiffres', () => { + const profile = { ...mockProfile, phone: 612345678 } as Profile; + const viewModel = presenter.toViewModel(profile); + + expect(viewModel.phone).toBe('06 12 34 56 78'); + }); + + it('devrait formater un numéro à 9 chiffres', () => { + const profile = { ...mockProfile, phone: 123456789 } as Profile; + const viewModel = presenter.toViewModel(profile); + + expect(viewModel.phone).toBe('01 23 45 67 89'); + }); + + it('devrait retourner le numéro tel quel si moins de 9 chiffres', () => { + const profile = { ...mockProfile, phone: 1234567 } as Profile; + const viewModel = presenter.toViewModel(profile); + + expect(viewModel.phone).toBe('01234567'); + }); + + it('devrait retourner le numéro tel quel si plus de 10 chiffres', () => { + const profile = { ...mockProfile, phone: 12345678901 } as Profile; + const viewModel = presenter.toViewModel(profile); + + expect(viewModel.phone).toBe('012345678901'); + }); + + it('devrait retourner une chaîne vide pour un téléphone null', () => { + const profile = { ...mockProfile, phone: null } as any; + const viewModel = presenter.toViewModel(profile); + + expect(viewModel.phone).toBe(''); + }); }); }); diff --git a/src/app/testing/profile.mock.ts b/src/app/testing/profile.mock.ts index 6720ae9..df5cec2 100644 --- a/src/app/testing/profile.mock.ts +++ b/src/app/testing/profile.mock.ts @@ -14,6 +14,7 @@ export const mockProfiles: Profile[] = [ reseaux: JSON.parse('{"linkedin": "https://linkedin.com/in/test"}'), bio: 'Passionné de code.', cv: 'cv.pdf', + phone: 123, projets: ['p1', 'p2'], apropos: 'Développeur Angular & Node.js', },