Compare commits

..

17 commits

Author SHA1 Message Date
0fdda87381 Merge pull request 'fix: Activity should log partial payment amount' (#141) from claude/issue-140 into master
All checks were successful
Refresh staging / refresh (push) Successful in 8s
Reviewed-on: #141
2026-06-28 01:41:18 +00:00
9f681ace89 Merge remote-tracking branch 'origin/master' into pr141-fix
All checks were successful
PR checks / checks (pull_request) Successful in 54s
PR checks / integration (pull_request) Successful in 32s
# Conflicts:
#	App/components/po/po-detail.tsx
2026-06-28 02:34:26 +05:30
7e313bb3f4 Merge pull request 'feat(po): allow attachments in any PO state except rejected/cancelled' (#146) from claude/po-attachments-any-state into master
All checks were successful
Refresh staging / refresh (push) Successful in 8s
Reviewed-on: #146
2026-06-27 20:16:28 +00:00
e481eb0a15 feat(po): allow attachments in any state except rejected/cancelled
All checks were successful
PR checks / checks (pull_request) Successful in 50s
PR checks / integration (pull_request) Successful in 31s
Broadens the feature-flagged attachment affordance (same flag,
NEXT_PUBLIC_CLOSED_PO_ATTACHMENTS_ENABLED) from CLOSED-only to **any PO state
except REJECTED / CANCELLED**, for the same roles: the PO's own submitter plus
Accounts / Manager / SuperUser.

- lib/permissions.ts: canAddClosedPoAttachment → canAddPoAttachment(role,
  status, { isSubmitter }); allows the submitter + ACCOUNTS/MANAGER/SUPERUSER
  in any non-voided state. REJECTED/CANCELLED are always refused.
- uploadPoDocuments: voided POs are refused regardless of the flag; with the
  flag on, uploads are restricted to those roles in any live state (the normal
  create/receipt actors qualify, so those flows keep working); with the flag
  off, the legacy behaviour stands (closed POs immutable).
- po-detail.tsx: the Attachments card now shows the uploader for any non-voided
  state when permitted (not just CLOSED).
- Renamed ClosedPoAttachmentUploader → PoAttachmentUploader and the test file
  to po-attachment-permissions.test.ts (flag-on matrix now covers live states +
  rejected/cancelled refusal). Docs updated (feature-flags, .env.example,
  CLAUDE.md).

Full unit + integration suites green; tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 01:42:13 +05:30
65be4ef330 Merge remote-tracking branch 'origin/master' into pr141-fix
All checks were successful
PR checks / checks (pull_request) Successful in 52s
PR checks / integration (pull_request) Successful in 32s
# Conflicts:
#	App/components/po/po-detail.tsx
2026-06-28 01:24:00 +05:30
ebb6230755 Merge pull request 'fix(po): upload attachments server-side so they persist & show' (#144) from claude/flamboyant-gagarin-370922 into master
All checks were successful
Refresh staging / refresh (push) Successful in 8s
Reviewed-on: #144
2026-06-27 19:47:26 +00:00
fc68a84636 Merge pull request 'fix: Add Duplicate PO button' (#145) from claude/issue-142 into master
All checks were successful
Refresh staging / refresh (push) Successful in 7s
Reviewed-on: #145
2026-06-27 19:47:20 +00:00
158b446117 feat(po): feature-flagged attachments on closed POs (bug remediation)
All checks were successful
PR checks / checks (pull_request) Successful in 51s
PR checks / integration (pull_request) Successful in 31s
Adds NEXT_PUBLIC_CLOSED_PO_ATTACHMENTS_ENABLED. When on, a CLOSED PO's own
submitter -- plus Accounts / Manager / SuperUser -- can attach documents to
it, so POs whose uploads were lost to the document-upload bug can be fixed
without reopening them. Off by default, so production stays unchanged until
enabled.

- lib/permissions.ts: canAddClosedPoAttachment(role, { isSubmitter }) gated
  by the flag; allowed roles are ACCOUNTS/MANAGER/SUPERUSER (plus the PO's
  own submitter regardless of role).
- uploadPoDocuments: a CLOSED PO is otherwise immutable, so it now enforces
  the permission server-side; the normal create/receipt flows upload while
  the PO is pre-CLOSED and are unaffected.
- po-detail.tsx: when allowed, the Attachments card renders an uploader
  (ClosedPoAttachmentUploader) and shows even when the PO has no docs yet.
- Enabled on staging (staging-up.sh) so the remediation can be exercised;
  documented in .env.example and CLAUDE.md.

Tests: closed-po-attachments.test.ts covers the flag-on role matrix (own
submitter / Accounts / Manager / SuperUser allowed; other submitter-role and
auditor refused; non-closed PO unaffected); po-document-upload.test.ts adds
the flag-off case (closed PO stays immutable). Full unit + integration suites
green; tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 01:11:29 +05:30
Claude (auto-fix)
4dc10b834c feat(po): add Duplicate PO button to prefill a new PO
All checks were successful
PR checks / checks (pull_request) Successful in 52s
PR checks / integration (pull_request) Successful in 30s
Anyone with create_po browsing a PO now sees a Duplicate action that
opens the New Purchase Order form prefilled from the source PO. Like the
existing cart→new-PO prefill, nothing is written until the user saves or
submits — a duplicate is just a clean draft of the editable order fields.

- po-detail.tsx: Duplicate link in the header, gated by
  hasPermission(currentRole, "create_po") + !readOnly, linking to
  /po/new?duplicate=<id>.
- po/new/page.tsx: when ?duplicate=<id> is present, fetch the source PO
  and map it onto the form's initial props via the new pure helper.
- new-po-form.tsx: accept initial-value props for title, accounting code
  (+ per-item toggle), project code, place of delivery, date required,
  quotation/requisition refs, terms — following the existing prop pattern.
- lib/duplicate-po.ts: pure, unit-tested mapping (Decimals→numbers, dates
  →yyyy-MM-dd, saved-terms snapshot with legacy tc* fallback). Attachments,
  status/dates, payment data and audit history are intentionally not copied.

Fixes #142

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 00:40:36 +05:30
2afdeec6ad fix(po): upload attachments server-side so they persist & show
Some checks failed
PR checks / checks (pull_request) Failing after 2m14s
PR checks / integration (pull_request) Failing after 2m13s
PO/receipt attachments were uploaded via a browser presigned PUT straight
to R2 (POST /api/files/sign -> PUT uploadUrl -> linkDocument). That direct
browser->R2 PUT only succeeds when the bucket carries a CORS policy
allowing PUT from the portal origin; in production that policy was missing,
so the browser silently blocked the PUT, linkDocument never ran, and no
PODocument row was created -- "documents uploaded but not visible anywhere"
(0 PODocument rows in prod/staging).

Route uploads through a server action (uploadPoDocuments) that writes the
file with uploadBuffer and creates the PODocument row atomically -- the
same pattern the crewing module already uses for CV/crew-document uploads,
and within the 10mb serverActions.bodySizeLimit. This removes the R2-CORS
dependency and guarantees a created row always has its stored file.

Removes the now-dead presigned path (lib/upload-files.ts,
app/actions/link-document.ts, api/files/sign/route.ts).

Adds an integration test that drives the action against a real DB and
asserts the PODocument row is created and surfaced by the exact include
the PO detail page renders from (i.e. the attachment is visible).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 00:28:16 +05:30
Claude (auto-fix)
3335977773 feat(activity): show partial payment amount in PO timeline
All checks were successful
PR checks / checks (pull_request) Successful in 53s
PR checks / integration (pull_request) Successful in 31s
The PO Activity timeline rendered every partial payment as the generic
"Partial payment confirmed". markPaid() already persists the instalment
amount on the PARTIAL_PAYMENT_CONFIRMED action's metadata
(metadata.paymentAmount), so surface it: the row now reads
"Partial payment of <amount> confirmed" using the PO's own currency.

Falls back to the plain label when paymentAmount is missing or
non-numeric (older audit rows) so historical POs never render NaN.

Extracted ACTION_LABELS + the new actionLabel() helper into
lib/po-activity.ts so the label logic is unit-testable without pulling
the server-only PoDetail component (and its storage/auth imports) into
jsdom.

Fixes #140
2026-06-28 00:24:28 +05:30
bf7ea1a9e6 Merge pull request 'fix: Remove the Approved From and Approved to search field' (#137) from claude/issue-136 into master
All checks were successful
Refresh staging / refresh (push) Successful in 7s
Reviewed-on: #137
2026-06-25 22:25:09 +00:00
90a831790d Merge pull request 'fix(automation): issue watcher must not open PRs without tests' (#138) from fix/watcher-test-gate into master
All checks were successful
Refresh staging / refresh (push) Successful in 7s
Reviewed-on: #138
2026-06-25 22:24:54 +00:00
a5248e5c23 fix(automation): issue watcher must not open PRs without tests
All checks were successful
PR checks / checks (pull_request) Successful in 48s
PR checks / integration (pull_request) Successful in 30s
PR #137 shipped a code change under App/app with no test — the test-presence
gate in pr-checks.yml would reject it, but the watcher opened the PR anyway.

Add the same gate to the watcher's fix phase: before pushing/opening a PR, run
the pr-checks.yml test-presence check against the branch diff. If code under
App/(app|lib|components|hooks) changed with no accompanying test, the watcher
does NOT open a PR — it marks the issue claude-failed and comments, so the
queue can retry. Never raises a PR the CI would immediately fail.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 03:47:59 +05:30
efa9d90ddb test(history): cover removal of Approved From/To filters (#136)
All checks were successful
PR checks / checks (pull_request) Successful in 51s
PR checks / integration (pull_request) Successful in 32s
Adds a unit test for HistoryFilters asserting the Approved From / Approved
To date filters no longer render (the issue #136 change) while the remaining
filters (created-date range, cost centre, accounting code, status) stay.
Satisfies the test-presence policy that PR #137 was missing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 03:46:03 +05:30
Claude (auto-fix)
5218eb3717 feat(history): remove Approved From/To search fields
Some checks failed
PR checks / checks (pull_request) Failing after 3s
PR checks / integration (pull_request) Successful in 33s
Drop the two approval-date pickers from the PO History filter bar per
the manager's request — they were deemed unnecessary. Removes the
inputs, their useState hooks, and their entries in buildParams/clear/
hasFilters.

The approvedFrom/approvedTo URL params are left intact server-side
(page.tsx, lib/history-filter.ts, export route) so existing deep-links
from the dashboard "Approved This Month" card and the report drill-downs
keep pre-filtering History by approval date.

Fixes #136

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 03:23:08 +05:30
6c5aebd3fb Merge pull request 'chore(staging): enable submitter-view-all PO history flag on staging' (#135) from claude/determined-borg-f034f5 into master
All checks were successful
Refresh staging / refresh (push) Successful in 7s
Reviewed-on: #135
2026-06-25 21:42:03 +00:00
24 changed files with 1006 additions and 162 deletions

View file

@ -78,6 +78,11 @@ FORGEJO_TOKEN=
# Let submitters (TECHNICAL/MANNING) read & export every PO and open the History
# page (read-only). Opt-in — on only when exactly "true".
# NEXT_PUBLIC_SUBMITTER_VIEW_ALL_ENABLED=true
# Let a PO's own submitter (plus Accounts/Manager/SuperUser) add attachments to it
# in any state except rejected/cancelled — remediation for POs whose uploads were
# lost to the document-upload bug, and the general "attach after the fact" affordance.
# Opt-in — on only when exactly "true".
# NEXT_PUBLIC_CLOSED_PO_ATTACHMENTS_ENABLED=true
# ── Non-production banner ─────────────────────────────────────
# When set, a fixed "internal dev / staging" banner is shown (EnvBanner).

View file

@ -295,6 +295,7 @@ APP_INTERNAL_URL # Base URL PdfService reaches the app at (falls back to
NEXT_PUBLIC_INVENTORY_ENABLED # Inventory feature flag
NEXT_PUBLIC_SUBMITTER_VIEW_ALL_ENABLED # Opt-in ("true"): submitters (TECHNICAL/MANNING) read & export every PO + History (read-only)
NEXT_PUBLIC_CREWING_ENABLED # Crewing module feature flag (opt-in "true"; off by default)
NEXT_PUBLIC_CLOSED_PO_ATTACHMENTS_ENABLED # Opt-in ("true"): a PO's own submitter + Accounts/Manager/SuperUser may add attachments in any state except rejected/cancelled (upload-bug remediation + general "attach after the fact"). Off by default.
NEXT_PUBLIC_ENV_LABEL # When set, shows a non-prod banner (EnvBanner). Leave unset in prod.
```

View file

@ -36,8 +36,6 @@ export function HistoryFilters({ vessels, accounts, perPageOptions, defaultPerPa
const [dateFrom, setDateFrom] = useState(sp.get("dateFrom") ?? "");
const [dateTo, setDateTo] = useState(sp.get("dateTo") ?? "");
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"));
@ -65,8 +63,6 @@ export function HistoryFilters({ vessels, accounts, perPageOptions, defaultPerPa
const params = new URLSearchParams();
if (dateFrom) params.set("dateFrom", dateFrom);
if (dateTo) params.set("dateTo", dateTo);
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);
@ -83,14 +79,14 @@ export function HistoryFilters({ vessels, accounts, perPageOptions, defaultPerPa
}
function clear() {
setDateFrom(""); setDateTo(""); setApprovedFrom(""); setApprovedTo(""); setVesselId(""); setAccountId(""); setStatuses([]);
setDateFrom(""); setDateTo(""); 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 || accountId || statuses.length > 0;
const hasFilters = dateFrom || dateTo || vesselId || accountId || statuses.length > 0;
const statusLabel =
statuses.length === 0
@ -112,16 +108,6 @@ export function HistoryFilters({ vessels, accounts, perPageOptions, defaultPerPa
<input type="date" value={dateTo} onChange={(e) => setDateTo(e.target.value)}
className="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20" />
</div>
<div>
<label className="block text-xs font-medium text-neutral-600 mb-1">Approved From</label>
<input type="date" value={approvedFrom} onChange={(e) => setApprovedFrom(e.target.value)}
className="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20" />
</div>
<div>
<label className="block text-xs font-medium text-neutral-600 mb-1">Approved To</label>
<input type="date" value={approvedTo} onChange={(e) => setApprovedTo(e.target.value)}
className="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20" />
</div>
<div>
<label className="block text-xs font-medium text-neutral-600 mb-1">Cost Centre</label>
<select value={vesselId} onChange={(e) => setVesselId(e.target.value)}

View file

@ -4,7 +4,7 @@ import { useState } from "react";
import { useRouter } from "next/navigation";
import { confirmReceipt } from "./actions";
import { FileUploader } from "@/components/po/file-uploader";
import { uploadAndLinkFiles } from "@/lib/upload-files";
import { uploadPoDocuments } from "@/app/actions/upload-po-documents";
interface LineItem {
id: string;
@ -66,7 +66,7 @@ export function ReceiptForm({ poId, lineItems, isPartiallyReceived, isPartiallyP
return;
}
if (files.length > 0) {
const uploadErr = await uploadAndLinkFiles(poId, files, "receipt");
const uploadErr = await uploadPoDocuments(poId, files, "receipt");
if (uploadErr) {
setError(uploadErr.error);
setSubmitting(false);

View file

@ -13,7 +13,7 @@ import { ProjectCodeField } from "@/components/po/project-code-field";
import { PoTermsEditor } from "@/components/po/po-terms-editor";
import { UnsavedChangesGuard } from "@/components/po/unsaved-changes-guard";
import type { CatalogueCategory, PoTerm } from "@/lib/terms";
import { uploadAndLinkFiles } from "@/lib/upload-files";
import { uploadPoDocuments } from "@/app/actions/upload-po-documents";
import type { LineItemInput } from "@/lib/validations/po";
export type VesselOption = { id: string; code: string; name: string };
@ -38,9 +38,21 @@ interface Props {
initialVendorId?: string;
initialVesselId?: string;
initialCompanyId?: string;
// Duplicate-PO prefill (issue #142) — copy editable order fields onto a new draft.
initialTitle?: string;
initialAccountId?: string;
initialMultiAccount?: boolean;
initialProjectCode?: string | null;
initialPlaceOfDelivery?: string | null;
initialDateRequired?: string;
initialPiQuotationNo?: string;
initialPiQuotationDate?: string;
initialRequisitionNo?: string;
initialRequisitionDate?: string;
initialTerms?: PoTerm[];
}
export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptions, projectCodeOptions, termsCatalogue, defaultTerms, initialLineItems, initialVendorId, initialVesselId, initialCompanyId }: Props) {
export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptions, projectCodeOptions, termsCatalogue, defaultTerms, initialLineItems, initialVendorId, initialVesselId, initialCompanyId, initialTitle, initialAccountId, initialMultiAccount, initialProjectCode, initialPlaceOfDelivery, initialDateRequired, initialPiQuotationNo, initialPiQuotationDate, initialRequisitionNo, initialRequisitionDate, initialTerms }: Props) {
const router = useRouter();
const [lineItems, setLineItems] = useState<LineItemInput[]>(
initialLineItems && initialLineItems.length > 0 ? initialLineItems : [EMPTY_LINE]
@ -48,9 +60,11 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
const [files, setFiles] = useState<File[]>([]);
const [submitting, setSubmitting] = useState<"draft" | "submit" | null>(null);
const [error, setError] = useState("");
const [multiAccount, setMultiAccount] = useState(false);
const [defaultAccountId, setDefaultAccountId] = useState("");
const [terms, setTerms] = useState<PoTerm[]>(defaultTerms);
const [multiAccount, setMultiAccount] = useState(initialMultiAccount ?? false);
const [defaultAccountId, setDefaultAccountId] = useState(initialAccountId ?? "");
const [terms, setTerms] = useState<PoTerm[]>(
initialTerms && initialTerms.length > 0 ? initialTerms : defaultTerms
);
const [dirty, setDirty] = useState(false);
const markDirty = () => setDirty(true);
@ -80,7 +94,7 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
return;
}
if (files.length > 0) {
const uploadErr = await uploadAndLinkFiles(result.id, files);
const uploadErr = await uploadPoDocuments(result.id, files);
if (uploadErr) {
setError(uploadErr.error);
setSubmitting(null);
@ -114,7 +128,7 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
<label className="block text-sm font-medium text-neutral-700 mb-1.5">
Title <span className="text-danger">*</span>
</label>
<input name="title" required className={INPUT_CLS} placeholder="Brief description of what is being ordered" />
<input name="title" required defaultValue={initialTitle ?? ""} className={INPUT_CLS} placeholder="Brief description of what is being ordered" />
</div>
{/* Cost Centre — vessels only */}
@ -163,7 +177,7 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1.5">Project Code</label>
<ProjectCodeField options={projectCodeOptions} className={INPUT_CLS} />
<ProjectCodeField options={projectCodeOptions} current={initialProjectCode} className={INPUT_CLS} />
{projectCodeOptions.length === 0 && (
<p className="mt-1.5 text-xs text-neutral-500">
No project codes configured yet a Manager can add them under Administration Project Codes.
@ -172,7 +186,7 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1.5">Delivery Date Required</label>
<input name="dateRequired" type="date" className={INPUT_CLS} />
<input name="dateRequired" type="date" defaultValue={initialDateRequired ?? ""} className={INPUT_CLS} />
</div>
</div>
</section>
@ -183,11 +197,11 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1.5">PI / Quotation No.</label>
<input name="piQuotationNo" className={INPUT_CLS} placeholder='e.g. Verbal, INV-001' />
<input name="piQuotationNo" defaultValue={initialPiQuotationNo ?? ""} className={INPUT_CLS} placeholder='e.g. Verbal, INV-001' />
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1.5">PI / Quotation Date</label>
<input name="piQuotationDate" type="date" className={INPUT_CLS} />
<input name="piQuotationDate" type="date" defaultValue={initialPiQuotationDate ?? ""} className={INPUT_CLS} />
</div>
</div>
</section>
@ -198,11 +212,11 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1.5">Cost Centre / Office Requisition No.</label>
<input name="requisitionNo" className={INPUT_CLS} placeholder="Optional" />
<input name="requisitionNo" defaultValue={initialRequisitionNo ?? ""} className={INPUT_CLS} placeholder="Optional" />
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1.5">Requisition Date</label>
<input name="requisitionDate" type="date" className={INPUT_CLS} />
<input name="requisitionDate" type="date" defaultValue={initialRequisitionDate ?? ""} className={INPUT_CLS} />
</div>
</div>
</section>
@ -212,7 +226,7 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
<h2 className="text-base font-semibold text-neutral-900 mb-4">Delivery</h2>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1.5">Place of Delivery</label>
<DeliveryLocationField options={deliveryOptions} className={INPUT_CLS} />
<DeliveryLocationField options={deliveryOptions} current={initialPlaceOfDelivery} className={INPUT_CLS} />
{deliveryOptions.length === 0 && (
<p className="mt-1.5 text-xs text-neutral-500">
No delivery locations configured yet a Manager can add them under Administration Delivery Locations.

View file

@ -6,6 +6,7 @@ import { NewPoForm } from "./new-po-form";
import { buildAccountGroups } from "@/lib/cost-centre-groups";
import { formatDeliveryLocation } from "@/lib/delivery-location";
import { getTermsCatalogue, getDefaultPoTerms } from "@/lib/terms-data";
import { buildDuplicatePrefill, type DuplicatePrefill } from "@/lib/duplicate-po";
import type { Metadata } from "next";
import type { LineItemInput } from "@/lib/validations/po";
import type { CartItem } from "@/lib/cart";
@ -13,7 +14,7 @@ import type { CartItem } from "@/lib/cart";
export const metadata: Metadata = { title: "New Purchase Order" };
interface Props {
searchParams: Promise<{ cart?: string; vesselId?: string }>;
searchParams: Promise<{ cart?: string; vesselId?: string; duplicate?: string }>;
}
export default async function NewPoPage({ searchParams }: Props) {
@ -22,11 +23,23 @@ export default async function NewPoPage({ searchParams }: Props) {
if (!hasPermission(session.user.role, "create_po")) redirect("/dashboard");
const { cart, vesselId: initialVesselId } = await searchParams;
const { cart, vesselId, duplicate } = await searchParams;
// Duplicate-PO prefill (issue #142): copy a source PO's editable order fields
// onto a fresh draft. Nothing is written until the user saves/submits — same
// shape as the cart→new-PO prefill below, just a richer field set.
let dup: DuplicatePrefill | null = null;
let initialLineItems: LineItemInput[] | undefined;
let initialVendorId: string | undefined;
if (cart) {
let initialVesselId: string | undefined = vesselId;
if (duplicate) {
const source = await db.purchaseOrder.findUnique({
where: { id: duplicate },
include: { lineItems: { orderBy: { sortOrder: "asc" } } },
});
if (source) dup = buildDuplicatePrefill(source);
} else if (cart) {
try {
const cartItems: CartItem[] = JSON.parse(decodeURIComponent(cart));
if (Array.isArray(cartItems) && cartItems.length > 0) {
@ -83,9 +96,21 @@ export default async function NewPoPage({ searchParams }: Props) {
projectCodeOptions={projectCodeOptions}
termsCatalogue={termsCatalogue}
defaultTerms={defaultTerms}
initialLineItems={initialLineItems}
initialVendorId={initialVendorId}
initialVesselId={initialVesselId}
initialLineItems={dup?.initialLineItems ?? initialLineItems}
initialVendorId={dup?.initialVendorId ?? initialVendorId}
initialVesselId={dup?.initialVesselId ?? initialVesselId}
initialCompanyId={dup?.initialCompanyId}
initialTitle={dup?.initialTitle}
initialAccountId={dup?.initialAccountId}
initialMultiAccount={dup?.initialMultiAccount}
initialProjectCode={dup?.initialProjectCode}
initialPlaceOfDelivery={dup?.initialPlaceOfDelivery}
initialDateRequired={dup?.initialDateRequired}
initialPiQuotationNo={dup?.initialPiQuotationNo}
initialPiQuotationDate={dup?.initialPiQuotationDate}
initialRequisitionNo={dup?.initialRequisitionNo}
initialRequisitionDate={dup?.initialRequisitionDate}
initialTerms={dup?.initialTerms}
/>
</div>
);

View file

@ -1,32 +0,0 @@
"use server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { revalidatePath } from "next/cache";
export async function linkDocument({
poId,
storageKey,
fileName,
fileSize,
mimeType,
}: {
poId: string;
storageKey: string;
fileName: string;
fileSize: number;
mimeType: string;
}): Promise<{ ok: true } | { error: string }> {
const session = await auth();
if (!session?.user) return { error: "Unauthorized" };
const po = await db.purchaseOrder.findUnique({ where: { id: poId }, select: { id: true } });
if (!po) return { error: "PO not found" };
await db.pODocument.create({
data: { poId, storageKey, fileName, fileSize, mimeType },
});
revalidatePath(`/po/${poId}`);
return { ok: true };
}

View file

@ -0,0 +1,86 @@
"use server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { buildStorageKey, uploadBuffer } from "@/lib/storage";
import { canAddPoAttachment } from "@/lib/permissions";
import { CLOSED_PO_ATTACHMENTS_ENABLED } from "@/lib/feature-flags";
import { revalidatePath } from "next/cache";
// Matches the FileUploader hint ("up to 10 MB each") and
// next.config.ts → experimental.serverActions.bodySizeLimit.
const MAX_BYTES = 10 * 1024 * 1024;
/**
* Persist PO attachments **server-side**: write each file to storage with
* `uploadBuffer` and create its `PODocument` row in the same step.
*
* Replaces the earlier browser-presigned-PUT flow (`POST /api/files/sign` the
* browser `PUT`s the file straight to R2 `linkDocument` creates the row). That
* direct browserR2 `PUT` only works if the R2 bucket carries a CORS policy
* allowing `PUT` from the portal's origin. In production that policy was missing,
* so the browser silently blocked the upload, `linkDocument` was never reached,
* and **no `PODocument` row was created** the "documents uploaded but not
* visible anywhere" report (0 PODocument rows in prod/staging).
*
* Uploading through the server the same pattern the crewing module already uses
* for CVs / crew documents (`uploadBuffer`) removes the CORS dependency and
* makes the store-and-link atomic, so a created row always has its file.
*
* Returns `{ error }` on the first failure, or `null` on success (the contract
* the PO and receipt forms already expect).
*/
export async function uploadPoDocuments(
poId: string,
files: File[],
type: "po-document" | "receipt" = "po-document"
): Promise<{ error: string } | null> {
const session = await auth();
if (!session?.user) return { error: "Unauthorized" };
const po = await db.purchaseOrder.findUnique({
where: { id: poId },
select: { id: true, status: true, submitterId: true },
});
if (!po) return { error: "PO not found" };
// A voided PO never accepts attachments, regardless of the flag.
if (po.status === "REJECTED" || po.status === "CANCELLED") {
return { error: "Attachments can't be added to a rejected or cancelled purchase order." };
}
if (CLOSED_PO_ATTACHMENTS_ENABLED) {
// Feature on: only the PO's submitter + Accounts / Manager / SuperUser may
// attach, in any non-voided state. The normal create / receipt flows are run
// by exactly those actors, so they keep working.
const allowed = canAddPoAttachment(session.user.role, po.status, {
isSubmitter: po.submitterId === session.user.id,
});
if (!allowed) {
return { error: "Adding attachments to this purchase order isn't allowed." };
}
} else if (po.status === "CLOSED") {
// Feature off: a closed PO stays immutable (legacy behaviour). The create /
// receipt flows upload while the PO is pre-CLOSED, so they're unaffected.
return { error: "Adding attachments to a closed purchase order isn't allowed." };
}
for (const file of files) {
if (!(file instanceof File) || file.size === 0) continue;
if (file.size > MAX_BYTES) {
return { error: `${file.name} is larger than the 10 MB limit.` };
}
const key = buildStorageKey(type, poId, file.name);
const mimeType = file.type || "application/octet-stream";
const buffer = Buffer.from(await file.arrayBuffer());
await uploadBuffer(key, buffer, mimeType);
await db.pODocument.create({
data: { poId, storageKey: key, fileName: file.name, fileSize: file.size, mimeType },
});
}
revalidatePath(`/po/${poId}`);
return null;
}

View file

@ -1,34 +0,0 @@
import { auth } from "@/auth";
import { generateUploadUrl, buildStorageKey } from "@/lib/storage";
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
const signSchema = z.object({
fileName: z.string().min(1),
mimeType: z.string().min(1),
poId: z.string().min(1),
type: z.enum(["po-document", "receipt"]),
});
export async function POST(request: NextRequest) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await request.json();
const parsed = signSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Invalid request" }, { status: 400 });
}
const { fileName, mimeType, poId, type } = parsed.data;
const key = buildStorageKey(type, poId, fileName);
try {
const uploadUrl = await generateUploadUrl(key, mimeType);
return NextResponse.json({ uploadUrl, key });
} catch {
return NextResponse.json({ error: "Failed to generate upload URL" }, { status: 500 });
}
}

View file

@ -0,0 +1,55 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { FileUploader } from "@/components/po/file-uploader";
import { uploadPoDocuments } from "@/app/actions/upload-po-documents";
/**
* Feature-flagged uploader shown on a PO's detail page so its submitter (or
* Accounts / Manager / SuperUser) can add documents after the fact in any state
* except rejected/cancelled. Gating is decided server-side in po-detail.tsx; the
* server action re-checks the permission, so this component is only the UI.
*/
export function PoAttachmentUploader({ poId }: { poId: string }) {
const router = useRouter();
const [files, setFiles] = useState<File[]>([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
async function handleUpload() {
if (files.length === 0) return;
setBusy(true);
setError("");
const err = await uploadPoDocuments(poId, files);
if (err) {
setError(err.error);
setBusy(false);
return;
}
setFiles([]);
setBusy(false);
router.refresh();
}
return (
<div className="mt-5 border-t border-neutral-100 pt-4">
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Add attachments</p>
<p className="mt-0.5 text-xs text-neutral-400">
Attach any documents that are missing from this purchase order.
</p>
<div className="mt-3">
<FileUploader files={files} onChange={setFiles} disabled={busy} />
</div>
{error && <p className="mt-2 text-sm text-danger-700">{error}</p>}
<button
type="button"
onClick={handleUpload}
disabled={busy || files.length === 0}
className="mt-3 rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:opacity-90 disabled:opacity-50"
>
{busy ? "Uploading…" : `Upload${files.length > 0 ? ` ${files.length} file${files.length > 1 ? "s" : ""}` : ""}`}
</button>
</div>
);
}

View file

@ -5,11 +5,14 @@ import { DiscardDraftButton } from "@/components/po/discard-draft-button";
import { SubmitDraftButton } from "@/components/po/submit-draft-button";
import { CancelPoButton, SupersedeForm } from "@/components/po/cancel-po-controls";
import { EmailVendorButton } from "@/components/po/email-vendor-button";
import { PoAttachmentUploader } from "@/components/po/po-attachment-uploader";
import { formatCurrency, formatDate, formatDateTime } from "@/lib/utils";
import { generateDownloadUrl } from "@/lib/storage";
import { groupAttachments } from "@/lib/attachments";
import { canAddPoAttachment, hasPermission } from "@/lib/permissions";
import { TC_FIXED_LINE } from "@/lib/validations/po";
import { parsePoTerms } from "@/lib/terms";
import { actionLabel } from "@/lib/po-activity";
import type { LineItemInput } from "@/lib/validations/po";
import type { Role } from "@prisma/client";
@ -87,26 +90,6 @@ interface Props {
vendorEmail?: string | null;
}
const ACTION_LABELS: Record<string, string> = {
CREATED: "Created",
SUBMITTED: "Submitted for review",
APPROVED: "Approved",
APPROVED_WITH_NOTE: "Approved with note",
REJECTED: "Rejected",
EDITS_REQUESTED: "Edits requested",
VENDOR_ID_REQUESTED: "Vendor ID requested",
VENDOR_ID_PROVIDED: "Vendor ID provided",
PAYMENT_SENT: "Payment confirmed",
PARTIAL_PAYMENT_CONFIRMED: "Partial payment confirmed",
RECEIPT_CONFIRMED: "Receipt confirmed",
PARTIAL_RECEIPT_CONFIRMED: "Partial receipt confirmed",
CLOSED: "Closed",
MANAGER_LINE_EDIT: "Manager amended line items",
PRODUCT_PRICE_UPDATED: "Product prices updated",
CANCELLED: "Cancelled",
SUPERSEDED: "Superseded",
};
export async function PoDetail({ po, currentUserId, currentRole, readOnly = false, vendorEmail = null }: Props) {
const lineItemsForEditor = po.lineItems.map((li) => ({
name: li.name,
@ -171,6 +154,13 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
);
const attachmentGroups = groupAttachments(docsWithUrls);
// Feature-flagged: the PO's submitter (or Accounts / Manager / SuperUser) may add
// attachments after the fact, in any state except rejected/cancelled. Never in
// readOnly. The server action re-checks this permission.
const canAddAttachment =
!readOnly &&
canAddPoAttachment(currentRole, po.status, { isSubmitter: po.submitter.id === currentUserId });
const canConfirmReceipt =
(po.status === "PAID_DELIVERED" || po.status === "PARTIALLY_CLOSED" || po.status === "PARTIALLY_PAID") &&
(po.submitter.id === currentUserId || currentRole === "SUPERUSER") &&
@ -216,6 +206,16 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
!readOnly && (
<DiscardDraftButton poId={po.id} />
)}
{/* Duplicate anyone who can create POs (issue #142). Opens a new PO
form prefilled from this PO; nothing is written until they save. */}
{!readOnly && hasPermission(currentRole, "create_po") && (
<Link
href={`/po/new?duplicate=${po.id}`}
className="rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50"
>
Duplicate
</Link>
)}
{/* Export buttons — available once approved, and for cancelled POs (watermarked) */}
{["MGR_APPROVED", "SENT_FOR_PAYMENT", "PARTIALLY_PAID", "PAID_DELIVERED", "PARTIALLY_CLOSED", "CLOSED", "CANCELLED"].includes(po.status) && (<>
<a
@ -498,9 +498,12 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
})()}
{/* Documents — grouped by lifecycle stage (submission / payment / delivery) */}
{attachmentGroups.length > 0 && (
{(attachmentGroups.length > 0 || canAddAttachment) && (
<div className="rounded-lg border border-neutral-200 bg-white p-6">
<h3 className="text-sm font-semibold text-neutral-900 mb-4">Attachments</h3>
{attachmentGroups.length === 0 && (
<p className="text-sm text-neutral-400">No attachments yet.</p>
)}
<div className="space-y-5">
{attachmentGroups.map((group) => (
<div key={group.meta.key}>
@ -531,6 +534,7 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
</div>
))}
</div>
{canAddAttachment && <PoAttachmentUploader poId={po.id} />}
</div>
)}
@ -577,7 +581,7 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
<div className="absolute -left-1.5 mt-1.5 h-3 w-3 rounded-full border-2 border-white bg-neutral-400" />
<div className="flex items-baseline gap-2">
<span className="text-sm font-medium text-neutral-900">
{ACTION_LABELS[action.actionType] ?? action.actionType}
{actionLabel(action, po.currency)}
</span>
<span className="text-xs text-neutral-400">by {action.actor.name}</span>
<span className="text-xs text-neutral-400 ml-auto">{formatDateTime(action.createdAt)}</span>

105
App/lib/duplicate-po.ts Normal file
View file

@ -0,0 +1,105 @@
/**
* Duplicate-PO prefill (issue #142) map a source PurchaseOrder onto the
* initial-value props the New PO form consumes. Pure (no DB / no I/O) so the
* mapping is unit-testable; the page just fetches the PO and hands it here.
*
* Nothing is written: a duplicate is only a prefilled draft. Attachments,
* status/dates, payment data and audit history are intentionally NOT copied
* a duplicate starts as a clean draft of the editable order fields.
*/
import { parsePoTerms, legacyPoTerms } from "@/lib/terms";
import type { PoTerm } from "@/lib/terms";
import type { LineItemInput } from "@/lib/validations/po";
type DecimalLike = { toNumber: () => number } | number | null | undefined;
const num = (v: DecimalLike, fallback = 0): number =>
v == null ? fallback : typeof v === "number" ? v : v.toNumber();
export type DuplicateSourceLineItem = {
name: string;
description?: string | null;
quantity: DecimalLike;
unit: string;
size?: string | null;
unitPrice: DecimalLike;
gstRate?: DecimalLike;
productId?: string | null;
accountId?: string | null;
};
export type DuplicateSourcePo = {
title: string;
vesselId: string;
accountId: string;
companyId?: string | null;
vendorId?: string | null;
projectCode?: string | null;
placeOfDelivery?: string | null;
dateRequired?: Date | null;
piQuotationNo?: string | null;
piQuotationDate?: Date | null;
requisitionNo?: string | null;
requisitionDate?: Date | null;
terms?: unknown;
tcDelivery?: string | null;
tcDispatch?: string | null;
tcInspection?: string | null;
tcTransitInsurance?: string | null;
tcPaymentTerms?: string | null;
tcOthers?: string | null;
lineItems: DuplicateSourceLineItem[];
};
export type DuplicatePrefill = {
initialLineItems: LineItemInput[];
initialMultiAccount: boolean;
initialVendorId?: string;
initialVesselId: string;
initialCompanyId?: string;
initialTitle: string;
initialAccountId: string;
initialProjectCode: string | null;
initialPlaceOfDelivery: string | null;
initialDateRequired?: string;
initialPiQuotationNo?: string;
initialPiQuotationDate?: string;
initialRequisitionNo?: string;
initialRequisitionDate?: string;
initialTerms: PoTerm[];
};
/** Format a Date to a `yyyy-MM-dd` value for a native date input. */
export const toDateInputValue = (d: Date | null | undefined): string | undefined =>
d ? new Date(d).toISOString().split("T")[0] : undefined;
export function buildDuplicatePrefill(source: DuplicateSourcePo): DuplicatePrefill {
const savedTerms = parsePoTerms(source.terms);
return {
initialLineItems: source.lineItems.map((li) => ({
name: li.name,
description: li.description ?? "",
quantity: num(li.quantity, 1),
unit: li.unit,
size: li.size ?? "",
unitPrice: num(li.unitPrice, 0),
gstRate: li.gstRate != null ? num(li.gstRate, 0.18) : 0.18,
productId: li.productId ?? undefined,
accountId: li.accountId ?? undefined,
})),
initialMultiAccount: source.lineItems.some((li) => !!li.accountId),
initialVendorId: source.vendorId ?? undefined,
initialVesselId: source.vesselId,
initialCompanyId: source.companyId ?? undefined,
initialTitle: source.title,
initialAccountId: source.accountId,
initialProjectCode: source.projectCode ?? null,
initialPlaceOfDelivery: source.placeOfDelivery ?? null,
initialDateRequired: toDateInputValue(source.dateRequired),
initialPiQuotationNo: source.piQuotationNo ?? undefined,
initialPiQuotationDate: toDateInputValue(source.piQuotationDate),
initialRequisitionNo: source.requisitionNo ?? undefined,
initialRequisitionDate: toDateInputValue(source.requisitionDate),
initialTerms: savedTerms.length > 0 ? savedTerms : legacyPoTerms(source),
};
}

View file

@ -15,6 +15,13 @@
* etc.). Opt-in (off unless explicitly "true") because the feature is built incrementally;
* keeping it dark by default leaves production unchanged. See lib/permissions.ts (§6 matrix)
* and wiki Crewing-Implementation-Spec.
*
* NEXT_PUBLIC_CLOSED_PO_ATTACHMENTS_ENABLED=true lets a PO's own submitter, plus
* Accounts / Manager / SuperUser, add attachments to it in any state EXCEPT
* rejected/cancelled. Remediation path for the upload bug where documents never persisted
* (no PODocument row), and the general "attach a document after the fact" affordance.
* Opt-in (off unless "true") so production is unchanged until enabled.
* See lib/permissions.ts (canAddPoAttachment).
*/
export const INVENTORY_ENABLED =
@ -25,3 +32,6 @@ export const SUBMITTER_VIEW_ALL_ENABLED =
export const CREWING_ENABLED =
process.env.NEXT_PUBLIC_CREWING_ENABLED === "true";
export const CLOSED_PO_ATTACHMENTS_ENABLED =
process.env.NEXT_PUBLIC_CLOSED_PO_ATTACHMENTS_ENABLED === "true";

View file

@ -1,5 +1,5 @@
import type { Role } from "@prisma/client";
import { SUBMITTER_VIEW_ALL_ENABLED } from "./feature-flags";
import type { Role, POStatus } from "@prisma/client";
import { SUBMITTER_VIEW_ALL_ENABLED, CLOSED_PO_ATTACHMENTS_ENABLED } from "./feature-flags";
export type Permission =
| "create_po"
@ -278,3 +278,31 @@ export function submitterCanViewAll(role: Role): boolean {
export function canViewAllPos(role: Role): boolean {
return hasPermission(role, "view_all_pos") || submitterCanViewAll(role);
}
// ── PO attachments (feature-flagged) ──────────────────────────────────────────
// Roles that may attach to a PO (besides the PO's own submitter, who is always
// allowed) when NEXT_PUBLIC_CLOSED_PO_ATTACHMENTS_ENABLED=true.
const PO_ATTACHMENT_ROLES: Role[] = ["ACCOUNTS", "MANAGER", "SUPERUSER"];
// A PO in one of these terminal/voided states never accepts new attachments.
const NO_ATTACHMENT_STATUSES: POStatus[] = ["REJECTED", "CANCELLED"];
/**
* Feature-flagged: whether the current user may add attachments to a PO.
*
* When `NEXT_PUBLIC_CLOSED_PO_ATTACHMENTS_ENABLED` is on, the PO's own submitter
* plus Accounts / Manager / SuperUser may attach documents to a PO in **any
* state except `REJECTED` / `CANCELLED`** (a voided PO is never editable). This
* is the remediation path for the upload bug where documents never persisted, and
* the general "add a document after the fact" affordance. Off by default no
* post-hoc attachment UI and closed POs stay immutable.
*/
export function canAddPoAttachment(
role: Role,
status: POStatus,
opts: { isSubmitter: boolean }
): boolean {
if (!CLOSED_PO_ATTACHMENTS_ENABLED) return false;
if (NO_ATTACHMENT_STATUSES.includes(status)) return false;
return opts.isSubmitter || PO_ATTACHMENT_ROLES.includes(role);
}

40
App/lib/po-activity.ts Normal file
View file

@ -0,0 +1,40 @@
import type { Prisma } from "@prisma/client";
import { formatCurrency } from "@/lib/utils";
// Human-readable labels for each POAction type, shown in the PO Activity timeline.
export const ACTION_LABELS: Record<string, string> = {
CREATED: "Created",
SUBMITTED: "Submitted for review",
APPROVED: "Approved",
APPROVED_WITH_NOTE: "Approved with note",
REJECTED: "Rejected",
EDITS_REQUESTED: "Edits requested",
VENDOR_ID_REQUESTED: "Vendor ID requested",
VENDOR_ID_PROVIDED: "Vendor ID provided",
PAYMENT_SENT: "Payment confirmed",
PARTIAL_PAYMENT_CONFIRMED: "Partial payment confirmed",
RECEIPT_CONFIRMED: "Receipt confirmed",
PARTIAL_RECEIPT_CONFIRMED: "Partial receipt confirmed",
CLOSED: "Closed",
MANAGER_LINE_EDIT: "Manager amended line items",
PRODUCT_PRICE_UPDATED: "Product prices updated",
CANCELLED: "Cancelled",
SUPERSEDED: "Superseded",
};
// Produce the Activity-timeline label for an action. Most actions use the static
// ACTION_LABELS map; PARTIAL_PAYMENT_CONFIRMED interpolates the instalment amount
// from the action's metadata (already persisted by markPaid) — issue #140.
export function actionLabel(
action: { actionType: string; metadata: Prisma.JsonValue },
currency: string,
): string {
const fallback = ACTION_LABELS[action.actionType] ?? action.actionType;
if (action.actionType === "PARTIAL_PAYMENT_CONFIRMED") {
const amount = (action.metadata as { paymentAmount?: unknown } | null)?.paymentAmount;
if (typeof amount === "number" && Number.isFinite(amount)) {
return `Partial payment of ${formatCurrency(amount, currency)} confirmed`;
}
}
return fallback;
}

View file

@ -1,34 +0,0 @@
import { linkDocument } from "@/app/actions/link-document";
export async function uploadAndLinkFiles(
poId: string,
files: File[],
type: "po-document" | "receipt" = "po-document"
): Promise<{ error: string } | null> {
for (const file of files) {
const signRes = await fetch("/api/files/sign", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fileName: file.name, mimeType: file.type || "application/octet-stream", poId, type }),
});
if (!signRes.ok) return { error: `Failed to get upload URL for ${file.name}` };
const { uploadUrl, key } = await signRes.json();
const putRes = await fetch(uploadUrl, {
method: "PUT",
headers: { "Content-Type": file.type || "application/octet-stream" },
body: file,
});
if (!putRes.ok) return { error: `Failed to upload ${file.name}` };
const result = await linkDocument({
poId,
storageKey: key,
fileName: file.name,
fileSize: file.size,
mimeType: file.type || "application/octet-stream",
});
if ("error" in result) return { error: result.error };
}
return null;
}

View file

@ -0,0 +1,170 @@
/**
* Integration test for the feature-flagged PO-attachment permission
* (NEXT_PUBLIC_CLOSED_PO_ATTACHMENTS_ENABLED). With the flag ON, a PO's own
* submitter plus Accounts / Manager / SuperUser may attach documents to a PO
* in **any state except REJECTED / CANCELLED**; everyone else, and any voided PO,
* is refused. (The flag-OFF behaviour lives in po-document-upload.test.ts.)
*/
import { vi, describe, it, expect, beforeAll, afterEach } from "vitest";
vi.mock("@/auth", () => ({ auth: vi.fn() }));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
vi.mock("@/lib/storage", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/storage")>();
return { ...actual, uploadBuffer: vi.fn().mockResolvedValue(undefined) };
});
// Flip ONLY the attachment flag on; everything else stays real.
vi.mock("@/lib/feature-flags", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/feature-flags")>();
return { ...actual, CLOSED_PO_ATTACHMENTS_ENABLED: true };
});
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { uploadBuffer } from "@/lib/storage";
import type { Role, POStatus } from "@prisma/client";
import { createPo } from "@/app/(portal)/po/new/actions";
import { uploadPoDocuments } from "@/app/actions/upload-po-documents";
import { makeSession, getSeedUser, getSeedVessel, getSeedAccount, makePoForm, deletePosByTitle } from "./helpers";
const PREFIX = "INTTEST_POATTACH_";
const VOID_ERROR = "Attachments can't be added to a rejected or cancelled purchase order.";
const DENY_ERROR = "Adding attachments to this purchase order isn't allowed.";
let techId: string; // the PO's submitter
let vesselId: string;
let accountId: string;
const userIds: Record<string, string> = {};
beforeAll(async () => {
const [tech, accounts, manager, superuser, manning, auditor, vessel, account] = await Promise.all([
getSeedUser("tech@pelagia.local"),
getSeedUser("accounts@pelagia.local"),
getSeedUser("manager@pelagia.local"),
getSeedUser("superuser@pelagia.local"),
getSeedUser("manning@pelagia.local"),
getSeedUser("auditor@pelagia.local"),
getSeedVessel("MV Pelagia Star"),
getSeedAccount("700201"),
]);
techId = tech.id;
vesselId = vessel.id;
accountId = account.id;
userIds.ACCOUNTS = accounts.id;
userIds.MANAGER = manager.id;
userIds.SUPERUSER = superuser.id;
userIds.MANNING = manning.id;
userIds.AUDITOR = auditor.id;
});
afterEach(async () => {
await deletePosByTitle(PREFIX);
vi.clearAllMocks();
});
function as(userId: string, role: Role) {
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(userId, role));
}
function pdf(name: string): File {
return new File(["%PDF-1.4 hello"], name, { type: "application/pdf" });
}
// A PO submitted by the TECHNICAL user, forced into `status`.
async function makePo(title: string, status: POStatus): Promise<string> {
as(techId, "TECHNICAL");
const result = await createPo(makePoForm({ title, vesselId, accountId, intent: "draft" }));
expect(result).not.toHaveProperty("error");
const poId = (result as { id: string }).id;
if (status !== "DRAFT") {
await db.purchaseOrder.update({ where: { id: poId }, data: { status } });
}
return poId;
}
describe("PO attachment permissions (flag on)", () => {
it("lets the PO's own submitter attach to their PO", async () => {
const poId = await makePo(`${PREFIX}Submitter`, "CLOSED");
as(techId, "TECHNICAL");
const err = await uploadPoDocuments(poId, [pdf("missing-invoice.pdf")]);
expect(err).toBeNull();
expect(await db.pODocument.count({ where: { poId } })).toBe(1);
});
it.each<[string, Role]>([
["ACCOUNTS", "ACCOUNTS"],
["MANAGER", "MANAGER"],
["SUPERUSER", "SUPERUSER"],
])("lets %s attach to a closed PO they did not submit", async (key, role) => {
const poId = await makePo(`${PREFIX}${key}`, "CLOSED");
as(userIds[key], role);
const err = await uploadPoDocuments(poId, [pdf("doc.pdf")]);
expect(err).toBeNull();
expect(await db.pODocument.count({ where: { poId } })).toBe(1);
});
// The headline of this change: not just CLOSED — any live state.
it.each<POStatus>(["MGR_REVIEW", "MGR_APPROVED", "SENT_FOR_PAYMENT", "PAID_DELIVERED", "EDITS_REQUESTED"])(
"lets Manager attach to a PO in %s",
async (status) => {
const poId = await makePo(`${PREFIX}${status}`, status);
as(userIds.MANAGER, "MANAGER");
const err = await uploadPoDocuments(poId, [pdf("doc.pdf")]);
expect(err).toBeNull();
expect(await db.pODocument.count({ where: { poId } })).toBe(1);
}
);
it.each<POStatus>(["REJECTED", "CANCELLED"])(
"refuses attachments to a %s PO, even for Manager",
async (status) => {
const poId = await makePo(`${PREFIX}${status}`, status);
as(userIds.MANAGER, "MANAGER");
const err = await uploadPoDocuments(poId, [pdf("doc.pdf")]);
expect(err).toEqual({ error: VOID_ERROR });
expect(uploadBuffer).not.toHaveBeenCalled();
expect(await db.pODocument.count({ where: { poId } })).toBe(0);
}
);
it("refuses a voided PO even for its own submitter", async () => {
const poId = await makePo(`${PREFIX}SubmitterRejected`, "REJECTED");
as(techId, "TECHNICAL");
const err = await uploadPoDocuments(poId, [pdf("doc.pdf")]);
expect(err).toEqual({ error: VOID_ERROR });
expect(await db.pODocument.count({ where: { poId } })).toBe(0);
});
it("refuses a submitter-role user who is not this PO's submitter", async () => {
const poId = await makePo(`${PREFIX}OtherSubmitter`, "MGR_APPROVED");
as(userIds.MANNING, "MANNING"); // a submitter role, but not the PO's submitter
const err = await uploadPoDocuments(poId, [pdf("doc.pdf")]);
expect(err).toEqual({ error: DENY_ERROR });
expect(uploadBuffer).not.toHaveBeenCalled();
expect(await db.pODocument.count({ where: { poId } })).toBe(0);
});
it("refuses a role outside the allow-list (auditor)", async () => {
const poId = await makePo(`${PREFIX}Auditor`, "CLOSED");
as(userIds.AUDITOR, "AUDITOR");
const err = await uploadPoDocuments(poId, [pdf("doc.pdf")]);
expect(err).toEqual({ error: DENY_ERROR });
expect(await db.pODocument.count({ where: { poId } })).toBe(0);
});
it("still allows the normal create flow (DRAFT submitter)", async () => {
const poId = await makePo(`${PREFIX}Draft`, "DRAFT");
as(techId, "TECHNICAL");
const err = await uploadPoDocuments(poId, [pdf("draft-doc.pdf")]);
expect(err).toBeNull();
expect(await db.pODocument.count({ where: { poId } })).toBe(1);
});
});

View file

@ -0,0 +1,154 @@
/**
* Integration test for PO document upload + visibility (regression for the
* "documents uploaded but not visible anywhere" report).
*
* Drives the real `uploadPoDocuments` server action against a real DB and asserts
* that a `PODocument` row is created AND surfaced by the exact include the PO
* detail page uses i.e. the attachment is actually visible. Storage I/O
* (`uploadBuffer`) is stubbed so the test doesn't depend on R2 / the filesystem;
* the bug was the missing DB row, which is asserted here for real.
*/
import { vi, describe, it, expect, beforeAll, afterEach } from "vitest";
vi.mock("@/auth", () => ({ auth: vi.fn() }));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
// Keep buildStorageKey real (it shapes the storage key the UI groups on); stub
// only the actual storage write so the test is hermetic.
vi.mock("@/lib/storage", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/storage")>();
return { ...actual, uploadBuffer: vi.fn().mockResolvedValue(undefined) };
});
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { uploadBuffer } from "@/lib/storage";
import { createPo } from "@/app/(portal)/po/new/actions";
import { uploadPoDocuments } from "@/app/actions/upload-po-documents";
import { makeSession, getSeedUser, getSeedVessel, getSeedAccount, makePoForm, deletePosByTitle } from "./helpers";
const PREFIX = "INTTEST_PODOC_";
let submitterId: string;
let vesselId: string;
let accountId: string;
beforeAll(async () => {
const [user, vessel, account] = await Promise.all([
getSeedUser("manager@pelagia.local"),
getSeedVessel("MV Pelagia Star"),
getSeedAccount("700201"),
]);
submitterId = user.id;
vesselId = vessel.id;
accountId = account.id;
});
afterEach(async () => {
await deletePosByTitle(PREFIX);
vi.clearAllMocks();
});
async function makePo(title: string): Promise<string> {
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(submitterId, "MANAGER"));
const result = await createPo(makePoForm({ title, vesselId, accountId, intent: "draft" }));
expect(result).not.toHaveProperty("error");
return (result as { id: string }).id;
}
function pdf(name: string, contents = "%PDF-1.4 hello"): File {
return new File([contents], name, { type: "application/pdf" });
}
describe("uploadPoDocuments", () => {
it("creates a PODocument row and stores the file, so it is visible on the PO", async () => {
const poId = await makePo(`${PREFIX}Visible`);
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(submitterId, "MANAGER"));
const file = pdf("invoice.pdf");
const err = await uploadPoDocuments(poId, [file]);
expect(err).toBeNull();
// The file was actually handed to storage with its bytes + mime type.
expect(uploadBuffer).toHaveBeenCalledTimes(1);
const [key, buffer, mime] = vi.mocked(uploadBuffer).mock.calls[0];
expect(key).toMatch(/^po-document\//);
expect(mime).toBe("application/pdf");
expect(Buffer.isBuffer(buffer)).toBe(true);
// The row exists — this is what was missing in the broken flow.
const docs = await db.pODocument.findMany({ where: { poId } });
expect(docs).toHaveLength(1);
expect(docs[0]).toMatchObject({
fileName: "invoice.pdf",
mimeType: "application/pdf",
storageKey: key,
});
expect(docs[0].fileSize).toBeGreaterThan(0);
// And it is surfaced by the exact include the PO detail page renders from.
const po = await db.purchaseOrder.findUnique({
where: { id: poId },
include: { documents: { orderBy: { uploadedAt: "desc" } } },
});
expect(po?.documents.map((d) => d.fileName)).toEqual(["invoice.pdf"]);
});
it("tags receipt uploads with the receipt prefix (delivery group)", async () => {
const poId = await makePo(`${PREFIX}Receipt`);
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(submitterId, "MANAGER"));
const err = await uploadPoDocuments(poId, [pdf("delivery-note.pdf")], "receipt");
expect(err).toBeNull();
const doc = await db.pODocument.findFirstOrThrow({ where: { poId } });
expect(doc.storageKey).toMatch(/^receipt\//);
});
it("stores every file when several are uploaded at once", async () => {
const poId = await makePo(`${PREFIX}Multi`);
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(submitterId, "MANAGER"));
const err = await uploadPoDocuments(poId, [pdf("a.pdf"), pdf("b.pdf"), pdf("c.pdf")]);
expect(err).toBeNull();
expect(await db.pODocument.count({ where: { poId } })).toBe(3);
});
it("skips empty files without creating rows", async () => {
const poId = await makePo(`${PREFIX}Empty`);
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(submitterId, "MANAGER"));
const err = await uploadPoDocuments(poId, [new File([], "blank.pdf", { type: "application/pdf" })]);
expect(err).toBeNull();
expect(uploadBuffer).not.toHaveBeenCalled();
expect(await db.pODocument.count({ where: { poId } })).toBe(0);
});
it("rejects an unauthenticated caller and writes nothing", async () => {
const poId = await makePo(`${PREFIX}NoAuth`);
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(null);
const err = await uploadPoDocuments(poId, [pdf("x.pdf")]);
expect(err).toEqual({ error: "Unauthorized" });
expect(await db.pODocument.count({ where: { poId } })).toBe(0);
});
it("errors when the PO does not exist", async () => {
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(submitterId, "MANAGER"));
const err = await uploadPoDocuments("nonexistent-po-id", [pdf("x.pdf")]);
expect(err).toEqual({ error: "PO not found" });
expect(uploadBuffer).not.toHaveBeenCalled();
});
// The closed-PO remediation flag is OFF here (no mock; env unset), so a CLOSED PO
// stays immutable even for its own submitter. The flag-ON matrix lives in
// closed-po-attachments.test.ts.
it("refuses to attach to a CLOSED PO while the flag is off", async () => {
const poId = await makePo(`${PREFIX}ClosedOff`);
await db.purchaseOrder.update({ where: { id: poId }, data: { status: "CLOSED" } });
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(submitterId, "MANAGER"));
const err = await uploadPoDocuments(poId, [pdf("late.pdf")]);
expect(err).toEqual({ error: "Adding attachments to a closed purchase order isn't allowed." });
expect(uploadBuffer).not.toHaveBeenCalled();
expect(await db.pODocument.count({ where: { poId } })).toBe(0);
});
});

View file

@ -0,0 +1,145 @@
import { describe, it, expect } from "vitest";
import { buildDuplicatePrefill, toDateInputValue } from "@/lib/duplicate-po";
import type { DuplicateSourcePo } from "@/lib/duplicate-po";
// A Prisma Decimal stand-in: just needs a toNumber().
const dec = (n: number) => ({ toNumber: () => n });
function makeSource(overrides: Partial<DuplicateSourcePo> = {}): DuplicateSourcePo {
return {
title: "Spare parts for HNR1",
vesselId: "vsl_1",
accountId: "acc_1",
companyId: "co_1",
vendorId: "ven_1",
projectCode: "Haldia Reach",
placeOfDelivery: "Pelagia — Cochin yard",
dateRequired: new Date("2026-07-15T00:00:00.000Z"),
piQuotationNo: "INV-001",
piQuotationDate: new Date("2026-06-01T00:00:00.000Z"),
requisitionNo: "REQ-42",
requisitionDate: new Date("2026-05-20T00:00:00.000Z"),
terms: [{ category: "Delivery", text: "Within 4 to 5 days" }],
lineItems: [
{
name: "Gasket",
description: "Rubber gasket",
quantity: dec(3),
unit: "pc",
size: "M",
unitPrice: dec(120.5),
gstRate: dec(0.18),
productId: "prod_1",
accountId: null,
},
],
...overrides,
};
}
describe("buildDuplicatePrefill", () => {
it("copies the editable order fields onto the new draft", () => {
const r = buildDuplicatePrefill(makeSource());
expect(r.initialTitle).toBe("Spare parts for HNR1");
expect(r.initialVesselId).toBe("vsl_1");
expect(r.initialAccountId).toBe("acc_1");
expect(r.initialCompanyId).toBe("co_1");
expect(r.initialVendorId).toBe("ven_1");
expect(r.initialProjectCode).toBe("Haldia Reach");
expect(r.initialPlaceOfDelivery).toBe("Pelagia — Cochin yard");
expect(r.initialPiQuotationNo).toBe("INV-001");
expect(r.initialRequisitionNo).toBe("REQ-42");
});
it("formats dates as yyyy-MM-dd for native date inputs", () => {
const r = buildDuplicatePrefill(makeSource());
expect(r.initialDateRequired).toBe("2026-07-15");
expect(r.initialPiQuotationDate).toBe("2026-06-01");
expect(r.initialRequisitionDate).toBe("2026-05-20");
});
it("maps line items to the editor shape, converting Decimals to numbers", () => {
const r = buildDuplicatePrefill(makeSource());
expect(r.initialLineItems).toEqual([
{
name: "Gasket",
description: "Rubber gasket",
quantity: 3,
unit: "pc",
size: "M",
unitPrice: 120.5,
gstRate: 0.18,
productId: "prod_1",
accountId: undefined,
},
]);
});
it("enables per-item accounting codes only when a line item carries one", () => {
expect(buildDuplicatePrefill(makeSource()).initialMultiAccount).toBe(false);
const multi = makeSource({
lineItems: [
{ name: "A", quantity: dec(1), unit: "pc", unitPrice: dec(10), accountId: "acc_2" },
],
});
const r = buildDuplicatePrefill(multi);
expect(r.initialMultiAccount).toBe(true);
expect(r.initialLineItems[0].accountId).toBe("acc_2");
});
it("defaults a missing gstRate to 0.18", () => {
const src = makeSource({
lineItems: [{ name: "A", quantity: dec(2), unit: "pc", unitPrice: dec(5) }],
});
expect(buildDuplicatePrefill(src).initialLineItems[0].gstRate).toBe(0.18);
});
it("uses the saved terms snapshot when present", () => {
const r = buildDuplicatePrefill(makeSource());
expect(r.initialTerms).toEqual([{ category: "Delivery", text: "Within 4 to 5 days" }]);
});
it("falls back to legacy tc* terms when no JSON snapshot exists", () => {
const src = makeSource({
terms: null,
tcDelivery: "Next day",
tcPaymentTerms: "Net 15",
});
const r = buildDuplicatePrefill(src);
expect(r.initialTerms).toEqual(
expect.arrayContaining([
{ category: "Delivery", text: "Next day" },
{ category: "Payment Terms", text: "Net 15" },
])
);
});
it("normalises absent optional fields to undefined/null", () => {
const src = makeSource({
companyId: null,
vendorId: null,
projectCode: null,
placeOfDelivery: null,
dateRequired: null,
piQuotationNo: null,
piQuotationDate: null,
requisitionNo: null,
requisitionDate: null,
});
const r = buildDuplicatePrefill(src);
expect(r.initialCompanyId).toBeUndefined();
expect(r.initialVendorId).toBeUndefined();
expect(r.initialDateRequired).toBeUndefined();
expect(r.initialPiQuotationNo).toBeUndefined();
expect(r.initialProjectCode).toBeNull();
expect(r.initialPlaceOfDelivery).toBeNull();
});
});
describe("toDateInputValue", () => {
it("returns yyyy-MM-dd for a date and undefined for null", () => {
expect(toDateInputValue(new Date("2026-01-09T12:00:00.000Z"))).toBe("2026-01-09");
expect(toDateInputValue(null)).toBeUndefined();
expect(toDateInputValue(undefined)).toBeUndefined();
});
});

View file

@ -0,0 +1,36 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
// HistoryFilters reads the URL via next/navigation; mock both hooks it uses.
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), refresh: vi.fn() }),
useSearchParams: () => new URLSearchParams(""),
}));
import { HistoryFilters } from "@/app/(portal)/history/history-filters";
const props = {
vessels: [{ id: "v1", name: "Vessel One" }],
accounts: [],
perPageOptions: [25, 50, 100],
defaultPerPage: 25,
};
describe("HistoryFilters", () => {
// Regression guard for issue #136: the "Approved From" / "Approved To" date
// filters were removed from PO History. They must not reappear.
it("does not render the Approved From / Approved To filters", () => {
render(<HistoryFilters {...props} />);
expect(screen.queryByText("Approved From")).toBeNull();
expect(screen.queryByText("Approved To")).toBeNull();
});
it("still renders the remaining filters (created-date range, cost centre, accounting code, status)", () => {
render(<HistoryFilters {...props} />);
expect(screen.getByText("From")).toBeInTheDocument();
expect(screen.getByText("To")).toBeInTheDocument();
expect(screen.getByText("Cost Centre")).toBeInTheDocument();
expect(screen.getByText("Accounting Code")).toBeInTheDocument();
expect(screen.getByText("Status")).toBeInTheDocument();
});
});

View file

@ -0,0 +1,54 @@
import { describe, it, expect } from "vitest";
import { actionLabel } from "@/lib/po-activity";
import { formatCurrency } from "@/lib/utils";
describe("actionLabel (Activity timeline)", () => {
it("interpolates the instalment amount for a partial payment (issue #140)", () => {
const label = actionLabel(
{ actionType: "PARTIAL_PAYMENT_CONFIRMED", metadata: { paymentAmount: 5000 } },
"INR"
);
expect(label).toBe(`Partial payment of ${formatCurrency(5000, "INR")} confirmed`);
expect(label).toContain("5,000");
});
it("respects the PO currency", () => {
const label = actionLabel(
{ actionType: "PARTIAL_PAYMENT_CONFIRMED", metadata: { paymentAmount: 1200 } },
"USD"
);
expect(label).toBe(`Partial payment of ${formatCurrency(1200, "USD")} confirmed`);
});
it("falls back to the plain label when paymentAmount is missing (older audit rows)", () => {
expect(
actionLabel({ actionType: "PARTIAL_PAYMENT_CONFIRMED", metadata: null }, "INR")
).toBe("Partial payment confirmed");
expect(
actionLabel(
{ actionType: "PARTIAL_PAYMENT_CONFIRMED", metadata: { paymentRef: "TXN-1" } },
"INR"
)
).toBe("Partial payment confirmed");
});
it("falls back when paymentAmount is non-numeric, never rendering NaN", () => {
const label = actionLabel(
{ actionType: "PARTIAL_PAYMENT_CONFIRMED", metadata: { paymentAmount: "5000" } },
"INR"
);
expect(label).toBe("Partial payment confirmed");
expect(label).not.toContain("NaN");
});
it("leaves other action labels unchanged", () => {
expect(
actionLabel({ actionType: "PAYMENT_SENT", metadata: { paymentAmount: 5000 } }, "INR")
).toBe("Payment confirmed");
expect(actionLabel({ actionType: "APPROVED", metadata: null }, "INR")).toBe("Approved");
});
it("falls back to the raw action type for unknown actions", () => {
expect(actionLabel({ actionType: "MYSTERY", metadata: null }, "INR")).toBe("MYSTERY");
});
});

View file

@ -60,6 +60,11 @@ requires an interactive ESLint migration (a follow-up). Integration tests are
type-checked here but executed against the `pelagia_test` DB by the autofix / locally
(not in this shared CI, to avoid prod-mirror schema drift).
The **issue watcher pre-applies gate 1 (test-presence) locally** before opening a PR:
if Claude's fix changes code under `App/app|lib|components|hooks` but adds no test, the
watcher does **not** open a PR — it marks the issue `claude-failed` and comments — so it
never raises a PR that this CI would immediately reject. Re-queue (`claude-queue`) to retry.
A [`PULL_REQUEST_TEMPLATE.md`](../.forgejo/PULL_REQUEST_TEMPLATE.md) carries the checklist.
## Components

View file

@ -339,6 +339,22 @@ while [ "$f" -lt "$n_fix" ]; do
commits=$(git -C "$WORKDIR" rev-list "origin/$BASE_BRANCH..HEAD" --count)
if [ "$commits" -gt 0 ]; then
# Test-presence gate -- mirror .forgejo/workflows/pr-checks.yml so the watcher
# never opens a PR the CI will immediately reject. "Code" = app source under
# App/(app|lib|components|hooks); tests, prisma, config, docs are exempt.
changed=$(git -C "$WORKDIR" diff --name-only "origin/$BASE_BRANCH...HEAD")
code_changed=$(printf '%s\n' "$changed" | grep -E '^App/(app|lib|components|hooks)/' | grep -vE '(\.test\.|\.spec\.|/tests/)' || true)
test_changed=$(printf '%s\n' "$changed" | grep -E '(\.test\.|\.spec\.|/tests/)' || true)
if [ -n "$code_changed" ] && [ -z "$test_changed" ]; then
log "Test-presence gate FAILED for #$num: code changed with no test; not opening a PR"
set_labels "$num" "claude-working" "claude-failed"
add_comment "$num" "$BOT_MARKER
[Claude] Implemented a change but added **no test**, so no PR was opened. The contribution policy (\`pr-checks.yml\`) requires a test for any change under \`App/app|lib|components|hooks\`, and would reject this. Re-add \`claude-queue\` to retry, or pick it up interactively.
Code files that needed an accompanying test:
$code_changed"
continue
fi
log "Claude made $commits commit(s); pushing $branch"
if ! git -C "$WORKDIR" push -f -u origin "$branch" -q 2>>"$LOG_FILE"; then
log "push failed for #$num"; set_labels "$num" "claude-working" "claude-failed"; continue

View file

@ -43,6 +43,7 @@ AZURE_AD_TENANT_ID="dev-placeholder"
DATABASE_URL="$TEST_URL"
GST_SERVICE_URL="http://localhost:3003"
NEXT_PUBLIC_SUBMITTER_VIEW_ALL_ENABLED=true
NEXT_PUBLIC_CLOSED_PO_ATTACHMENTS_ENABLED=true
NEXT_PUBLIC_ENV_LABEL="INTERNAL DEV / STAGING - NOT PRODUCTION"
PORT=$PORT
EOF
@ -55,6 +56,10 @@ fi
if ! grep -qE '^NEXT_PUBLIC_SUBMITTER_VIEW_ALL_ENABLED=' "$DIR/App/.env"; then
printf 'NEXT_PUBLIC_SUBMITTER_VIEW_ALL_ENABLED=true\n' >> "$DIR/App/.env"
fi
# Closed-PO attachment remediation — let staging exercise fixing bug-affected POs.
if ! grep -qE '^NEXT_PUBLIC_CLOSED_PO_ATTACHMENTS_ENABLED=' "$DIR/App/.env"; then
printf 'NEXT_PUBLIC_CLOSED_PO_ATTACHMENTS_ENABLED=true\n' >> "$DIR/App/.env"
fi
# pm2-run wrapper so the dev server always gets nvm on PATH and the right port.
# Bind to 127.0.0.1 only -- staging is reachable solely via SSH tunnel