fix: validate tenant and lease contact details
This commit is contained in:
1 parent
8603b98714
commit
bf6d8e6bb6
27 files changed
+444
-54
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user