import type { SaveTransport } from '../utils/saveOperation'; import { useCallback } from 'react'; import { useHttpService } from './HttpService'; import { environment } from '../environments/environment'; const UPLOADS_API = environment.apiUrl; const THUMBNAIL_MAX_PX = 500; const THUMBNAIL_QUALITY = 0.9; /** * ITEM 3 - WHAT THE THUMBNAIL IS ENCODED AS, AND WHY IT IS NOT ALWAYS JPEG. * * Measured in a browser: a practice uploaded a **PNG**, S3 held the true PNG (byte-identical to the * source, `ContentType: image/png`), and the rendered `img.src` read `data:image/png;base64,...` whose * payload began `ff d8 ff e0... JFIF` - **JPEG**. The label came from the file's own content type; the * bytes came from `toDataURL('image/jpeg')` here. * * THE COST IS NOT THE WRONG LABEL, IT IS THE ALPHA. **JPEG has no alpha channel**, so a * transparent-background logo - which is what a real logo file is - is flattened onto an opaque * background, permanently, in the copy every screen actually renders. And since made the * portal header render the inline thumbnail (the stored value is an S3 object key, and presigning inside * that component was rejected by its own no-dependency test), that solid box now sits at the top of * **every signed-in page**. * * NOT FIXED BY RELABELLING THE DATA URL `image/jpeg`. That makes the label and the bytes agree by * conceding the alpha loss, which is the half that damages a practice's brand. * * The formats below are the ones `HTMLCanvasElement.toDataURL` is required to support besides JPEG. * A type the browser cannot encode is not an error - it silently returns **PNG** - so the produced * type is read back from the data URL rather than assumed, and that is what `uploadPhoto` reports. */ const ALPHA_PRESERVING_THUMBNAIL_TYPES = new Set(['image/png', 'image/webp']); export const thumbnailMimeType = (sourceContentType?: string | null): string => ALPHA_PRESERVING_THUMBNAIL_TYPES.has((sourceContentType ?? '').toLowerCase()) ? (sourceContentType as string).toLowerCase() : 'image/jpeg'; /** The cap the server keeps a photo's bytes under before it shows them inline. */ export const PHOTO_MAX_BYTES = 256 * 1024; const SCALE_STEPS_PX = [THUMBNAIL_MAX_PX, 400, 300, 200]; export type ScaledImage = { blob: Blob; contentType: string }; const encode = (canvas: HTMLCanvasElement, type: string): Promise => new Promise((resolve, reject) => canvas.toBlob( (blob) => (blob ? resolve(blob) : reject(new Error('Failed to encode the image'))), type, THUMBNAIL_QUALITY)); /** * The image as the record will show it: scaled down and re-encoded here, then uploaded as the file itself. The server * shows these bytes inline only after this exact upload scans clean, so nothing a browser draws is ever trusted. * Smaller sizes are tried until it fits the cap; the produced type is read from the blob, never assumed. */ const createScaledImage = (file: File): Promise => new Promise((resolve, reject) => { const img = new Image(); const objectUrl = URL.createObjectURL(file); img.onload = async () => { URL.revokeObjectURL(objectUrl); try { for (const maxPx of SCALE_STEPS_PX) { let { width, height } = img; const scale = Math.min(1, maxPx / Math.max(width, height)); width = Math.max(1, Math.round(width * scale)); height = Math.max(1, Math.round(height * scale)); const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height; canvas.getContext('2d')!.drawImage(img, 0, 0, width, height); const blob = await encode(canvas, thumbnailMimeType(file.type)); if (blob.size <= PHOTO_MAX_BYTES) { resolve({ blob, contentType: blob.type || 'image/png' }); return; } } reject(new Error('The image is too large to use as a photo')); } catch (error) { reject(error); } }; img.onerror = () => { URL.revokeObjectURL(objectUrl); reject(new Error('Failed to load image for thumbnail')); }; img.src = objectUrl; }); export const usePhotoUploadService = () => { const { get, post } = useHttpService(); /** * `patientId` NAMES WHOSE RECORD A DOCUMENT IS FOR, and it is ignored for every upload type that is * not one. A patient's own upload does not need it - the server reads the caller's own id and * refuses a body naming anybody else - but a RECEPTIONIST scanning a card at check-in does, because * without it the server has only the staff member's identity and the file lands where neither the * patient nor the clinician can read it. */ const getPresignedUploadUrl = useCallback(async ( type: string, contentType: string, filename: string, patientId?: string, // WHOSE RECORD, WITHOUT NAMING THEM. Some kinds resolve the patient from an order server-side, // because the screen that files against it deliberately has no patient id to give. orderId?: string, transport?: Partial, // The document request this file answers: the upload is then issued for that request alone. taskId?: string, ): Promise<{ uploadUrl: string; key: string; headers?: Record }> => { return post<{ uploadUrl: string; key: string; headers?: Record }>(`${UPLOADS_API}/uploads/presign`, { type, contentType, filename, ...(patientId ? { patientId } : {}), ...(orderId ? { orderId } : {}), ...(taskId ? { taskId } : {}), }, { ...transport }); }, [post]); // `transport`: the save this upload belongs to - it is not sent once that save has moved on, and is cancelled with it. // `signedHeaders`: what the upload URL was signed with; sent exactly, or the store refuses the upload. const uploadToS3 = useCallback(async (uploadUrl: string, file: File, transport?: Partial, signedHeaders?: Record): Promise => { transport?.admit?.(); const response = await fetch(uploadUrl, { method: 'PUT', body: file, headers: { ...signedHeaders, 'Content-Type': file.type }, signal: transport?.signal, }); if (!response.ok) { throw new Error(`S3 upload failed: ${response.status} ${response.statusText}`); } }, []); // `carrier` names the record that holds the file (Communication/{id}, Media/{id}); a message's or media item's file // opens only for whoever may read that record. const getPresignedDownloadUrl = useCallback(async (key: string, transport?: SaveTransport, carrier?: string): Promise => { const { downloadUrl } = await get<{ downloadUrl: string }>( `${UPLOADS_API}/uploads/presign?key=${encodeURIComponent(key)}${carrier ? `&carrier=${encodeURIComponent(carrier)}` : ''}`, transport ? { admit: transport.admit, signal: transport.signal } : {}); return downloadUrl; }, [get]); // The upload and the inline thumbnail every photo record carries, whichever route issued the upload. // The scaled image is the uploaded file; the record names it by key, and the server fills the bytes it shows. const uploadScaled = useCallback(async ( presign: (contentType: string) => Promise<{ uploadUrl: string; key: string }>, file: File, transport?: SaveTransport, ): Promise<{ key: string; contentType: string; size: number }> => { const scaled = await createScaledImage(file); const { uploadUrl, key } = await presign(scaled.contentType); await uploadToS3(uploadUrl, new File([scaled.blob], file.name, { type: scaled.contentType }), transport); return { key, contentType: scaled.contentType, size: scaled.blob.size }; }, [uploadToS3]); const uploadPhoto = useCallback(async (file: File, uploadType: string = 'profile-picture', patientId?: string, transport?: SaveTransport) => uploadScaled((contentType) => getPresignedUploadUrl(uploadType, contentType, file.name, patientId, undefined, transport), file, transport), [getPresignedUploadUrl, uploadScaled]); // A Patient's profile photo: issued for the patient named, keyed to that patient whoever uploads it. The server // admits the patient themselves, a helper acting for them, or staff who may change the record now. const uploadPatientPhoto = useCallback(async (file: File, patientId: string, transport?: SaveTransport) => uploadScaled((contentType) => post<{ uploadUrl: string; key: string }>(`${UPLOADS_API}/uploads/profile-photo/presign`, { contentType, filename: file.name, patientId, }, { ...transport }), file, transport), [post, uploadScaled]); /** * A DOCUMENT A PATIENT WAS ASKED FOR, WHICH IS NOT A PHOTO EVEN WHEN IT IS ONE. * * `uploadPhoto` builds a thumbnail, which is meaningless for a PDF and is the whole difference * between the two. What a referral letter needs is the presign and the PUT, and nothing else: * the record stores the object key, and the file is fetched through a presigned download when * somebody opens it. * * AND IT IS NOT ONLY THE PDF. `createScaledImage` draws the file into a canvas through an `Image`, * so it works only for something the browser can DECODE - which excludes HEIC, the iPhone camera's * default format and therefore the commonest file a patient will ever send. Routing one through * `uploadPhoto` fails in the browser, after the presign has already been issued, with an error * about loading an image. * * The upload type is `document`, which already exists and already admits patients - a patient * and their practice both upload there, and both have to be able to read what the other put * there. */ // The KIND is a parameter because the server keys and gates on it: `document` is the // patient-facing one, and a laboratory's own report is filed by the bench, which that kind does // not admit. Defaulted, so every existing caller is unchanged. const uploadDocument = useCallback(async ( file: File, patientId?: string, options: { uploadType?: string; orderId?: string; transport?: Partial; taskId?: string } = {}, ): Promise<{ key: string }> => { const { uploadUrl, key, headers } = await getPresignedUploadUrl( options.uploadType ?? 'document', file.type, file.name, patientId, options.orderId, options.transport, options.taskId, ); await uploadToS3(uploadUrl, file, options.transport, headers); options.transport?.admit?.(); return { key }; }, [getPresignedUploadUrl, uploadToS3]); return { uploadPhoto, uploadPatientPhoto, uploadDocument, getPresignedDownloadUrl, uploadSigned: uploadToS3 }; };