From 70c158b37490b19ce30b206e79d83463d5763f2b Mon Sep 17 00:00:00 2001 From: SJCoetz Date: Thu, 6 Aug 2026 23:20:26 +0200 Subject: [PATCH] feat(stock-purchases): add additional_attachments service helpers Co-authored-by: Orca --- ...tock-purchases-service.attachments.test.ts | 101 ++++++++++++++++++ src/lib/server/stock-purchases-service.ts | 98 +++++++++++++++++ src/lib/types.ts | 13 +++ 3 files changed, 212 insertions(+) create mode 100644 src/lib/server/stock-purchases-service.attachments.test.ts diff --git a/src/lib/server/stock-purchases-service.attachments.test.ts b/src/lib/server/stock-purchases-service.attachments.test.ts new file mode 100644 index 0000000..a7006d1 --- /dev/null +++ b/src/lib/server/stock-purchases-service.attachments.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect, vi } from "vitest"; + +const getOneMock = vi.fn(); +const updateMock = vi.fn(); + +vi.mock("@/lib/server/pocketbase-admin", () => ({ + getAdminPocketBase: vi.fn().mockResolvedValue({ + collection: () => ({ + getOne: getOneMock, + update: updateMock, + }), + }), +})); + +import { + addStockPurchaseAttachment, + removeStockPurchaseAttachment, +} from "./stock-purchases-service"; + +describe("addStockPurchaseAttachment", () => { + it("appends the new token to additional_attachments", async () => { + getOneMock.mockResolvedValueOnce({ + id: "sp-1", + additional_attachments: ["existing.pdf"], + }); + updateMock.mockResolvedValueOnce({ + additional_attachments: ["existing.pdf", "newtoken.pdf"], + }); + + const result = await addStockPurchaseAttachment({ + purchaseId: "sp-1", + file: new File(["data"], "invoice.pdf", { type: "application/pdf" }), + }); + + expect(updateMock).toHaveBeenCalledTimes(1); + expect(result.attachments).toEqual(["existing.pdf", "newtoken.pdf"]); + expect(result.added).toEqual(["newtoken.pdf"]); + }); + + it("normalises a legacy string field to an array", async () => { + getOneMock.mockResolvedValueOnce({ + id: "sp-2", + additional_attachments: "legacy-token.pdf", // pre-multi PB row + }); + updateMock.mockResolvedValueOnce({ + additional_attachments: ["legacy-token.pdf", "fresh.pdf"], + }); + + const result = await addStockPurchaseAttachment({ + purchaseId: "sp-2", + file: new File(["x"], "fresh.pdf", { type: "application/pdf" }), + }); + + expect(result.attachments).toContain("fresh.pdf"); + }); + + it("throws when the purchase has no additional_attachments field yet (PB schema missing)", async () => { + getOneMock.mockResolvedValueOnce({ id: "sp-3" }); + updateMock.mockRejectedValueOnce(new Error("Failed to update record.")); + + await expect( + addStockPurchaseAttachment({ + purchaseId: "sp-3", + file: new File(["x"], "x.pdf", { type: "application/pdf" }), + }), + ).rejects.toThrow(); + }); +}); + +describe("removeStockPurchaseAttachment", () => { + it("removes the matching token and returns the rest", async () => { + getOneMock.mockResolvedValueOnce({ + id: "sp-1", + additional_attachments: ["keep.pdf", "drop.pdf"], + }); + updateMock.mockResolvedValueOnce({ additional_attachments: ["keep.pdf"] }); + + const result = await removeStockPurchaseAttachment({ + purchaseId: "sp-1", + token: "drop.pdf", + }); + + expect(result.attachments).toEqual(["keep.pdf"]); + expect(updateMock).toHaveBeenCalledWith("sp-1", { + additional_attachments: ["keep.pdf"], + }); + }); + + it("throws 'not found' when the token is not in the array", async () => { + getOneMock.mockResolvedValueOnce({ + id: "sp-1", + additional_attachments: ["keep.pdf"], + }); + updateMock.mockClear(); + + await expect( + removeStockPurchaseAttachment({ purchaseId: "sp-1", token: "nope.pdf" }), + ).rejects.toThrow(/not found/i); + expect(updateMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/server/stock-purchases-service.ts b/src/lib/server/stock-purchases-service.ts index 8d55da2..da9b691 100644 --- a/src/lib/server/stock-purchases-service.ts +++ b/src/lib/server/stock-purchases-service.ts @@ -1388,3 +1388,101 @@ export async function generateStockPurchasePdf( generatedAt: generatedAtIso, }; } + +// ============================================================================ +// Free-form attachments (operator-curated, distinct from the versioned PDF). +// +// `stock_purchases.documents` / `documents_history` are a strict versioned +// PDF ledger (see the send-email + list-pdf routes) — we do NOT touch them +// from this code path. Operator-dropped attachments live on the sibling +// `stock_purchases.additional_attachments` field, mirroring +// `purchase_orders.additional_attachments`. +// ============================================================================ + +/** + * Append a file to `stock_purchases.additional_attachments`. PB's + * FormData update REPLACES the field, so we read the current array, + * write the new file, and re-read to discover PB's slug-on-disk + * token (the operator's original filename is preserved separately + * via the `documents_history`-style audit on PO equivalents; for + * stock purchases we don't keep a history because the field is + * free-form and operator-curated). + * + * Mirrors `purchase_orders.additional_attachments` semantics. + */ +export async function addStockPurchaseAttachment(input: { + purchaseId: string; + file: File; +}): Promise<{ attachments: string[]; added: string[] }> { + const pb = await getAdminPocketBase(); + // Read the current array so we can diff what's new after the + // FormData write (PB's update REPLACES the file field, so we + // can't see the "before" state from the response alone). + const before = await pb + .collection(STOCK_PURCHASES_COLL) + .getOne<{ additional_attachments?: string[] | string }>( + input.purchaseId, + { fields: "id,additional_attachments" }, + ); + const beforeArr = normaliseAttachmentArray( + before?.additional_attachments, + ); + + const fd = new FormData(); + fd.append("additional_attachments", input.file); + // PB returns the updated record including the file field — use it + // directly instead of a second getOne (mirrors the PDF-render + // pattern at the top of this file). + const updated = await pb + .collection(STOCK_PURCHASES_COLL) + .update<{ additional_attachments?: string[] | string }>( + input.purchaseId, + fd, + ); + const afterArr = normaliseAttachmentArray(updated?.additional_attachments); + + return { + attachments: afterArr, + added: afterArr.filter((t) => !beforeArr.includes(t)), + }; +} + +/** + * Remove a single attachment token from + * `stock_purchases.additional_attachments`. PB does not expose a + * per-file delete from a multi-file field via the SDK; we filter + * the array client-side and write the rest back. The original PB + * file on disk is NOT deleted (same limitation as the PO route). + */ +export async function removeStockPurchaseAttachment(input: { + purchaseId: string; + token: string; +}): Promise<{ attachments: string[] }> { + const pb = await getAdminPocketBase(); + const row = await pb + .collection(STOCK_PURCHASES_COLL) + .getOne<{ additional_attachments?: string[] | string }>( + input.purchaseId, + { fields: "id,additional_attachments" }, + ); + const arr = normaliseAttachmentArray(row?.additional_attachments); + + if (!arr.includes(input.token)) { + throw new Error(`Attachment not found: ${input.token}`); + } + + const remaining = arr.filter((t) => t !== input.token); + await pb.collection(STOCK_PURCHASES_COLL).update(input.purchaseId, { + additional_attachments: remaining, + }); + + return { attachments: remaining }; +} + +/** PB may return a single string for a 1-row field; coerce to array. */ +function normaliseAttachmentArray( + raw: string | string[] | null | undefined, +): string[] { + if (raw == null) return []; + return Array.isArray(raw) ? raw : [raw]; +} diff --git a/src/lib/types.ts b/src/lib/types.ts index eb1569c..a4ba041 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1440,6 +1440,19 @@ export interface StockPurchase extends BaseRecord { * 2026-07-16 (was previously absent on stock purchases). */ documents_history?: PurchaseOrderDocumentEntry[]; + /** + * Free-form attachments the operator has dropped on the PO + * (supplier invoice, packing slip, photo of damaged stock). + * Distinct from `documents`, which is the versioned-PDF ledger + * (see `documents_history`). Multi-file, no PB-side cap; the + * upload route enforces 25 MB per file. + * + * Mirrors `purchase_orders.additional_attachments` (see + * `src/app/api/v1/purchase-orders/[id]/attachments/upload/`). + * + * PB schema: added 2026-08-06. + */ + additional_attachments?: string[]; expand?: { supplier?: Supplier; };