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;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentDocument;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentDocumentType;
import java.time.Instant;
import java.time.LocalDate;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.List;
final class 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(
String id,
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.application.apartment.ApartmentDocumentService;
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 java.io.IOException;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import org.springframework.http.ContentDisposition;
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.oauth2.core.oidc.user.OidcUser;
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.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
@@ -51,17 +51,27 @@ List<ApartmentDocumentApi.Response> list(
ResponseEntity<ApartmentDocumentApi.Response> upload(
@AuthenticationPrincipal OidcUser principal,
@PathVariable String apartmentId,
@RequestParam String type,
@RequestParam LocalDate establishedOn,
@RequestParam(required = false) LocalDate expiresOn,
@RequestPart("file") MultipartFile file) {
@RequestParam(required = false) String type,
@RequestParam(required = false) String establishedOn,
@RequestParam(required = false) String expiresOn,
@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 =
documents.upload(
authenticatedLandlord.id(principal),
new ApartmentId(apartmentId),
ApartmentDocumentType.fromCode(type),
establishedOn,
expiresOn,
request.documentType(),
request.establishmentDate(),
request.expiryDate(),
upload(file),
principal.getSubject());
return ResponseEntity.status(HttpStatus.CREATED)
@@ -98,6 +108,15 @@ ResponseEntity<Void> delete(
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) {
try {
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);
}
}
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"));
}
@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
void downloadReturnsPdfBytesWithASafeAttachmentHeader() throws Exception {
when(documents.download(LANDLORD_ID, APARTMENT_ID, "document-1"))
@@ -113,6 +113,26 @@ void uploadReplacesTheCurrentDocumentOfTheSameTypeWithAStagedDeletion() {
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
void uploadRestoresTheReplacedFileAndDiscardsTheNewFileWhenSavingMetadataFails() {
ApartmentDocument previous =
@@ -93,18 +93,28 @@
<fieldset [disabled]="uploading()">
<mat-form-field appearance="outline">
<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>
@for (type of documentTypes; track type.value) {
<mat-option [value]="type.value">{{ type.label }}</mat-option>
}
</mat-select>
</mat-form-field>
@if (fieldError('type'); as error) {
<p class="form-error" role="alert">{{ error }}</p>
}
<label class="file-picker">
<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>Le type et le contenu PDF sont vérifiés avant l’envoi.</small>
</label>
@if (fieldError('file'); as error) {
<p class="form-error" role="alert">{{ error }}</p>
}
<div class="date-fields">
<mat-form-field appearance="outline">
<mat-label>Date d’établissement</mat-label>
@@ -112,16 +122,29 @@
matInput
id="establishedOn"
type="date"
(input)="clearUploadFieldError('establishedOn')"
[formField]="uploadForm.establishedOn"
/>
</mat-form-field>
@if (fieldError('establishedOn'); as error) {
<p class="form-error" role="alert">{{ error }}</p>
}
<mat-form-field appearance="outline">
<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>
@if (fieldError('expiresOn'); as error) {
<p class="form-error" role="alert">{{ error }}</p>
}
</div>
@if (validationError() || uploadError()) {
<p class="form-error" role="alert">{{ validationError() || uploadError() }}</p>
@if (uploadError()) {
<p class="form-error" role="alert">{{ uploadError() }}</p>
}
<button
mat-flat-button
@@ -83,6 +83,25 @@ describe('ApartmentDocumentManager', () => {
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 () => {
const fixture = TestBed.createComponent(ApartmentDocumentManager);
fixture.componentRef.setInput('apartmentId', 'apartment-1');
@@ -120,7 +139,7 @@ describe('ApartmentDocumentManager', () => {
);
if (!dpeOption) throw new Error('Option DPE introuvable.');
(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]');
Object.defineProperty(fileInput, 'files', {
configurable: true,
@@ -133,7 +152,7 @@ describe('ApartmentDocumentManager', () => {
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain(
'Renseignez la date d’établissement du document.',
'Renseignez une date d’établissement valide.',
);
http.expectNone((request) => request.method === 'POST');
});
@@ -152,7 +171,7 @@ describe('ApartmentDocumentManager', () => {
);
if (!dpeOption) throw new Error('Option DPE introuvable.');
(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]');
Object.defineProperty(fileInput, 'files', {
configurable: true,
@@ -192,7 +211,7 @@ describe('ApartmentDocumentManager', () => {
);
if (!dpeOption) throw new Error('Option DPE introuvable.');
(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]');
Object.defineProperty(fileInput, 'files', {
configurable: true,
@@ -234,6 +253,116 @@ describe('ApartmentDocumentManager', () => {
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', () => {
const fixture = TestBed.createComponent(ApartmentDocumentManager);
fixture.componentRef.setInput('apartmentId', 'apartment-1');
@@ -334,4 +463,38 @@ describe('ApartmentDocumentManager', () => {
expect(fixture.nativeElement.textContent).not.toContain('dpe.pdf');
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 { FormsModule } from '@angular/forms';
import { FormField, FormRoot, form, required, validate } from '@angular/forms/signals';
@@ -13,7 +13,7 @@ import {
ApartmentDocumentType,
ApartmentService,
} 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 {
apartmentDocumentTypeLabel,
@@ -27,6 +27,15 @@ const frenchDate = new Intl.DateTimeFormat('fr-FR', {
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({
selector: 'app-apartment-document-manager',
imports: [
@@ -46,6 +55,8 @@ const frenchDate = new Intl.DateTimeFormat('fr-FR', {
export class ApartmentDocumentManager {
private readonly apartments = inject(ApartmentService);
private readonly destroyRef = inject(DestroyRef);
private readonly host = inject(ElementRef<HTMLElement>);
private fileSelectionId = 0;
private requestId = 0;
readonly actions = inject(ApartmentDocumentActions);
@@ -74,8 +85,8 @@ export class ApartmentDocumentManager {
});
readonly selectedFile = signal<File | null>(null);
readonly uploading = signal(false);
readonly validationError = signal('');
readonly uploadError = signal('');
readonly uploadFieldErrors = signal<FieldValidationError[]>([]);
constructor() {
effect(() => this.load(this.apartmentId()));
@@ -111,73 +122,133 @@ export class ApartmentDocumentManager {
this.uploadForm().markAsTouched();
const model = this.uploadModel();
const file = this.selectedFile();
if (!model.type) {
this.validationError.set('Choisissez un type de document.');
} 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('');
const fieldErrors = this.validationErrors(model, file);
if (fieldErrors.length) {
this.uploadError.set('');
this.uploading.set(true);
this.apartments
.uploadDocument(this.apartmentId(), {
file,
type: model.type,
establishedOn: model.establishedOn,
expiresOn: model.expiresOn || null,
})
.pipe(
takeUntilDestroyed(this.destroyRef),
finalize(() => this.uploading.set(false)),
)
.subscribe({
next: (uploaded) => {
this.documents.update((documents) =>
[
...documents.filter((document) => document.type !== uploaded.type),
uploaded,
].sort(
(left, right) =>
apartmentDocumentTypes.findIndex(
(type) => type.value === left.type,
) -
apartmentDocumentTypes.findIndex(
(type) => type.value === right.type,
),
),
);
this.uploadModel.set({ type: '', establishedOn: '', expiresOn: '' });
this.selectedFile.set(null);
},
error: (error: unknown) =>
this.uploadError.set(
httpErrorMessage(error, 'Impossible de téléverser ce document.'),
this.uploadFieldErrors.set(fieldErrors);
this.focus(fieldErrors[0].field);
return;
}
if (!file || !model.type) return;
this.uploadError.set('');
this.uploadFieldErrors.set([]);
this.uploading.set(true);
this.apartments
.uploadDocument(this.apartmentId(), {
file,
type: model.type,
establishedOn: model.establishedOn,
expiresOn: model.expiresOn || null,
})
.pipe(
takeUntilDestroyed(this.destroyRef),
finalize(() => this.uploading.set(false)),
)
.subscribe({
next: (uploaded) => {
this.documents.update((documents) =>
[
...documents.filter((document) => document.type !== uploaded.type),
uploaded,
].sort(
(left, right) =>
apartmentDocumentTypes.findIndex(
(type) => type.value === left.type,
) -
apartmentDocumentTypes.findIndex(
(type) => type.value === right.type,
),
),
});
);
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 {
const input = event.target;
const file = input instanceof HTMLInputElement ? (input.files?.item(0) ?? null) : null;
if (
file &&
file.name.toLowerCase().endsWith('.pdf') &&
(file.type === 'application/pdf' || file.type === '')
) {
this.selectedFile.set(file);
this.validationError.set('');
} else {
this.selectedFile.set(null);
if (file) this.validationError.set('Sélectionnez un fichier PDF.');
}
if (input instanceof HTMLInputElement) input.value = '';
clearUploadFieldError(field: UploadField): void {
this.uploadFieldErrors.update((errors) => errors.filter((error) => error.field !== field));
}
fieldError(field: UploadField): string {
const code = this.uploadFieldErrors().find((error) => error.field === field)?.code;
if (!code) return '';
if (field === 'type') return 'Choisissez un type de document.';
if (field === 'establishedOn') return 'Renseignez une date d’établissement valide.';
if (field === 'expiresOn')
return code === 'before_establishment'
? 'La date d’expiration doit être postérieure ou égale à la date d’établissement.'
: 'Renseignez une date d’expiration valide.';
return code === 'invalid'
? '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 {