feat(buyer-tasks): add shared notes to sent POs

This commit is contained in:
Dave 2026-11-11 12:27:02 +02:00
parent 694c2a2292
commit fc497fad96
10 changed files with 462 additions and 3 deletions

View file

@ -25,6 +25,7 @@
"pb:migrate:collection-ai": "npx tsx scripts/migrations/2026-10-17-add-collection-ai.ts",
"pb:migrate:pool-pending": "npx tsx scripts/migrations/2026-10-30-add-stock-pool-pending.ts",
"pb:migrate:quote-email-to": "npx tsx scripts/migrations/2026-10-30-add-quote-email-to.ts",
"pb:migrate:buyer-task-note": "npx tsx scripts/migrations/2026-11-11-add-buyer-task-note.ts",
"pb:migrate:company-letterhead": "npx tsx scripts/migrations/2026-10-31-add-company-letterhead.ts",
"pb:migrate:collection-auto-push": "npx tsx scripts/migrations/2026-10-28-add-collection-auto-push.ts",
"pb:migrate:order-assistant": "npx tsx scripts/migrations/2026-10-24-add-order-assistant-fields.ts",

View file

@ -0,0 +1,34 @@
import "./_env-loader";
import PocketBase from "pocketbase";
async function main() {
const url = process.env.NEXT_PUBLIC_POCKETBASE_URL || process.env.PB_URL || "http://127.0.0.1:8090";
const email = process.env.PB_ADMIN_EMAIL;
const password = process.env.PB_ADMIN_PASSWORD;
if (!email || !password) throw new Error("Set PB_ADMIN_EMAIL and PB_ADMIN_PASSWORD.");
const pb = new PocketBase(url);
await pb.collection("_superusers").authWithPassword(email, password);
for (const name of ["purchase_orders", "stock_purchases"]) {
const collection = await pb.collections.getOne(name);
const existing = collection.fields.find((field: { name: string }) => field.name === "buyer_task_note");
if (existing && existing.type !== "json") {
throw new Error(`${name}.buyer_task_note already exists with a different type.`);
}
if (!existing) {
await pb.collections.update(collection.id, {
fields: [...collection.fields, { name: "buyer_task_note", type: "json", maxSize: 16384 }],
});
}
const verified = await pb.collections.getOne(name);
if (!verified.fields.some((field: { name: string; type: string }) => field.name === "buyer_task_note" && field.type === "json")) {
throw new Error(`Could not verify ${name}.buyer_task_note.`);
}
console.log(`Verified ${name}.buyer_task_note`);
}
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.message : "PO note migration failed.");
process.exitCode = 1;
});

View file

@ -0,0 +1,52 @@
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { getBrowserSessionUser } from "@/lib/server/browser-session";
import { readBuyerTaskNote, saveBuyerTaskNote } from "@/lib/server/buyer-task-note-service";
import { GET, PUT } from "./route";
vi.mock("@/lib/server/browser-session", () => ({ getBrowserSessionUser: vi.fn() }));
vi.mock("@/lib/server/buyer-task-note-service", () => ({
BUYER_TASK_NOTE_MAX_LENGTH: 2000,
readBuyerTaskNote: vi.fn(),
saveBuyerTaskNote: vi.fn(),
}));
const url = "http://localhost/api/v1/buyer-tasks/notes/customer/po-1";
const ctx = { params: Promise.resolve({ source: "customer", id: "po-1" }) };
const note = {
text: "Collect Friday",
editor: "Sarah",
savedAt: "2026-11-11T12:35:00Z",
revision: "rev-1",
};
describe("PO note route", () => {
beforeEach(() => {
vi.resetAllMocks();
});
it("requires a browser session before reading a note", async () => {
vi.mocked(getBrowserSessionUser).mockRejectedValue(Object.assign(new Error(), { status: 401 }));
const response = await GET(new NextRequest(url), ctx);
expect(response.status).toBe(401);
expect(readBuyerTaskNote).not.toHaveBeenCalled();
});
it("attributes the saved note to the browser session and returns it on read", async () => {
vi.mocked(getBrowserSessionUser).mockResolvedValue({ id: "user-1", name: "Sarah", user_type: null });
vi.mocked(saveBuyerTaskNote).mockResolvedValue({ note, conflict: false });
vi.mocked(readBuyerTaskNote).mockResolvedValue(note);
const response = await PUT(new NextRequest(url, {
method: "PUT",
body: JSON.stringify({ text: note.text, revision: null, editor: "Someone else" }),
}), ctx);
expect(response.status).toBe(200);
expect(saveBuyerTaskNote).toHaveBeenCalledWith("customer", "po-1", note.text, null, "Sarah");
const read = await GET(new NextRequest(url), ctx);
expect(read.headers.get("Cache-Control")).toBe("no-store");
expect(await read.json()).toEqual({ note });
});
});

