diff --git a/package.json b/package.json index 1310e5b..a48787a 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/migrations/2026-11-11-add-buyer-task-note.ts b/scripts/migrations/2026-11-11-add-buyer-task-note.ts new file mode 100644 index 0000000..7028896 --- /dev/null +++ b/scripts/migrations/2026-11-11-add-buyer-task-note.ts @@ -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; +}); diff --git a/src/app/api/v1/buyer-tasks/notes/[source]/[id]/route.test.ts b/src/app/api/v1/buyer-tasks/notes/[source]/[id]/route.test.ts new file mode 100644 index 0000000..4923ebd --- /dev/null +++ b/src/app/api/v1/buyer-tasks/notes/[source]/[id]/route.test.ts @@ -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 }); + }); +}); diff --git a/src/app/api/v1/buyer-tasks/notes/[source]/[id]/route.ts b/src/app/api/v1/buyer-tasks/notes/[source]/[id]/route.ts new file mode 100644 index 0000000..f114138 --- /dev/null +++ b/src/app/api/v1/buyer-tasks/notes/[source]/[id]/route.ts @@ -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); + } +} diff --git a/src/components/buyer-tasks/buyer-task-group-card.test.tsx b/src/components/buyer-tasks/buyer-task-group-card.test.tsx index e1ed4fc..12803da 100644 --- a/src/components/buyer-tasks/buyer-task-group-card.test.tsx +++ b/src/components/buyer-tasks/buyer-task-group-card.test.tsx @@ -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( + + ); + expect(screen.queryByRole("button", { name: "Notes" })).not.toBeInTheDocument(); + rerender(); + 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( + + ); + 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( diff --git a/src/components/buyer-tasks/buyer-task-group-card.tsx b/src/components/buyer-tasks/buyer-task-group-card.tsx index 9d72504..2ac6568 100644 --- a/src/components/buyer-tasks/buyer-task-group-card.tsx +++ b/src/components/buyer-tasks/buyer-task-group-card.tsx @@ -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({ )} + {group.step === "po_sent" && group.purchaseOrderId && ( + + )} diff --git a/src/components/buyer-tasks/buyer-task-note.tsx b/src/components/buyer-tasks/buyer-task-note.tsx new file mode 100644 index 0000000..00a98f3 --- /dev/null +++ b/src/components/buyer-tasks/buyer-task-note.tsx @@ -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(); + 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 ( + + { + setOpen(!open); + if (!open) { + setNote(undefined); + void requestNote("GET"); + } + }} + > + + Notes + + + {note !== undefined && ( + <> + {note && ( + + {[...(note.previous ?? []), note].filter((entry) => entry.text).map((entry) => ( + + {entry.text} + + #{entry.editor} + + {noteDate.format(new Date(entry.savedAt))} + + + + ))} + + )} + New PO note + setDraft(event.target.value)} + rows={3} + maxLength={2000} + disabled={busy} + /> + {conflict && ( + + Another note was posted. Your draft is still here. Post it again to add it below. + + )} + void requestNote("PUT")}> + Post note + + > + )} + {busy && {note === undefined ? "Loading note…" : "Saving note…"}} + {error && {error}} + {error && note === undefined && ( + void requestNote("GET")}>Retry + )} + + + ); +} diff --git a/src/lib/server/buyer-task-note-service.test.ts b/src/lib/server/buyer-task-note-service.test.ts new file mode 100644 index 0000000..8d9aced --- /dev/null +++ b/src/lib/server/buyer-task-note-service.test.ts @@ -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"); + }); +}); diff --git a/src/lib/server/buyer-task-note-service.ts b/src/lib/server/buyer-task-note-service.ts new file mode 100644 index 0000000..3b6094e --- /dev/null +++ b/src/lib/server/buyer-task-note-service.ts @@ -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>(); + +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); + } +} diff --git a/src/lib/server/buyer-tasks-service.ts b/src/lib/server/buyer-tasks-service.ts index 955cabe..c4c128d 100644 --- a/src/lib/server/buyer-tasks-service.ts +++ b/src/lib/server/buyer-tasks-service.ts @@ -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;
{entry.text}
#{entry.editor}
+ Another note was posted. Your draft is still here. Post it again to add it below. +
{note === undefined ? "Loading note…" : "Saving note…"}
{error}