src/app/features/template-export/template-export-api/template-export-api.service.ts
import { inject, Injectable } from "@angular/core";
import { FileService } from "../../file/file.service";
import { SafeUrl } from "@angular/platform-browser";
import { Entity } from "app/core/entity/model/entity";
import { from, Observable, of, throwError } from "rxjs";
import { HttpResponse } from "@angular/common/http";
import { NotAvailableOfflineError } from "../../../core/session/not-available-offline.error";
import { NAVIGATOR_TOKEN } from "../../../utils/di-tokens";
import { switchMap } from "rxjs/operators";
import { TemplateExport } from "../template-export.entity";
import { resolveLocaleText } from "../../../core/language/active-locale";
import { Logging } from "../../../core/logging/logging.service";
import { environment } from "../../../../environments/environment";
import {
  TemplateExportComplement,
  TemplateExportContextService,
} from "../template-export-context/template-export-context.service";
import { EntityRelationResolverService } from "../../../core/entity/entity-relation-resolver/entity-relation-resolver.service";

/**
 * Format of API response body upon uploading a new template file.
 */
interface TemplateUploadResponseDto {
  templateId: string;
}

/**
 * Format of API request body to render one or more files from a template.
 * TemplateId is provided via URL path.
 *
 * `data` is a single record for `/render`, or an array of records for `/render-batch`
 * (the backend renders each one and returns either a ZIP or a combined PDF).
 */
interface TemplateRenderRequestDto {
  /**
   * target file type (e.g. "pdf")
   */
  convertTo: string;

  /**
   * The data used to fill placeholders in the template.
   */
  data: Object | Object[];

  /**
   * Additional context data available in the template under the `{c.…}` prefix.
   */
  complement?: TemplateExportComplement;
}

export interface TemplateExportResult {
  filename: string;
  file: ArrayBuffer;
}

/**
 * Interact with the PDF Template Generation API that uses File Templates to generate custom pdf documents.
 */
@Injectable({
  providedIn: "root",
})
export class TemplateExportApiService extends FileService {
  private navigator = inject<Navigator>(NAVIGATOR_TOKEN);
  private readonly exportContext = inject(TemplateExportContextService);
  private readonly relationResolver = inject(EntityRelationResolverService);

  readonly API_URL = environment.API_PROXY_PREFIX + "/v1/export";

  /*
  --- FileService methods ---
  */

  /**
   * Upload a new template file to the API.
   * @param file to be uploaded to the API as a template
   * @param entity
   * @param property
   * @return The template ID generated by the API
   */
  uploadFile(
    file: File,
    entity: TemplateExport,
    property: string,
  ): Observable<string> {
    if (!this.navigator.onLine) {
      return throwError(() => new NotAvailableOfflineError("File Attachments"));
    }

    const formData = new FormData();
    formData.append("template", file, file.name);

    return this.httpClient.post(this.API_URL + "/template", formData).pipe(
      switchMap(async (res: TemplateUploadResponseDto) => {
        entity.templateId = res.templateId;
        await this.entityMapper.save(entity);
        return res.templateId;
      }),
    );
  }

  protected override getShowFileUrl(
    entity: TemplateExport,
    property: string,
  ): string {
    return this.API_URL + "/template/" + entity.getId();
  }

  loadFile(entity: Entity, property: string): Observable<SafeUrl> {
    // should not be required for our use cases of the Template Export API
    throw new Error("Method not implemented.");
  }

  removeFile(entity: Entity, property: string): Observable<any> {
    // we do not do explicit file removal due to the design of the API
    Logging.debug("skipping file removal for Template Export API");
    return of(true);
  }

  removeAllFiles(entity: Entity): Observable<any> {
    Logging.debug("skipping file removal for Template Export API");
    return of(true);
  }

  /*
  --- PDF Generation API methods ---
  */

  /**
   * POST a render request to the given endpoint and resolve the response's Content-Disposition
   * filename, falling back to `fallbackFilename` if the header is missing or cannot be parsed.
   *
   * Shared by the single-record and batch render methods below, which only differ in the
   * endpoint URL, the shape of `data`, and the fallback filename.
   */
  private renderTemplate(
    url: string,
    data: Object | Object[],
    fallbackFilename: string,
  ): Observable<TemplateExportResult> {
    const complement = this.exportContext.getComplement();

    // give the template access to details of linked records (e.g. a Note's related
    // Child) instead of just their id - resolved one level deep, see the service.
    return from(this.relationResolver.resolveRelations(data)).pipe(
      switchMap((resolvedData) =>
        this.httpClient.post(
          url,
          {
            convertTo: "pdf",
            data: resolvedData,
            ...(complement ? { complement } : {}),
          } as TemplateRenderRequestDto,
          { observe: "response", responseType: "arraybuffer" },
        ),
      ),
      switchMap(async (res: HttpResponse<ArrayBuffer>) => {
        // the API returns the filename in the Content-Disposition header as a URL-encoded string with special delimiters
        const disposition = res.headers.get("Content-Disposition");
        let filenameMatch: RegExpMatchArray | null = null;
        if (disposition) {
          try {
            filenameMatch = decodeURIComponent(disposition).match(
              /filename="?([^";]+)"?/,
            );
          } catch (err) {
            // malformed percent-encoding must not fail an otherwise successful
            // download - fall back to fallbackFilename below
            Logging.warn(
              "TemplateExportApiService: could not decode Content-Disposition filename",
              err,
            );
          }
        }

        return {
          filename: filenameMatch?.[1] ?? fallbackFilename,
          file: res.body,
        };
      }),
    );
  }

  /**
   * Generate a PDF applying actual data to an existing template.
   * @param template The TemplateExport entity to render
   * @param data The data object (typically an entity) to be applied to the template
   * @return An array buffer of the generated PDF
   */
  generatePdfFromTemplate(
    template: TemplateExport,
    data: Object,
  ): Observable<TemplateExportResult> {
    return this.renderTemplate(
      this.API_URL + "/render/" + template.getId(),
      data,
      resolveLocaleText(template.title),
    );
  }

  /**
   * Generate output from one template for many records in a single request.
   *
   * Both modes go through the template engine's native batch rendering — the backend
   * just forwards the request:
   * - `mode: "zip"` (default): N independent files packaged in a ZIP archive.
   * - `mode: "combined"`: N rendered files merged into a single multi-page PDF.
   *
   * @param template The TemplateExport entity to render
   * @param dataList The array of data objects (typically entities) to apply to the template
   * @param mode How the backend should aggregate the output
   * @return The generated file (ZIP or PDF) and a derived filename
   */
  generateBatchFromTemplate(
    template: TemplateExport,
    dataList: Object[],
    mode: "zip" | "combined" = "zip",
  ): Observable<TemplateExportResult> {
    return this.renderTemplate(
      this.API_URL + "/render-batch/" + template.getId() + "?mode=" + mode,
      dataList,
      resolveLocaleText(template.title) +
        (mode === "combined" ? ".pdf" : ".zip"),
    );
  }
}

results matching ""

    No results matching ""