From 4e6175153dd3d29a61c4c3907d00d6d31a6fe768 Mon Sep 17 00:00:00 2001 From: "Claude (auto-fix)" Date: Fri, 19 Jun 2026 04:43:44 +0530 Subject: [PATCH 1/4] fix(po): show all attachments grouped by type on PO details All PO attachments are stored as PODocument rows whose lifecycle stage (submission vs delivery) is encoded in the storageKey prefix. The PO details screen previously listed them in a single flat "Attachments" block, giving no indication of which were submission documents (invoice, quotation) versus delivery receipts. Add lib/attachments.ts to derive a user-facing group from the storageKey prefix (submission / payment / delivery / other) and render each non-empty group as a labelled subsection on the PO details screen, in lifecycle order. Unknown prefixes fall back to an "Other" group so nothing is ever hidden. Fixes #10 --- App/components/po/po-detail.tsx | 58 +++++++++++------- App/lib/attachments.ts | 96 ++++++++++++++++++++++++++++++ App/tests/unit/attachments.test.ts | 67 +++++++++++++++++++++ 3 files changed, 201 insertions(+), 20 deletions(-) create mode 100644 App/lib/attachments.ts create mode 100644 App/tests/unit/attachments.test.ts diff --git a/App/components/po/po-detail.tsx b/App/components/po/po-detail.tsx index 21288b0..9ed173b 100644 --- a/App/components/po/po-detail.tsx +++ b/App/components/po/po-detail.tsx @@ -5,6 +5,7 @@ import { DiscardDraftButton } from "@/components/po/discard-draft-button"; import { SubmitDraftButton } from "@/components/po/submit-draft-button"; import { formatCurrency, formatDate, formatDateTime } from "@/lib/utils"; import { generateDownloadUrl } from "@/lib/storage"; +import { groupAttachments } from "@/lib/attachments"; import { TC_FIXED_LINE } from "@/lib/validations/po"; import type { LineItemInput } from "@/lib/validations/po"; 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." : "Line items were amended by manager. Current values shown; original values shown with strikethrough."; - const downloadUrls = await Promise.all( - po.documents.map((doc) => generateDownloadUrl(doc.storageKey)) + const docsWithUrls = await Promise.all( + po.documents.map(async (doc) => ({ + ...doc, + url: await generateDownloadUrl(doc.storageKey), + })) ); + const attachmentGroups = groupAttachments(docsWithUrls); const canConfirmReceipt = (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 )} - {/* Documents */} - {po.documents.length > 0 && ( + {/* Documents — grouped by lifecycle stage (submission / payment / delivery) */} + {attachmentGroups.length > 0 && (

Attachments

