fix: validate tenant and lease contact details

This commit is contained in:
nessar committed 2026-08-17 14:18:14 +02:00
1 parent 8603b98714
commit bf6d8e6bb6
27 files changed
+444 -54

No files matched your search

+11
View File
@@ -6,15 +6,26 @@ import {
} from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { MAT_NATIVE_DATE_FORMATS, MAT_DATE_LOCALE, provideNativeDateAdapter } from '@angular/material/core';
import { routes } from './app.routes';
import { ConfigService } from './services/config.service';
const frenchDateFormats = {
...MAT_NATIVE_DATE_FORMATS,
display: {
...MAT_NATIVE_DATE_FORMATS.display,
dateInput: { year: 'numeric', month: '2-digit', day: '2-digit' },
},
};
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes),
provideHttpClient(),
provideNativeDateAdapter(frenchDateFormats),
{ provide: MAT_DATE_LOCALE, useValue: 'fr-FR' },
provideAppInitializer(() => {
const configService = inject(ConfigService);
return configService.loadConfig();
+1
View File
@@ -42,6 +42,7 @@
@if (activeTab() === 'tenants') {
<app-tenants-page
[tenants]="tenants()"
[attachmentError]="tenantAttachmentError()"
(tenantSaved)="saveTenant($event)"
(deleteRequested)="deleteTenant($event)"
(attachmentUploadRequested)="uploadTenantAttachment($event)"
+17 -1
View File
@@ -41,6 +41,7 @@ export class App implements OnInit {
readonly apartments = signal<Apartment[]>([]);
readonly tenants = signal<Tenant[]>([]);
readonly leases = signal<Lease[]>([]);
readonly tenantAttachmentError = signal('');
private readonly _isLoggedIn = signal<boolean>(false);
private readonly _username = signal<string>('');
private readonly _email = signal<string>('');
@@ -179,9 +180,13 @@ export class App implements OnInit {
}
uploadTenantAttachment(request: { tenantId: string; file: File }): void {
this.tenantAttachmentError.set('');
this.tenantService.uploadAttachment(request.tenantId, request.file).subscribe({
next: () => this.loadTenants(),
error: (err) => console.error('Error uploading tenant attachment', err),
error: (error: unknown) => {
console.error('Error uploading tenant attachment', error);
this.tenantAttachmentError.set(this.errorMessage(error, 'Impossible d’envoyer cette pièce jointe.'));
},
});
}
@@ -234,4 +239,15 @@ export class App implements OnInit {
link.click();
URL.revokeObjectURL(url);
}
private errorMessage(error: unknown, fallback: string): string {
if (typeof error !== 'object' || error === null || !('error' in error)) {
return fallback;
}
const response = error.error;
if (typeof response !== 'object' || response === null || !('message' in response)) {
return fallback;
}
return typeof response.message === 'string' ? response.message : fallback;
}
}
@@ -88,39 +88,37 @@
<span>01</span>
<div>
<h2>Parties du bail</h2>
<p>Choisissez le logement et le locataire.</p>
<p>Choisissez le logement et les locataires.</p>
</div>
</div>
<div class="form-grid">
<mat-form-field appearance="outline"
><mat-label>Appartement</mat-label
><select
matNativeControl
><mat-select
name="apartmentId"
[ngModel]="apartmentId()"
(ngModelChange)="apartmentId.set($event)"
required
>
<option value=""></option>
<mat-option value="">Sélectionnez un appartement</mat-option>
@for (apartment of apartments(); track apartment.id) {
<option [value]="apartment.id">{{ apartment.name }}</option>
<mat-option [value]="apartment.id">{{ apartment.name }}</mat-option>
}
</select></mat-form-field
</mat-select></mat-form-field
>
<mat-form-field appearance="outline"
><mat-label>Locataire</mat-label
><select
matNativeControl
name="tenantId"
[ngModel]="tenantId()"
(ngModelChange)="tenantId.set($event)"
><mat-label>Locataires</mat-label
><mat-select
name="tenantIds"
[ngModel]="tenantIds()"
(ngModelChange)="tenantIds.set($event)"
multiple
required
>
<option value=""></option>
@for (tenant of tenants(); track tenant.id) {
<option [value]="tenant.id">{{ tenant.firstName }} {{ tenant.lastName }}</option>
<mat-option [value]="tenant.id">{{ tenant.firstName }} {{ tenant.lastName }}</mat-option>
}
</select></mat-form-field
</mat-select></mat-form-field
>
</div>
</section>
@@ -137,22 +135,24 @@
><mat-label>Date de début</mat-label
><input
matInput
type="date"
[matDatepicker]="startDatePicker"
name="startDate"
[ngModel]="startDate()"
(ngModelChange)="startDate.set($event)"
required
/></mat-form-field>
/><mat-datepicker-toggle matIconSuffix [for]="startDatePicker" />
<mat-datepicker #startDatePicker /></mat-form-field>
<mat-form-field appearance="outline"
><mat-label>Date de fin</mat-label
><input
matInput
type="date"
[matDatepicker]="endDatePicker"
name="endDate"
[ngModel]="endDate()"
(ngModelChange)="endDate.set($event)"
required
/></mat-form-field>
/><mat-datepicker-toggle matIconSuffix [for]="endDatePicker" />
<mat-datepicker #endDatePicker /></mat-form-field>
<mat-form-field appearance="outline"
><mat-label>Loyer mensuel</mat-label
><input
@@ -223,19 +223,22 @@
<mat-form-field appearance="outline"
><mat-label>E-mail du garant</mat-label
><input
matInput
type="email"
matInput
type="email"
name="guarantorEmail"
[ngModel]="guarantorEmail()"
(ngModelChange)="guarantorEmail.set($event)"
(ngModelChange)="guarantorEmail.set($event)"
autocomplete="email"
/></mat-form-field>
<mat-form-field appearance="outline"
><mat-label>Téléphone du garant</mat-label
><input
matInput
matInput
type="tel"
name="guarantorPhone"
[ngModel]="guarantorPhone()"
(ngModelChange)="guarantorPhone.set($event)"
(ngModelChange)="guarantorPhone.set($event)"
autocomplete="tel"
/></mat-form-field>
<mat-form-field appearance="outline" class="full-field"
><mat-label>Adresse du garant</mat-label
@@ -34,14 +34,15 @@ describe('LeasesPage', () => {
expect(component.guarantors()).toHaveLength(1);
component.apartmentId.set('apartment-1');
component.tenantId.set('tenant-1');
component.startDate.set('2026-09-01');
component.endDate.set('2027-08-31');
component.tenantIds.set(['tenant-1']);
component.startDate.set(new Date(2026, 8, 1));
component.endDate.set(new Date(2027, 7, 31));
component.monthlyRent.set(850);
component.save();
expect(savedLease?.tenantIds).toEqual(['tenant-1']);
expect(savedLease?.guarantors[0].lastName).toBe('Durand');
expect(savedLease?.startDate).toBe('2026-09-01');
});
it('rejects incomplete lease and guarantor drafts', () => {
@@ -53,6 +54,21 @@ describe('LeasesPage', () => {
expect(component.error()).toContain('Sélectionnez');
});
it('rejects malformed guarantor contact details', () => {
const component = createComponent();
component.guarantorFirstName.set('Alex');
component.guarantorLastName.set('Durand');
component.guarantorEmail.set('alex-at-example.com');
component.addGuarantor();
expect(component.error()).toContain('e-mail valide');
component.guarantorEmail.set('alex@example.com');
component.guarantorPhone.set('not-a-phone');
component.addGuarantor();
expect(component.error()).toContain('numéro français');
});
it('resets the form and resolves display labels', () => {
const component = createComponent();
component.monthlyRent.set(900);
@@ -3,13 +3,23 @@ import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { Apartment } from '../../services/apartment.service';
import { Guarantor, Lease } from '../../services/lease.service';
import { Tenant } from '../../services/tenant.service';
import { isValidEmail, isValidPhone } from '../../shared/contact-validation';
@Component({
selector: 'app-leases-page',
imports: [FormsModule, MatButtonModule, MatFormFieldModule, MatInputModule],
imports: [
FormsModule,
MatButtonModule,
MatFormFieldModule,
MatInputModule,
MatSelectModule,
MatDatepickerModule,
],
templateUrl: './leases-page.html',
styleUrl: './leases-page.css',
})
@@ -23,9 +33,9 @@ export class LeasesPage {
readonly showForm = signal(false);
readonly apartmentId = signal('');
readonly tenantId = signal('');
readonly startDate = signal('');
readonly endDate = signal('');
readonly tenantIds = signal<string[]>([]);
readonly startDate = signal<Date | null>(null);
readonly endDate = signal<Date | null>(null);
readonly monthlyRent = signal(0);
readonly monthlyCharges = signal(0);
readonly securityDeposit = signal(0);
@@ -55,6 +65,14 @@ export class LeasesPage {
this.error.set('Le prénom, le nom et l’e-mail du garant sont obligatoires.');
return;
}
if (!isValidEmail(this.guarantorEmail())) {
this.error.set('Saisissez une adresse e-mail valide pour le garant.');
return;
}
if (!isValidPhone(this.guarantorPhone())) {
this.error.set('Saisissez un numéro français ou international valide pour le garant.');
return;
}
this.guarantors.update((guarantors) => [
...guarantors,
{
@@ -82,7 +100,7 @@ export class LeasesPage {
save(): void {
if (
!this.apartmentId() ||
!this.tenantId() ||
this.tenantIds().length === 0 ||
!this.startDate() ||
!this.endDate() ||
this.monthlyRent() <= 0
@@ -90,12 +108,16 @@ export class LeasesPage {
this.error.set('Sélectionnez un bien et un locataire, puis complétez les dates et le loyer.');
return;
}
if (this.endDate()! < this.startDate()!) {
this.error.set('La date de fin doit être postérieure à la date de début.');
return;
}
this.leaseSaved.emit({
apartmentId: this.apartmentId(),
tenantIds: [this.tenantId()],
tenantIds: this.tenantIds(),
guarantors: this.guarantors(),
startDate: this.startDate(),
endDate: this.endDate(),
startDate: this.toIsoDate(this.startDate()!),
endDate: this.toIsoDate(this.endDate()!),
monthlyRent: this.monthlyRent(),
monthlyCharges: this.monthlyCharges(),
securityDeposit: this.securityDeposit(),
@@ -125,9 +147,9 @@ export class LeasesPage {
private reset(): void {
this.apartmentId.set('');
this.tenantId.set('');
this.startDate.set('');
this.endDate.set('');
this.tenantIds.set([]);
this.startDate.set(null);
this.endDate.set(null);
this.monthlyRent.set(0);
this.monthlyCharges.set(0);
this.securityDeposit.set(0);
@@ -139,4 +161,10 @@ export class LeasesPage {
this.guarantorAddress.set('');
this.error.set('');
}
private toIsoDate(date: Date): string {
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${date.getFullYear()}-${month}-${day}`;
}
}
@@ -127,6 +127,15 @@
overflow: hidden;
clip: rect(0 0 0 0);
}
.upload-action small {
color: var(--text-muted);
font-weight: 500;
}
.attachment-error {
margin: 8px 0 0;
color: var(--danger);
font-size: 0.78rem;
}
.card-actions {
display: flex;
justify-content: flex-end;
@@ -63,16 +63,20 @@
</button>
}
@if (tenant.id) {
@if (attachmentUploadError() || attachmentError()) {
<p class="attachment-error" role="alert">
{{ attachmentUploadError() || attachmentError() }}
</p>
}
<label class="upload-action">
<input
type="file"
accept="application/pdf,image/*"
[attr.aria-label]="
'Ajouter une pièce jointe pour ' + tenant.firstName + ' ' + tenant.lastName
"
(change)="selectFile(tenant, $event)"
/>
+ Ajouter une pièce jointe
+ Ajouter une pièce jointe <small>· 250 Mo maximum</small>
</label>
}
</div>
@@ -136,6 +140,7 @@
[ngModel]="email()"
(ngModelChange)="email.set($event)"
required
autocomplete="email"
/></mat-form-field>
<mat-form-field appearance="outline"
><mat-label>Téléphone</mat-label
@@ -145,6 +150,7 @@
name="phone"
[ngModel]="phone()"
(ngModelChange)="phone.set($event)"
autocomplete="tel"
/></mat-form-field>
<mat-form-field appearance="outline" class="full-field"
><mat-label>Adresse complète</mat-label
@@ -21,6 +21,23 @@ describe('TenantsPage', () => {
expect(component.showForm()).toBe(false);
});
it('rejects malformed tenant contact details', () => {
const fixture = TestBed.createComponent(TenantsPage);
fixture.componentRef.setInput('tenants', []);
const component = fixture.componentInstance;
component.firstName.set('Camille');
component.lastName.set('Martin');
component.email.set('camille-at-example.com');
component.save();
expect(component.error()).toContain('e-mail valide');
component.email.set('camille@example.com');
component.phone.set('invalid');
component.save();
expect(component.error()).toContain('numéro français');
});
it('emits a selected attachment for an existing tenant', () => {
const fixture = TestBed.createComponent(TenantsPage);
fixture.componentRef.setInput('tenants', []);
@@ -46,6 +63,23 @@ describe('TenantsPage', () => {
expect(uploadedFile).toBe(file);
});
it('rejects an attachment larger than 250 Mo before upload', () => {
const fixture = TestBed.createComponent(TenantsPage);
fixture.componentRef.setInput('tenants', []);
const component = fixture.componentInstance;
const file = new File(['identity'], 'archive.zip');
Object.defineProperty(file, 'size', { value: 250 * 1024 * 1024 + 1 });
const input = document.createElement('input');
Object.defineProperty(input, 'files', { value: { item: () => file } });
component.selectFile(
{ id: 'tenant-1', firstName: 'Camille', lastName: 'Martin', email: 'camille@example.com', phone: '', address: '' },
{ target: input } as unknown as Event,
);
expect(component.attachmentUploadError()).toContain('250 Mo');
});
it('opens, fills, and closes the edit form', () => {
const fixture = TestBed.createComponent(TenantsPage);
fixture.componentRef.setInput('tenants', []);
@@ -3,6 +3,7 @@ import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { isValidEmail, isValidPhone } from '../../shared/contact-validation';
import { Tenant, TenantAttachment } from '../../services/tenant.service';
export interface TenantSaveRequest {
@@ -23,6 +24,7 @@ export interface TenantAttachmentRequest {
})
export class TenantsPage {
readonly tenants = input.required<Tenant[]>();
readonly attachmentError = input('');
readonly tenantSaved = output<TenantSaveRequest>();
readonly deleteRequested = output<string>();
readonly attachmentUploadRequested = output<{ tenantId: string; file: File }>();
@@ -36,6 +38,7 @@ export class TenantsPage {
readonly phone = signal('');
readonly address = signal('');
readonly error = signal('');
readonly attachmentUploadError = signal('');
constructor() {
effect(() => this.fillForm(this.editingTenant()));
@@ -62,6 +65,14 @@ export class TenantsPage {
this.error.set('Le prénom, le nom et l’e-mail sont obligatoires.');
return;
}
if (!isValidEmail(this.email())) {
this.error.set('Saisissez une adresse e-mail valide.');
return;
}
if (!isValidPhone(this.phone())) {
this.error.set('Saisissez un numéro français ou international valide.');
return;
}
this.tenantSaved.emit({
tenant: {
firstName: this.firstName().trim(),
@@ -77,7 +88,10 @@ export class TenantsPage {
selectFile(tenant: Tenant, event: Event): void {
const file = (event.target as HTMLInputElement).files?.item(0);
if (tenant.id && file) {
if (file && file.size > 250 * 1024 * 1024) {
this.attachmentUploadError.set('Une pièce jointe ne peut pas dépasser 250 Mo.');
} else if (tenant.id && file) {
this.attachmentUploadError.set('');
this.attachmentUploadRequested.emit({ tenantId: tenant.id, file });
}
(event.target as HTMLInputElement).value = '';
@@ -0,0 +1,17 @@
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export function isValidEmail(email: string): boolean {
return emailPattern.test(email.trim());
}
export function isValidPhone(phone: string): boolean {
const compact = phone.trim().replace(/[\s()./-]/g, '');
if (!compact) {
return true;
}
const international = compact.startsWith('00') ? `+${compact.slice(2)}` : compact;
return international.startsWith('+')
? /^\+\d{8,15}$/.test(international)
: /^0\d{9}$/.test(international);
}