Merge branch 'master' into claude/issue-124
All checks were successful
PR checks / checks (pull_request) Successful in 49s
PR checks / integration (pull_request) Successful in 30s

This commit is contained in:
shad0w 2026-06-25 21:23:01 +00:00
commit 87fbeecf52
24 changed files with 1072 additions and 75 deletions

4
.gitignore vendored
View file

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

View file

@ -147,6 +147,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/<poId>/<slug>.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.
@ -170,6 +172,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 `<SelectCheckbox>` links, then `cmp=1` compares just the selected set). Weekly focuses one FY month and buckets by week-of-month (W1W5). The shared `<ReportsToolbar>` (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)

View file

@ -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<string[]>(sp.getAll("status"));
const [statusOpen, setStatusOpen] = useState(false);
const statusRef = useRef<HTMLDivElement>(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) => <option key={v.id} value={v.id}>{v.name}</option>)}
</select>
</div>
<div>
<label className="block text-xs font-medium text-neutral-600 mb-1">Accounting Code</label>
<SearchableSelect
name="accountId"
value={accountId}
onChange={setAccountId}
groups={accounts}
placeholder="All accounting codes"
/>
</div>
<div className="relative" ref={statusRef}>
<label className="block text-xs font-medium text-neutral-600 mb-1">Status</label>
<button type="button" onClick={() => setStatusOpen((o) => !o)}

View file

