feat(stock-purchases): add additional_attachments service helpers

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
SJCoetz 2026-08-06 23:20:26 +02:00
parent 9e118a3b5f
commit 70c158b374
3 changed files with 212 additions and 0 deletions

View file

@ -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();
});
});

View file

@ -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];
}

View file

@ -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;
};