This commit is contained in:
@@ -1,7 +1,8 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable, from, switchMap } from 'rxjs';
|
||||||
import { ApiConfigService } from '../../../core/services/api-config.service';
|
import { ApiConfigService } from '../../../core/services/api-config.service';
|
||||||
|
import { ImageUtils } from '../../../shared/utilities/image-utils';
|
||||||
import {
|
import {
|
||||||
PagedResult, ExpenseListItemDto, ExpenseDto, CreateExpenseRequest, UpdateExpenseRequest,
|
PagedResult, ExpenseListItemDto, ExpenseDto, CreateExpenseRequest, UpdateExpenseRequest,
|
||||||
RejectExpenseRequest, PayExpenseRequest,
|
RejectExpenseRequest, PayExpenseRequest,
|
||||||
@@ -39,8 +40,15 @@ export class ExpenseApiService {
|
|||||||
reject(id: number, r: RejectExpenseRequest): Observable<void> { return this.http.post<void>(`${this.endpoint}/${id}/reject`, r); }
|
reject(id: number, r: RejectExpenseRequest): Observable<void> { return this.http.post<void>(`${this.endpoint}/${id}/reject`, r); }
|
||||||
pay(id: number, r: PayExpenseRequest): Observable<void> { return this.http.post<void>(`${this.endpoint}/${id}/pay`, r); }
|
pay(id: number, r: PayExpenseRequest): Observable<void> { return this.http.post<void>(`${this.endpoint}/${id}/pay`, r); }
|
||||||
uploadReceipt(id: number, file: File): Observable<void> {
|
uploadReceipt(id: number, file: File): Observable<void> {
|
||||||
const form = new FormData(); form.append('file', file);
|
// Resize/re-encode phone photos client-side first — a raw 12MP camera JPEG
|
||||||
|
// is several MB and would 413 on mobile. PDFs pass through untouched.
|
||||||
|
return from(ImageUtils.compressForUpload(file)).pipe(
|
||||||
|
switchMap(prepared => {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', prepared);
|
||||||
return this.http.post<void>(`${this.endpoint}/${id}/receipt`, form);
|
return this.http.post<void>(`${this.endpoint}/${id}/receipt`, form);
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Fetches the receipt as a Blob via HttpClient so the auth interceptor attaches
|
* Fetches the receipt as a Blob via HttpClient so the auth interceptor attaches
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import imageCompression from 'browser-image-compression';
|
|
||||||
import { PDFDocument } from 'pdf-lib';
|
import { PDFDocument } from 'pdf-lib';
|
||||||
|
import { ImageUtils } from '../../../shared/utilities/image-utils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds a single merged PDF from a list of paper-proof attachments (mostly phone photos).
|
* Builds a single merged PDF from a list of paper-proof attachments (mostly phone photos).
|
||||||
@@ -12,11 +12,6 @@ import { PDFDocument } from 'pdf-lib';
|
|||||||
* All work happens client-side; the resulting Blob is uploaded as the session's proof.pdf.
|
* All work happens client-side; the resulting Blob is uploaded as the session's proof.pdf.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Tunables — adjust if proofs look too soft (raise) or files are too large (lower).
|
|
||||||
const MAX_EDGE_PX = 2000; // longest image edge after compression
|
|
||||||
const JPEG_QUALITY = 0.72; // 0..1
|
|
||||||
const MAX_SIZE_MB = 1; // target ceiling per image
|
|
||||||
|
|
||||||
// US Letter, in PDF points (72pt = 1in).
|
// US Letter, in PDF points (72pt = 1in).
|
||||||
const PAGE_W = 612;
|
const PAGE_W = 612;
|
||||||
const PAGE_H = 792;
|
const PAGE_H = 792;
|
||||||
@@ -42,13 +37,7 @@ export async function buildProofPdf(files: File[]): Promise<ProofBuildResult> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (file.type.startsWith('image/')) {
|
if (file.type.startsWith('image/')) {
|
||||||
const compressed = await imageCompression(file, {
|
const compressed = await ImageUtils.compressForUpload(file);
|
||||||
maxWidthOrHeight: MAX_EDGE_PX,
|
|
||||||
maxSizeMB: MAX_SIZE_MB,
|
|
||||||
initialQuality: JPEG_QUALITY,
|
|
||||||
fileType: 'image/jpeg',
|
|
||||||
useWebWorker: true,
|
|
||||||
});
|
|
||||||
const jpgBytes = new Uint8Array(await compressed.arrayBuffer());
|
const jpgBytes = new Uint8Array(await compressed.arrayBuffer());
|
||||||
const img = await doc.embedJpg(jpgBytes);
|
const img = await doc.embedJpg(jpgBytes);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import imageCompression from 'browser-image-compression';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client-side image compression shared by every photo upload (expense receipts,
|
||||||
|
* offering paper-proofs). Phone cameras produce 12MP+ JPEGs that are several MB
|
||||||
|
* each; resizing the longest edge and re-encoding as JPEG keeps uploads small
|
||||||
|
* enough to clear the API's per-endpoint size limits (and the reverse proxy).
|
||||||
|
*
|
||||||
|
* Tunables: raise quality/edge if proofs look too soft; lower them if files are
|
||||||
|
* too large.
|
||||||
|
*/
|
||||||
|
export class ImageUtils {
|
||||||
|
/** Longest image edge (px) after compression. */
|
||||||
|
public static readonly MAX_EDGE_PX = 2000;
|
||||||
|
/** JPEG encoder quality, 0..1. */
|
||||||
|
public static readonly JPEG_QUALITY = 0.72;
|
||||||
|
/** Target ceiling per image, in megabytes. */
|
||||||
|
public static readonly MAX_SIZE_MB = 1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compresses a single image File to a resized JPEG. Non-image files (e.g. a
|
||||||
|
* PDF) are returned untouched.
|
||||||
|
*
|
||||||
|
* The result is always renamed to a ".jpg" extension with an "image/jpeg"
|
||||||
|
* type, because the API derives a stored receipt's content-type from its
|
||||||
|
* filename extension — keeping a ".png" name on JPEG bytes would make the
|
||||||
|
* download serve a broken image.
|
||||||
|
*/
|
||||||
|
public static async compressForUpload(file: File): Promise<File> {
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
const compressed = await imageCompression(file, {
|
||||||
|
maxWidthOrHeight: ImageUtils.MAX_EDGE_PX,
|
||||||
|
maxSizeMB: ImageUtils.MAX_SIZE_MB,
|
||||||
|
initialQuality: ImageUtils.JPEG_QUALITY,
|
||||||
|
fileType: 'image/jpeg',
|
||||||
|
useWebWorker: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const baseName = file.name.replace(/\.[^.]+$/, '');
|
||||||
|
return new File([compressed], `${baseName}.jpg`, { type: 'image/jpeg' });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,11 @@ server {
|
|||||||
ssl_certificate_key /etc/letsencrypt/live/app.rolac.org/privkey.pem;
|
ssl_certificate_key /etc/letsencrypt/live/app.rolac.org/privkey.pem;
|
||||||
|
|
||||||
location /api/ {
|
location /api/ {
|
||||||
|
# nginx defaults to 1 MB, which 413s phone-camera receipt uploads before they
|
||||||
|
# reach the API. Keep this >= the largest API [RequestSizeLimit] (offerings = 50 MB)
|
||||||
|
# so the per-endpoint limits in the controllers stay the real authority.
|
||||||
|
client_max_body_size 50m;
|
||||||
|
|
||||||
proxy_pass http://api:8080/api/;
|
proxy_pass http://api:8080/api/;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ server {
|
|||||||
|
|
||||||
# API -> api container. The SPA calls same-origin /api/... (environment.prod.ts).
|
# API -> api container. The SPA calls same-origin /api/... (environment.prod.ts).
|
||||||
location /api/ {
|
location /api/ {
|
||||||
|
# nginx defaults to 1 MB, which 413s phone-camera receipt uploads before they
|
||||||
|
# reach the API. Keep this >= the largest API [RequestSizeLimit] (offerings = 50 MB)
|
||||||
|
# so the per-endpoint limits in the controllers stay the real authority.
|
||||||
|
client_max_body_size 50m;
|
||||||
|
|
||||||
set $upstream_api api;
|
set $upstream_api api;
|
||||||
proxy_pass http://$upstream_api:8080$request_uri;
|
proxy_pass http://$upstream_api:8080$request_uri;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
|
|||||||
Reference in New Issue
Block a user