import { HttpBackend, HttpClient, HttpEventType, HttpHeaders, HttpRequest, } from '@angular/common/http'; import { inject, Injectable } from '@angular/core'; import { concatMap, from, last, map, Observable, tap, toArray, } from 'rxjs'; import type { ApiResponseModel, FileGenerateRequestModel, FileReadModel, FileReadRequestModel, SignedUploadInstructionModel, } from '@africanies/africanies-models'; import { ApiClient } from '../http/api-client'; import { asString } from '../http/wire'; import { FILE_GENERATE_PATH, FILE_READ_MULTIPLE_PARAM, FILE_READ_PATH, FILE_UPLOAD_DEFAULT_FOLDER, mapFileRead, mapFileReadList, mapSignedUploadInstructions, toFileGenerateRequest, } from './file.mapper'; /** * Options for {@link FileService.uploadFiles}. */ export interface FileUploadOptions { /** * Optional 0–90 progress callback while files are PUT to S3. * Host apps can bind this to a progress bar; omitted by default. */ onProgress?: (percent: number) => void; } /** * File utility reads (`POST /file/read`) and signed S3 uploads * (`POST /file/generate` + PUT to the presigned URL). * * Primary preview path when a record stores a `file_ref` string (shipments, * tracking items, waybills, KYC, etc.). Body `{ ref }` → `data` with * `mime_type` and `base_64` for `` / PDF viewers. * * Document catalog previews use {@link DocumentService.readById} instead * (`GET /public/document/read/{id}` → `data.file_ref.base_64`). * * Uploads keep generate + S3 PUT in one method so callers cannot half-run * the flow. S3 uses a bare {@link HttpClient} over {@link HttpBackend} so * Authorization / `x-shipment-mode` never reach the bucket. * * @example * ```ts * const files = inject(FileService); * * files.read(item.file_ref).subscribe((res) => { * if (res.success) console.log(res.data?.base_64); * }); * * files.uploadFiles([photo], 'images/items').subscribe((keys) => { * console.log(keys[0]); // s3_key for photos_s3_key * }); * ``` */ @Injectable({ providedIn: 'root' }) export class FileService { private readonly api = inject(ApiClient); /** Bypass app interceptors for presigned S3 PUTs. */ private readonly s3Http = new HttpClient(inject(HttpBackend)); /** * Resolve one file reference (`POST /file/read`, body `{ ref }`). * * @param ref - Storage / document UUID from `file_ref` on a record. * @returns Normalized envelope with mapped {@link FileReadModel}. */ read(ref: string): Observable> { return this.readByBody({ ref: asString(ref) }); } /** * Resolve multiple files for one ref (`POST /file/read?multiple=yes`). * * E-commerce waybill flows may return several pages in `data[]`. * * @param ref - Storage reference token. * @returns Normalized envelope with mapped {@link FileReadModel}[]. */ readMultiple(ref: string): Observable> { return this.readByBodyMultiple({ ref: asString(ref) }); } /** * Resolve a file from an explicit request body. * * @param body - Wire body (`{ ref }`). * @returns Normalized envelope with mapped {@link FileReadModel}. */ readByBody( body: FileReadRequestModel, ): Observable> { return this.api .post(FILE_READ_PATH, { ref: asString(body?.ref), }) .pipe( map((res) => ({ ...res, data: res.data == null ? null : mapFileRead(res.data), })), ); } /** * Multi-file variant of {@link readByBody}. * * @param body - Wire body (`{ ref }`). * @returns Normalized envelope with mapped {@link FileReadModel}[]. */ readByBodyMultiple( body: FileReadRequestModel, ): Observable> { return this.api .post( FILE_READ_PATH, { ref: asString(body?.ref) }, { params: { multiple: FILE_READ_MULTIPLE_PARAM } }, ) .pipe( map((res) => ({ ...res, data: res.data == null ? null : mapFileReadList(res.data), })), ); } /** * Generate signed upload URLs, PUT each file to S3, and return matching * `s3_key` values in the same order as `files`. * * @param files - Image blobs (JPEG / JPG / PNG only). * @param folder - Storage folder (default {@link FILE_UPLOAD_DEFAULT_FOLDER}). * @param options - Optional progress callback. * @returns Observable of S3 keys. */ uploadFiles( files: Blob[], folder: string = FILE_UPLOAD_DEFAULT_FOLDER, options?: FileUploadOptions, ): Observable { if (!files.length) { throw new Error('At least one file is required for upload.'); } const requests = files.map((file) => toFileGenerateRequest(file, folder)); return this.generateUploadInstructions(requests).pipe( concatMap((instructions) => from(files).pipe( concatMap((file, index) => this.uploadFileToS3( file, instructions[index], index, files.length, options?.onProgress, ).pipe(map(() => instructions[index].s3_key)), ), toArray(), ), ), ); } private generateUploadInstructions( files: FileGenerateRequestModel[], ): Observable { return this.api .post( FILE_GENERATE_PATH, { files }, { toast: false }, ) .pipe( map((res) => mapSignedUploadInstructions( res.data, files.length, res.success !== false, ), ), ); } private uploadFileToS3( file: Blob, instruction: SignedUploadInstructionModel, fileIndex: number, totalFiles: number, onProgress?: (percent: number) => void, ): Observable { const headers = this.getS3UploadHeaders(instruction); const request = new HttpRequest( instruction.method, instruction.upload_url, file, { headers, reportProgress: true, }, ); return this.s3Http.request(request).pipe( tap((event) => { if (!onProgress) { return; } const fileProgress = event.type === HttpEventType.UploadProgress && event.total ? Math.round((100 * event.loaded) / event.total) : event.type === HttpEventType.Response ? 100 : 0; onProgress( Math.round(((fileIndex + fileProgress / 100) / totalFiles) * 90), ); }), last((event) => event.type === HttpEventType.Response), ); } private getS3UploadHeaders( instruction: SignedUploadInstructionModel, ): HttpHeaders { const contentType = instruction.headers?.['Content-Type'] || instruction.mime; if (!contentType) { throw new Error('The upload service did not provide a Content-Type.'); } let headers = new HttpHeaders({ 'Content-Type': contentType, }); const aclFromHeaders = instruction.headers?.['x-amz-acl']; if (aclFromHeaders) { return headers.set('x-amz-acl', aclFromHeaders); } const signedUrl = new URL(instruction.upload_url); const signedHeaders = signedUrl.searchParams .get('X-Amz-SignedHeaders') ?.toLowerCase() .split(';'); if (signedHeaders?.includes('x-amz-acl')) { const acl = signedUrl.searchParams.get('x-amz-acl'); if (!acl) { throw new Error( 'The pre-signed S3 URL requires an ACL header but does not provide one.', ); } headers = headers.set('x-amz-acl', acl); } return headers; } }