View file

@ -0,0 +1,58 @@
import { NextRequest, NextResponse } from "next/server";
import { getBrowserSessionUser } from "@/lib/server/browser-session";
import {
BUYER_TASK_NOTE_MAX_LENGTH,
readBuyerTaskNote,
saveBuyerTaskNote,
} from "@/lib/server/buyer-task-note-service";
type Context = { params: Promise<{ source: string; id: string }> };
function noteError(error: unknown) {
const status = (error as { status?: number } | null)?.status;
if (status === 401) return NextResponse.json({ error: "Sign in to access PO notes." }, { status });
if (status === 404) return NextResponse.json({ error: "PO not found." }, { status });
if (status === 409) return NextResponse.json({ error: "This PO is no longer in PO Sent." }, { status });
if (status === 413) return NextResponse.json({ error: "This PO scratchpad is full." }, { status });
if (status === 503) return NextResponse.json({ error: "PO note storage is unavailable." }, { status });
return NextResponse.json({ error: "Could not access the PO note." }, { status: 500 });
}
export async function GET(req: NextRequest, { params }: Context) {
try {
await getBrowserSessionUser(req);
const { source, id } = await params;
if (source !== "customer" && source !== "stock") {
return NextResponse.json({ error: "Invalid PO source." }, { status: 400 });
}
const note = await readBuyerTaskNote(source, id);
return NextResponse.json({ note }, { headers: { "Cache-Control": "no-store" } });
} catch (error) {
return noteError(error);
}
}
export async function PUT(req: NextRequest, { params }: Context) {
try {
const user = await getBrowserSessionUser(req);
const { source, id } = await params;
if (source !== "customer" && source !== "stock") {
return NextResponse.json({ error: "Invalid PO source." }, { status: 400 });
}
const body = await req.json().catch(() => null);
if (
typeof body?.text !== "string" ||
!body.text.trim() ||
body.text.length > BUYER_TASK_NOTE_MAX_LENGTH ||
(body.revision !== null && typeof body.revision !== "string")
) {
return NextResponse.json({ error: "Invalid note or revision." }, { status: 400 });
}
const result = await saveBuyerTaskNote(source, id, body.text.trim(), body.revision, user.name || user.id);
return NextResponse.json(result, { status: result.conflict ? 409 : 200 });
} catch (error) {
return noteError(error);
}
}

View file

