MEGAINFRA-WEBAPP/src/lib/purchasing/documents.ts

54 lines
4.2 KiB
TypeScript

import {z} from 'zod';
import {PDFDocument} from 'pdf-lib';
import type {RecordModel} from 'pocketbase';
import type {DocumentReference,PurchasingContext} from './types';
import {PurchasingError} from './errors';
export const RecordId=z.string().regex(/^[a-z0-9]{15}$/);
export const DocumentReferenceSchema=z.object({kind:z.enum(['po','receipt','source','page','candidate']),id:RecordId}).strict();
export function parseDocumentReference(input:unknown):DocumentReference{return DocumentReferenceSchema.parse(input);}
export function safeAttachmentName(filename:string):string{return filename.split(/[\\/]/).pop()!.replace(/[\x00-\x1f\x7f"<>]/g,'').slice(0,200)||'document';}
export function documentDownloadUrl(reference:DocumentReference):string {
parseDocumentReference(reference);
const base=new URL(process.env.MCP_PUBLIC_URL||'');
return new URL(`/api/mcp/documents/${reference.kind}/${reference.id}`,base).toString();
}
const MAX_BYTES=20*1024*1024;
async function boundedBytes(response:Response):Promise<Uint8Array>{
if(!response.ok)throw new PurchasingError('document_unavailable',502);
if(Number(response.headers.get('content-length'))>MAX_BYTES)throw new PurchasingError('document_too_large',413);
if(!response.body)throw new PurchasingError('document_empty',502);
const reader=response.body.getReader(),parts:Uint8Array[]=[];let size=0;
try{for(;;){const next=await reader.read();if(next.done)break;size+=next.value.byteLength;if(size>MAX_BYTES){await reader.cancel();throw new PurchasingError('document_too_large',413);}parts.push(next.value);}}finally{reader.releaseLock();}
return new Uint8Array(Buffer.concat(parts));
}
export async function loadDocument(ctx:PurchasingContext,reference:DocumentReference):Promise<{bytes:Uint8Array;mime:string;filename:string}>{
const ref=parseDocumentReference(reference);
if(ref.kind==='candidate'){
const note=await ctx.pb.collection('dn_batch_notes').getOne(ref.id,{fields:'id,batch,pages'});
const saved:Array<number>=Array.isArray(note.pages)?note.pages:[];
if(!saved.length||saved.length>100||new Set(saved).size!==saved.length)throw new PurchasingError('invalid_candidate_pages');
const rows=await ctx.pb.collection('dn_batch_pages').getFullList({filter:ctx.pb.filter('batch={:batch}',{batch:note.batch}),fields:'id,batch,global_page,image'});
const pdf=await PDFDocument.create();let total=0;
for(const page of saved){
const matches=rows.filter(row=>row.batch===note.batch&&row.global_page===page);
if(matches.length!==1)throw new PurchasingError('candidate_source_page_missing',404);
const source=await loadDocument(ctx,{kind:'page',id:matches[0].id});total+=source.bytes.length;
if(total>MAX_BYTES)throw new PurchasingError('document_too_large',413);
const image=source.mime==='image/jpeg'?await pdf.embedJpg(source.bytes):source.mime==='image/png'?await pdf.embedPng(source.bytes):null;
if(!image)throw new PurchasingError('source_page_type_unsupported');
const sheet=pdf.addPage([image.width,image.height]);sheet.drawImage(image,{x:0,y:0,width:image.width,height:image.height});
}
const bytes=await pdf.save();if(bytes.length>MAX_BYTES)throw new PurchasingError('document_too_large',413);
return {bytes,mime:'application/pdf',filename:`delivery-${ref.id}.pdf`};
}
const mapping={po:['purchase_orders','purchase_order_file'],receipt:['delivery_notes','slip_image'],source:['dn_batch_sources','file'],page:['dn_batch_pages','image']} as const;
const [collection,field]=mapping[ref.kind];
const record=await ctx.pb.collection(collection).getOne(ref.id,{fields:`id,collectionId,${field}`});
const filename=record[field];if(typeof filename!=='string'||!filename)throw new PurchasingError('document_not_found',404);
const url=ctx.pb.files.getURL({...record,collectionName:collection} as RecordModel,filename,{token:await ctx.pb.files.getToken()});
const upstream=await fetch(url,{headers:{Authorization:ctx.pb.authStore.token},signal:AbortSignal.timeout(60000)});
const mime=(upstream.headers.get('content-type')||'application/octet-stream').split(';')[0].toLowerCase();
const allowed=new Set(['application/pdf','image/png','image/jpeg','image/webp']);
const bytes=await boundedBytes(upstream);
return {bytes,mime:allowed.has(mime)?mime:'application/octet-stream',filename:safeAttachmentName(filename)};
}