From 3e8f5fb0c7f6f7c7ce1a2b190e18af86666b153f Mon Sep 17 00:00:00 2001 From: "Claude (auto-fix)" Date: Wed, 24 Jun 2026 13:18:00 +0530 Subject: [PATCH 1/9] feat(history): add Accounting Code search filter to PO History PO History had no way to narrow by accounting code. Add an "Accounting Code" filter (the shared type-to-search combobox) alongside Cost Centre, backed by the PO-level account already included in the query. - history/page.tsx: read `accountId` searchParam, fetch selectable leaf accounting codes (active, no children) via buildAccountGroups, apply `where.accountId`, thread the param into pagination + export links, and surface an Accounting Code column for context. - history-filters.tsx: new SearchableSelect control wired into buildParams/apply/clear/hasFilters like the Cost Centre select. - api/reports/export: apply the same `accountId` filter so CSV/PDF export respects the on-screen filter. - tests/staging: verify picking a code drives an `accountId` query param. Fixes #121 Co-Authored-By: Claude Opus 4.8 (1M context) --- App/app/(portal)/history/history-filters.tsx | 21 ++++++++++++--- App/app/(portal)/history/page.tsx | 22 +++++++++++++--- App/app/api/reports/export/route.ts | 2 ++ .../issue-121-history-accounting-code.spec.ts | 26 +++++++++++++++++++ 4 files changed, 65 insertions(+), 6 deletions(-) create mode 100644 App/tests/staging/issue-121-history-accounting-code.spec.ts diff --git a/App/app/(portal)/history/history-filters.tsx b/App/app/(portal)/history/history-filters.tsx index 6ee2598..d9fe99f 100644 --- a/App/app/(portal)/history/history-filters.tsx +++ b/App/app/(portal)/history/history-filters.tsx @@ -2,6 +2,8 @@ import { useRouter, useSearchParams } from "next/navigation"; import { useEffect, useRef, useState } from "react"; +import { SearchableSelect } from "@/components/ui/searchable-select"; +import type { AccountGroup } from "@/app/(portal)/po/new/new-po-form"; const STATUSES = [ { value: "DRAFT", label: "Draft" }, @@ -19,11 +21,12 @@ const STATUSES = [ interface Props { vessels: { id: string; name: string }[]; + accounts: AccountGroup[]; perPageOptions: number[]; defaultPerPage: number; } -export function HistoryFilters({ vessels, perPageOptions, defaultPerPage }: Props) { +export function HistoryFilters({ vessels, accounts, perPageOptions, defaultPerPage }: Props) { const router = useRouter(); const sp = useSearchParams(); @@ -36,6 +39,7 @@ export function HistoryFilters({ vessels, perPageOptions, defaultPerPage }: Prop const [approvedFrom, setApprovedFrom] = useState(sp.get("approvedFrom") ?? ""); const [approvedTo, setApprovedTo] = useState(sp.get("approvedTo") ?? ""); const [vesselId, setVesselId] = useState(sp.get("vesselId") ?? ""); + const [accountId, setAccountId] = useState(sp.get("accountId") ?? ""); const [statuses, setStatuses] = useState(sp.getAll("status")); const [statusOpen, setStatusOpen] = useState(false); const statusRef = useRef(null); @@ -64,6 +68,7 @@ export function HistoryFilters({ vessels, perPageOptions, defaultPerPage }: Prop if (approvedFrom) params.set("approvedFrom", approvedFrom); if (approvedTo) params.set("approvedTo", approvedTo); if (vesselId) params.set("vesselId", vesselId); + if (accountId) params.set("accountId", accountId); for (const s of statuses) params.append("status", s); if (nextPerPage !== defaultPerPage) params.set("perPage", String(nextPerPage)); return params; @@ -78,14 +83,14 @@ export function HistoryFilters({ vessels, perPageOptions, defaultPerPage }: Prop } function clear() { - setDateFrom(""); setDateTo(""); setApprovedFrom(""); setApprovedTo(""); setVesselId(""); setStatuses([]); + setDateFrom(""); setDateTo(""); setApprovedFrom(""); setApprovedTo(""); setVesselId(""); setAccountId(""); setStatuses([]); const params = new URLSearchParams(); if (perPage !== defaultPerPage) params.set("perPage", String(perPage)); const qs = params.toString(); router.push(qs ? `/history?${qs}` : "/history"); } - const hasFilters = dateFrom || dateTo || approvedFrom || approvedTo || vesselId || statuses.length > 0; + const hasFilters = dateFrom || dateTo || approvedFrom || approvedTo || vesselId || accountId || statuses.length > 0; const statusLabel = statuses.length === 0 @@ -125,6 +130,16 @@ export function HistoryFilters({ vessels, perPageOptions, defaultPerPage }: Prop {vessels.map((v) => )} +
+ + +
- +
@@ -150,6 +162,7 @@ export default async function HistoryPage({ searchParams }: Props) { PO Number Title Cost Centre + Accounting Code Submitter Status Amount @@ -169,6 +182,9 @@ export default async function HistoryPage({ searchParams }: Props) { {po.title} {po.vessel.name} + + {po.account.code} {po.account.name} + {po.submitter.name} diff --git a/App/app/api/reports/export/route.ts b/App/app/api/reports/export/route.ts index ee82eae..3fa2fbc 100644 --- a/App/app/api/reports/export/route.ts +++ b/App/app/api/reports/export/route.ts @@ -30,6 +30,7 @@ export async function GET(request: NextRequest) { const approvedFrom = sp.get("approvedFrom"); const approvedTo = sp.get("approvedTo"); const vesselId = sp.get("vesselId"); + const accountId = sp.get("accountId"); const statuses = sp.getAll("status").filter(Boolean); const where: NonNullable[0]>["where"] = {}; @@ -54,6 +55,7 @@ export async function GET(request: NextRequest) { where.approvedAt = approvedAt; } if (vesselId) where.vesselId = vesselId; + if (accountId) where.accountId = accountId; if (statuses.length > 0) where.status = { in: statuses as POStatus[] }; const orders = await db.purchaseOrder.findMany({ diff --git a/App/tests/staging/issue-121-history-accounting-code.spec.ts b/App/tests/staging/issue-121-history-accounting-code.spec.ts new file mode 100644 index 0000000..4ddc57e --- /dev/null +++ b/App/tests/staging/issue-121-history-accounting-code.spec.ts @@ -0,0 +1,26 @@ +import { test, expect } from "@playwright/test"; +import { USERS, login } from "./helpers"; + +/** + * Issue #121 — PO History gains an Accounting Code filter. The control is the + * shared type-to-search combobox; picking a code and applying narrows the list + * via an `accountId` query param (mirrored by the CSV/PDF export links). + */ +test("#121 history can be filtered by accounting code", async ({ page }) => { + await login(page, USERS.MANAGER); + await page.goto("/history"); + + // The Accounting Code control is present (its trigger shows the empty label). + const trigger = page.getByRole("button", { name: "All accounting codes" }); + await expect(trigger).toBeVisible(); + + // Open it and pick the first accounting code from the searchable list. + await trigger.click(); + await expect(page.getByPlaceholder("Type code or name…")).toBeVisible(); + await page.locator(".max-h-72 button").first().click(); + + await page.getByRole("button", { name: "Apply" }).click(); + + // Applying the filter drives an accountId query param. + await expect(page).toHaveURL(/accountId=/); +}); From d1af1e6b1278f64b5ee71935986f3c63f66519ac Mon Sep 17 00:00:00 2001 From: Hardik Date: Wed, 24 Jun 2026 14:55:40 +0530 Subject: [PATCH 2/9] fix(pdf): let PdfService reach the PO export route past auth middleware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Email PO to vendor" (issue #14) relies on PdfService fetching /api/po//export?...&svc= WITHOUT a user session, authenticating with a `svc` token that matches PDF_SERVICE_TOKEN. The route handler validates that token, but the auth middleware runs first and its matcher doesn't exempt the export route — so every unauthenticated fetch was redirected to /login (307) and the svc bypass never executed. Net effect: the feature could never render a real PDF on any deployed env, even with the service configured. Fix: middleware now lets exactly `/api/po//export` through when its `svc` query param matches `process.env.PDF_SERVICE_TOKEN` (the route handler still re-validates it — defense in depth). Everything else stays auth-gated. The match lives in a dependency-free, edge-safe, unit-tested helper (lib/pdf-export-auth.ts); middleware already reads server env at runtime via auth()/NEXTAUTH_SECRET, so reading PDF_SERVICE_TOKEN there is consistent. Verified on a running build: correct svc + real PO -> 200, correct svc + bogus PO -> 404 (handler ran), wrong/no svc -> 307 (still gated). 324 unit tests green; tsc clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- App/lib/pdf-export-auth.ts | 24 ++++++++++++++++++++++++ App/middleware.ts | 9 +++++++++ App/tests/unit/pdf-export-auth.test.ts | 26 ++++++++++++++++++++++++++ 3 files changed, 59 insertions(+) create mode 100644 App/lib/pdf-export-auth.ts create mode 100644 App/tests/unit/pdf-export-auth.test.ts diff --git a/App/lib/pdf-export-auth.ts b/App/lib/pdf-export-auth.ts new file mode 100644 index 0000000..e0fa972 --- /dev/null +++ b/App/lib/pdf-export-auth.ts @@ -0,0 +1,24 @@ +// Service-token auth for the PO export route, shared by the auth middleware and +// (conceptually) the export route handler. +// +// PdfService ("Email PO to vendor", issue #14) fetches `/api/po//export` +// WITHOUT a user session, authenticating with a `svc` query param that must equal +// PDF_SERVICE_TOKEN. The route handler validates that token, but the auth +// middleware runs first and would otherwise redirect the unauthenticated request +// to /login — so the middleware uses this to let exactly that one route through +// when the token matches. +// +// Kept dependency-free so it's safe to import into the Edge middleware and easy to +// unit-test. `token` is `process.env.PDF_SERVICE_TOKEN` (undefined when the PDF +// service isn't configured → always denied). +const EXPORT_PATH = /^\/api\/po\/[^/]+\/export\/?$/; + +export function isPdfExportServiceRequest( + pathname: string, + svc: string | null | undefined, + token: string | undefined +): boolean { + if (!token || !svc) return false; + if (svc !== token) return false; + return EXPORT_PATH.test(pathname); +} diff --git a/App/middleware.ts b/App/middleware.ts index fa42626..57edf65 100644 --- a/App/middleware.ts +++ b/App/middleware.ts @@ -1,11 +1,20 @@ import { auth } from "@/auth"; import { NextResponse } from "next/server"; +import { isPdfExportServiceRequest } from "@/lib/pdf-export-auth"; export default auth((req) => { const isAuthenticated = !!req.auth; const pathname = req.nextUrl.pathname; const isLoginPage = pathname === "/login"; + // PdfService fetches the PO export page unauthenticated, using a `svc` token + // that matches PDF_SERVICE_TOKEN (the route handler re-validates it). Let that + // one route through so the service token isn't bounced to /login by the gate + // below. Everything else stays auth-protected. + if (isPdfExportServiceRequest(pathname, req.nextUrl.searchParams.get("svc"), process.env.PDF_SERVICE_TOKEN)) { + return NextResponse.next(); + } + if (!isAuthenticated && !isLoginPage) { const loginUrl = new URL("/login", req.url); loginUrl.searchParams.set("callbackUrl", pathname); diff --git a/App/tests/unit/pdf-export-auth.test.ts b/App/tests/unit/pdf-export-auth.test.ts new file mode 100644 index 0000000..e5c6bb6 --- /dev/null +++ b/App/tests/unit/pdf-export-auth.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect } from "vitest"; +import { isPdfExportServiceRequest } from "@/lib/pdf-export-auth"; + +const TOKEN = "a".repeat(64); + +describe("isPdfExportServiceRequest", () => { + it("allows the export route when the svc token matches", () => { + expect(isPdfExportServiceRequest("/api/po/cmqrug123/export", TOKEN, TOKEN)).toBe(true); + expect(isPdfExportServiceRequest("/api/po/cmqrug123/export/", TOKEN, TOKEN)).toBe(true); // trailing slash + }); + + it("denies when the token is missing, empty, or wrong", () => { + expect(isPdfExportServiceRequest("/api/po/x/export", TOKEN, undefined)).toBe(false); // service not configured + expect(isPdfExportServiceRequest("/api/po/x/export", null, TOKEN)).toBe(false); // no svc on request + expect(isPdfExportServiceRequest("/api/po/x/export", "", TOKEN)).toBe(false); + expect(isPdfExportServiceRequest("/api/po/x/export", "wrong", TOKEN)).toBe(false); + }); + + it("only matches the PO export route, not other paths", () => { + expect(isPdfExportServiceRequest("/api/po/x/export/extra", TOKEN, TOKEN)).toBe(false); + expect(isPdfExportServiceRequest("/api/po/x", TOKEN, TOKEN)).toBe(false); + expect(isPdfExportServiceRequest("/dashboard", TOKEN, TOKEN)).toBe(false); + expect(isPdfExportServiceRequest("/api/reports/spend", TOKEN, TOKEN)).toBe(false); + expect(isPdfExportServiceRequest("/api/po//export", TOKEN, TOKEN)).toBe(false); // empty id + }); +}); From a9fd927c1fa1f28e1b90504f6dc8164966e0deea Mon Sep 17 00:00:00 2001 From: Hardik Date: Wed, 24 Jun 2026 15:01:25 +0530 Subject: [PATCH 3/9] feat(pdf): cache the PO PDF per vendor email, refresh only the link timer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously every "Email to vendor" click re-rendered the PO via PdfService and re-uploaded to R2 under a timestamped key — wasteful, and it orphaned a new object each time. Now the PDF is stored at a deterministic per-PO key (buildPoPdfKey → po-pdf//.pdf). On each send, statObject() checks for an existing copy: if it exists and is at least as new as the PO's updatedAt, it's reused (no re-render, no re-upload) and only a fresh presigned URL is minted — refreshing the 7-day download timer. It re-renders only when there's no copy yet or the PO changed since the cached one (so an edited PO never emails a stale PDF). - lib/storage.ts: buildPoPdfKey (deterministic) + statObject (HEAD/stat, no body transfer; null when absent). - email-actions.ts: reuse-or-render decision keyed on updatedAt; always re-presign. - Tests: +2 (reuse-on-second-send-only-refreshes-link, re-render-when-changed). email-vendor suite 8 green; tsc clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- App/CLAUDE.md | 2 ++ App/app/(portal)/po/[id]/email-actions.ts | 17 ++++++--- App/lib/storage.ts | 40 ++++++++++++++++++++++ App/tests/integration/email-vendor.test.ts | 32 ++++++++++++++++- 4 files changed, 85 insertions(+), 6 deletions(-) diff --git a/App/CLAUDE.md b/App/CLAUDE.md index 7a40344..8bbc382 100644 --- a/App/CLAUDE.md +++ b/App/CLAUDE.md @@ -141,6 +141,8 @@ An **Email to vendor** button on the PO detail (`po-detail.tsx`, available once The pipeline (no mailto attachment — `mailto:` can't carry files): `prepareVendorEmail(poId)` (`po/[id]/email-actions.ts`) → `renderPoPdf` (`lib/pdf-service.ts`) → **PdfService** (a standalone Express + Playwright microservice, the GstService/EpfoService pattern) renders the existing `/api/po/[id]/export?format=pdf&pdf=1` page to a real PDF via headless Chromium → `uploadBuffer` to R2 (`po-pdf/…`) → `generateDownloadUrl` (presigned, **7-day** TTL) → returns a `mailto:` with the link. The export route accepts a server-only `svc` token (`PDF_SERVICE_TOKEN`) so PdfService can fetch the page without a user session, and `pdf=1` drops the on-screen print button + `window.print()` auto-trigger. Gated by `PDF_SERVICE_URL`/`PDF_SERVICE_TOKEN` — if unset the action returns a friendly "not configured" error. **No new DB model/migration.** +**Caching:** the PDF is stored at a **deterministic per-PO key** (`buildPoPdfKey` → `po-pdf//.pdf`, no timestamp). On each send, `statObject(key)` checks for an existing copy: if one exists and its `lastModified >= po.updatedAt`, it's **reused** (no re-render, no re-upload) and only a **fresh presigned URL is minted** (refreshing the 7-day timer). It re-renders only when there's no copy yet or the PO changed since the cached one. + ### Inventory (feature-flagged) Inventory (`ItemInventory`, keyed by `productId` + `siteId`) is **incremented at PO approval** — not on close — for the ordered quantities, when the PO has a `siteId`. The whole inventory surface (site stock, consumption) is gated by `NEXT_PUBLIC_INVENTORY_ENABLED` (see `lib/feature-flags.ts`); the vendor/product catalogue used for PO creation stays available regardless. diff --git a/App/app/(portal)/po/[id]/email-actions.ts b/App/app/(portal)/po/[id]/email-actions.ts index 64c5c43..bb3844b 100644 --- a/App/app/(portal)/po/[id]/email-actions.ts +++ b/App/app/(portal)/po/[id]/email-actions.ts @@ -2,7 +2,7 @@ import { auth } from "@/auth"; import { db } from "@/lib/db"; -import { buildStorageKey, uploadBuffer, generateDownloadUrl } from "@/lib/storage"; +import { buildPoPdfKey, uploadBuffer, generateDownloadUrl, statObject } from "@/lib/storage"; import { renderPoPdf, isPdfServiceConfigured, PdfServiceError } from "@/lib/pdf-service"; type Result = { ok: true; mailto: string; to: string } | { error: string }; @@ -47,13 +47,20 @@ export async function prepareVendorEmail(poId: string): Promise { return { error: "PDF emailing is not configured on this environment." }; } - // Render → store → presigned link. + // Render → store → presigned link. The PDF is cached at a deterministic + // per-PO key: if a copy already exists and is at least as new as the PO's last + // change, reuse it and only mint a fresh presigned URL (refreshing the 7-day + // timer). Re-render only when there's no copy yet or the PO changed since. let link: string; try { - const pdf = await renderPoPdf(poId); const slug = po.poNumber.replace(/\//g, "-"); - const key = buildStorageKey("po-pdf", poId, `${slug}.pdf`); - await uploadBuffer(key, pdf, "application/pdf"); + const key = buildPoPdfKey(poId, `${slug}.pdf`); + const cached = await statObject(key); + const isFresh = cached !== null && cached.lastModified >= po.updatedAt; + if (!isFresh) { + const pdf = await renderPoPdf(poId); + await uploadBuffer(key, pdf, "application/pdf"); + } link = await generateDownloadUrl(key, LINK_TTL_SECONDS); } catch (e) { if (e instanceof PdfServiceError) return { error: `Could not generate the PO PDF: ${e.message}` }; diff --git a/App/lib/storage.ts b/App/lib/storage.ts index 0d6e687..e0e5a04 100644 --- a/App/lib/storage.ts +++ b/App/lib/storage.ts @@ -59,6 +59,16 @@ export function buildSignatureKey(userId: string, ext: string): string { return `signatures/${userId}.${ext}`; } +/** + * Deterministic key for a PO's rendered PDF (one object per PO, no timestamp) so + * "Email to vendor" can reuse a previously rendered copy instead of re-rendering + * and re-uploading on every send (see `prepareVendorEmail`). + */ +export function buildPoPdfKey(poId: string, fileName: string): string { + const safe = fileName.replace(/[^a-zA-Z0-9._-]/g, "_"); + return `po-pdf/${poId}/${safe}`; +} + /** * Storage key for a company branding asset (logo or stamp/seal). * Deterministic per company+type so a re-upload overwrites the previous file. @@ -106,6 +116,36 @@ export async function uploadBuffer( } } +/** + * Lightweight existence/metadata check for a stored object (no body transfer). + * Returns `{ lastModified }` when the object exists, or `null` when it doesn't. + * Used to reuse a cached PO PDF when it's still current. + */ +export async function statObject(key: string): Promise<{ lastModified: Date } | null> { + try { + if (isDev) { + const fs = await import("fs/promises"); + const path = await import("path"); + const filePath = path.join(process.cwd(), ".dev-uploads", ...key.split("/")); + const s = await fs.stat(filePath); + return { lastModified: s.mtime }; + } + const { S3Client, HeadObjectCommand } = await import("@aws-sdk/client-s3"); + const s3 = new S3Client({ + region: "auto", + endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`, + credentials: { + accessKeyId: process.env.R2_ACCESS_KEY_ID!, + secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!, + }, + }); + const r = await s3.send(new HeadObjectCommand({ Bucket: process.env.R2_BUCKET_NAME!, Key: key })); + return { lastModified: r.LastModified ?? new Date(0) }; + } catch { + return null; // missing object (404/NotFound) or any access error → treat as absent + } +} + /** * Fetch a stored file as a Buffer (server-side). */ diff --git a/App/tests/integration/email-vendor.test.ts b/App/tests/integration/email-vendor.test.ts index 62f8634..25d919f 100644 --- a/App/tests/integration/email-vendor.test.ts +++ b/App/tests/integration/email-vendor.test.ts @@ -17,13 +17,15 @@ vi.mock("@/lib/storage", async (importOriginal) => { ...actual, uploadBuffer: vi.fn(async () => {}), generateDownloadUrl: vi.fn(async () => "https://files.example/po.pdf?sig=abc"), + statObject: vi.fn(async () => null), // default: no cached object → render }; }); import { auth } from "@/auth"; import { db } from "@/lib/db"; import { prepareVendorEmail } from "@/app/(portal)/po/[id]/email-actions"; -import { isPdfServiceConfigured } from "@/lib/pdf-service"; +import { isPdfServiceConfigured, renderPoPdf } from "@/lib/pdf-service"; +import { statObject, uploadBuffer, generateDownloadUrl } from "@/lib/storage"; import { makeSession, getSeedUser, getSeedVessel, getSeedAccount } from "./helpers"; const PREFIX = "INTTEST_EMAILVENDOR_"; @@ -98,6 +100,34 @@ describe("prepareVendorEmail", () => { expect(decodeURIComponent(result.mailto)).toContain("https://files.example/po.pdf?sig=abc"); }); + it("reuses the cached PDF on a second send and only refreshes the link (7-day timer)", async () => { + as(techId, "TECHNICAL"); + const poId = await makePo("MGR_APPROVED", vendorWithEmailId); + + // 1st send: no cached object → render + upload once. + vi.mocked(statObject).mockResolvedValueOnce(null); + expect("ok" in (await prepareVendorEmail(poId))).toBe(true); + expect(vi.mocked(renderPoPdf)).toHaveBeenCalledTimes(1); + expect(vi.mocked(uploadBuffer)).toHaveBeenCalledTimes(1); + + // 2nd send: a cached object newer than the PO → reuse, no re-render, fresh link. + vi.mocked(statObject).mockResolvedValueOnce({ lastModified: new Date(Date.now() + 60_000) }); + expect("ok" in (await prepareVendorEmail(poId))).toBe(true); + expect(vi.mocked(renderPoPdf)).toHaveBeenCalledTimes(1); // unchanged — reused + expect(vi.mocked(uploadBuffer)).toHaveBeenCalledTimes(1); // unchanged — reused + expect(vi.mocked(generateDownloadUrl)).toHaveBeenCalledTimes(2); // re-presigned each send + }); + + it("re-renders when the PO changed since the cached copy", async () => { + as(techId, "TECHNICAL"); + const poId = await makePo("MGR_APPROVED", vendorWithEmailId); + // Cached object older than the PO's updatedAt → stale → re-render. + vi.mocked(statObject).mockResolvedValueOnce({ lastModified: new Date(0) }); + expect("ok" in (await prepareVendorEmail(poId))).toBe(true); + expect(vi.mocked(renderPoPdf)).toHaveBeenCalledTimes(1); + expect(vi.mocked(uploadBuffer)).toHaveBeenCalledTimes(1); + }); + it("is available once payment is recorded too (PARTIALLY_PAID)", async () => { as(techId, "TECHNICAL"); const poId = await makePo("PARTIALLY_PAID", vendorWithEmailId); From 7acd86e3dd80671dc82b90d6bdd5e97948062ed8 Mon Sep 17 00:00:00 2001 From: Hardik Date: Wed, 24 Jun 2026 15:20:55 +0530 Subject: [PATCH 4/9] feat(automation): watcher that addresses claude-review: comments on Claude PRs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sibling to claude-issue-watcher.sh: polls open Claude-raised PRs (head branch under claude/, or labelled claude-pr) for review comments carrying the marker `claude-review:` — in the PR conversation, review summaries, or inline on-file comments — and runs headless Claude Code on the PR's own branch to address them, pushing the follow-up commit(s) to the same branch. - Authorization gate: only repo collaborators (write access) + the owner can trigger it; the bot's own comments are ignored. - Idempotent: handled comments are tracked by a hidden marker on the bot's acknowledgements, so the 10-min poll never redoes a comment. - Own clone (~/pelagia-pr-review), config, and lock so it never races the issue watcher. Token needs write:repository + write:issue. Adds the script, an example config, .gitignore entries for the live config/lock, and an automation/README.md section with deploy + cron steps. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 4 + automation/README.md | 67 +++++ automation/claude-pr-review-watcher.sh | 277 ++++++++++++++++++ .../pr-review-watcher.config.example.json | 13 + 4 files changed, 361 insertions(+) create mode 100644 automation/claude-pr-review-watcher.sh create mode 100644 automation/pr-review-watcher.config.example.json diff --git a/.gitignore b/.gitignore index a179b25..82ba000 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,10 @@ automation/watcher.config.json automation/logs/ automation/.watcher.lock +# Claude PR-review-comment watcher (real token + lock stay local; shares logs/) +automation/pr-review-watcher.config.json +automation/.pr-review-watcher.lock + # OS .DS_Store Thumbs.db diff --git a/automation/README.md b/automation/README.md index 4b4a88e..9c19b0c 100644 --- a/automation/README.md +++ b/automation/README.md @@ -70,6 +70,7 @@ A [`PULL_REQUEST_TEMPLATE.md`](../.forgejo/PULL_REQUEST_TEMPLATE.md) carries the | Forgejo helper | `App/lib/forgejo.ts` | Needs `FORGEJO_URL`, `FORGEJO_REPO`, `FORGEJO_TOKEN` env (token scope: `write:issue`) | | Issue watcher (active) | `automation/claude-issue-watcher.sh` on pms1 | Bash port; runs 24/7 via cron. Config + logs under `~/issue-watcher/` | | Issue watcher (Windows, disabled) | `automation/claude-issue-watcher.ps1` | PowerShell original. `PelagiaClaudeIssueWatcher` task is **disabled** (pms1 is the sole worker; two pollers would race) | +| PR review-comment watcher | `automation/claude-pr-review-watcher.sh` on pms1 | Addresses `claude-review:` comments on Claude-raised PRs. Own cron entry, own clone (`~/pelagia-pr-review`), own config + lock. See below | | Forgejo helper | `App/lib/forgejo.ts` | Needs `FORGEJO_URL`, `FORGEJO_REPO`, `FORGEJO_TOKEN` env (token scope: `write:issue`) | | Deploy workflow | `.forgejo/workflows/deploy.yml` | Triggers on `v*` tags; runs on the `host` runner | | Runner | pms1 `~/forgejo-runner`, pm2 process `forgejo-runner` | Registered as `pms1-host` with labels `host`, `docker` | @@ -93,6 +94,72 @@ activates automatically once signed in. (An `ANTHROPIC_API_KEY` env var also sat The Windows variant (`.ps1` + `register-watcher-task.ps1`) is the portable fallback; re-enable its task only if pms1 is unavailable, and disable one before enabling the other. +## PR review-comment watcher + +Where the issue watcher turns *issues* into PRs, the **PR review-comment watcher** +([`automation/claude-pr-review-watcher.sh`](claude-pr-review-watcher.sh)) closes the +loop on the other side: it addresses **review comments left on the PRs Claude already +raised**. This is how you iterate on an automated PR without dropping into an +interactive session — leave a comment, Claude pushes a follow-up commit. + +**How to use it (as a reviewer):** on any open Claude-raised PR, leave a comment that +starts with the marker **`claude-review:`** — the text after the marker is the +instruction. It works in three places: + +- the **PR conversation** (a normal PR comment), +- a **review summary** (the overall body of a submitted review), +- an **inline / on-file comment** (Claude is given the file, line, and diff hunk). + +Example inline comment on `App/lib/foo.ts`: + +> `claude-review:` this should null-check `order.vendor` before dereferencing it, and add a test for the null case. + +**What the watcher does each run (every 10 min via cron):** + +1. Lists open PRs Claude raised — head branch starts with `prBranchPrefix` (`claude/`) + or the PR is labelled `claude-pr`. +2. Collects every `claude-review:` comment **from repo collaborators only** (write + access; the repo owner is always included). Comments from anyone else, and the + bot's own comments, are ignored. This is the safety gate — only trusted users can + make Claude push code. +3. Skips comments already handled in a previous run (tracked by a hidden + `` marker the bot stamps on its acknowledgements, + so a 10-minute poll never redoes the same comment). +4. Checks out the **PR's own branch** in `~/pelagia-pr-review`, runs headless Claude + Code with the collected instructions (+ the same `pelagia_test` / port-3100 test + environment the fixer uses), then pushes the new commit(s) to **the same branch** — + updating the open PR in place. +5. Acknowledges: posts a reply listing what it addressed (with the handled marker) and + adds a 🚀 reaction to each handled PR-conversation comment. + +If Claude judges a comment unclear, out of scope, or too risky to do unattended +(migrations, payments, permissions), it makes no commit for it and the watcher posts a +"produced no change — a human may need to take these" reply. The comments are still +marked handled so the poll doesn't loop on them; re-comment with a clearer +`claude-review:` instruction to retry. + +**Deploy on pms1** (mirrors the issue watcher): + +```sh +# 1. Place the script + config alongside the issue watcher +cp automation/claude-pr-review-watcher.sh ~/pr-review-watcher/ +cp automation/pr-review-watcher.config.example.json ~/pr-review-watcher/pr-review-watcher.config.json +# 2. Edit the config: real token (scope write:repository,write:issue), claudeExe = `which claude` +# 3. Add a crontab entry, OFFSET from the issue watcher so the two don't run at the same minute: +# 5,15,25,35,45,55 * * * * PATH=:$PATH ~/pr-review-watcher/claude-pr-review-watcher.sh >> ~/pr-review-watcher/logs/cron.log 2>&1 +``` + +- **Token scope:** needs `write:repository` (push to the PR branch) **plus** + `write:issue` (post comments + reactions) — one scope more than the issue watcher. +- **Own everything:** separate clone (`~/pelagia-pr-review`), config + (`pr-review-watcher.config.json`), and lock (`.pr-review-watcher.lock`) so it never + races the issue watcher. Logs land in the same `logs/` dir + (`pr-review-.log`, per-PR `claude-pr--*.log`). +- Same **auth preflight** as the issue watcher — no-ops until Claude Code is signed in + on pms1 (or `ANTHROPIC_API_KEY` is set). +- A Windows `.ps1` port is not provided yet (pms1 is the sole worker); port it from + `claude-issue-watcher.ps1` only if you need a failover. + ## Test database (for autofix verification) So the fix stage can verify against realistic data without touching production: diff --git a/automation/claude-pr-review-watcher.sh b/automation/claude-pr-review-watcher.sh new file mode 100644 index 0000000..fb31622 --- /dev/null +++ b/automation/claude-pr-review-watcher.sh @@ -0,0 +1,277 @@ +#!/usr/bin/env bash +# Claude PR-review-comment watcher -- Linux port (runs on pms1 via cron). +# +# Sibling to claude-issue-watcher.sh. Where that watcher turns *issues* into PRs, +# this one addresses *review comments* left on the PRs Claude already raised. +# +# Per run: +# 1. List open PRs that Claude raised (head branch starts with prBranchPrefix, +# or labelled `claude-pr`). +# 2. On each, collect every comment carrying the marker `claude-review:` -- +# from the PR conversation, from review summaries, and from inline (on-file) +# review comments -- but ONLY from repo collaborators (write access). +# 3. Skip comments already handled in a previous run (tracked by a hidden marker +# in the bot's acknowledgement comments). +# 4. Run headless Claude Code on the PR's own branch with those instructions; +# it edits + verifies, the watcher pushes the new commit(s) to the SAME branch +# (updating the PR in place), then acknowledges each comment (reply + reaction). +# +# Config: pr-review-watcher.config.json next to this script (or pass a path as $1). +# See automation/README.md > "PR review-comment watcher". + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CONFIG="${1:-$SCRIPT_DIR/pr-review-watcher.config.json}" +[ -f "$CONFIG" ] || { echo "Config not found: $CONFIG (copy pr-review-watcher.config.example.json and fill in the token)"; exit 1; } + +cfg() { jq -r "$1" "$CONFIG"; } +FORGEJO_URL=$(cfg .forgejoUrl) +REPO=$(cfg .repo) +TOKEN=$(cfg .token) +WORKDIR=$(cfg .workDir) +BASE_BRANCH=$(cfg .baseBranch) +PR_BRANCH_PREFIX=$(cfg '.prBranchPrefix // "claude/"') +MARKER=$(cfg '.marker // "claude-review:"') +MAX_PRS=$(cfg '.maxPrsPerRun // 1') +MAX_COMMENTS=$(cfg '.maxCommentsPerPr // 20') +CLAUDE=$(cfg .claudeExe) +TURNS=$(cfg '.claudeMaxTurns // 150') +API="$FORGEJO_URL/api/v1" + +# Hidden marker the bot stamps on its acknowledgement comments. The "handled:" +# line lists every comment key it has addressed, so subsequent runs skip them. +HANDLED_TAG='ppms-review-bot handled:' +ACK_REACTION='rocket' + +LOG_DIR="$SCRIPT_DIR/logs" +mkdir -p "$LOG_DIR" +LOG_FILE="$LOG_DIR/pr-review-$(date +%F).log" +log() { echo "$(date +%T) $*" | tee -a "$LOG_FILE"; } + +# --- single-instance lock (separate from the issue watcher's) --- +exec 9>"$SCRIPT_DIR/.pr-review-watcher.lock" +if ! flock -n 9; then log "Another PR-review watcher run is active; exiting."; exit 0; fi + +# --- preflight: idle until Claude Code is authenticated on this host --- +if [ ! -f "$HOME/.claude/.credentials.json" ] && [ -z "${ANTHROPIC_API_KEY:-}" ]; then + log "Claude Code not authenticated yet (no ~/.claude/.credentials.json or ANTHROPIC_API_KEY); skipping." + exit 0 +fi + +# --- Forgejo API helpers (curl + jq) --- +api() { # METHOD PATH [JSON_BODY] + local method=$1 path=$2 body=${3:-} + if [ -n "$body" ]; then + curl -fsS -X "$method" "$API$path" -H "Authorization: token $TOKEN" \ + -H "Content-Type: application/json" --data "$body" + else + curl -fsS -X "$method" "$API$path" -H "Authorization: token $TOKEN" + fi +} +# Soft variant: never aborts the run on a single failed call (e.g. reactions +# unsupported on a given comment type). Returns empty + logs instead. +api_soft() { api "$@" 2>/dev/null || { log "api_soft: $1 $2 failed (ignored)"; printf ''; }; } + +add_pr_comment() { # NUMBER TEXT + api POST "/repos/$REPO/issues/$1/comments" "$(jq -nc --arg b "$2" '{body:$b}')" >/dev/null +} +react() { # COMMENT_ID (PR-conversation comments only; best-effort) + api_soft POST "/repos/$REPO/issues/comments/$1/reactions" \ + "$(jq -nc --arg c "$ACK_REACTION" '{content:$c}')" >/dev/null +} + +# --- prepare the dedicated work clone --- +host_no_scheme=$(printf '%s' "$FORGEJO_URL" | sed 's#^https\?://##') +owner=${REPO%%/*} +CLONE_URL="http://${owner}:${TOKEN}@${host_no_scheme}/${REPO}.git" +[ "${FORGEJO_URL#https}" != "$FORGEJO_URL" ] && CLONE_URL="https://${owner}:${TOKEN}@${host_no_scheme}/${REPO}.git" + +if [ ! -d "$WORKDIR/.git" ]; then + log "Cloning $REPO into $WORKDIR" + if ! git clone -q "$CLONE_URL" "$WORKDIR"; then log "git clone failed"; exit 1; fi + git -C "$WORKDIR" config user.name "Claude (review-bot)" + git -C "$WORKDIR" config user.email "claude-autofix@pelagiamarine.com" +fi + +# --- identity + authorization set --- +# The bot's own login (so its acknowledgements are never treated as instructions). +BOT_LOGIN=$(api GET "/user" | jq -r '.login // ""') +# Collaborators = users with write access. The repo owner is always allowed. +COLLAB=$(api GET "/repos/$REPO/collaborators?limit=100" \ + | jq -c --arg owner "$owner" '[.[].login] + [$owner] | unique') +log "Authorized commenters: $(printf '%s' "$COLLAB" | jq -r 'join(", ")') (bot=$BOT_LOGIN)" + +# --- find Claude-raised open PRs (head branch under the prefix, or labelled claude-pr) --- +prs=$(api GET "/repos/$REPO/pulls?state=open&limit=50" \ + | jq -c --arg pfx "$PR_BRANCH_PREFIX" \ + '[ .[] | select((.head.ref | startswith($pfx)) or (((.labels//[])|map(.name))|index("claude-pr"))) ] | sort_by(.number)') +prs=$(printf '%s' "$prs" | jq -c ".[:$MAX_PRS]") +n_prs=$(printf '%s' "$prs" | jq 'length') +log "Found $n_prs Claude-raised open PR(s) to scan for '$MARKER' comments" + +# Pull the instruction text that follows the marker out of a comment body. +instr_of() { # BODY -> text after the first marker occurrence, trimmed + jq -rn --arg b "$1" --arg m "$MARKER" \ + '$b | split($m) | .[1:] | join($m) | gsub("^\\s+|\\s+$";"")' +} + +p=0 +while [ "$p" -lt "$n_prs" ]; do + pr=$(printf '%s' "$prs" | jq -c ".[$p]") + p=$((p+1)) + num=$(printf '%s' "$pr" | jq -r .number) + title=$(printf '%s' "$pr" | jq -r .title) + branch=$(printf '%s' "$pr" | jq -r .head.ref) + log "-- PR #$num ($branch): $title" + + # ---- gather candidate comments from the three sources ---- + conv=$(api GET "/repos/$REPO/issues/$num/comments?limit=100") + reviews=$(api GET "/repos/$REPO/pulls/$num/reviews?limit=100") + + # Keys already addressed in a prior run (scanned from the bot's ack comments). + handled=$(printf '%s' "$conv" | jq -c --arg tag "$HANDLED_TAG" \ + '[ .[].body // "" | select(contains($tag)) | scan("(?:conv|summary|inline):[0-9]+") ] | unique') + + sel='select(.body != null) | select(.body | contains($m)) + | select(.user.login as $u | ($collab | index($u))) + | select(.user.login != $bot)' + + conv_tasks=$(printf '%s' "$conv" | jq -c --arg m "$MARKER" --argjson collab "$COLLAB" --arg bot "$BOT_LOGIN" " + [ .[] | $sel | { key:(\"conv:\"+(.id|tostring)), kind:\"conv\", id:.id, user:.user.login, + loc:\"PR conversation\", body:.body } ]") + + summary_tasks=$(printf '%s' "$reviews" | jq -c --arg m "$MARKER" --argjson collab "$COLLAB" --arg bot "$BOT_LOGIN" " + [ .[] | select(.body != \"\") | $sel + | { key:(\"summary:\"+(.id|tostring)), kind:\"summary\", id:.id, user:.user.login, + loc:\"review summary\", body:.body } ]") + + # Inline (on-file) comments live under each review. + inline_tasks='[]' + for rid in $(printf '%s' "$reviews" | jq -r '.[].id'); do + rc=$(api_soft GET "/repos/$REPO/pulls/$num/reviews/$rid/comments") + [ -z "$rc" ] && continue + t=$(printf '%s' "$rc" | jq -c --arg m "$MARKER" --argjson collab "$COLLAB" --arg bot "$BOT_LOGIN" " + [ .[] | $sel + | { key:(\"inline:\"+(.id|tostring)), kind:\"inline\", id:.id, user:.user.login, + loc:(\"inline \"+(.path//\"?\")+\":\"+((.line // .original_line // 0)|tostring)), + hunk:(.diff_hunk // \"\"), body:.body } ]") + inline_tasks=$(jq -nc --argjson a "$inline_tasks" --argjson b "$t" '$a + $b') + done + + all=$(jq -nc --argjson a "$conv_tasks" --argjson b "$summary_tasks" --argjson c "$inline_tasks" '$a + $b + $c') + fresh=$(printf '%s' "$all" | jq -c --argjson h "$handled" '[ .[] | select(.key as $k | ($h|index($k)) | not) ]') + fresh=$(printf '%s' "$fresh" | jq -c ".[:$MAX_COMMENTS]") + n=$(printf '%s' "$fresh" | jq 'length') + if [ "$n" -eq 0 ]; then log " no new '$MARKER' comments"; continue; fi + log " $n new '$MARKER' comment(s) to address" + + # ---- check out the PR branch in the work clone ---- + git -C "$WORKDIR" fetch origin -q + if ! git -C "$WORKDIR" checkout -B "$branch" "origin/$branch" -q 2>>"$LOG_FILE"; then + log " checkout of origin/$branch failed; skipping PR #$num"; continue + fi + git -C "$WORKDIR" clean -fdq + + # ---- build the prompt ---- + keys=$(printf '%s' "$fresh" | jq -r '[.[].key] | join(" ")') + prompt_file=$(mktemp) + { + printf '%s\n' "You are addressing REVIEW COMMENTS on PR #$num of the Pelagia Portal (PPMS), a Next.js 15" + printf '%s\n' "purchase-order management system. The web app lives in App/ -- read App/CLAUDE.md first." + printf '%s\n' "You are already checked out on the PR branch '$branch'. Inspect what the PR changed with:" + printf '%s\n' " git -C . log --oneline origin/$BASE_BRANCH..HEAD && git diff origin/$BASE_BRANCH...HEAD" + printf '\n## PR #%s: %s\n\n' "$num" "$title" + printf '%s\n\n' "## Review comments to address (each begins with '$MARKER')" + i=0 + while [ "$i" -lt "$n" ]; do + item=$(printf '%s' "$fresh" | jq -c ".[$i]") + i=$((i+1)) + u=$(printf '%s' "$item" | jq -r .user) + loc=$(printf '%s' "$item" | jq -r .loc) + body=$(printf '%s' "$item" | jq -r .body) + hunk=$(printf '%s' "$item" | jq -r '.hunk // ""') + instr=$(instr_of "$body") + printf '### Comment %s -- %s (by %s)\n' "$i" "$loc" "$u" + if [ -n "$hunk" ] && [ "$hunk" != "null" ]; then + printf 'Code under review:\n```\n%s\n```\n' "$hunk" + fi + printf 'Instruction: %s\n\n' "$instr" + done + printf '%s\n' "## Test environment available to you" + printf '%s\n' "- App/.env points DATABASE_URL at a TEST database (pelagia_test) -- a daily mirror of" + printf '%s\n' " production, safe to read and write. It is NOT production. Email is console-logged and" + printf '%s\n' " storage is local in this dev mode." + printf '%s\n' "- Run integration tests after loading the env:" + printf '%s\n' " cd App && set -a && . ./.env && set +a && pnpm test:integration" + printf '%s\n' "- If you need runtime verification you MAY start a dev server ON PORT 3100 ONLY:" + printf '%s\n' " cd App && pnpm dev -p 3100 (production runs on 3000 -- NEVER touch 3000)" + printf '%s\n' " Stop ONLY your own server by port ('fuser -k 3100/tcp'); NEVER a broad 'pkill -f next'." + printf '%s\n' "" + printf '%s\n' "## Your job (PR policy: every code change ships with tests + docs)" + printf '%s\n' "1. Make the focused changes the review comments ask for -- nothing more." + printf '%s\n' "2. If you change code under App/app|lib|components|hooks, add or update a test (the PR check" + printf '%s\n' " rejects code changes with no test change). Model integration tests on" + printf '%s\n' " App/tests/integration/dashboard-approved-this-month.test.ts." + printf '%s\n' "3. Verify: 'cd App && pnpm type-check' (no new errors); run relevant tests." + printf '%s\n' "4. Update any docs the change affects (App/README.md, App/CLAUDE.md, Docs/, CHANGELOG.md)." + printf '%s\n' "5. Commit ALL changes to the current branch with a conventional message referencing #$num." + printf '%s\n' "6. Do NOT push, do NOT switch branches, do NOT open/close PRs. The supervisor pushes." + printf '%s\n' "If a comment is unclear, out of scope, or too risky to do unattended (migrations, payments," + printf '%s\n' "permissions), make NO commit for it and explain why in CLAUDE_RESULT.md in the repo root." + } > "$prompt_file" + + clog="$LOG_DIR/claude-pr-$num-$(date +%Y%m%d-%H%M%S).log" + log " Running Claude on PR #$num (log: $clog)" + ( cd "$WORKDIR" && "$CLAUDE" -p --dangerously-skip-permissions \ + --max-turns "$TURNS" --output-format text < "$prompt_file" > "$clog" 2>&1 ); rc=$? + log " Claude exited with code $rc for PR #$num" + rm -f "$prompt_file" + + note="" + if [ -f "$WORKDIR/CLAUDE_RESULT.md" ]; then + note=$(cat "$WORKDIR/CLAUDE_RESULT.md") + rm -f "$WORKDIR/CLAUDE_RESULT.md" + git -C "$WORKDIR" checkout -- . 2>/dev/null + fi + + # ---- build the acknowledgement (lists handled keys + quotes each comment) ---- + ack_items=$(printf '%s' "$fresh" | jq -r '.[] | "- **\(.loc)** (by \(.user))"') + + commits=$(git -C "$WORKDIR" rev-list "origin/$branch..HEAD" --count 2>/dev/null || echo 0) + if [ "${commits:-0}" -gt 0 ]; then + log " Claude made $commits commit(s); pushing to $branch" + if ! git -C "$WORKDIR" push -u origin "$branch" -q 2>>"$LOG_FILE"; then + log " push failed for PR #$num" + add_pr_comment "$num" "[Claude review-bot] Addressed the review comments locally but the push to \`$branch\` failed. See watcher logs on pms1: \`$clog\`. +" + continue + fi + body="[Claude review-bot] Addressed the following review comment(s) on \`$branch\` ($commits commit(s) pushed): + +$ack_items +${note:+ +Notes: +$note +} +" + add_pr_comment "$num" "$body" + # Best-effort reaction on PR-conversation comments (reactions API is keyed + # to issue comments; inline/summary review comments are tracked by the marker). + for cid in $(printf '%s' "$fresh" | jq -r '.[] | select(.kind=="conv") | .id'); do react "$cid"; done + log " PR #$num updated and acknowledged" + else + log " No commits produced for PR #$num" + reason=${note:-"Claude did not produce a change. See watcher logs on pms1: \`$clog\`."} + add_pr_comment "$num" "[Claude review-bot] Reviewed the marked comment(s) but produced no change: + +$reason + +A human may need to take these: +$ack_items +" + log " PR #$num: no change, acknowledged (marked handled to avoid re-running)" + fi +done + +log "PR-review watcher run complete." diff --git a/automation/pr-review-watcher.config.example.json b/automation/pr-review-watcher.config.example.json new file mode 100644 index 0000000..8709972 --- /dev/null +++ b/automation/pr-review-watcher.config.example.json @@ -0,0 +1,13 @@ +{ + "forgejoUrl": "https://git.pelagiamarine.com", + "repo": "shad0w/pelagia-portal", + "token": "", + "workDir": "/home/shad0w/pelagia-pr-review", + "baseBranch": "master", + "prBranchPrefix": "claude/", + "marker": "claude-review:", + "maxPrsPerRun": 1, + "maxCommentsPerPr": 20, + "claudeExe": "/home/shad0w/.nvm/versions/node//bin/claude", + "claudeMaxTurns": 150 +} From e1907e6aec8444e5a759b1436b2e3c7bf2317dc8 Mon Sep 17 00:00:00 2001 From: Hardik Date: Wed, 24 Jun 2026 15:48:48 +0530 Subject: [PATCH 5/9] fix(automation): scan all Claude PRs for review comments; drop author-based bot filter Two issues surfaced on first live run: - The per-run cap truncated the PR list to the lowest-numbered PR, so a comment on a higher-numbered PR was never scanned. The cap now limits only how many PRs Claude RUNS on; comment-less PRs are skipped for free, so newer PRs are never crowded out. - The bot posts as the repo owner's account, so excluding "the bot's own comments" by author also excluded the owner's legitimate review comments (and required a read:user token scope, which 403'd). Replaced with a guard that excludes only comments carrying the HANDLED_TAG marker -- robust even when the bot and the reviewer are the same account. The /user call is gone. Co-Authored-By: Claude Opus 4.8 --- automation/claude-pr-review-watcher.sh | 34 +++++++++++++++++--------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/automation/claude-pr-review-watcher.sh b/automation/claude-pr-review-watcher.sh index fb31622..cfe11cf 100644 --- a/automation/claude-pr-review-watcher.sh +++ b/automation/claude-pr-review-watcher.sh @@ -94,21 +94,23 @@ if [ ! -d "$WORKDIR/.git" ]; then git -C "$WORKDIR" config user.email "claude-autofix@pelagiamarine.com" fi -# --- identity + authorization set --- -# The bot's own login (so its acknowledgements are never treated as instructions). -BOT_LOGIN=$(api GET "/user" | jq -r '.login // ""') +# --- authorization set --- # Collaborators = users with write access. The repo owner is always allowed. +# (The bot may post as the owner's account, so we never filter by author to spot +# the bot's own comments -- its acknowledgements are excluded by the HANDLED_TAG +# marker instead, and human acks lack the claude-review: marker anyway.) COLLAB=$(api GET "/repos/$REPO/collaborators?limit=100" \ | jq -c --arg owner "$owner" '[.[].login] + [$owner] | unique') -log "Authorized commenters: $(printf '%s' "$COLLAB" | jq -r 'join(", ")') (bot=$BOT_LOGIN)" +log "Authorized commenters: $(printf '%s' "$COLLAB" | jq -r 'join(", ")')" # --- find Claude-raised open PRs (head branch under the prefix, or labelled claude-pr) --- prs=$(api GET "/repos/$REPO/pulls?state=open&limit=50" \ | jq -c --arg pfx "$PR_BRANCH_PREFIX" \ '[ .[] | select((.head.ref | startswith($pfx)) or (((.labels//[])|map(.name))|index("claude-pr"))) ] | sort_by(.number)') -prs=$(printf '%s' "$prs" | jq -c ".[:$MAX_PRS]") +# Scan ALL matching PRs (not truncated) -- the per-run cap below limits only how +# many PRs Claude actually RUNS on, so comment-less PRs never crowd out newer ones. n_prs=$(printf '%s' "$prs" | jq 'length') -log "Found $n_prs Claude-raised open PR(s) to scan for '$MARKER' comments" +log "Found $n_prs Claude-raised open PR(s) to scan for '$MARKER' comments (will run Claude on up to $MAX_PRS with new comments)" # Pull the instruction text that follows the marker out of a comment body. instr_of() { # BODY -> text after the first marker occurrence, trimmed @@ -117,6 +119,7 @@ instr_of() { # BODY -> text after the first marker occurrence, trimmed } p=0 +processed=0 while [ "$p" -lt "$n_prs" ]; do pr=$(printf '%s' "$prs" | jq -c ".[$p]") p=$((p+1)) @@ -133,15 +136,17 @@ while [ "$p" -lt "$n_prs" ]; do handled=$(printf '%s' "$conv" | jq -c --arg tag "$HANDLED_TAG" \ '[ .[].body // "" | select(contains($tag)) | scan("(?:conv|summary|inline):[0-9]+") ] | unique') + # A candidate must carry the marker, NOT be one of the bot's own ack comments + # (those carry HANDLED_TAG), and come from an authorized (collaborator) user. sel='select(.body != null) | select(.body | contains($m)) - | select(.user.login as $u | ($collab | index($u))) - | select(.user.login != $bot)' + | select(.body | contains($tag) | not) + | select(.user.login as $u | ($collab | index($u)))' - conv_tasks=$(printf '%s' "$conv" | jq -c --arg m "$MARKER" --argjson collab "$COLLAB" --arg bot "$BOT_LOGIN" " + conv_tasks=$(printf '%s' "$conv" | jq -c --arg m "$MARKER" --arg tag "$HANDLED_TAG" --argjson collab "$COLLAB" " [ .[] | $sel | { key:(\"conv:\"+(.id|tostring)), kind:\"conv\", id:.id, user:.user.login, loc:\"PR conversation\", body:.body } ]") - summary_tasks=$(printf '%s' "$reviews" | jq -c --arg m "$MARKER" --argjson collab "$COLLAB" --arg bot "$BOT_LOGIN" " + summary_tasks=$(printf '%s' "$reviews" | jq -c --arg m "$MARKER" --arg tag "$HANDLED_TAG" --argjson collab "$COLLAB" " [ .[] | select(.body != \"\") | $sel | { key:(\"summary:\"+(.id|tostring)), kind:\"summary\", id:.id, user:.user.login, loc:\"review summary\", body:.body } ]") @@ -151,7 +156,7 @@ while [ "$p" -lt "$n_prs" ]; do for rid in $(printf '%s' "$reviews" | jq -r '.[].id'); do rc=$(api_soft GET "/repos/$REPO/pulls/$num/reviews/$rid/comments") [ -z "$rc" ] && continue - t=$(printf '%s' "$rc" | jq -c --arg m "$MARKER" --argjson collab "$COLLAB" --arg bot "$BOT_LOGIN" " + t=$(printf '%s' "$rc" | jq -c --arg m "$MARKER" --arg tag "$HANDLED_TAG" --argjson collab "$COLLAB" " [ .[] | $sel | { key:(\"inline:\"+(.id|tostring)), kind:\"inline\", id:.id, user:.user.login, loc:(\"inline \"+(.path//\"?\")+\":\"+((.line // .original_line // 0)|tostring)), @@ -164,7 +169,12 @@ while [ "$p" -lt "$n_prs" ]; do fresh=$(printf '%s' "$fresh" | jq -c ".[:$MAX_COMMENTS]") n=$(printf '%s' "$fresh" | jq 'length') if [ "$n" -eq 0 ]; then log " no new '$MARKER' comments"; continue; fi - log " $n new '$MARKER' comment(s) to address" + if [ "$processed" -ge "$MAX_PRS" ]; then + log " $n new '$MARKER' comment(s) but per-run cap ($MAX_PRS) reached; deferring PR #$num to next run" + continue + fi + processed=$((processed+1)) + log " $n new '$MARKER' comment(s) to address (PR $processed/$MAX_PRS this run)" # ---- check out the PR branch in the work clone ---- git -C "$WORKDIR" fetch origin -q From 8b56c79f5f361061e47dad7facbd3a2b6a89539d Mon Sep 17 00:00:00 2001 From: Hardik Date: Wed, 24 Jun 2026 15:53:05 +0530 Subject: [PATCH 6/9] docs: changelog + PdfService README for reports, email-to-vendor, microservices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The repo CHANGELOG had fallen behind. Adds [Unreleased] entries for the recently shipped work and a README for the previously undocumented PdfService. Added (changelog): - Reports — Purchasing spend analytics (cost centres + accounting codes). - Email PO to vendor + the PdfService microservice (cached per PO). - EpfoService + PdfService microservices and their release auto-deploy. - Unsaved-changes prompt on PO create/edit (#18). - Crew login-on-hire for management ranks. - Delivery Locations (#19), T&C catalogue (#11), advance payment (#92). Fixed (changelog): - "Email to vendor" never rendered (auth middleware bounced the svc fetch) — #127. - Reports charts all one colour (RSC client/server boundary) — #120. New: PdfService/README.md (endpoints, token/origin security, env, app integration). Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 10 ++++++++ PdfService/README.md | 54 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 PdfService/README.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 855b5f8..ad32504 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ ### Added +- **Reports — Purchasing spend analytics** (`view_analytics`: Manager / SuperUser / Auditor / Admin) — `/reports/cost-centres` and `/reports/accounting-codes`, each an index → drill-down → detail. KPI tiles, comparison + trend charts (one colour per item), Top-N tables, per-row sparklines, and CSV export; URL-driven filters (granularity Weekly / Monthly / Yearly, financial year, Top/Bottom-N, an "Add to graph" custom comparison). Spend = post-approval POs by `approvedAt`/`totalAmount`, allocated across each PO's line-item accounting codes. Pure, unit-tested core in `lib/reports.ts`. +- **Email PO to vendor** (issue #14) — one-click Outlook draft to the vendor's primary contact with a **7-day download link** to the PO PDF. Rendered by the new **PdfService** microservice (Express + Playwright → headless Chromium) and stored in R2; the PDF is **cached per PO**, so repeat sends reuse the copy and only refresh the link. +- **Microservices** — `EpfoService` (UAN / EPFO assisted-lookup proxy; live portal nav stubbed behind `EPFO_LIVE`) and `PdfService` (PO → PDF) join `GstService`. All three are **auto-deployed on each release tag** via the root `ecosystem.config.js` + `deploy.yml` (`pm2 startOrReload … --update-env`). +- **Unsaved-changes prompt** (issue #18) — leaving the PO create/edit screen with unsaved edits offers **Save as draft / Discard / Stay** (in-app navigation) or the browser's native warning (refresh / close). +- **Crew login on hire** (crewing, feature-flagged) — onboarding, direct placement, and admin crew-create accept an explicit **login email + initial password** for management ranks (`Rank.grantsLogin`), creating the `SITE_STAFF` login in one step. +- **Delivery Locations** (issue #19) — admin-managed `Company`+address list backing the PO "Place of Delivery" dropdown, gated by `manage_delivery_locations` (Manager / SuperUser / Admin). +- **Terms & Conditions catalogue** (issue #11) — admin-managed, user-defined T&C categories + clauses feeding a dynamic PO terms editor; the chosen rows are a JSON snapshot on `PurchaseOrder.terms`. +- **Advance payment on approval** (issue #92) — the approving Manager sets how much is paid first; the resolved absolute amount is stored on `PurchaseOrder.suggestedAdvancePayment` and prefills the first Accounts payment. - **Companies (multi-company invoicing)** — new `Company` model and `/admin/companies` CRUD. A PO is billed under a selected company (name, short `code`, GST number, address, phone/mobile, contact + invoice email, invoice address). The company's details populate the exported PO header / invoice block. - **Structured PO numbers** (`lib/po-number.ts`) — `COMPANY/VESSEL/ID/FY` (e.g. `PMS/HNR1/9000/2024-25`); Indian financial year; system-generated IDs start at 9000. Imported POs keep their original number. - **3-level accounting-code hierarchy** — `Account.parentId` self-relation (Top Category → Sub-Category → Leaf), 6-digit numeric codes seeded from `prisma/accounting-codes-data.ts`. Only leaf codes are PO-selectable, via a searchable, portal-rendered combobox. @@ -29,4 +37,6 @@ ### Fixed +- **"Email to vendor" never rendered a real PDF** (issue #14) — the auth middleware redirected PdfService's unauthenticated `svc`-token export fetch to `/login` before the route's token check ran, so the bypass never executed. `/api/po//export` is now allowed through when its `svc` token matches `PDF_SERVICE_TOKEN` (`lib/pdf-export-auth.ts`); everything else stays auth-gated. +- **Reports comparison charts all rendered one colour** — `SERIES_COLORS` lived in a `"use client"` module and was imported by the server-component report pages, where a plain value becomes a client-reference proxy (so `SERIES_COLORS[i]` was `undefined` and recharts fell back to its default stroke). Moved the palette to a dependency-free shared module (`lib/report-colors.ts`). - Production `P2022 … column does not exist` after deploy — caused by shipping code whose Prisma client expected a column before `migrate deploy` had run. Migrations must be applied before the new build serves traffic (now documented in the README). diff --git a/PdfService/README.md b/PdfService/README.md new file mode 100644 index 0000000..0c91fb4 --- /dev/null +++ b/PdfService/README.md @@ -0,0 +1,54 @@ +# PdfService + +Renders a PPMS purchase order to a real **PDF** for the **"Email PO to vendor"** +feature — a standalone **Express + Playwright** microservice, mirroring +`GstService` / `EpfoService`. + +The app's `/api/po/:id/export?format=pdf&pdf=1` produces a print-styled HTML page; +PdfService loads that URL in **headless Chromium** and prints it to an A4 PDF. The +export URL carries a short-lived **`svc` token** so the export route serves the +page without a user session (the app's auth middleware allows that one route +through when the token matches — see `App/lib/pdf-export-auth.ts`). + +## Endpoints + +| Method | Path | Body / Headers | Returns | +|---|---|---|---| +| GET | `/health` | — | `{ status, browser }` | +| POST | `/pdf` | `{ url }` + header `x-pdf-token` | `application/pdf` (else `401` / `400` / `403` / `502`) | + +## Security + +- **Token** — when `PDF_SERVICE_TOKEN` is set, `/pdf` requires a matching + `x-pdf-token` header (the app and PdfService share the secret). +- **Origin allow-list (anti-SSRF)** — when `ALLOWED_ORIGIN` is set, PdfService + only navigates to URLs whose origin matches it. +- Both unset (dev) → checks are skipped. + +## Env + +``` +PORT=3005 +PDF_SERVICE_TOKEN= # shared secret with the app (app side: PDF_SERVICE_TOKEN) +ALLOWED_ORIGIN= # e.g. http://localhost:3000 (optional) +``` + +## Run + +``` +npm install +npm run dev # tsx watch src/index.ts +npm run build && npm start # node dist/index.js +``` + +## App integration + +`App/lib/pdf-service.ts` (`renderPoPdf`) POSTs `{ url }` to `/pdf`. The app gates +the feature on `PDF_SERVICE_URL` + `PDF_SERVICE_TOKEN` (`isPdfServiceConfigured()`), +uploads the returned PDF to R2 at a **per-PO key** (reused across sends), and +returns a `mailto:` with a 7-day presigned link. `APP_INTERNAL_URL` is the base URL +PdfService reaches the app at (falls back to `NEXTAUTH_URL`). + +On **pms1** the service is auto-deployed on each release tag via the root +`ecosystem.config.js` (pm2 `pdf-service`, port 3005) — see +[Deployment and Operations](https://git.pelagiamarine.com/shad0w/pelagia-portal/wiki/Deployment-and-Operations#microservices). From 6e5b42932bc330872626c55e9992105245780cfe Mon Sep 17 00:00:00 2001 From: Hardik Date: Wed, 24 Jun 2026 16:01:28 +0530 Subject: [PATCH 7/9] feat(automation): one-command updater for the deployed PR-review watcher update-pr-review-watcher.sh refreshes the deployed script from the repo via a dedicated self-update checkout (~/pr-review-watcher/.src) that never races the issue watcher's work clone. Reads the live config for auth, never clobbers the config (real token), and self-updates. Optional ref arg for pre-merge testing. Co-Authored-By: Claude Opus 4.8 --- automation/README.md | 12 ++++++++ automation/update-pr-review-watcher.sh | 40 ++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 automation/update-pr-review-watcher.sh diff --git a/automation/README.md b/automation/README.md index 9c19b0c..da8ef8d 100644 --- a/automation/README.md +++ b/automation/README.md @@ -160,6 +160,18 @@ cp automation/pr-review-watcher.config.example.json ~/pr-review-watcher/pr-revie - A Windows `.ps1` port is not provided yet (pms1 is the sole worker); port it from `claude-issue-watcher.ps1` only if you need a failover. +**Updating the deployed copy:** `update-pr-review-watcher.sh` refreshes the watcher +script in one command, from a dedicated self-update checkout (`~/pr-review-watcher/.src`) +that never races the issue watcher's clone. Copy it once, then: + +```sh +cp automation/update-pr-review-watcher.sh ~/pr-review-watcher/ # one-time +~/pr-review-watcher/update-pr-review-watcher.sh # pull from master +~/pr-review-watcher/update-pr-review-watcher.sh some/branch # or a branch (pre-merge testing) +``` + +It reads the live config for the token/URL, never clobbers the config, and self-updates. + ## Test database (for autofix verification) So the fix stage can verify against realistic data without touching production: diff --git a/automation/update-pr-review-watcher.sh b/automation/update-pr-review-watcher.sh new file mode 100644 index 0000000..99ac74e --- /dev/null +++ b/automation/update-pr-review-watcher.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Refresh the deployed PR-review watcher from the repo, in one command. +# +# ~/pr-review-watcher/update-pr-review-watcher.sh # from master (default) +# ~/pr-review-watcher/update-pr-review-watcher.sh some/branch # from a branch (pre-merge testing) +# +# Pulls the latest script into a dedicated self-update checkout (~/pr-review-watcher/.src), +# separate from any work clone so it never races the issue watcher, then copies the +# watcher (and this updater) into place. NEVER touches the live config (real token). + +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CONFIG="$HERE/pr-review-watcher.config.json" +[ -f "$CONFIG" ] || { echo "Config not found: $CONFIG (deploy the watcher first)"; exit 1; } +REF="${1:-master}" +SRC="$HERE/.src" + +cfg() { jq -r "$1" "$CONFIG"; } +URL=$(cfg .forgejoUrl); REPO=$(cfg .repo); TOKEN=$(cfg .token) +host=${URL#*://}; scheme=${URL%%://*}; owner=${REPO%%/*} +CLONE="${scheme}://${owner}:${TOKEN}@${host}/${REPO}.git" + +if [ ! -d "$SRC/.git" ]; then + echo "First run: cloning $REPO into $SRC" + git clone -q "$CLONE" "$SRC" +fi +git -C "$SRC" remote set-url origin "$CLONE" # keep the token fresh if it was rotated +git -C "$SRC" fetch origin -q --prune +# Prefer the remote-tracking ref; fall back to a literal ref (tag) if not a branch. +git -C "$SRC" checkout -f -q "origin/$REF" 2>/dev/null || git -C "$SRC" checkout -f -q "$REF" +git -C "$SRC" clean -fdq + +cp "$SRC/automation/claude-pr-review-watcher.sh" "$HERE/" +cp "$SRC/automation/update-pr-review-watcher.sh" "$HERE/" 2>/dev/null || true # self-update +# Seed the config from the example ONLY if missing -- never clobber the real token. +[ -f "$CONFIG" ] || cp "$SRC/automation/pr-review-watcher.config.example.json" "$CONFIG" + +echo "Updated from '$REF' ($(git -C "$SRC" rev-parse --short HEAD)). Watcher script is current." +echo "Dry-run: $HERE/claude-pr-review-watcher.sh $CONFIG" From 0814b040e11c4b1e26e22702f7ad8bc2c29eea82 Mon Sep 17 00:00:00 2001 From: Hardik Date: Wed, 24 Jun 2026 16:27:22 +0530 Subject: [PATCH 8/9] fix(automation): bound + detach the Claude run so a hang can't wedge or stick First live run on PR #126 left the foreground shell stuck: Claude pushed the commit itself and then did not exit, so the supervisor code after it (push + ack + handled marker) never ran. Under cron that also holds the flock lock, freezing every later run, and the comment never gets marked handled (so it would be re-processed forever). - Wrap the Claude invocation in `setsid timeout -k 30s "$CLAUDE_TIMEOUT"` (default 30m). `setsid` detaches from the controlling terminal so a lingering child can't stick an interactive run; `timeout` returns control to the supervisor, which still pushes any commits (idempotent if Claude pushed) and writes the handled marker. A timed-out run (rc=124) is logged, not fatal. - Give this watcher its own dev port (devPort, default 3101) distinct from the issue watcher's 3100, and reap it after each run -- no cross-watcher kill. - Reinforce the prompt: stop any dev server and END THE TURN; never push. Adds claudeTimeout + devPort to the example config and documents both. Co-Authored-By: Claude Opus 4.8 --- automation/README.md | 7 ++++ automation/claude-pr-review-watcher.sh | 33 +++++++++++++++---- .../pr-review-watcher.config.example.json | 4 ++- 3 files changed, 37 insertions(+), 7 deletions(-) diff --git a/automation/README.md b/automation/README.md index da8ef8d..6e09b9d 100644 --- a/automation/README.md +++ b/automation/README.md @@ -157,6 +157,13 @@ cp automation/pr-review-watcher.config.example.json ~/pr-review-watcher/pr-revie (`pr-review-.log`, per-PR `claude-pr--*.log`). - Same **auth preflight** as the issue watcher — no-ops until Claude Code is signed in on pms1 (or `ANTHROPIC_API_KEY` is set). +- **Bounded + detached run:** each Claude invocation is wrapped in `setsid timeout` + (`claudeTimeout`, default `30m`). `setsid` detaches it from any terminal (so a manual + run can't leave your shell stuck on a lingering child); `timeout` guarantees control + returns to the supervisor — so even a stuck/misbehaving run still gets its commits + pushed and its `handled:` marker written, and can never wedge the flock lock for later + cron runs. Runtime checks use **port `3101`** (`devPort`), distinct from the issue + watcher's `3100`, and the watcher reaps that port after every run. - A Windows `.ps1` port is not provided yet (pms1 is the sole worker); port it from `claude-issue-watcher.ps1` only if you need a failover. diff --git a/automation/claude-pr-review-watcher.sh b/automation/claude-pr-review-watcher.sh index cfe11cf..a76ebb0 100644 --- a/automation/claude-pr-review-watcher.sh +++ b/automation/claude-pr-review-watcher.sh @@ -37,6 +37,15 @@ MAX_PRS=$(cfg '.maxPrsPerRun // 1') MAX_COMMENTS=$(cfg '.maxCommentsPerPr // 20') CLAUDE=$(cfg .claudeExe) TURNS=$(cfg '.claudeMaxTurns // 150') +# Hard wall-clock cap on a single Claude run. --max-turns bounds turns but a single +# stuck turn (or a server Claude spawned) can still block forever -- which under cron +# would hold the flock lock and freeze every later run. `timeout` guarantees control +# returns to the supervisor so it can still push partial work + write the handled marker. +CLAUDE_TIMEOUT=$(cfg '.claudeTimeout // "30m"') +# Ephemeral dev-server port for Claude's runtime checks. DISTINCT from the issue +# watcher's 3100 so the two never collide if their cron runs overlap (3000=prod, +# 3100=autofix/issue-watcher, 3200=staging, 3101=this watcher). +DEV_PORT=$(cfg '.devPort // 3101') API="$FORGEJO_URL/api/v1" # Hidden marker the bot stamps on its acknowledgement comments. The "handled:" @@ -214,9 +223,9 @@ while [ "$p" -lt "$n_prs" ]; do printf '%s\n' " storage is local in this dev mode." printf '%s\n' "- Run integration tests after loading the env:" printf '%s\n' " cd App && set -a && . ./.env && set +a && pnpm test:integration" - printf '%s\n' "- If you need runtime verification you MAY start a dev server ON PORT 3100 ONLY:" - printf '%s\n' " cd App && pnpm dev -p 3100 (production runs on 3000 -- NEVER touch 3000)" - printf '%s\n' " Stop ONLY your own server by port ('fuser -k 3100/tcp'); NEVER a broad 'pkill -f next'." + printf '%s\n' "- If you need runtime verification you MAY start a dev server ON PORT $DEV_PORT ONLY:" + printf '%s\n' " cd App && pnpm dev -p $DEV_PORT (production runs on 3000 -- NEVER touch 3000)" + printf '%s\n' " Stop ONLY your own server by port ('fuser -k $DEV_PORT/tcp'); NEVER a broad 'pkill -f next'." printf '%s\n' "" printf '%s\n' "## Your job (PR policy: every code change ships with tests + docs)" printf '%s\n' "1. Make the focused changes the review comments ask for -- nothing more." @@ -227,15 +236,27 @@ while [ "$p" -lt "$n_prs" ]; do printf '%s\n' "4. Update any docs the change affects (App/README.md, App/CLAUDE.md, Docs/, CHANGELOG.md)." printf '%s\n' "5. Commit ALL changes to the current branch with a conventional message referencing #$num." printf '%s\n' "6. Do NOT push, do NOT switch branches, do NOT open/close PRs. The supervisor pushes." + printf '%s\n' "7. BEFORE you finish: stop any dev server you started ('fuser -k $DEV_PORT/tcp'), leave NO" + printf '%s\n' " background process running, and then END YOUR TURN. Do not wait or keep the session open." printf '%s\n' "If a comment is unclear, out of scope, or too risky to do unattended (migrations, payments," printf '%s\n' "permissions), make NO commit for it and explain why in CLAUDE_RESULT.md in the repo root." } > "$prompt_file" clog="$LOG_DIR/claude-pr-$num-$(date +%Y%m%d-%H%M%S).log" - log " Running Claude on PR #$num (log: $clog)" - ( cd "$WORKDIR" && "$CLAUDE" -p --dangerously-skip-permissions \ + log " Running Claude on PR #$num (log: $clog, timeout: $CLAUDE_TIMEOUT)" + # `timeout` sends TERM at the limit, then KILL 30s later, in its own session + # (-s/setsid via `setsid`) so the whole process group dies -- not just `claude`, + # but any dev server it spawned. Bounded so a stuck run can never wedge the lock. + ( cd "$WORKDIR" && setsid timeout -k 30s "$CLAUDE_TIMEOUT" \ + "$CLAUDE" -p --dangerously-skip-permissions \ --max-turns "$TURNS" --output-format text < "$prompt_file" > "$clog" 2>&1 ); rc=$? - log " Claude exited with code $rc for PR #$num" + if [ "$rc" -eq 124 ]; then + log " Claude TIMED OUT after $CLAUDE_TIMEOUT on PR #$num (rc=124) -- continuing with any committed work" + else + log " Claude exited with code $rc for PR #$num" + fi + # Backstop: reap a dev server the run may have left on this watcher's dev port. + fuser -k "$DEV_PORT/tcp" >/dev/null 2>&1 || true rm -f "$prompt_file" note="" diff --git a/automation/pr-review-watcher.config.example.json b/automation/pr-review-watcher.config.example.json index 8709972..4676c29 100644 --- a/automation/pr-review-watcher.config.example.json +++ b/automation/pr-review-watcher.config.example.json @@ -9,5 +9,7 @@ "maxPrsPerRun": 1, "maxCommentsPerPr": 20, "claudeExe": "/home/shad0w/.nvm/versions/node//bin/claude", - "claudeMaxTurns": 150 + "claudeMaxTurns": 150, + "claudeTimeout": "30m", + "devPort": 3101 } From d0e43135f8c3d63b726ee262e5644bca269f2dd4 Mon Sep 17 00:00:00 2001 From: "Claude (review-bot)" Date: Wed, 24 Jun 2026 16:09:54 +0530 Subject: [PATCH 9/9] feat(reports): drill from cost centre / accounting code into PO History (#126) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Report detail pages now link to the underlying POs, addressing the PR #126 review comment: drilling into a cost centre or accounting code opens PO History pre-filtered to that dimension and the period in view. - Cost Centre / Accounting Code detail pages gain a "View POs" link. - periodRange() maps the on-screen period onto History's approved-date window (weekly→month, monthly→FY, yearly→full span); spend is dated by approvedAt. - PO History gains an accountId filter (any tree node, expanded to leaves via accountLeafIds()) matching PO-level OR line-item accounts — the same basis the reports use. - History page + CSV/PDF export share one buildPoHistoryWhere() builder so they never diverge. - Tests: unit (periodRange, accountLeafIds) + integration (History account filter across PO-level/line-item, with the approved window). Co-Authored-By: Claude Opus 4.8 (1M context) --- App/CLAUDE.md | 2 + App/app/(portal)/history/page.tsx | 29 +--- .../reports/accounting-codes/[id]/page.tsx | 22 ++- .../reports/cost-centres/[id]/page.tsx | 16 ++- App/app/api/reports/export/route.ts | 42 ++---- App/lib/history-filter.ts | 68 ++++++++++ App/lib/reports.ts | 62 +++++++++ .../history-account-filter.test.ts | 128 ++++++++++++++++++ App/tests/unit/reports.test.ts | 45 ++++++ CHANGELOG.md | 1 + 10 files changed, 349 insertions(+), 66 deletions(-) create mode 100644 App/lib/history-filter.ts create mode 100644 App/tests/integration/history-account-filter.test.ts diff --git a/App/CLAUDE.md b/App/CLAUDE.md index 7a40344..3dfd093 100644 --- a/App/CLAUDE.md +++ b/App/CLAUDE.md @@ -164,6 +164,8 @@ Spend analytics under a **Reports** sidebar section (with a **"Purchasing"** sub **Filters** live in the **URL query** so the server component re-renders — no client fetching: `gran` (**weekly** / monthly / yearly), `fy`, `month` (weekly), `scope` (Top/Bottom-N), `parent` (accounting drill), `tier` / `break` / `topn` (detail breakdowns), and `sel` + `cmp` (the **custom "Add to graph"** multi-select — tick rows via the `` links, then `cmp=1` compares just the selected set). Weekly focuses one FY month and buckets by week-of-month (W1–W5). The shared `` (client) writes the params; charts are **recharts** (`components/reports/charts.tsx`) — the comparison chart plots **one colour-coded series per item** (cost centre / accounting code) in every granularity, including the yearly grouped-bars view (x-axis = FYs, a coloured bar per item — not one colour per year); KPIs/tables/breadcrumbs are server-rendered. Export → `/api/reports/spend?dim=…` (CSV mirroring the on-screen view, incl. the custom selection). +**Drill to POs (#126):** each detail page (`/reports/cost-centres/[id]`, `/reports/accounting-codes/[id]`) has a **"View POs"** link to **PO History** pre-filtered to that cost centre / accounting code over the period in view — `periodRange(gran, fy, month, fys)` (`lib/reports.ts`) maps the on-screen period onto History's `approvedFrom`/`approvedTo` (weekly → the focused month, monthly → the FY, yearly → the full FY span; spend is dated by `approvedAt`). PO History (`/history`) gained an **`accountId`** filter that accepts **any** account-tree node and matches a PO whose **PO-level account or any line-item account** is a leaf under it (`accountLeafIds()` expands the node) — the same attribution basis the reports use. The History page **and** its CSV/PDF export route (`/api/reports/export`) build their `where` from one shared `lib/history-filter.ts` `buildPoHistoryWhere()` so they stay in lockstep. + Sites are **not** cost centres (only vessels are). ### Crewing (feature-flagged) diff --git a/App/app/(portal)/history/page.tsx b/App/app/(portal)/history/page.tsx index d92b608..7274d04 100644 --- a/App/app/(portal)/history/page.tsx +++ b/App/app/(portal)/history/page.tsx @@ -7,10 +7,10 @@ import { formatCurrency, formatDate } from "@/lib/utils"; import { PoStatusBadge } from "@/components/po/po-status-badge"; import { HistoryFilters } from "./history-filters"; import { buildAccountGroups } from "@/lib/cost-centre-groups"; +import { buildPoHistoryWhere } from "@/lib/history-filter"; import { resolvePagination } from "@/lib/pagination"; import { Suspense } from "react"; import type { Metadata } from "next"; -import type { POStatus } from "@prisma/client"; export const metadata: Metadata = { title: "History" }; @@ -47,31 +47,10 @@ export default async function HistoryPage({ searchParams }: Props) { const { dateFrom, dateTo, approvedFrom, approvedTo, vesselId, accountId, status, page: pageParam, perPage: perPageParam } = await searchParams; - const where: NonNullable[0]>["where"] = {}; - if (dateFrom || dateTo) { - const createdAt: { gte?: Date; lt?: Date } = {}; - if (dateFrom) createdAt.gte = new Date(dateFrom); - if (dateTo) { - const end = new Date(dateTo); - end.setDate(end.getDate() + 1); - createdAt.lt = end; - } - where.createdAt = createdAt; - } - if (approvedFrom || approvedTo) { - const approvedAt: { gte?: Date; lt?: Date } = {}; - if (approvedFrom) approvedAt.gte = new Date(approvedFrom); - if (approvedTo) { - const end = new Date(approvedTo); - end.setDate(end.getDate() + 1); - approvedAt.lt = end; - } - where.approvedAt = approvedAt; - } - if (vesselId) where.vesselId = vesselId; - if (accountId) where.accountId = accountId; const statuses = (Array.isArray(status) ? status : status ? [status] : []).filter(Boolean); - if (statuses.length > 0) where.status = { in: statuses as POStatus[] }; + const where = await buildPoHistoryWhere({ + dateFrom, dateTo, approvedFrom, approvedTo, vesselId, accountId, statuses, + }); const total = await db.purchaseOrder.count({ where }); const { perPage, page, totalPages, skip, take } = resolvePagination({ diff --git a/App/app/(portal)/reports/accounting-codes/[id]/page.tsx b/App/app/(portal)/reports/accounting-codes/[id]/page.tsx index b8033a4..837053b 100644 --- a/App/app/(portal)/reports/accounting-codes/[id]/page.tsx +++ b/App/app/(portal)/reports/accounting-codes/[id]/page.tsx @@ -11,6 +11,7 @@ import { accountNodeWeekly, costCentresForAccount, childBreakdown, + periodRange, parseGranularity, resolveFy, resolveMonth, @@ -89,6 +90,10 @@ export default async function AccountingCodeDetail({ return `${base}?${p.toString()}`; }; const exportHref = `/api/reports/spend?dim=accounting-code-detail&id=${id}&fy=${fy}&gran=${gran}&break=${breakMode}`; + // Drill into the POs behind this spend: PO History filtered to this accounting + // code (expanded to its leaves) over the period in view (dated by approvedAt). + const { from, to } = periodRange(gran, fy, month, ds.fys); + const poListHref = `/history?accountId=${id}&approvedFrom=${from}&approvedTo=${to}`; const path = idx.pathTo(id); const trail = [ @@ -111,12 +116,17 @@ export default async function AccountingCodeDetail({ exportHref={exportHref} /> - - ← Back to Accounting Codes - +
+ + ← Back to Accounting Codes + + + View POs · {periodLabel} → + +
@@ -93,9 +98,14 @@ export default async function CostCentreDetail({ exportHref={exportHref} /> - - ← Back to Cost Centres - +
+ + ← Back to Cost Centres + + + View POs · {periodLabel} → + +
diff --git a/App/app/api/reports/export/route.ts b/App/app/api/reports/export/route.ts index 3fa2fbc..cdc3f0a 100644 --- a/App/app/api/reports/export/route.ts +++ b/App/app/api/reports/export/route.ts @@ -1,8 +1,8 @@ import { auth } from "@/auth"; import { db } from "@/lib/db"; import { hasPermission, submitterCanViewAll } from "@/lib/permissions"; +import { buildPoHistoryWhere } from "@/lib/history-filter"; import { NextRequest, NextResponse } from "next/server"; -import type { POStatus } from "@prisma/client"; const PO_STATUS_LABELS: Record = { DRAFT: "Draft", SUBMITTED: "Submitted", MGR_REVIEW: "Pending Approval", @@ -25,38 +25,16 @@ export async function GET(request: NextRequest) { const sp = request.nextUrl.searchParams; const format = sp.get("format") ?? "csv"; - const dateFrom = sp.get("dateFrom"); - const dateTo = sp.get("dateTo"); - const approvedFrom = sp.get("approvedFrom"); - const approvedTo = sp.get("approvedTo"); - const vesselId = sp.get("vesselId"); - const accountId = sp.get("accountId"); - const statuses = sp.getAll("status").filter(Boolean); - const where: NonNullable[0]>["where"] = {}; - if (dateFrom || dateTo) { - const createdAt: { gte?: Date; lt?: Date } = {}; - if (dateFrom) createdAt.gte = new Date(dateFrom); - if (dateTo) { - const end = new Date(dateTo); - end.setDate(end.getDate() + 1); - createdAt.lt = end; - } - where.createdAt = createdAt; - } - if (approvedFrom || approvedTo) { - const approvedAt: { gte?: Date; lt?: Date } = {}; - if (approvedFrom) approvedAt.gte = new Date(approvedFrom); - if (approvedTo) { - const end = new Date(approvedTo); - end.setDate(end.getDate() + 1); - approvedAt.lt = end; - } - where.approvedAt = approvedAt; - } - if (vesselId) where.vesselId = vesselId; - if (accountId) where.accountId = accountId; - if (statuses.length > 0) where.status = { in: statuses as POStatus[] }; + const where = await buildPoHistoryWhere({ + dateFrom: sp.get("dateFrom"), + dateTo: sp.get("dateTo"), + approvedFrom: sp.get("approvedFrom"), + approvedTo: sp.get("approvedTo"), + vesselId: sp.get("vesselId"), + accountId: sp.get("accountId"), + statuses: sp.getAll("status"), + }); const orders = await db.purchaseOrder.findMany({ where, diff --git a/App/lib/history-filter.ts b/App/lib/history-filter.ts new file mode 100644 index 0000000..cfaf1e6 --- /dev/null +++ b/App/lib/history-filter.ts @@ -0,0 +1,68 @@ +/** + * Shared `where` builder for the PO History list (`/history` page) and its + * CSV/PDF export route, so the two never drift. Filters: created-date range, + * approved-date range, cost centre (vessel), status, and — for report + * drill-downs (issue #124 review) — an accounting code. + * + * The `accountId` filter accepts any account-tree node (Heading / Sub-heading / + * Leaf); it expands to the leaf codes underneath via `accountLeafIds` and + * matches a PO whose **PO-level account** or **any line item account** is in + * that leaf set — the same attribution basis the spend reports use. + */ +import { db } from "@/lib/db"; +import { accountLeafIds } from "@/lib/reports"; +import type { POStatus } from "@prisma/client"; + +type PoWhere = NonNullable[0]>["where"]; + +export interface HistoryFilterParams { + dateFrom?: string | null; + dateTo?: string | null; + approvedFrom?: string | null; + approvedTo?: string | null; + vesselId?: string | null; + accountId?: string | null; + statuses?: string[]; +} + +export async function buildPoHistoryWhere(p: HistoryFilterParams): Promise { + const where: NonNullable = {}; + + if (p.dateFrom || p.dateTo) { + const createdAt: { gte?: Date; lt?: Date } = {}; + if (p.dateFrom) createdAt.gte = new Date(p.dateFrom); + if (p.dateTo) { + const end = new Date(p.dateTo); + end.setDate(end.getDate() + 1); + createdAt.lt = end; + } + where.createdAt = createdAt; + } + + if (p.approvedFrom || p.approvedTo) { + const approvedAt: { gte?: Date; lt?: Date } = {}; + if (p.approvedFrom) approvedAt.gte = new Date(p.approvedFrom); + if (p.approvedTo) { + const end = new Date(p.approvedTo); + end.setDate(end.getDate() + 1); + approvedAt.lt = end; + } + where.approvedAt = approvedAt; + } + + if (p.vesselId) where.vesselId = p.vesselId; + + const statuses = (p.statuses ?? []).filter(Boolean); + if (statuses.length > 0) where.status = { in: statuses as POStatus[] }; + + if (p.accountId) { + const accounts = await db.account.findMany({ select: { id: true, parentId: true } }); + const leaves = accountLeafIds(accounts, p.accountId); + where.OR = [ + { accountId: { in: leaves } }, + { lineItems: { some: { accountId: { in: leaves } } } }, + ]; + } + + return where; +} diff --git a/App/lib/reports.ts b/App/lib/reports.ts index 7ece38c..24c439c 100644 --- a/App/lib/reports.ts +++ b/App/lib/reports.ts @@ -350,3 +350,65 @@ export function parseSel(v: string | undefined): string[] { export function toggleSel(sel: string[], id: string): string[] { return sel.includes(id) ? sel.filter((x) => x !== id) : [...sel, id]; } + +// ── Report → PO drill-down ───────────────────────────────────────────────── + +/** + * Leaf account ids under `accountId` (the node itself when it is already a + * leaf), from the raw `{ id, parentId }` account rows. A report drill-down can + * target any tier, but a PO / line item only ever carries a leaf code — so this + * translates a drilled node into the concrete leaf set PO History filters by. + * Returns `[]` for an unknown id. + */ +export function accountLeafIds( + accounts: { id: string; parentId: string | null }[], + accountId: string, +): string[] { + const ids = new Set(accounts.map((a) => a.id)); + if (!ids.has(accountId)) return []; + const kids = new Map(); + for (const a of accounts) { + if (a.parentId === null) continue; + if (!kids.has(a.parentId)) kids.set(a.parentId, []); + kids.get(a.parentId)!.push(a.id); + } + const out: string[] = []; + const walk = (id: string) => { + const cs = kids.get(id) ?? []; + if (cs.length === 0) out.push(id); + else cs.forEach(walk); + }; + walk(accountId); + return out; +} + +/** + * The approved-date window (`from`..`to`, inclusive `YYYY-MM-DD`) a report + * detail view currently shows, so drilling into the underlying POs carries + * "that period" onto PO History's `approvedFrom`/`approvedTo` (spend is dated by + * `approvedAt`). Mirrors the on-screen period label: + * - weekly → the focused FY month + * - monthly → the whole selected FY (Apr–Mar) + * - yearly → the full span of FYs in the dataset + */ +export function periodRange( + gran: Granularity, + fy: number, + month: number, + fys: number[], +): { from: string; to: string } { + const iso = (y: number, m: number, d: number) => + `${y}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}`; + if (gran === "yearly") { + const first = fys[0] ?? fy; + const last = fys[fys.length - 1] ?? fy; + return { from: iso(first, 4, 1), to: iso(last + 1, 3, 31) }; + } + if (gran === "weekly") { + const cal = (month + 3) % 12; // FY-month index (Apr=0) → calendar month 0–11 + const year = fy + (month >= 9 ? 1 : 0); // Jan–Mar roll into the next calendar year + const lastDay = new Date(year, cal + 1, 0).getDate(); + return { from: iso(year, cal + 1, 1), to: iso(year, cal + 1, lastDay) }; + } + return { from: iso(fy, 4, 1), to: iso(fy + 1, 3, 31) }; +} diff --git a/App/tests/integration/history-account-filter.test.ts b/App/tests/integration/history-account-filter.test.ts new file mode 100644 index 0000000..275073a --- /dev/null +++ b/App/tests/integration/history-account-filter.test.ts @@ -0,0 +1,128 @@ +/** + * Integration test for the PO History accounting-code filter (PR #126 review). + * + * Report drill-downs link from a cost-centre / accounting-code detail page into + * `/history` with the code (and period) applied as filters. `buildPoHistoryWhere` + * powers both the History page and its export route. The accounting-code filter + * accepts any tree node and must match a PO whose **PO-level account** OR **any + * line item account** falls under that node's leaves — the same attribution + * basis the spend reports use. + */ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; + +import { db } from "@/lib/db"; +import { buildPoHistoryWhere } from "@/lib/history-filter"; +import { deletePosByTitle } from "./helpers"; + +const PREFIX = "INTTEST_HIST_ACCT_"; +const CODE = `INTTEST_${Date.now()}_`; + +let submitterId: string; +let vesselId: string; +let topId: string; // heading +let leafAId: string; +let leafBId: string; +let leafXId: string; // unrelated leaf + +const approvedAt = new Date("2025-06-15T12:00:00Z"); // FY 2025–26 + +async function makePo(opts: { + title: string; + accountId: string; + lineAccountId?: string | null; +}) { + return db.purchaseOrder.create({ + data: { + poNumber: `${PREFIX}${opts.title}`, + title: `${PREFIX}${opts.title}`, + status: "CLOSED", + totalAmount: 1000, + approvedAt, + submitterId, + vesselId, + accountId: opts.accountId, + lineItems: { + create: [ + { + name: "Item", + quantity: 1, + unit: "pc", + unitPrice: 1000, + totalPrice: 1000, + accountId: opts.lineAccountId ?? null, + }, + ], + }, + }, + }); +} + +beforeAll(async () => { + const [user, vessel] = await Promise.all([ + db.user.findFirstOrThrow({ where: { role: "MANAGER" } }), + db.vessel.findFirstOrThrow(), + ]); + submitterId = user.id; + vesselId = vessel.id; + + // Brand-new account subtree (T → S → A, B) + an unrelated leaf X, so no + // pre-existing prod PO references them and counts isolate to our fixtures. + const top = await db.account.create({ data: { code: `${CODE}T`, name: "IntTest Top" } }); + const sub = await db.account.create({ data: { code: `${CODE}S`, name: "IntTest Sub", parentId: top.id } }); + const a = await db.account.create({ data: { code: `${CODE}A`, name: "IntTest Leaf A", parentId: sub.id } }); + const b = await db.account.create({ data: { code: `${CODE}B`, name: "IntTest Leaf B", parentId: sub.id } }); + const x = await db.account.create({ data: { code: `${CODE}X`, name: "IntTest Leaf X" } }); + topId = top.id; + leafAId = a.id; + leafBId = b.id; + leafXId = x.id; + + await makePo({ title: "po1_levelA", accountId: leafAId }); // PO-level A + await makePo({ title: "po2_levelX_lineB", accountId: leafXId, lineAccountId: leafBId }); // line item B + await makePo({ title: "po3_levelX", accountId: leafXId }); // X only +}); + +afterAll(async () => { + await deletePosByTitle(PREFIX); + await db.account.deleteMany({ where: { code: { startsWith: CODE } } }); +}); + +async function countMine(accountId: string) { + const where = await buildPoHistoryWhere({ accountId }); + return db.purchaseOrder.count({ where: { ...where, title: { startsWith: PREFIX } } }); +} + +describe("PO History accounting-code filter", () => { + it("a heading expands to its leaves and matches PO-level or line-item accounts", async () => { + // T → {A, B}: po1 (PO-level A) and po2 (line item B); not po3 (X only). + expect(await countMine(topId)).toBe(2); + }); + + it("a leaf matches only POs carrying that exact code (PO-level)", async () => { + expect(await countMine(leafAId)).toBe(1); // po1 + }); + + it("matches a PO via a line-item account even when the PO-level account differs", async () => { + expect(await countMine(leafBId)).toBe(1); // po2 (PO-level X, line item B) + }); + + it("the unrelated leaf matches its own POs", async () => { + expect(await countMine(leafXId)).toBe(2); // po2 + po3 (both PO-level X) + }); + + it("combines with the approved-date window", async () => { + const inWindow = await buildPoHistoryWhere({ + accountId: topId, + approvedFrom: "2025-04-01", + approvedTo: "2026-03-31", + }); + expect(await db.purchaseOrder.count({ where: { ...inWindow, title: { startsWith: PREFIX } } })).toBe(2); + + const outOfWindow = await buildPoHistoryWhere({ + accountId: topId, + approvedFrom: "2024-04-01", + approvedTo: "2025-03-31", + }); + expect(await db.purchaseOrder.count({ where: { ...outOfWindow, title: { startsWith: PREFIX } } })).toBe(0); + }); +}); diff --git a/App/tests/unit/reports.test.ts b/App/tests/unit/reports.test.ts index 0eb63ec..3b7723d 100644 --- a/App/tests/unit/reports.test.ts +++ b/App/tests/unit/reports.test.ts @@ -21,6 +21,8 @@ import { parseSel, toggleSel, allocatePoSpend, + accountLeafIds, + periodRange, type ReportDataset, type AccountNode, } from "@/lib/reports"; @@ -49,6 +51,49 @@ const DS: ReportDataset = { ], }; +describe("accountLeafIds (report → PO drill-down)", () => { + const RAW = ACCOUNTS.map((a) => ({ id: a.id, parentId: a.parentId })); + + it("expands a heading to every leaf underneath it", () => { + expect(accountLeafIds(RAW, "H").sort()).toEqual(["L1", "L2"]); + expect(accountLeafIds(RAW, "S").sort()).toEqual(["L1", "L2"]); + }); + it("returns a leaf node as itself", () => { + expect(accountLeafIds(RAW, "L1")).toEqual(["L1"]); + }); + it("returns [] for an unknown id", () => { + expect(accountLeafIds(RAW, "nope")).toEqual([]); + }); +}); + +describe("periodRange (report → PO History approved window)", () => { + it("monthly → the whole selected FY (Apr–Mar)", () => { + expect(periodRange("monthly", 2025, 0, [2024, 2025])).toEqual({ + from: "2025-04-01", + to: "2026-03-31", + }); + }); + it("yearly → the full span of FYs in the dataset", () => { + expect(periodRange("yearly", 2025, 0, [2024, 2025])).toEqual({ + from: "2024-04-01", + to: "2026-03-31", + }); + }); + it("weekly → the focused FY month (Apr=0)", () => { + expect(periodRange("weekly", 2025, 0, [2025])).toEqual({ + from: "2025-04-01", + to: "2025-04-30", + }); + }); + it("weekly → a Jan–Mar month rolls into the next calendar year", () => { + // FY-month index 9 = Jan, which belongs to calendar year fy+1. + expect(periodRange("weekly", 2025, 9, [2025])).toEqual({ + from: "2026-01-01", + to: "2026-01-31", + }); + }); +}); + describe("financial-year helpers", () => { it("maps Apr–Mar to the Indian FY start year", () => { expect(fyStartYear(new Date(2025, 3, 1))).toBe(2025); // Apr diff --git a/CHANGELOG.md b/CHANGELOG.md index 855b5f8..1a14401 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- **Report → PO drill-down** (#126) — the Cost Centre and Accounting Code report detail pages gain a **"View POs"** link that opens **PO History** pre-filtered to that cost centre / accounting code and the period currently in view (mapped to the approved-date window, since spend is dated by `approvedAt`). PO History gains an **Accounting Code** filter that accepts any tree node and matches a PO whose PO-level account **or** any line-item account falls under that node's leaves. The History page and its CSV/PDF export share one `buildPoHistoryWhere` builder so they never diverge. - **Companies (multi-company invoicing)** — new `Company` model and `/admin/companies` CRUD. A PO is billed under a selected company (name, short `code`, GST number, address, phone/mobile, contact + invoice email, invoice address). The company's details populate the exported PO header / invoice block. - **Structured PO numbers** (`lib/po-number.ts`) — `COMPANY/VESSEL/ID/FY` (e.g. `PMS/HNR1/9000/2024-25`); Indian financial year; system-generated IDs start at 9000. Imported POs keep their original number. - **3-level accounting-code hierarchy** — `Account.parentId` self-relation (Top Category → Sub-Category → Leaf), 6-digit numeric codes seeded from `prisma/accounting-codes-data.ts`. Only leaf codes are PO-selectable, via a searchable, portal-rendered combobox.