Align apartment document upload validation (#51)

This commit is contained in:
nessar committed 2026-08-17 14:18:16 +02:00
1 parent 82293a322d
commit cd32d05466
7 files changed
+492 -83

No files matched your search

@@ -1,12 +1,76 @@
package fr.nessar.lmnporganizer.rental.adapter.in.web.apartment; package fr.nessar.lmnporganizer.rental.adapter.in.web.apartment;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentDocument; import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentDocument;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentDocumentType;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.List;
final class ApartmentDocumentApi { final class ApartmentDocumentApi {
private ApartmentDocumentApi() {} private ApartmentDocumentApi() {}
record UploadRequest(String type, String establishedOn, String expiresOn) {
List<FieldValidationError> validationErrors() {
List<FieldValidationError> errors = new ArrayList<>();
if (blank(type)) {
errors.add(new FieldValidationError("type", "required"));
} else if (documentType() == null) {
errors.add(new FieldValidationError("type", "invalid"));
}
if (blank(establishedOn)) {
errors.add(new FieldValidationError("establishedOn", "required"));
} else if (establishmentDate() == null) {
errors.add(new FieldValidationError("establishedOn", "invalid"));
}
if (expiresOn != null && expiryDate() == null) {
errors.add(new FieldValidationError("expiresOn", "invalid"));
}
if (establishmentDate() != null
&& expiryDate() != null
&& expiryDate().isBefore(establishmentDate())) {
errors.add(new FieldValidationError("expiresOn", "before_establishment"));
}
return errors;
}
private static boolean blank(String value) {
return value == null || value.isBlank();
}
ApartmentDocumentType documentType() {
try {
return ApartmentDocumentType.fromCode(type);
} catch (IllegalArgumentException exception) {
return null;
}
}
LocalDate establishmentDate() {
return date(establishedOn);
}
LocalDate expiryDate() {
return expiresOn == null || expiresOn.isBlank() ? null : date(expiresOn);
}
private static LocalDate date(String value) {
if (value == null || value.isBlank()) {
return null;
}
try {
return LocalDate.parse(value);
} catch (DateTimeParseException exception) {
return null;
}
}
}
record ValidationErrorResponse(String code, List<FieldValidationError> errors) {}
record FieldValidationError(String field, String code) {}
record Response( record Response(
String id, String id,
String type, String type,
@@ -3,10 +3,9 @@ package fr.nessar.lmnporganizer.rental.adapter.in.web.apartment;
import fr.nessar.lmnporganizer.rental.adapter.in.web.common.AuthenticatedLandlord; import fr.nessar.lmnporganizer.rental.adapter.in.web.common.AuthenticatedLandlord;
import fr.nessar.lmnporganizer.rental.application.apartment.ApartmentDocumentService; import fr.nessar.lmnporganizer.rental.application.apartment.ApartmentDocumentService;
import fr.nessar.lmnporganizer.rental.application.shared.AttachmentStorageFailure; import fr.nessar.lmnporganizer.rental.application.shared.AttachmentStorageFailure;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentDocumentType;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentId; import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentId;
import java.io.IOException; import java.io.IOException;
import java.time.LocalDate; import java.util.ArrayList;
import java.util.List; import java.util.List;
import org.springframework.http.ContentDisposition; import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
@@ -16,6 +15,7 @@ import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.core.oidc.user.OidcUser; import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
@@ -51,17 +51,27 @@ List<ApartmentDocumentApi.Response> list(
ResponseEntity<ApartmentDocumentApi.Response> upload( ResponseEntity<ApartmentDocumentApi.Response> upload(
@AuthenticationPrincipal OidcUser principal, @AuthenticationPrincipal OidcUser principal,
@PathVariable String apartmentId, @PathVariable String apartmentId,
@RequestParam String type, @RequestParam(required = false) String type,
@RequestParam LocalDate establishedOn, @RequestParam(required = false) String establishedOn,
@RequestParam(required = false) LocalDate expiresOn, @RequestParam(required = false) String expiresOn,
@RequestPart("file") MultipartFile file) { @RequestPart(value = "file", required = false) MultipartFile file) {
ApartmentDocumentApi.UploadRequest request =
new ApartmentDocumentApi.UploadRequest(type, establishedOn, expiresOn);
List<ApartmentDocumentApi.FieldValidationError> errors =
new ArrayList<>(request.validationErrors());
if (file == null || file.isEmpty()) {
errors.add(new ApartmentDocumentApi.FieldValidationError("file", "required"));
}
if (!errors.isEmpty()) {
throw new UploadValidationFailure(errors);
}
var stored = var stored =
documents.upload( documents.upload(
authenticatedLandlord.id(principal), authenticatedLandlord.id(principal),
new ApartmentId(apartmentId), new ApartmentId(apartmentId),
ApartmentDocumentType.fromCode(type), request.documentType(),
establishedOn, request.establishmentDate(),
expiresOn, request.expiryDate(),
upload(file), upload(file),
principal.getSubject()); principal.getSubject());
return ResponseEntity.status(HttpStatus.CREATED) return ResponseEntity.status(HttpStatus.CREATED)
@@ -98,6 +108,15 @@ ResponseEntity<Void> delete(
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} }
@ExceptionHandler(UploadValidationFailure.class)
ResponseEntity<ApartmentDocumentApi.ValidationErrorResponse> invalidUpload(
UploadValidationFailure failure) {
return ResponseEntity.badRequest()
.body(
new ApartmentDocumentApi.ValidationErrorResponse(
"validation_failed", failure.errors()));
}
private static ApartmentDocumentService.PdfUpload upload(MultipartFile file) { private static ApartmentDocumentService.PdfUpload upload(MultipartFile file) {
try { try {
return new ApartmentDocumentService.PdfUpload( return new ApartmentDocumentService.PdfUpload(
@@ -106,4 +125,16 @@ private static ApartmentDocumentService.PdfUpload upload(MultipartFile file) {
throw new AttachmentStorageFailure("Apartment document could not be opened.", failure); throw new AttachmentStorageFailure("Apartment document could not be opened.", failure);
} }
} }
private static final class UploadValidationFailure extends RuntimeException {
private final List<ApartmentDocumentApi.FieldValidationError> errors;
private UploadValidationFailure(List<ApartmentDocumentApi.FieldValidationError> errors) {
this.errors = List.copyOf(errors);
}
private List<ApartmentDocumentApi.FieldValidationError> errors() {
return errors;
}
}
} }
@@ -112,6 +112,43 @@ void uploadUsesTheAuthenticatedScopeAndNativeMultipartFields() throws Exception
org.mockito.ArgumentMatchers.eq("subject-1")); org.mockito.ArgumentMatchers.eq("subject-1"));
} }
@Test
void uploadReturnsStableFieldErrorsForInvalidMetadata() throws Exception {
MockMultipartFile file =
new MockMultipartFile("file", "dpe.pdf", MediaType.APPLICATION_PDF_VALUE, PDF);
mockMvc
.perform(
multipart("/api/apartments/apartment-1/documents")
.file(file)
.param("type", "UNKNOWN")
.param("establishedOn", "2026-07-02")
.param("expiresOn", "2026-07-01")
.with(owner())
.with(csrf()))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("validation_failed"))
.andExpect(jsonPath("$.errors[0].field").value("type"))
.andExpect(jsonPath("$.errors[0].code").value("invalid"))
.andExpect(jsonPath("$.errors[1].field").value("expiresOn"))
.andExpect(jsonPath("$.errors[1].code").value("before_establishment"));
}
@Test
void uploadReportsAMissingMultipartFileAsAFieldError() throws Exception {
mockMvc
.perform(
multipart("/api/apartments/apartment-1/documents")
.param("type", "DPE")
.param("establishedOn", "2026-07-01")
.with(owner())
.with(csrf()))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("validation_failed"))
.andExpect(jsonPath("$.errors[0].field").value("file"))
.andExpect(jsonPath("$.errors[0].code").value("required"));
}
@Test @Test
void downloadReturnsPdfBytesWithASafeAttachmentHeader() throws Exception { void downloadReturnsPdfBytesWithASafeAttachmentHeader() throws Exception {
when(documents.download(LANDLORD_ID, APARTMENT_ID, "document-1")) when(documents.download(LANDLORD_ID, APARTMENT_ID, "document-1"))
@@ -113,6 +113,26 @@ void uploadReplacesTheCurrentDocumentOfTheSameTypeWithAStagedDeletion() {
verify(storage, never()).restore(deletion); verify(storage, never()).restore(deletion);
} }
@Test
void uploadAcceptsPdfMimeAndSignatureWithoutAPdfFileExtension() {
when(identifiers.next()).thenReturn("document-1");
when(storage.store(PDF)).thenReturn("storage-1");
when(apartments.save(apartment)).thenReturn(apartment);
ApartmentDocument document =
service(10_000)
.upload(
LANDLORD_ID,
APARTMENT_ID,
ApartmentDocumentType.ENERGY_PERFORMANCE_DIAGNOSIS,
LocalDate.of(2026, 7, 19),
null,
upload("dpe", "application/pdf", PDF),
"subject-1");
assertEquals("dpe.pdf", document.fileName());
}
@Test @Test
void uploadRestoresTheReplacedFileAndDiscardsTheNewFileWhenSavingMetadataFails() { void uploadRestoresTheReplacedFileAndDiscardsTheNewFileWhenSavingMetadataFails() {
ApartmentDocument previous = ApartmentDocument previous =
@@ -93,18 +93,28 @@
<fieldset [disabled]="uploading()"> <fieldset [disabled]="uploading()">
<mat-form-field appearance="outline"> <mat-form-field appearance="outline">
<mat-label>Type de document</mat-label> <mat-label>Type de document</mat-label>
<mat-select [formField]="uploadForm.type"> <mat-select
[formField]="uploadForm.type"
(selectionChange)="clearUploadFieldError('type')"
>
<mat-option value="">Choisissez un type</mat-option> <mat-option value="">Choisissez un type</mat-option>
@for (type of documentTypes; track type.value) { @for (type of documentTypes; track type.value) {
<mat-option [value]="type.value">{{ type.label }}</mat-option> <mat-option [value]="type.value">{{ type.label }}</mat-option>
} }
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
@if (fieldError('type'); as error) {
<p class="form-error" role="alert">{{ error }}</p>
}
<label class="file-picker"> <label class="file-picker">
<span>Fichier PDF</span> <span>Fichier PDF</span>
<input type="file" accept=".pdf,application/pdf" (change)="selectFile($event)" /> <input type="file" accept="application/pdf" (change)="selectFile($event)" />
<small>{{ selectedFile()?.name ?? 'Aucun fichier sélectionné' }}</small> <small>{{ selectedFile()?.name ?? 'Aucun fichier sélectionné' }}</small>
<small>Le type et le contenu PDF sont vérifiés avant l’envoi.</small>
</label> </label>
@if (fieldError('file'); as error) {
<p class="form-error" role="alert">{{ error }}</p>
}
<div class="date-fields"> <div class="date-fields">
<mat-form-field appearance="outline"> <mat-form-field appearance="outline">
<mat-label>Date d’établissement</mat-label> <mat-label>Date d’établissement</mat-label>
@@ -112,16 +122,29 @@
matInput matInput
id="establishedOn" id="establishedOn"
type="date" type="date"
(input)="clearUploadFieldError('establishedOn')"
[formField]="uploadForm.establishedOn" [formField]="uploadForm.establishedOn"
/> />
</mat-form-field> </mat-form-field>
@if (fieldError('establishedOn'); as error) {
<p class="form-error" role="alert">{{ error }}</p>
}
<mat-form-field appearance="outline"> <mat-form-field appearance="outline">
<mat-label>Date d’expiration (facultative)</mat-label> <mat-label>Date d’expiration (facultative)</mat-label>
<input matInput id="expiresOn" type="date" [formField]="uploadForm.expiresOn" /> <input
matInput
id="expiresOn"
type="date"
(input)="clearUploadFieldError('expiresOn')"
[formField]="uploadForm.expiresOn"
/>
</mat-form-field> </mat-form-field>
@if (fieldError('expiresOn'); as error) {
<p class="form-error" role="alert">{{ error }}</p>
}
</div> </div>
@if (validationError() || uploadError()) { @if (uploadError()) {
<p class="form-error" role="alert">{{ validationError() || uploadError() }}</p> <p class="form-error" role="alert">{{ uploadError() }}</p>
} }
<button <button
mat-flat-button mat-flat-button
@@ -83,6 +83,25 @@ describe('ApartmentDocumentManager', () => {
http.expectNone((request) => request.method === 'POST'); http.expectNone((request) => request.method === 'POST');
}); });
it('shows every local upload error and focuses the first invalid control', () => {
const fixture = TestBed.createComponent(ApartmentDocumentManager);
fixture.componentRef.setInput('apartmentId', 'apartment-1');
fixture.detectChanges();
http.expectOne('/api/apartments/apartment-1/documents').flush([]);
fixture.detectChanges();
fixture.nativeElement.querySelector('.upload-submit').click();
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Choisissez un type de document.');
expect(fixture.nativeElement.textContent).toContain('Sélectionnez un fichier PDF.');
expect(fixture.nativeElement.textContent).toContain(
'Renseignez une date d’établissement valide.',
);
expect(document.activeElement).toBe(fixture.nativeElement.querySelector('mat-select'));
http.expectNone((request) => request.method === 'POST');
});
it('requires a PDF after the document type is selected', async () => { it('requires a PDF after the document type is selected', async () => {
const fixture = TestBed.createComponent(ApartmentDocumentManager); const fixture = TestBed.createComponent(ApartmentDocumentManager);
fixture.componentRef.setInput('apartmentId', 'apartment-1'); fixture.componentRef.setInput('apartmentId', 'apartment-1');
@@ -120,7 +139,7 @@ describe('ApartmentDocumentManager', () => {
); );
if (!dpeOption) throw new Error('Option DPE introuvable.'); if (!dpeOption) throw new Error('Option DPE introuvable.');
(dpeOption as HTMLElement).click(); (dpeOption as HTMLElement).click();
const file = new File(['pdf'], 'dpe.pdf', { type: 'application/pdf' }); const file = new File(['%PDF-document'], 'dpe.pdf', { type: 'application/pdf' });
const fileInput: HTMLInputElement = fixture.nativeElement.querySelector('input[type=file]'); const fileInput: HTMLInputElement = fixture.nativeElement.querySelector('input[type=file]');
Object.defineProperty(fileInput, 'files', { Object.defineProperty(fileInput, 'files', {
configurable: true, configurable: true,
@@ -133,7 +152,7 @@ describe('ApartmentDocumentManager', () => {
fixture.detectChanges(); fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain( expect(fixture.nativeElement.textContent).toContain(
'Renseignez la date d’établissement du document.', 'Renseignez une date d’établissement valide.',
); );
http.expectNone((request) => request.method === 'POST'); http.expectNone((request) => request.method === 'POST');
}); });
@@ -152,7 +171,7 @@ describe('ApartmentDocumentManager', () => {
); );
if (!dpeOption) throw new Error('Option DPE introuvable.'); if (!dpeOption) throw new Error('Option DPE introuvable.');
(dpeOption as HTMLElement).click(); (dpeOption as HTMLElement).click();
const file = new File(['pdf'], 'dpe.pdf', { type: 'application/pdf' }); const file = new File(['%PDF-document'], 'dpe.pdf', { type: 'application/pdf' });
const fileInput: HTMLInputElement = fixture.nativeElement.querySelector('input[type=file]'); const fileInput: HTMLInputElement = fixture.nativeElement.querySelector('input[type=file]');
Object.defineProperty(fileInput, 'files', { Object.defineProperty(fileInput, 'files', {
configurable: true, configurable: true,
@@ -192,7 +211,7 @@ describe('ApartmentDocumentManager', () => {
); );
if (!dpeOption) throw new Error('Option DPE introuvable.'); if (!dpeOption) throw new Error('Option DPE introuvable.');
(dpeOption as HTMLElement).click(); (dpeOption as HTMLElement).click();
const file = new File(['pdf'], 'dpe-2026.pdf', { type: 'application/pdf' }); const file = new File(['%PDF-document'], 'dpe-2026', { type: 'application/pdf' });
const fileInput: HTMLInputElement = fixture.nativeElement.querySelector('input[type=file]'); const fileInput: HTMLInputElement = fixture.nativeElement.querySelector('input[type=file]');
Object.defineProperty(fileInput, 'files', { Object.defineProperty(fileInput, 'files', {
configurable: true, configurable: true,
@@ -234,6 +253,116 @@ describe('ApartmentDocumentManager', () => {
expect(submit.disabled).toBe(false); expect(submit.disabled).toBe(false);
}); });
it('accepts a PDF MIME type and signature without relying on the file name', async () => {
const fixture = TestBed.createComponent(ApartmentDocumentManager);
fixture.componentRef.setInput('apartmentId', 'apartment-1');
fixture.detectChanges();
http.expectOne('/api/apartments/apartment-1/documents').flush([]);
fixture.detectChanges();
const file = new File(['%PDF-document'], 'dpe', { type: 'application/pdf' });
const fileInput: HTMLInputElement = fixture.nativeElement.querySelector('input[type=file]');
Object.defineProperty(fileInput, 'files', {
configurable: true,
value: { item: () => file },
});
fileInput.dispatchEvent(new Event('change'));
await fixture.whenStable();
expect(fixture.componentInstance.selectedFile()).toBe(file);
});
it('rejects a filename-only PDF before upload', async () => {
const fixture = TestBed.createComponent(ApartmentDocumentManager);
fixture.componentRef.setInput('apartmentId', 'apartment-1');
fixture.detectChanges();
http.expectOne('/api/apartments/apartment-1/documents').flush([]);
fixture.detectChanges();
const file = new File(['%PDF-document'], 'dpe.pdf', { type: '' });
const fileInput: HTMLInputElement = fixture.nativeElement.querySelector('input[type=file]');
Object.defineProperty(fileInput, 'files', {
configurable: true,
value: { item: () => file },
});
fileInput.dispatchEvent(new Event('change'));
await fixture.whenStable();
fixture.detectChanges();
expect(fixture.componentInstance.selectedFile()).toBeNull();
expect(fixture.nativeElement.textContent).toContain('Sélectionnez un fichier PDF valide.');
});
it('keeps the replacement form and existing document after a failed upload', () => {
const fixture = TestBed.createComponent(ApartmentDocumentManager);
fixture.componentRef.setInput('apartmentId', 'apartment-1');
fixture.detectChanges();
http.expectOne('/api/apartments/apartment-1/documents').flush([
{
id: 'document-1',
type: 'DPE',
establishedOn: '2026-07-19',
expiresOn: null,
fileName: 'dpe-existant.pdf',
sha256: 'a'.repeat(64),
authorId: 'owner-1',
uploadedAt: '2026-07-19T10:00:00Z',
},
]);
const file = new File(['%PDF-document'], 'dpe-2026', { type: 'application/pdf' });
fixture.componentInstance.uploadModel.set({
type: 'DPE',
establishedOn: '2026-07-20',
expiresOn: '',
});
fixture.componentInstance.selectedFile.set(file);
fixture.componentInstance.submitUpload();
const upload = http.expectOne('/api/apartments/apartment-1/documents');
upload.flush(
{
code: 'validation_failed',
errors: [{ field: 'establishedOn', code: 'invalid' }],
},
{ status: 400, statusText: 'Bad Request' },
);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('dpe-existant.pdf');
expect(fixture.componentInstance.uploadModel()).toEqual({
type: 'DPE',
establishedOn: '2026-07-20',
expiresOn: '',
});
expect(fixture.componentInstance.selectedFile()).toBe(file);
expect(fixture.nativeElement.textContent).toContain(
'Renseignez une date d’établissement valide.',
);
expect(fixture.nativeElement.textContent).not.toContain(
'Veuillez corriger les champs signalés.',
);
expect(document.activeElement).toBe(fixture.nativeElement.querySelector('#establishedOn'));
});
it('clears only the corrected upload field error', () => {
const fixture = TestBed.createComponent(ApartmentDocumentManager);
fixture.componentRef.setInput('apartmentId', 'apartment-1');
fixture.detectChanges();
http.expectOne('/api/apartments/apartment-1/documents').flush([]);
fixture.componentInstance.uploadFieldErrors.set([
{ field: 'type', code: 'invalid' },
{ field: 'establishedOn', code: 'invalid' },
{ field: 'expiresOn', code: 'before_establishment' },
]);
fixture.componentInstance.clearUploadFieldError('establishedOn');
expect(fixture.componentInstance.uploadFieldErrors()).toEqual([
{ field: 'type', code: 'invalid' },
{ field: 'expiresOn', code: 'before_establishment' },
]);
});
it('shows a load failure and retries without presenting an empty result', () => { it('shows a load failure and retries without presenting an empty result', () => {
const fixture = TestBed.createComponent(ApartmentDocumentManager); const fixture = TestBed.createComponent(ApartmentDocumentManager);
fixture.componentRef.setInput('apartmentId', 'apartment-1'); fixture.componentRef.setInput('apartmentId', 'apartment-1');
@@ -334,4 +463,38 @@ describe('ApartmentDocumentManager', () => {
expect(fixture.nativeElement.textContent).not.toContain('dpe.pdf'); expect(fixture.nativeElement.textContent).not.toContain('dpe.pdf');
expect(fixture.nativeElement.textContent).toContain('Aucun document enregistré.'); expect(fixture.nativeElement.textContent).toContain('Aucun document enregistré.');
}); });
it('keeps a document visible when its deletion fails', async () => {
const fixture = TestBed.createComponent(ApartmentDocumentManager);
fixture.componentRef.setInput('apartmentId', 'apartment-1');
fixture.detectChanges();
http.expectOne('/api/apartments/apartment-1/documents').flush([
{
id: 'document-1',
type: 'DPE',
establishedOn: '2026-07-19',
expiresOn: null,
fileName: 'dpe.pdf',
sha256: 'a'.repeat(64),
authorId: 'owner-1',
uploadedAt: '2026-07-19T10:00:00Z',
},
]);
fixture.detectChanges();
vi.useFakeTimers();
fixture.nativeElement.querySelector('.delete-document').click();
fixture.detectChanges();
const confirm: HTMLButtonElement | null = document.querySelector('.confirm-action');
if (!confirm) throw new Error('Confirmation de suppression introuvable.');
confirm.click();
await vi.runAllTimersAsync();
const request = http.expectOne('/api/apartments/apartment-1/documents/document-1');
request.flush({}, { status: 500, statusText: 'Server Error' });
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('dpe.pdf');
expect(fixture.nativeElement.textContent).toContain('Impossible de supprimer ce document.');
});
}); });
@@ -1,4 +1,4 @@
import { Component, DestroyRef, effect, inject, input, signal } from '@angular/core'; import { Component, DestroyRef, effect, ElementRef, inject, input, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { FormField, FormRoot, form, required, validate } from '@angular/forms/signals'; import { FormField, FormRoot, form, required, validate } from '@angular/forms/signals';
@@ -13,7 +13,7 @@ import {
ApartmentDocumentType, ApartmentDocumentType,
ApartmentService, ApartmentService,
} from '../../services/apartment.service'; } from '../../services/apartment.service';
import { httpErrorMessage } from '../../services/http-error-message'; import { FieldValidationError, saveFailure } from '../../services/http-error-message';
import { ApartmentDocumentActions } from './apartment-document-actions.service'; import { ApartmentDocumentActions } from './apartment-document-actions.service';
import { import {
apartmentDocumentTypeLabel, apartmentDocumentTypeLabel,
@@ -27,6 +27,15 @@ const frenchDate = new Intl.DateTimeFormat('fr-FR', {
timeZone: 'UTC', timeZone: 'UTC',
}); });
type UploadField = 'type' | 'file' | 'establishedOn' | 'expiresOn';
type UploadFieldError = FieldValidationError & { field: UploadField };
function isUploadField(field: string): field is UploadField {
return (
field === 'type' || field === 'file' || field === 'establishedOn' || field === 'expiresOn'
);
}
@Component({ @Component({
selector: 'app-apartment-document-manager', selector: 'app-apartment-document-manager',
imports: [ imports: [
@@ -46,6 +55,8 @@ const frenchDate = new Intl.DateTimeFormat('fr-FR', {
export class ApartmentDocumentManager { export class ApartmentDocumentManager {
private readonly apartments = inject(ApartmentService); private readonly apartments = inject(ApartmentService);
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
private readonly host = inject(ElementRef<HTMLElement>);
private fileSelectionId = 0;
private requestId = 0; private requestId = 0;
readonly actions = inject(ApartmentDocumentActions); readonly actions = inject(ApartmentDocumentActions);
@@ -74,8 +85,8 @@ export class ApartmentDocumentManager {
}); });
readonly selectedFile = signal<File | null>(null); readonly selectedFile = signal<File | null>(null);
readonly uploading = signal(false); readonly uploading = signal(false);
readonly validationError = signal('');
readonly uploadError = signal(''); readonly uploadError = signal('');
readonly uploadFieldErrors = signal<FieldValidationError[]>([]);
constructor() { constructor() {
effect(() => this.load(this.apartmentId())); effect(() => this.load(this.apartmentId()));
@@ -111,73 +122,133 @@ export class ApartmentDocumentManager {
this.uploadForm().markAsTouched(); this.uploadForm().markAsTouched();
const model = this.uploadModel(); const model = this.uploadModel();
const file = this.selectedFile(); const file = this.selectedFile();
if (!model.type) { const fieldErrors = this.validationErrors(model, file);
this.validationError.set('Choisissez un type de document.'); if (fieldErrors.length) {
} else if (!file) {
this.validationError.set('Sélectionnez un fichier PDF.');
} else if (!model.establishedOn) {
this.validationError.set('Renseignez la date d’établissement du document.');
} else if (model.expiresOn && model.expiresOn < model.establishedOn) {
this.validationError.set(
'La date d’expiration doit être postérieure ou égale à la date d’établissement.',
);
} else {
this.validationError.set('');
this.uploadError.set(''); this.uploadError.set('');
this.uploading.set(true); this.uploadFieldErrors.set(fieldErrors);
this.apartments this.focus(fieldErrors[0].field);
.uploadDocument(this.apartmentId(), { return;
file, }
type: model.type, if (!file || !model.type) return;
establishedOn: model.establishedOn, this.uploadError.set('');
expiresOn: model.expiresOn || null, this.uploadFieldErrors.set([]);
}) this.uploading.set(true);
.pipe( this.apartments
takeUntilDestroyed(this.destroyRef), .uploadDocument(this.apartmentId(), {
finalize(() => this.uploading.set(false)), file,
) type: model.type,
.subscribe({ establishedOn: model.establishedOn,
next: (uploaded) => { expiresOn: model.expiresOn || null,
this.documents.update((documents) => })
[ .pipe(
...documents.filter((document) => document.type !== uploaded.type), takeUntilDestroyed(this.destroyRef),
uploaded, finalize(() => this.uploading.set(false)),
].sort( )
(left, right) => .subscribe({
apartmentDocumentTypes.findIndex( next: (uploaded) => {
(type) => type.value === left.type, this.documents.update((documents) =>
) - [
apartmentDocumentTypes.findIndex( ...documents.filter((document) => document.type !== uploaded.type),
(type) => type.value === right.type, uploaded,
), ].sort(
), (left, right) =>
); apartmentDocumentTypes.findIndex(
this.uploadModel.set({ type: '', establishedOn: '', expiresOn: '' }); (type) => type.value === left.type,
this.selectedFile.set(null); ) -
}, apartmentDocumentTypes.findIndex(
error: (error: unknown) => (type) => type.value === right.type,
this.uploadError.set( ),
httpErrorMessage(error, 'Impossible de téléverser ce document.'),
), ),
}); );
this.uploadModel.set({ type: '', establishedOn: '', expiresOn: '' });
this.selectedFile.set(null);
},
error: (error: unknown) => {
const failure = saveFailure(
error,
'Impossible de téléverser ce document. Vérifiez que le PDF est valide et respecte la taille maximale du serveur.',
);
const inlineErrors = failure.fieldErrors.filter(
(error): error is UploadFieldError => isUploadField(error.field),
);
this.uploadFieldErrors.set(inlineErrors);
if (inlineErrors.length) this.focus(inlineErrors[0].field);
this.uploadError.set(
inlineErrors.length > 0 &&
inlineErrors.length === failure.fieldErrors.length
? ''
: failure.message,
);
},
});
}
async selectFile(event: Event): Promise<void> {
const input = event.target;
const file = input instanceof HTMLInputElement ? (input.files?.item(0) ?? null) : null;
const selectionId = ++this.fileSelectionId;
if (input instanceof HTMLInputElement) input.value = '';
this.clearUploadFieldError('file');
if (!file) {
this.selectedFile.set(null);
return;
}
if (
file.type.toLowerCase() === 'application/pdf' &&
(await file.slice(0, 5).text()) === '%PDF-'
) {
if (selectionId !== this.fileSelectionId) return;
this.selectedFile.set(file);
} else {
if (selectionId !== this.fileSelectionId) return;
this.selectedFile.set(null);
this.uploadFieldErrors.update((errors) => [
...errors,
{ field: 'file', code: 'invalid' },
]);
} }
} }
selectFile(event: Event): void { clearUploadFieldError(field: UploadField): void {
const input = event.target; this.uploadFieldErrors.update((errors) => errors.filter((error) => error.field !== field));
const file = input instanceof HTMLInputElement ? (input.files?.item(0) ?? null) : null; }
if (
file && fieldError(field: UploadField): string {
file.name.toLowerCase().endsWith('.pdf') && const code = this.uploadFieldErrors().find((error) => error.field === field)?.code;
(file.type === 'application/pdf' || file.type === '') if (!code) return '';
) { if (field === 'type') return 'Choisissez un type de document.';
this.selectedFile.set(file); if (field === 'establishedOn') return 'Renseignez une date d’établissement valide.';
this.validationError.set(''); if (field === 'expiresOn')
} else { return code === 'before_establishment'
this.selectedFile.set(null); ? 'La date d’expiration doit être postérieure ou égale à la date d’établissement.'
if (file) this.validationError.set('Sélectionnez un fichier PDF.'); : 'Renseignez une date d’expiration valide.';
} return code === 'invalid'
if (input instanceof HTMLInputElement) input.value = ''; ? 'Sélectionnez un fichier PDF valide.'
: 'Sélectionnez un fichier PDF.';
}
private validationErrors(
model: { type: ApartmentDocumentType | ''; establishedOn: string; expiresOn: string },
file: File | null,
): UploadFieldError[] {
const errors: UploadFieldError[] = [];
if (!model.type) errors.push({ field: 'type', code: 'required' });
if (!file) errors.push({ field: 'file', code: 'required' });
if (!model.establishedOn) errors.push({ field: 'establishedOn', code: 'required' });
if (model.expiresOn && model.establishedOn && model.expiresOn < model.establishedOn)
errors.push({ field: 'expiresOn', code: 'before_establishment' });
return errors;
}
private focus(field: UploadField): void {
const selector = {
type: 'mat-select',
file: 'input[type=file]',
establishedOn: '#establishedOn',
expiresOn: '#expiresOn',
}[field];
const control = this.host.nativeElement.querySelector(selector);
if (control instanceof HTMLElement) control.focus();
} }
private load(apartmentId: string): void { private load(apartmentId: string): void {