@ -6,10 +6,11 @@ import Link from "next/link";
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" };
@ -23,6 +24,7 @@ interface Props {
approvedFrom?: string;
approvedTo?: string;
vesselId?: string;
accountId?: string;
status?: string | string[];
page?: string;
perPage?: string;
@ -42,33 +44,13 @@ export default async function HistoryPage({ searchParams }: Props) {
redirect("/dashboard");
}
const { dateFrom, dateTo, approvedFrom, approvedTo, vesselId, status, page: pageParam, perPage: perPageParam } =
const { dateFrom, dateTo, approvedFrom, approvedTo, vesselId, accountId, status, page: pageParam, perPage: perPageParam } =
await searchParams;
const where: NonNullable<Parameters<typeof db.purchaseOrder.findMany>[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;
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({
@ -79,7 +61,7 @@ export default async function HistoryPage({ searchParams }: Props) {
defaultPerPage: DEFAULT_PER_PAGE,
});
const [orders, vessels] = await Promise.all([
const [orders, vessels, leafAccounts] = await Promise.all([
db.purchaseOrder.findMany({
where,
include: { submitter: true, vessel: true, account: true },
@ -88,8 +70,15 @@ export default async function HistoryPage({ searchParams }: Props) {
take,
}),
db.vessel.findMany({ orderBy: { name: "asc" }, select: { id: true, name: true } }),
db.account.findMany({
where: { isActive: true, children: { none: {} } },
orderBy: { code: "asc" },
select: { id: true, code: true, name: true, parent: { select: { name: true, code: true, parent: { select: { name: true, code: true } } } } },
}),
]);
const accounts = buildAccountGroups(leafAccounts);
// Shared filter params for the pagination footer links (everything except `page`).
const pageParams = new URLSearchParams();
if (dateFrom) pageParams.set("dateFrom", dateFrom);
@ -97,6 +86,7 @@ export default async function HistoryPage({ searchParams }: Props) {
if (approvedFrom) pageParams.set("approvedFrom", approvedFrom);
if (approvedTo) pageParams.set("approvedTo", approvedTo);
if (vesselId) pageParams.set("vesselId", vesselId);
if (accountId) pageParams.set("accountId", accountId);
for (const s of statuses) pageParams.append("status", s);
pageParams.set("perPage", String(perPage));
@ -115,6 +105,7 @@ export default async function HistoryPage({ searchParams }: Props) {
if (approvedFrom) exportParams.set("approvedFrom", approvedFrom);
if (approvedTo) exportParams.set("approvedTo", approvedTo);
if (vesselId) exportParams.set("vesselId", vesselId);
if (accountId) exportParams.set("accountId", accountId);
for (const s of statuses) exportParams.append("status", s);
return (
@ -140,7 +131,7 @@ export default async function HistoryPage({ searchParams }: Props) {
</div>
<Suspense>
<HistoryFilters vessels={vessels} perPageOptions={PER_PAGE_OPTIONS} defaultPerPage={DEFAULT_PER_PAGE} />
<HistoryFilters vessels={vessels} accounts={accounts} perPageOptions={PER_PAGE_OPTIONS} defaultPerPage={DEFAULT_PER_PAGE} />
</Suspense>
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
@ -150,6 +141,7 @@ export default async function HistoryPage({ searchParams }: Props) {
<th className="px-4 py-3 text-left font-medium text-neutral-600">PO Number</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Title</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Cost Centre</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Accounting Code</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Submitter</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Status</th>
<th className="px-4 py-3 text-right font-medium text-neutral-600">Amount</th>
@ -169,6 +161,9 @@ export default async function HistoryPage({ searchParams }: Props) {
</td>
<td className="px-4 py-3 font-medium text-neutral-900 max-w-xs truncate">{po.title}</td>
<td className="px-4 py-3 text-neutral-600">{po.vessel.name}</td>
<td className="px-4 py-3 text-neutral-600">
<span className="font-mono text-xs text-neutral-400">{po.account.code}</span> {po.account.name}
</td>
<td className="px-4 py-3 text-neutral-600">{po.submitter.name}</td>
<td className="px-4 py-3">
<PoStatusBadge status={po.status} />

View file

@ -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<Result> {
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}` };

View file

@ -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}
/>
<Link
href={node.parentId ? `/reports/accounting-codes?fy=${fy}&gran=${gran}&parent=${node.parentId}` : `/reports/accounting-codes?fy=${fy}&gran=${gran}`}
className="mb-4 inline-flex items-center gap-1.5 text-sm font-medium text-primary-600 hover:text-primary-700"
>
Back to Accounting Codes
</Link>
<div className="mb-4 flex items-center justify-between gap-3">
<Link
href={node.parentId ? `/reports/accounting-codes?fy=${fy}&gran=${gran}&parent=${node.parentId}` : `/reports/accounting-codes?fy=${fy}&gran=${gran}`}
className="inline-flex items-center gap-1.5 text-sm font-medium text-primary-600 hover:text-primary-700"
>
Back to Accounting Codes
</Link>
<Link href={poListHref} className="inline-flex items-center gap-1.5 rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50 transition-colors">
View POs · {periodLabel}
</Link>
</div>
<ReportTitle
title={`${node.code} · ${node.name}`}

View file

@ -10,6 +10,7 @@ import {
costCentreRows,
costCentreWeekly,
topAccountsForCostCentre,
periodRange,
parseGranularity,
resolveFy,
resolveMonth,
@ -80,6 +81,10 @@ export default async function CostCentreDetail({
return `${base}?${p.toString()}`;
};
const exportHref = `/api/reports/spend?dim=cost-centre-detail&id=${id}&fy=${fy}&gran=${gran}&tier=${tier}`;
// Drill into the POs behind this spend: PO History filtered to this cost centre
// over the period currently in view (spend is dated by approvedAt).
const { from, to } = periodRange(gran, fy, month, ds.fys);
const poListHref = `/history?vesselId=${id}&approvedFrom=${from}&approvedTo=${to}`;
return (
<div>
@ -93,9 +98,14 @@ export default async function CostCentreDetail({
exportHref={exportHref}
/>
<Link href={`/reports/cost-centres?fy=${fy}&gran=${gran}`} className="mb-4 inline-flex items-center gap-1.5 text-sm font-medium text-primary-600 hover:text-primary-700">
Back to Cost Centres
</Link>
<div className="mb-4 flex items-center justify-between gap-3">
<Link href={`/reports/cost-centres?fy=${fy}&gran=${gran}`} className="inline-flex items-center gap-1.5 text-sm font-medium text-primary-600 hover:text-primary-700">
Back to Cost Centres
</Link>
<Link href={poListHref} className="inline-flex items-center gap-1.5 rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50 transition-colors">
View POs · {periodLabel}
</Link>
</div>
<ReportTitle title={row.name} subtitle={`Approved spend · ${periodLabel}`} />

View file

@ -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<string, string> = {
DRAFT: "Draft", SUBMITTED: "Submitted", MGR_REVIEW: "Pending Approval",
@ -25,36 +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 statuses = sp.getAll("status").filter(Boolean);
const where: NonNullable<Parameters<typeof db.purchaseOrder.findMany>[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 (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,

68
App/lib/history-filter.ts Normal file
View file

@ -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<Parameters<typeof db.purchaseOrder.findMany>[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<PoWhere> {
const where: NonNullable<PoWhere> = {};
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;
}

View file

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

View file

@ -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<string, string[]>();
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 (AprMar)
* - 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 011
const year = fy + (month >= 9 ? 1 : 0); // JanMar 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) };
}

View file

@ -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).
*/

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 (AprMar)", () => {
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 JanMar 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 AprMar to the Indian FY start year", () => {
expect(fyStartYear(new Date(2025, 3, 1))).toBe(2025); // Apr

View file

@ -4,6 +4,15 @@
### 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`.
- **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.
- **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 +38,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/<id>/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).

54
PdfService/README.md Normal file
View file

@ -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).

View file

@ -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,91 @@ 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
`<!-- ppms-review-bot handled: … -->` 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=<nvm bin>:$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-<date>.log`, per-PR `claude-pr-<n>-*.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.
**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:

View file

@ -0,0 +1,308 @@
#!/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')
# 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:"
# 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
# --- 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(", ")')"
# --- 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)')
# 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 (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
jq -rn --arg b "$1" --arg m "$MARKER" \
'$b | split($m) | .[1:] | join($m) | gsub("^\\s+|\\s+$";"")'
}
p=0
processed=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')
# 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(.body | contains($tag) | not)
| select(.user.login as $u | ($collab | index($u)))'
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" --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 } ]")
# 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" --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)),
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
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
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 $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."
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' "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, 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=$?
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=""
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\`.
<!-- $HANDLED_TAG -->"
continue
fi
body="[Claude review-bot] Addressed the following review comment(s) on \`$branch\` ($commits commit(s) pushed):
$ack_items
${note:+
Notes:
$note
}
<!-- $HANDLED_TAG $keys -->"
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
<!-- $HANDLED_TAG $keys -->"
log " PR #$num: no change, acknowledged (marked handled to avoid re-running)"
fi
done
log "PR-review watcher run complete."

View file

@ -0,0 +1,15 @@
{
"forgejoUrl": "https://git.pelagiamarine.com",
"repo": "shad0w/pelagia-portal",
"token": "<forgejo token with write:repository,write:issue>",
"workDir": "/home/shad0w/pelagia-pr-review",
"baseBranch": "master",
"prBranchPrefix": "claude/",
"marker": "claude-review:",
"maxPrsPerRun": 1,
"maxCommentsPerPr": 20,
"claudeExe": "/home/shad0w/.nvm/versions/node/<ver>/bin/claude",
"claudeMaxTurns": 150,
"claudeTimeout": "30m",
"devPort": 3101
}

View file

@ -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"