Merge commit '859be8c8d0' into fix-pr34

# Conflicts:
#	App/app/(portal)/history/history-filters.tsx
This commit is contained in:
Hardik 2026-06-19 12:35:35 +05:30
commit 791e99f3fd
13 changed files with 360 additions and 68 deletions

View file

@ -54,3 +54,8 @@ GST_SERVICE_URL=http://localhost:3003
FORGEJO_URL=https://git.pelagiamarine.com FORGEJO_URL=https://git.pelagiamarine.com
FORGEJO_REPO=shad0w/pelagia-portal FORGEJO_REPO=shad0w/pelagia-portal
FORGEJO_TOKEN= FORGEJO_TOKEN=
# ── Non-production banner ─────────────────────────────────────
# When set, a fixed "internal dev / staging" banner is shown (EnvBanner).
# Leave UNSET in production. Staging sets this automatically.
# NEXT_PUBLIC_ENV_LABEL="INTERNAL DEV / STAGING - NOT PRODUCTION"

View file

@ -95,7 +95,31 @@ NEXTAUTH_SECRET # Required always
NEXTAUTH_URL # Required always (e.g., http://localhost:3000) NEXTAUTH_URL # Required always (e.g., http://localhost:3000)
DATABASE_URL # PostgreSQL connection string DATABASE_URL # PostgreSQL connection string
AZURE_AD_CLIENT_ID, AZURE_AD_CLIENT_SECRET, AZURE_AD_TENANT_ID
# Microsoft Entra SSO (prod). auth.ts reads them at module
# load — set placeholders in non-SSO/dev envs so it boots.
# Optional in dev (defaults to local storage + console email): # Optional in dev (defaults to local storage + console email):
R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_BUCKET_NAME, R2_PUBLIC_URL R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_BUCKET_NAME, R2_PUBLIC_URL
RESEND_API_KEY, EMAIL_FROM, EMAIL_FROM_NAME RESEND_API_KEY, EMAIL_FROM, EMAIL_FROM_NAME
# Report Issue button (lib/forgejo.ts); token needs write:issue:
FORGEJO_URL, FORGEJO_REPO, FORGEJO_TOKEN
GST_SERVICE_URL # GstService microservice (defaults to localhost:3003)
NEXT_PUBLIC_INVENTORY_ENABLED # Inventory feature flag
NEXT_PUBLIC_ENV_LABEL # When set, shows a non-prod banner (EnvBanner). Leave unset in prod.
``` ```
### Operations & automation
This repo runs a self-hosted issue-to-deploy pipeline on the `pms1` server (Forgejo +
headless Claude Code). See [`../automation/README.md`](../automation/README.md). Relevant
when working in this codebase:
- The **Report Issue** button (portal header) files a Forgejo issue; a watcher triages it
and, for auto-fixable ones, implements a fix and opens a PR. Deploys are gated on a
human merging the PR and pushing a `vX.Y.Z` tag.
- Automated fixes and the **staging** instance run against `pelagia_test`, a **daily mirror
of the production database**, in dev mode (console email, local storage). Migrations are
applied to it, so its schema tracks `master`. Never assume an empty DB — it holds prod-like data.

View file