@ -52,6 +52,45 @@ describe("BuyerTaskGroupCard", () => {
}
});
it("offers shared notes only on a sent PO card", () => {
const group = { ...baseGroup, purchaseOrderId: "po-1" };
const { rerender } = render(
<BuyerTaskGroupCard group={{ ...group, step: "po_draft" }} onAction={vi.fn()} />
);
expect(screen.queryByRole("button", { name: "Notes" })).not.toBeInTheDocument();
rerender(<BuyerTaskGroupCard group={{ ...group, step: "po_sent" }} onAction={vi.fn()} />);
expect(screen.getByRole("button", { name: "Notes" })).toBeInTheDocument();
});
it("shows the saved editor as a hashtag beside the note", async () => {
const fetchNote = vi.spyOn(globalThis, "fetch").mockResolvedValue(
Response.json({
note: {
text: "Supplier confirmed", editor: "Sarah", savedAt: "2026-11-11T12:40:00Z", revision: "rev-2",
previous: [{ text: "Collect Friday", editor: "David", savedAt: "2026-11-11T12:35:00Z", revision: "rev-1" }],
},
})
);
try {
render(
<BuyerTaskGroupCard
group={{ ...baseGroup, step: "po_sent", purchaseOrderId: "po-1" }}
onAction={vi.fn()}
/>
);
fireEvent.click(screen.getByRole("button", { name: "Notes" }));
expect(await screen.findByText("#David")).toBeVisible();
expect(screen.getByText("#Sarah")).toBeVisible();
expect(screen.getByText("Collect Friday")).toBeVisible();
expect(screen.getByText("Supplier confirmed")).toBeVisible();
expect(screen.getByRole("textbox", { name: "New PO note" })).toHaveValue("");
expect(screen.getByText(/14:35/)).toHaveAttribute("datetime", "2026-11-11T12:35:00Z");
} finally {
fetchNote.mockRestore();
}
});
it("renders two buttons on the PO Sent tab", () => {
const onResend = vi.fn();
render(

View file

@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { BuyerTaskItemsList, type BuyerTaskItem } from "./buyer-task-items-list";
import type { BuyerTaskGroup } from "@/lib/server/buyer-tasks-service";
import { BuyerTaskNote } from "./buyer-task-note";
import { supplierColor } from "@/lib/supplier-color";
import { withAlpha } from "@/lib/item-status-ui";
@ -221,6 +222,13 @@ export function BuyerTaskGroupCard({
</div>
</div>
)}
{group.step === "po_sent" && group.purchaseOrderId && (
<BuyerTaskNote
key={`${group.source}:${group.purchaseOrderId}`}
source={group.source}
purchaseOrderId={group.purchaseOrderId}
/>
)}
<BuyerTaskItemsList items={group.items as BuyerTaskItem[]} />
</CardContent>
</Card>

View file

@ -0,0 +1,123 @@
"use client";
import { useId, useState } from "react";
import { StickyNote } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { getPocketBase } from "@/lib/pocketbase";
import type { BuyerTaskNote as SavedNote } from "@/lib/server/buyer-task-note-service";
import type { BuyerTaskGroupSource } from "@/lib/server/buyer-tasks-service";
const noteDate = new Intl.DateTimeFormat("en-ZA", {
dateStyle: "medium",
timeStyle: "short",
timeZone: "Africa/Johannesburg",
});
export function BuyerTaskNote({ source, purchaseOrderId }: {
source: BuyerTaskGroupSource;
purchaseOrderId: string;
}) {
const fieldId = useId();
const [open, setOpen] = useState(false);
const [busy, setBusy] = useState(false);
const [note, setNote] = useState<SavedNote | null | undefined>();
const [draft, setDraft] = useState("");
const [error, setError] = useState("");
const [conflict, setConflict] = useState(false);
const endpoint = `/api/v1/buyer-tasks/notes/${source}/${encodeURIComponent(purchaseOrderId)}`;
async function requestNote(method: "GET" | "PUT") {
setBusy(true);
setError("");
try {
const headers = new Headers({ Authorization: `Bearer ${getPocketBase().authStore.token}` });
const options: RequestInit = { method, headers };
if (method === "PUT") {
headers.set("Content-Type", "application/json");
options.body = JSON.stringify({ text: draft, revision: note?.revision ?? null });
}
const response = await fetch(endpoint, options);
const data = await response.json();
if (response.status === 409 && data.conflict) {
setNote(data.note);
setConflict(true);
return;
}
if (!response.ok) throw new Error(data.error || "Could not access the PO note.");
setNote(data.note);
if (method === "PUT") setDraft("");
setConflict(false);
} catch (err) {
setError(err instanceof Error ? err.message : "Could not access the PO note.");
} finally {
setBusy(false);
}
}
return (
<div className="border-t px-3 py-2">
<Button
type="button"
size="sm"
variant="ghost"
aria-expanded={open}
aria-controls={`${fieldId}-panel`}
disabled={busy}
onClick={() => {
setOpen(!open);
if (!open) {
setNote(undefined);
void requestNote("GET");
}
}}
>
<StickyNote aria-hidden="true" />
Notes
</Button>
<div id={`${fieldId}-panel`} hidden={!open} className="mt-2 space-y-2">
{note !== undefined && (
<>
{note && (
<ul aria-label="Posted notes" className="divide-y rounded-md border">
{[...(note.previous ?? []), note].filter((entry) => entry.text).map((entry) => (
<li key={entry.revision} className="flex flex-col gap-2 px-3 py-2 sm:flex-row sm:gap-3">
<p className="min-w-0 flex-1 whitespace-pre-wrap break-words text-sm">{entry.text}</p>
<div className="min-w-0 text-xs sm:w-36 sm:shrink-0">
<p className="break-words font-medium">#{entry.editor}</p>
<time className="block text-muted-foreground" dateTime={entry.savedAt}>
{noteDate.format(new Date(entry.savedAt))}
</time>
</div>
</li>
))}
</ul>
)}
<label className="sr-only" htmlFor={fieldId}>New PO note</label>
<Textarea
id={fieldId}
value={draft}
onChange={(event) => setDraft(event.target.value)}
rows={3}
maxLength={2000}
disabled={busy}
/>
{conflict && (
<p role="alert" className="text-sm">
Another note was posted. Your draft is still here. Post it again to add it below.
</p>
)}
<Button type="button" size="sm" disabled={busy || !draft.trim()} onClick={() => void requestNote("PUT")}>
Post note
</Button>
</>
)}
{busy && <p role="status" className="text-xs text-muted-foreground">{note === undefined ? "Loading note…" : "Saving note…"}</p>}
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
{error && note === undefined && (
<Button type="button" size="sm" variant="outline" disabled={busy} onClick={() => void requestNote("GET")}>Retry</Button>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,62 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { saveBuyerTaskNote } from "./buyer-task-note-service";
const { collection, getOne, update } = vi.hoisted(() => ({
collection: vi.fn(),
getOne: vi.fn(),
update: vi.fn(),
}));
vi.mock("./pocketbase-admin", () => ({
getAdminPocketBase: async () => ({ collection }),
}));
describe("saveBuyerTaskNote", () => {
beforeEach(() => {
vi.resetAllMocks();
collection.mockReturnValue({ getOne, update });
});
it("preserves the first save when two buyers save the same revision", async () => {
let record = { status: "sent", buyer_task_note: null as unknown };
getOne.mockImplementation(async () => ({ ...record }));
update.mockImplementation(async (_id, patch) => {
record = { ...record, ...patch };
return record;
});
const first = saveBuyerTaskNote("stock", "po-1", "Collect Friday", null, "Sarah");
const second = saveBuyerTaskNote("stock", "po-1", "Phone supplier", null, "David");
const results = await Promise.all([first, second]);
expect(results).toEqual([
{ note: expect.objectContaining({ text: "Collect Friday", editor: "Sarah" }), conflict: false },
{ note: expect.objectContaining({ text: "Collect Friday", editor: "Sarah" }), conflict: true },
]);
expect(collection).toHaveBeenCalledWith("stock_purchases");
expect(update).toHaveBeenCalledTimes(1);
});
it("appends a new entry and preserves the original editor and timestamp", async () => {
const original = { text: "Collect Friday", editor: "David", savedAt: "2026-11-11T12:35:00Z", revision: "rev-1" };
getOne.mockResolvedValue({ status: "sent", buyer_task_note: original });
update.mockImplementation(async (_id, patch) => patch);
const result = await saveBuyerTaskNote("customer", "po-1", "Supplier confirmed", "rev-1", "Sarah");
expect(result.note).toMatchObject({
text: "Supplier confirmed",
editor: "Sarah",
previous: [original],
});
});
it("reports when PocketBase silently drops the note field", async () => {
getOne.mockResolvedValue({ status: "sent" });
update.mockResolvedValue({ status: "sent" });
await expect(saveBuyerTaskNote("customer", "po-1", "Call back", null, "Sarah"))
.rejects.toMatchObject({ status: 503 });
expect(collection).toHaveBeenCalledWith("purchase_orders");
});
});

View file

@ -0,0 +1,81 @@
import { randomUUID } from "node:crypto";
import { getAdminPocketBase } from "./pocketbase-admin";
import type { BuyerTaskGroupSource } from "./buyer-tasks-service";
export const BUYER_TASK_NOTE_MAX_LENGTH = 2000;
type BuyerTaskNoteEntry = {
text: string;
editor: string;
savedAt: string;
revision: string;
};
export type BuyerTaskNote = BuyerTaskNoteEntry & { previous?: BuyerTaskNoteEntry[] };
const collections = { customer: "purchase_orders", stock: "stock_purchases" } as const;
const pendingSaves = new Map<string, Promise<unknown>>();
async function loadNote(source: BuyerTaskGroupSource, id: string) {
const pb = await getAdminPocketBase();
const collection = pb.collection(collections[source]);
const record = await collection.getOne(id, { fields: "id,status,buyer_task_note" });
if (record.status !== "sent" && record.status !== "sending") {
throw Object.assign(new Error("This PO is no longer in PO Sent."), { status: 409 });
}
return {
collection,
note: (record.buyer_task_note as BuyerTaskNote | null | undefined) ?? null,
};
}
export async function readBuyerTaskNote(source: BuyerTaskGroupSource, id: string) {
return (await loadNote(source, id)).note;
}
export async function saveBuyerTaskNote(
source: BuyerTaskGroupSource,
id: string,
text: string,
expectedRevision: string | null,
editor: string,
) {
const key = `${source}:${id}`;
// Serialize the revision check and save per PO within the app server.
const previous = pendingSaves.get(key) ?? Promise.resolve();
const save = previous.catch(() => {}).then(async () => {
const { collection, note } = await loadNote(source, id);
if ((note?.revision ?? null) !== expectedRevision) {
return { note, conflict: true };
}
const entries = [...(note?.previous ?? [])];
if (note?.text) {
const { text, editor, savedAt, revision } = note;
entries.push({ text, editor, savedAt, revision });
}
const next: BuyerTaskNote = {
text,
editor,
savedAt: new Date().toISOString(),
revision: randomUUID(),
...(entries.length ? { previous: entries } : {}),
};
if (Buffer.byteLength(JSON.stringify(next), "utf8") > 16_384) {
throw Object.assign(new Error("This PO scratchpad is full."), { status: 413 });
}
const saved = await collection.update(id, { buyer_task_note: next });
if (saved.buyer_task_note?.revision !== next.revision) {
throw Object.assign(new Error("PO note storage is unavailable."), { status: 503 });
}
return { note: next, conflict: false };
});
pendingSaves.set(key, save);
try {
return await save;
} finally {
if (pendingSaves.get(key) === save) pendingSaves.delete(key);
}
}

View file

@ -18,8 +18,7 @@ export type BuyerTaskTab = "needs_po" | "po_draft" | "po_sent";
export type BuyerTaskGroupSource = "customer" | "stock";
export type BuyerTaskGroup = {
/** Encoded as `${source}::${headerId}::${supplierId}` so a stock
* group and a customer group for the same supplier are distinct. */
/** Source, header and supplier identify the card. Sent customer cards also include the PO ID. */
groupId: string;
/**
* Identifier of the parent record (customer order for
@ -202,7 +201,9 @@ export async function listBuyerTasks(
if (tab === "po_draft" && po?.status !== "draft" && po?.status !== "mail_failed") continue;
if (tab === "po_sent" && !["sent", "sending"].includes(po?.status ?? "")) continue;
const groupId = `customer::${orderId}::${supplierId}`;
const groupId = tab === "po_sent"
? `customer::${orderId}::${supplierId}::${po?.id}`
: `customer::${orderId}::${supplierId}`;
let group = groups.get(groupId);
if (!group) {
const order = item.expand?.order as { order_number?: string } | undefined;