- +
)} diff --git a/App/lib/attachments.ts b/App/lib/attachments.ts new file mode 100644 index 0000000..b553ce5 --- /dev/null +++ b/App/lib/attachments.ts @@ -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//...` + * or `receipt//...`. 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 = { + 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 { + 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( + documents: T[] +): AttachmentGroup[] { + const buckets = new Map(); + 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 }] + : []; + }); +} diff --git a/App/tests/unit/attachments.test.ts b/App/tests/unit/attachments.test.ts new file mode 100644 index 0000000..b5a4deb --- /dev/null +++ b/App/tests/unit/attachments.test.ts @@ -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"]); + }); +}); From e94c7f99a3983c81b437c8c6834a2f29233008f7 Mon Sep 17 00:00:00 2001 From: "Claude (auto-fix)" Date: Fri, 19 Jun 2026 11:53:42 +0530 Subject: [PATCH 2/4] feat(history): allow filtering PO history by multiple statuses The PO history page previously allowed only a single status filter. This enhances it to accept multiple statuses that are OR-ed together (e.g. Closed + Approved shows all POs in either state), as requested. - Status filter is now a multi-select checkbox dropdown that serialises selections as repeated `status` query params. - History page and the reports export endpoint read all `status` values and query with `status: { in: [...] }` (OR semantics). - Single-status and no-status cases remain unchanged. Verified OR-query semantics against the test DB and confirmed both routes compile and respond. type-check passes for the changed files. Fixes #31 Co-Authored-By: Claude Opus 4.8 (1M context) --- App/app/(portal)/history/history-filters.tsx | 60 ++++++++++++++++---- App/app/(portal)/history/page.tsx | 7 ++- App/app/api/reports/export/route.ts | 4 +- 3 files changed, 55 insertions(+), 16 deletions(-) diff --git a/App/app/(portal)/history/history-filters.tsx b/App/app/(portal)/history/history-filters.tsx index 16b4132..3a63901 100644 --- a/App/app/(portal)/history/history-filters.tsx +++ b/App/app/(portal)/history/history-filters.tsx @@ -1,10 +1,9 @@ "use client"; import { useRouter, useSearchParams } from "next/navigation"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; const STATUSES = [ - { value: "", label: "All statuses" }, { value: "DRAFT", label: "Draft" }, { value: "SUBMITTED", label: "Submitted" }, { value: "MGR_REVIEW", label: "Pending Approval" }, @@ -28,23 +27,48 @@ export function HistoryFilters({ vessels }: Props) { const [dateFrom, setDateFrom] = useState(sp.get("dateFrom") ?? ""); const [dateTo, setDateTo] = useState(sp.get("dateTo") ?? ""); const [vesselId, setVesselId] = useState(sp.get("vesselId") ?? ""); - const [status, setStatus] = useState(sp.get("status") ?? ""); + const [statuses, setStatuses] = useState(sp.getAll("status")); + const [statusOpen, setStatusOpen] = useState(false); + const statusRef = useRef(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() { const params = new URLSearchParams(); if (dateFrom) params.set("dateFrom", dateFrom); if (dateTo) params.set("dateTo", dateTo); 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()}`); } function clear() { - setDateFrom(""); setDateTo(""); setVesselId(""); setStatus(""); + setDateFrom(""); setDateTo(""); setVesselId(""); setStatuses([]); router.push("/history"); } - const hasFilters = dateFrom || dateTo || vesselId || status; + const hasFilters = dateFrom || dateTo || 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 (
@@ -67,12 +91,26 @@ export function HistoryFilters({ vessels }: Props) { {vessels.map((v) => )}
-
+
- + + {statusOpen && ( +
+ {STATUSES.map((s) => ( + + ))} +
+ )}
diff --git a/App/app/(portal)/history/page.tsx b/App/app/(portal)/history/page.tsx index 3f47f90..bbbd230 100644 --- a/App/app/(portal)/history/page.tsx +++ b/App/app/(portal)/history/page.tsx @@ -17,7 +17,7 @@ interface Props { dateFrom?: string; dateTo?: string; vesselId?: string; - status?: string; + status?: string | string[]; }>; } @@ -41,7 +41,8 @@ export default async function HistoryPage({ searchParams }: Props) { where.createdAt = createdAt; } 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([ db.purchaseOrder.findMany({ @@ -57,7 +58,7 @@ export default async function HistoryPage({ searchParams }: Props) { if (dateFrom) exportParams.set("dateFrom", dateFrom); if (dateTo) exportParams.set("dateTo", dateTo); if (vesselId) exportParams.set("vesselId", vesselId); - if (status) exportParams.set("status", status); + for (const s of statuses) exportParams.append("status", s); return (
diff --git a/App/app/api/reports/export/route.ts b/App/app/api/reports/export/route.ts index a5aa672..9c3865b 100644 --- a/App/app/api/reports/export/route.ts +++ b/App/app/api/reports/export/route.ts @@ -25,7 +25,7 @@ export async function GET(request: NextRequest) { const dateFrom = sp.get("dateFrom"); const dateTo = sp.get("dateTo"); const vesselId = sp.get("vesselId"); - const status = sp.get("status"); + const statuses = sp.getAll("status").filter(Boolean); const where: NonNullable[0]>["where"] = {}; if (dateFrom || dateTo) { @@ -39,7 +39,7 @@ export async function GET(request: NextRequest) { where.createdAt = createdAt; } 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({ where, From e31014d45c6e555bcd16618043fc96a412b3c37f Mon Sep 17 00:00:00 2001 From: Hardik Date: Fri, 19 Jun 2026 12:07:55 +0530 Subject: [PATCH 3/4] docs: document the issue-to-deploy pipeline, staging, and test DB - App/README.md: add FORGEJO_*/NEXT_PUBLIC_ENV_LABEL env vars and an 'Operations & Automation' section pointing to automation/README.md. - App/CLAUDE.md: complete the env var list (AZURE_AD_*, FORGEJO_*, GST_SERVICE_URL, NEXT_PUBLIC_ENV_LABEL) and note the prod-mirror test DB used by autofix/staging. - .env.example: document NEXT_PUBLIC_ENV_LABEL. Co-Authored-By: Claude Opus 4.8 --- App/.env.example | 5 +++++ App/CLAUDE.md | 24 ++++++++++++++++++++++++ App/README.md | 30 ++++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+) diff --git a/App/.env.example b/App/.env.example index 3001b4b..a22649f 100644 --- a/App/.env.example +++ b/App/.env.example @@ -54,3 +54,8 @@ GST_SERVICE_URL=http://localhost:3003 FORGEJO_URL=https://git.pelagiamarine.com FORGEJO_REPO=shad0w/pelagia-portal 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" diff --git a/App/CLAUDE.md b/App/CLAUDE.md index c9b64ad..af0739a 100644 --- a/App/CLAUDE.md +++ b/App/CLAUDE.md @@ -95,7 +95,31 @@ NEXTAUTH_SECRET # Required always NEXTAUTH_URL # Required always (e.g., http://localhost:3000) 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): 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 + +# 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. diff --git a/App/README.md b/App/README.md index 5bedfef..6baba01 100644 --- a/App/README.md +++ b/App/README.md @@ -116,6 +116,15 @@ R2_PUBLIC_URL=https://..r2.cloudflarestorage.com RESEND_API_KEY=re_ EMAIL_FROM=noreply@yourdomain.com 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= + +# 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 @@ -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 | Command | Purpose | From f17df1ec6b0f4ac6049b2c5ea603915a9dcba120 Mon Sep 17 00:00:00 2001 From: Hardik Date: Fri, 19 Jun 2026 12:12:04 +0530 Subject: [PATCH 4/4] docs: update design docs to the actual self-hosted architecture The original design docs assumed Vercel + Supabase + GitHub Actions. Reality is a single self-hosted pms1 server (Next.js under pm2, native PostgreSQL 16, Forgejo Actions runner, Pangolin/Traefik tunnel). - 02-architecture.md: CI/CD + Hosting rows, deployment diagram (section 10), CI/testing note, branch strategy, and secrets location. - e2e-test-plan.md / e2e-test-framework.md: GitHub Actions -> Forgejo Actions. - 03-open-questions.md: drop the Vercel-serverless aside. Co-Authored-By: Claude Opus 4.8 --- Docs/02-architecture.md | 67 +++++++++++++++++++++++--------------- Docs/03-open-questions.md | 2 +- Docs/e2e-test-framework.md | 2 +- Docs/e2e-test-plan.md | 6 ++-- 4 files changed, 45 insertions(+), 32 deletions(-) diff --git a/Docs/02-architecture.md b/Docs/02-architecture.md index 1824971..f00147d 100644 --- a/Docs/02-architecture.md +++ b/Docs/02-architecture.md @@ -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 | | **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 | -| **CI/CD** | GitHub Actions | Lint, type-check, test, build on every PR; deploy on merge to main | -| **Hosting** | Vercel (app) + Supabase (Postgres + Storage fallback) | Zero-config deploys; Vercel serverless functions match Next.js well | +| **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** | 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 +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. + ``` -┌────────────────────────────────────────────────┐ -│ Vercel │ -│ │ -│ ┌──────────────────────────────────────────┐ │ -│ │ Next.js App (Edge + Node.js) │ │ -│ │ - Static assets via Vercel CDN │ │ -│ │ - Server Components on Node.js runtime │ │ -│ │ - API routes / Server Actions │ │ -│ └──────────────────────────────────────────┘ │ -└────────────────────────────────────────────────┘ - │ │ -┌────────▼──────┐ ┌────────▼──────────────┐ -│ Supabase │ │ Cloudflare R2 │ -│ PostgreSQL │ │ (document storage) │ -│ (managed, │ │ │ -│ auto-backup)│ └────────────────────────┘ -└───────────────┘ - │ -┌────────▼──────┐ -│ Resend │ -│ (email API) │ -└───────────────┘ + Internet (HTTPS, pms.pelagiamarine.com) + │ + ┌───────────▼────────────┐ + │ Pangolin / Traefik │ reverse proxy + tunnel + └───────────┬────────────┘ + │ +┌─────────────────────────────────────────────────────────────┐ +│ pms1 (Ubuntu) │ +│ │ +│ ┌──────────────────────────┐ ┌────────────────────────┐ │ +│ │ Next.js app (pm2: ppms) │ │ PostgreSQL 16 (native, │ │ +│ │ `next start`, port 3000 │──▶│ localhost:5432, db │ │ +│ │ Server Components/Actions│ │ `pelagia`) │ │ +│ └──────────────────────────┘ └────────────────────────┘ │ +│ │ │ +│ ├─▶ Cloudflare R2 (document storage, prod) │ +│ └─▶ Resend (email, prod) │ +│ │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ Forgejo (Docker) + Actions runner (pm2) │ │ +│ │ 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 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 | | 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 -- **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:`). - **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. -- **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). diff --git a/Docs/03-open-questions.md b/Docs/03-open-questions.md index b9e7a20..a0be0e3 100644 --- a/Docs/03-open-questions.md +++ b/Docs/03-open-questions.md @@ -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 | — | | 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 | — | -| 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 | — | diff --git a/Docs/e2e-test-framework.md b/Docs/e2e-test-framework.md index a9a4c73..94e9a15 100644 --- a/Docs/e2e-test-framework.md +++ b/Docs/e2e-test-framework.md @@ -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 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`). 5. **Visual regression** — Add Percy or Playwright's built-in screenshot comparison diff --git a/Docs/e2e-test-plan.md b/Docs/e2e-test-plan.md index 3421959..b4198df 100644 --- a/Docs/e2e-test-plan.md +++ b/Docs/e2e-test-plan.md @@ -218,10 +218,10 @@ The following areas are not yet covered by automated E2E tests: ## 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 -# .github/workflows/e2e.yml +# .forgejo/workflows/e2e.yml - name: Install Playwright browsers 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 env: 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_URL: "http://localhost:3000" ```