@ -116,6 +116,15 @@ R2_PUBLIC_URL=https://<bucket>.<account>.r2.cloudflarestorage.com
RESEND_API_KEY=re_<your key> RESEND_API_KEY=re_<your key>
EMAIL_FROM=noreply@yourdomain.com EMAIL_FROM=noreply@yourdomain.com
EMAIL_FROM_NAME="Pelagia Portal" EMAIL_FROM_NAME="Pelagia Portal"
# Report Issue button -> files a Forgejo issue (optional; token needs write:issue)
FORGEJO_URL=https://git.example.com
FORGEJO_REPO=owner/repo
FORGEJO_TOKEN=<forgejo access token>
# Non-prod banner (leave UNSET in production). When set, a fixed
# "INTERNAL DEV / STAGING - NOT PRODUCTION" banner is shown.
# NEXT_PUBLIC_ENV_LABEL="INTERNAL DEV / STAGING - NOT PRODUCTION"
``` ```
### 2. Run database migrations ### 2. Run database migrations
@ -135,6 +144,27 @@ The app listens on port 3000 by default. Point your reverse proxy (nginx, Caddy,
--- ---
## Operations & Automation
This repo carries its own self-hosted **issue-to-deploy pipeline** (Forgejo + Claude Code
on the `pms1` server). The full design and runbook live in
**[`../automation/README.md`](../automation/README.md)**. In short:
- **Report Issue button** (portal header) files a Forgejo issue tagged `portal`.
- A **watcher** triages each issue (Claude posts a requirements breakdown and routes it
to `claude-queue` or `interactive`), then for queued issues implements a fix and opens a PR.
- Merging a PR and pushing a **release tag (`vX.Y.Z`)** triggers a Forgejo Actions runner
that deploys to production.
- A **staging instance** (`automation/staging-up.sh`, pm2 `ppms-staging` on port 3200,
SSH-tunnel only) runs the latest `master` against a daily **prod-mirror test DB**
(`pelagia_test`) for smoke testing before tagging a release.
Operational scripts live under [`../automation/`](../automation/): `claude-issue-watcher.sh`
(watcher), `refresh-test-db.sh` (nightly test-DB refresh), `staging-up.sh` (staging),
and `staging-tunnel.cmd` (Windows tunnel launcher).
---
## Database Management ## Database Management
| Command | Purpose | | Command | Purpose |

View file

@ -1,10 +1,9 @@
"use client"; "use client";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { useState } from "react"; import { useEffect, useRef, useState } from "react";
const STATUSES = [ const STATUSES = [
{ value: "", label: "All statuses" },
{ value: "DRAFT", label: "Draft" }, { value: "DRAFT", label: "Draft" },
{ value: "SUBMITTED", label: "Submitted" }, { value: "SUBMITTED", label: "Submitted" },
{ value: "MGR_REVIEW", label: "Pending Approval" }, { value: "MGR_REVIEW", label: "Pending Approval" },
@ -30,7 +29,25 @@ export function HistoryFilters({ vessels }: Props) {
const [approvedFrom, setApprovedFrom] = useState(sp.get("approvedFrom") ?? ""); const [approvedFrom, setApprovedFrom] = useState(sp.get("approvedFrom") ?? "");
const [approvedTo, setApprovedTo] = useState(sp.get("approvedTo") ?? ""); const [approvedTo, setApprovedTo] = useState(sp.get("approvedTo") ?? "");
const [vesselId, setVesselId] = useState(sp.get("vesselId") ?? ""); const [vesselId, setVesselId] = useState(sp.get("vesselId") ?? "");
const [status, setStatus] = useState(sp.get("status") ?? ""); const [statuses, setStatuses] = useState<string[]>(sp.getAll("status"));
const [statusOpen, setStatusOpen] = useState(false);
const statusRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function onClick(e: MouseEvent) {
if (statusRef.current && !statusRef.current.contains(e.target as Node)) {
setStatusOpen(false);
}
}
document.addEventListener("mousedown", onClick);
return () => document.removeEventListener("mousedown", onClick);
}, []);
function toggleStatus(value: string) {
setStatuses((prev) =>
prev.includes(value) ? prev.filter((s) => s !== value) : [...prev, value]
);
}
function apply() { function apply() {
const params = new URLSearchParams(); const params = new URLSearchParams();
@ -39,16 +56,23 @@ export function HistoryFilters({ vessels }: Props) {
if (approvedFrom) params.set("approvedFrom", approvedFrom); if (approvedFrom) params.set("approvedFrom", approvedFrom);
if (approvedTo) params.set("approvedTo", approvedTo); if (approvedTo) params.set("approvedTo", approvedTo);
if (vesselId) params.set("vesselId", vesselId); if (vesselId) params.set("vesselId", vesselId);
if (status) params.set("status", status); for (const s of statuses) params.append("status", s);
router.push(`/history?${params.toString()}`); router.push(`/history?${params.toString()}`);
} }
function clear() { function clear() {
setDateFrom(""); setDateTo(""); setApprovedFrom(""); setApprovedTo(""); setVesselId(""); setStatus(""); setDateFrom(""); setDateTo(""); setApprovedFrom(""); setApprovedTo(""); setVesselId(""); setStatuses([]);
router.push("/history"); router.push("/history");
} }
const hasFilters = dateFrom || dateTo || approvedFrom || approvedTo || vesselId || status; const hasFilters = dateFrom || dateTo || approvedFrom || approvedTo || vesselId || statuses.length > 0;
const statusLabel =
statuses.length === 0
? "All statuses"
: statuses.length === 1
? (STATUSES.find((s) => s.value === statuses[0])?.label ?? statuses[0])
: `${statuses.length} statuses`;
return ( return (
<div className="mb-4 rounded-lg border border-neutral-200 bg-white p-4"> <div className="mb-4 rounded-lg border border-neutral-200 bg-white p-4">
@ -81,12 +105,26 @@ export function HistoryFilters({ vessels }: Props) {
{vessels.map((v) => <option key={v.id} value={v.id}>{v.name}</option>)} {vessels.map((v) => <option key={v.id} value={v.id}>{v.name}</option>)}
</select> </select>
</div> </div>
<div> <div className="relative" ref={statusRef}>
<label className="block text-xs font-medium text-neutral-600 mb-1">Status</label> <label className="block text-xs font-medium text-neutral-600 mb-1">Status</label>
<select value={status} onChange={(e) => setStatus(e.target.value)} <button type="button" onClick={() => setStatusOpen((o) => !o)}
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"> className="flex w-full items-center justify-between rounded-lg border border-neutral-300 px-3 py-2 text-left text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20">
{STATUSES.map((s) => <option key={s.value} value={s.value}>{s.label}</option>)} <span className={statuses.length === 0 ? "text-neutral-500" : "text-neutral-900"}>{statusLabel}</span>
</select> <svg className="ml-2 h-4 w-4 shrink-0 text-neutral-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fillRule="evenodd" d="M5.23 7.21a.75.75 0 011.06.02L10 11.17l3.71-3.94a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z" clipRule="evenodd" />
</svg>
</button>
{statusOpen && (
<div className="absolute z-10 mt-1 max-h-64 w-full overflow-auto rounded-lg border border-neutral-200 bg-white py-1 shadow-lg">
{STATUSES.map((s) => (
<label key={s.value} className="flex cursor-pointer items-center gap-2 px-3 py-1.5 text-sm hover:bg-neutral-50">
<input type="checkbox" checked={statuses.includes(s.value)} onChange={() => toggleStatus(s.value)}
className="h-4 w-4 rounded border-neutral-300 text-primary-600 focus:ring-primary-500/20" />
<span className="text-neutral-700">{s.label}</span>
</label>
))}
</div>
)}
</div> </div>
</div> </div>
<div className="mt-3 flex items-center gap-2"> <div className="mt-3 flex items-center gap-2">

View file

@ -19,7 +19,7 @@ interface Props {
approvedFrom?: string; approvedFrom?: string;
approvedTo?: string; approvedTo?: string;
vesselId?: string; vesselId?: string;
status?: string; status?: string | string[];
}>; }>;
} }
@ -53,7 +53,8 @@ export default async function HistoryPage({ searchParams }: Props) {
where.approvedAt = approvedAt; where.approvedAt = approvedAt;
} }
if (vesselId) where.vesselId = vesselId; if (vesselId) where.vesselId = vesselId;
if (status) where.status = status as POStatus; const statuses = (Array.isArray(status) ? status : status ? [status] : []).filter(Boolean);
if (statuses.length > 0) where.status = { in: statuses as POStatus[] };
const [orders, vessels] = await Promise.all([ const [orders, vessels] = await Promise.all([
db.purchaseOrder.findMany({ db.purchaseOrder.findMany({
@ -71,7 +72,7 @@ export default async function HistoryPage({ searchParams }: Props) {
if (approvedFrom) exportParams.set("approvedFrom", approvedFrom); if (approvedFrom) exportParams.set("approvedFrom", approvedFrom);
if (approvedTo) exportParams.set("approvedTo", approvedTo); if (approvedTo) exportParams.set("approvedTo", approvedTo);
if (vesselId) exportParams.set("vesselId", vesselId); if (vesselId) exportParams.set("vesselId", vesselId);
if (status) exportParams.set("status", status); for (const s of statuses) exportParams.append("status", s);
return ( return (
<div> <div>

View file

@ -27,7 +27,7 @@ export async function GET(request: NextRequest) {
const approvedFrom = sp.get("approvedFrom"); const approvedFrom = sp.get("approvedFrom");
const approvedTo = sp.get("approvedTo"); const approvedTo = sp.get("approvedTo");
const vesselId = sp.get("vesselId"); const vesselId = sp.get("vesselId");
const status = sp.get("status"); const statuses = sp.getAll("status").filter(Boolean);
const where: NonNullable<Parameters<typeof db.purchaseOrder.findMany>[0]>["where"] = {}; const where: NonNullable<Parameters<typeof db.purchaseOrder.findMany>[0]>["where"] = {};
if (dateFrom || dateTo) { if (dateFrom || dateTo) {
@ -51,7 +51,7 @@ export async function GET(request: NextRequest) {
where.approvedAt = approvedAt; where.approvedAt = approvedAt;
} }
if (vesselId) where.vesselId = vesselId; if (vesselId) where.vesselId = vesselId;
if (status) where.status = status as POStatus; if (statuses.length > 0) where.status = { in: statuses as POStatus[] };
const orders = await db.purchaseOrder.findMany({ const orders = await db.purchaseOrder.findMany({
where, where,

View file

@ -5,6 +5,7 @@ import { DiscardDraftButton } from "@/components/po/discard-draft-button";
import { SubmitDraftButton } from "@/components/po/submit-draft-button"; import { SubmitDraftButton } from "@/components/po/submit-draft-button";
import { formatCurrency, formatDate, formatDateTime } from "@/lib/utils"; import { formatCurrency, formatDate, formatDateTime } from "@/lib/utils";
import { generateDownloadUrl } from "@/lib/storage"; import { generateDownloadUrl } from "@/lib/storage";
import { groupAttachments } from "@/lib/attachments";
import { TC_FIXED_LINE } from "@/lib/validations/po"; import { TC_FIXED_LINE } from "@/lib/validations/po";
import type { LineItemInput } from "@/lib/validations/po"; import type { LineItemInput } from "@/lib/validations/po";
import type { Role } from "@prisma/client"; import type { Role } from "@prisma/client";
@ -149,9 +150,13 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
? "Submitter updated these line items after edits were requested. Previous values shown with strikethrough." ? "Submitter updated these line items after edits were requested. Previous values shown with strikethrough."
: "Line items were amended by manager. Current values shown; original values shown with strikethrough."; : "Line items were amended by manager. Current values shown; original values shown with strikethrough.";
const downloadUrls = await Promise.all( const docsWithUrls = await Promise.all(
po.documents.map((doc) => generateDownloadUrl(doc.storageKey)) po.documents.map(async (doc) => ({
...doc,
url: await generateDownloadUrl(doc.storageKey),
}))
); );
const attachmentGroups = groupAttachments(docsWithUrls);
const canConfirmReceipt = const canConfirmReceipt =
(po.status === "PAID_DELIVERED" || po.status === "PARTIALLY_CLOSED" || po.status === "PARTIALLY_PAID") && (po.status === "PAID_DELIVERED" || po.status === "PARTIALLY_CLOSED" || po.status === "PARTIALLY_PAID") &&
@ -399,27 +404,40 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
</div> </div>
)} )}
{/* Documents */} {/* Documents — grouped by lifecycle stage (submission / payment / delivery) */}
{po.documents.length > 0 && ( {attachmentGroups.length > 0 && (
<div className="rounded-lg border border-neutral-200 bg-white p-6"> <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> <h3 className="text-sm font-semibold text-neutral-900 mb-4">Attachments</h3>
<ul className="space-y-2"> <div className="space-y-5">
{po.documents.map((doc, i) => ( {attachmentGroups.map((group) => (
<li key={doc.id} className="flex items-center gap-3 text-sm"> <div key={group.meta.key}>
<a <h4 className="text-xs font-semibold uppercase tracking-wide text-neutral-500">
href={downloadUrls[i]} {group.meta.label}
target="_blank" <span className="ml-1.5 font-normal text-neutral-400">({group.items.length})</span>
rel="noopener noreferrer" </h4>
className="font-medium text-primary-600 hover:underline" {group.meta.description && (
> <p className="mt-0.5 text-xs text-neutral-400">{group.meta.description}</p>
{doc.fileName} )}
</a> <ul className="mt-2 space-y-2">
<span className="text-neutral-400 text-xs"> {group.items.map((doc) => (
{(doc.fileSize / 1024).toFixed(0)} KB · {formatDate(doc.uploadedAt)} <li key={doc.id} className="flex items-center gap-3 text-sm">
</span> <a
</li> href={doc.url}
target="_blank"
rel="noopener noreferrer"
className="font-medium text-primary-600 hover:underline"
>
{doc.fileName}
</a>
<span className="text-neutral-400 text-xs">
{(doc.fileSize / 1024).toFixed(0)} KB · {formatDate(doc.uploadedAt)}
</span>
</li>
))}
</ul>
</div>
))} ))}
</ul> </div>
</div> </div>
)} )}

96
App/lib/attachments.ts Normal file
View file

@ -0,0 +1,96 @@
/**
* Attachment grouping.
*
* All PO attachments are stored as `PODocument` rows. The lifecycle stage an
* attachment belongs to is encoded in the leading segment of its `storageKey`
* (see `buildStorageKey` in `lib/storage.ts`), e.g. `po-document/<poId>/...`
* or `receipt/<poId>/...`. This module derives a user-facing grouping from
* that prefix so the PO details screen can show every attachment grouped by
* type (submission, payment, delivery).
*/
export type AttachmentGroupKey = "submission" | "payment" | "delivery" | "other";
export interface AttachmentGroupMeta {
key: AttachmentGroupKey;
label: string;
description: string;
}
/** Display order for attachment groups (lifecycle order). */
export const ATTACHMENT_GROUP_ORDER: AttachmentGroupKey[] = [
"submission",
"payment",
"delivery",
"other",
];
export const ATTACHMENT_GROUP_META: Record<AttachmentGroupKey, AttachmentGroupMeta> = {
submission: {
key: "submission",
label: "Submission documents",
description: "Uploaded with the purchase order (e.g. invoice, quotation).",
},
payment: {
key: "payment",
label: "Payment documents",
description: "Uploaded at payment (e.g. payment proof).",
},
delivery: {
key: "delivery",
label: "Delivery receipts",
description: "Uploaded at delivery confirmation (e.g. delivery receipt).",
},
other: {
key: "other",
label: "Other attachments",
description: "",
},
};
/**
* Derive the lifecycle group of an attachment from its storage key prefix.
* Unknown prefixes fall back to "other" so nothing is ever hidden.
*/
export function categorizeAttachment(storageKey: string): AttachmentGroupKey {
const prefix = storageKey.split("/")[0];
switch (prefix) {
case "po-document":
return "submission";
case "payment-document":
case "payment":
return "payment";
case "receipt":
return "delivery";
default:
return "other";
}
}
export interface AttachmentGroup<T> {
meta: AttachmentGroupMeta;
items: T[];
}
/**
* Group attachments by lifecycle stage, returning only non-empty groups in
* canonical lifecycle order. Item order within each group is preserved.
*/
export function groupAttachments<T extends { storageKey: string }>(
documents: T[]
): AttachmentGroup<T>[] {
const buckets = new Map<AttachmentGroupKey, T[]>();
for (const doc of documents) {
const key = categorizeAttachment(doc.storageKey);
const bucket = buckets.get(key);
if (bucket) bucket.push(doc);
else buckets.set(key, [doc]);
}
return ATTACHMENT_GROUP_ORDER.flatMap((key) => {
const items = buckets.get(key);
return items && items.length > 0
? [{ meta: ATTACHMENT_GROUP_META[key], items }]
: [];
});
}

View file

@ -0,0 +1,67 @@
import { describe, expect, it } from "vitest";
import {
categorizeAttachment,
groupAttachments,
} from "@/lib/attachments";
describe("categorizeAttachment", () => {
it("maps po-document keys to the submission group", () => {
expect(categorizeAttachment("po-document/po123/1700-invoice.pdf")).toBe("submission");
});
it("maps receipt keys to the delivery group", () => {
expect(categorizeAttachment("receipt/po123/1700-delivery.pdf")).toBe("delivery");
});
it("maps payment keys to the payment group", () => {
expect(categorizeAttachment("payment-document/po123/proof.pdf")).toBe("payment");
expect(categorizeAttachment("payment/po123/proof.pdf")).toBe("payment");
});
it("falls back to other for unknown prefixes", () => {
expect(categorizeAttachment("something-else/x.pdf")).toBe("other");
expect(categorizeAttachment("no-slash")).toBe("other");
});
});
describe("groupAttachments", () => {
const doc = (id: string, storageKey: string) => ({ id, storageKey });
it("groups documents by lifecycle stage in canonical order", () => {
const groups = groupAttachments([
doc("a", "receipt/po1/delivery.pdf"),
doc("b", "po-document/po1/invoice.pdf"),
doc("c", "po-document/po1/quote.pdf"),
]);
expect(groups.map((g) => g.meta.key)).toEqual(["submission", "delivery"]);
expect(groups[0].items.map((d) => d.id)).toEqual(["b", "c"]);
expect(groups[1].items.map((d) => d.id)).toEqual(["a"]);
});
it("omits empty groups", () => {
const groups = groupAttachments([doc("a", "po-document/po1/invoice.pdf")]);
expect(groups).toHaveLength(1);
expect(groups[0].meta.key).toBe("submission");
});
it("returns an empty array when there are no documents", () => {
expect(groupAttachments([])).toEqual([]);
});
it("preserves input order within a group", () => {
const groups = groupAttachments([
doc("first", "receipt/po1/a.pdf"),
doc("second", "receipt/po1/b.pdf"),
]);
expect(groups[0].items.map((d) => d.id)).toEqual(["first", "second"]);
});
it("collects unknown prefixes into the other group last", () => {
const groups = groupAttachments([
doc("x", "mystery/po1/file.pdf"),
doc("y", "po-document/po1/invoice.pdf"),
]);
expect(groups.map((g) => g.meta.key)).toEqual(["submission", "other"]);
});
});

View file

@ -21,8 +21,8 @@ The portal is an internal line-of-business app with a well-defined data model, m
| **Charts** | Recharts | Lightweight; composable; works well with server-fetched data in RSC | | **Charts** | Recharts | Lightweight; composable; works well with server-fetched data in RSC |
| **Validation** | Zod | Schema validation shared between server actions and client form validation | | **Validation** | Zod | Schema validation shared between server actions and client form validation |
| **Testing** | Vitest + React Testing Library + Playwright | Unit/integration fast with Vitest; E2E critical paths with Playwright | | **Testing** | Vitest + React Testing Library + Playwright | Unit/integration fast with Vitest; E2E critical paths with Playwright |
| **CI/CD** | GitHub Actions | Lint, type-check, test, build on every PR; deploy on merge to main | | **CI/CD** | Forgejo + Forgejo Actions (self-hosted on the `pms1` server) | Issue→fix→PR pipeline; a release tag (`vX.Y.Z`) triggers a runner that deploys. See [`../automation/README.md`](../automation/README.md) |
| **Hosting** | Vercel (app) + Supabase (Postgres + Storage fallback) | Zero-config deploys; Vercel serverless functions match Next.js well | | **Hosting** | Self-hosted on `pms1` (Ubuntu); Next.js under **pm2**, **PostgreSQL 16** native on the same host; fronted by a Pangolin/Traefik tunnel | Single-VM self-host, no external PaaS; full control of data |
--- ---
@ -497,31 +497,42 @@ All other data operations (create PO, approve, reject, etc.) are Server Actions
## 10. Deployment Architecture ## 10. Deployment Architecture
The app is **self-hosted on a single server (`pms1`, Ubuntu)** — not a managed PaaS.
Public traffic reaches it through a Pangolin/Traefik tunnel; the Next.js app, database,
and the CI runner all live on the same host.
``` ```
┌────────────────────────────────────────────────┐ Internet (HTTPS, pms.pelagiamarine.com)
│ Vercel │
│ │ ┌───────────▼────────────┐
│ ┌──────────────────────────────────────────┐ │ │ Pangolin / Traefik │ reverse proxy + tunnel
│ │ Next.js App (Edge + Node.js) │ │ └───────────┬────────────┘
│ │ - Static assets via Vercel CDN │ │
│ │ - Server Components on Node.js runtime │ │ ┌─────────────────────────────────────────────────────────────┐
│ │ - API routes / Server Actions │ │ │ pms1 (Ubuntu) │
│ └──────────────────────────────────────────┘ │ │ │
└────────────────────────────────────────────────┘ │ ┌──────────────────────────┐ ┌────────────────────────┐ │
│ │ │ │ Next.js app (pm2: ppms) │ │ PostgreSQL 16 (native, │ │
┌────────▼──────┐ ┌────────▼──────────────┐ │ │ `next start`, port 3000 │──▶│ localhost:5432, db │ │
│ Supabase │ │ Cloudflare R2 │ │ │ Server Components/Actions│ │ `pelagia`) │ │
│ PostgreSQL │ │ (document storage) │ │ └──────────────────────────┘ └────────────────────────┘ │
│ (managed, │ │ │ │ │ │
│ auto-backup)│ └────────────────────────┘ │ ├─▶ Cloudflare R2 (document storage, prod) │
└───────────────┘ │ └─▶ Resend (email, prod) │
│ │
┌────────▼──────┐ │ ┌──────────────────────────────────────────────────────┐ │
│ Resend │ │ │ Forgejo (Docker) + Actions runner (pm2) │ │
│ (email API) │ │ │ issue→fix→PR→tag deploy — see automation/README.md │ │
└───────────────┘ │ │ Also: pelagia_test (prod-mirror DB) + staging │ │
│ └──────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
``` ```
**Deploy flow:** merge a PR to `master`, push a release tag `vX.Y.Z` → a Forgejo
Actions runner checks out the tag into `~/pms`, runs `pnpm install && pnpm build &&
prisma migrate deploy`, and `pm2 restart ppms`. Full runbook in
[`../automation/README.md`](../automation/README.md).
### Environment Variables ### Environment Variables
The set of required variables differs between development and production. The switch is automatic — controlled by `NODE_ENV` (set to `development` by `next dev` and `production` by `next build/start`). The set of required variables differs between development and production. The switch is automatic — controlled by `NODE_ENV` (set to `development` by `next dev` and `production` by `next build/start`).
@ -553,14 +564,16 @@ In development, uploaded files are stored in `.dev-uploads/` at the project root
| E2E | Playwright | Full happy paths per role: create PO → approve → pay → confirm receipt | | E2E | Playwright | Full happy paths per role: create PO → approve → pay → confirm receipt |
| Accessibility | axe-core + Playwright | WCAG violations on key pages | | Accessibility | axe-core + Playwright | WCAG violations on key pages |
CI runs all tests on every pull request. Playwright E2E runs against a preview deployment. Tests run on pull requests via Forgejo Actions. Automated fixes and the staging instance
run integration tests / a dev server against `pelagia_test`, a daily mirror of the
production database (see [`../automation/README.md`](../automation/README.md)).
--- ---
## 12. Development Conventions ## 12. Development Conventions
- **Branch strategy**: `main` (production) ← `staging` ← feature branches (`feat/`, `fix/`, `chore/`). - **Branch strategy**: `master` is the trunk and the source of releases. Work lands via PRs (feature branches `feat/`/`fix/`/`chore/`, or `claude/issue-N` from the automated pipeline); production is whatever `vX.Y.Z` tag is currently deployed. Staging is a deployed instance of latest `master`, not a branch.
- **Commit style**: Conventional Commits (`feat:`, `fix:`, `refactor:`). - **Commit style**: Conventional Commits (`feat:`, `fix:`, `refactor:`).
- **Code quality**: ESLint (Next.js config) + Prettier + TypeScript strict mode; enforced via husky pre-commit hook. - **Code quality**: ESLint (Next.js config) + Prettier + TypeScript strict mode; enforced via husky pre-commit hook.
- **Database migrations**: Never edit `schema.prisma` without generating and committing a migration (`prisma migrate dev`). Migration files are committed and reviewed in PRs. - **Database migrations**: Never edit `schema.prisma` without generating and committing a migration (`prisma migrate dev`). Migration files are committed and reviewed in PRs.
- **Secrets**: Never committed; managed via Vercel environment variable UI and `.env.local` locally (`.env.local` is git-ignored). - **Secrets**: Never committed. On the server they live in `~/pms/App/.env` / `.env.production`; locally in `.env.local` (git-ignored).

View file

@ -12,5 +12,5 @@ Track decisions that need sign-off before the corresponding feature is built. Up
| 6 | Should rejected POs be hard-deleted after a retention period or permanently archived? How long is the retention window? | Legal / compliance | Open | — | | 6 | Should rejected POs be hard-deleted after a retention period or permanently archived? How long is the retention window? | Legal / compliance | Open | — |
| 7 | Should documents (PO attachments, receipts) be publicly accessible via URL, or always served through a signed/authenticated download? | Security review | Open | — | | 7 | Should documents (PO attachments, receipts) be publicly accessible via URL, or always served through a signed/authenticated download? | Security review | Open | — |
| 8 | Are there specific vessels or accounts that certain submitters are restricted to (i.e., row-level vessel permissions), or is any submitter able to raise a PO against any vessel? | Design review | Open | — | | 8 | Are there specific vessels or accounts that certain submitters are restricted to (i.e., row-level vessel permissions), or is any submitter able to raise a PO against any vessel? | Design review | Open | — |
| 9 | What is the expected volume? (POs per day, concurrent users) — affects connection-pool sizing and whether Vercel serverless is sufficient. | Architecture review | Open | — | | 9 | What is the expected volume? (POs per day, concurrent users) — affects connection-pool sizing and `pms1` resourcing. | Architecture review | Open | — |
| 10 | Should Manager analytics (spend by vessel/month) include only CLOSED POs, or all POs from MGR_APPROVED onwards? | Design review | Open | — | | 10 | Should Manager analytics (spend by vessel/month) include only CLOSED POs, or all POs from MGR_APPROVED onwards? | Design review | Open | — |

View file

@ -302,7 +302,7 @@ HTML report at `playwright-report/index.html` after every run.
ambiguous elements (unit price input, line-item rows) so specs don't depend on ambiguous elements (unit price input, line-item rows) so specs don't depend on
implementation details like placeholder text or CSS class names. implementation details like placeholder text or CSS class names.
4. **CI integration** — Run `pnpm test:e2e` in GitHub Actions on every PR. 4. **CI integration** — Run `pnpm test:e2e` in Forgejo Actions (runner on `pms1`) on every PR.
Use `workers: 1` and `retries: 2` (already wired for `process.env.CI`). Use `workers: 1` and `retries: 2` (already wired for `process.env.CI`).
5. **Visual regression** — Add Percy or Playwright's built-in screenshot comparison 5. **Visual regression** — Add Percy or Playwright's built-in screenshot comparison

View file

@ -218,10 +218,10 @@ The following areas are not yet covered by automated E2E tests:
## 9 · Continuous Integration (Planned) ## 9 · Continuous Integration (Planned)
When wired into CI (GitHub Actions), the following configuration applies: When wired into CI (Forgejo Actions, runner on `pms1`), the following configuration applies:
```yaml ```yaml
# .github/workflows/e2e.yml # .forgejo/workflows/e2e.yml
- name: Install Playwright browsers - name: Install Playwright browsers
run: pnpm exec playwright install --with-deps chromium run: pnpm exec playwright install --with-deps chromium
@ -229,7 +229,7 @@ When wired into CI (GitHub Actions), the following configuration applies:
run: pnpm test:e2e run: pnpm test:e2e
env: env:
CI: "true" CI: "true"
DATABASE_URL: ${{ secrets.TEST_DATABASE_URL }} DATABASE_URL: ${{ secrets.TEST_DATABASE_URL }} # e.g. the pelagia_test mirror
NEXTAUTH_SECRET: ${{ secrets.NEXTAUTH_SECRET }} NEXTAUTH_SECRET: ${{ secrets.NEXTAUTH_SECRET }}
NEXTAUTH_URL: "http://localhost:3000" NEXTAUTH_URL: "http://localhost:3000"
``` ```