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/6] 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 4da39fe5d155a49dd0fdf8935e5d1427156b92d2 Mon Sep 17 00:00:00 2001 From: Hardik Date: Fri, 19 Jun 2026 11:51:59 +0530 Subject: [PATCH 2/6] fix(automation): apply master migrations to the test DB The test DB mirrors prod, which can be behind master, so the latest code 500s on columns prod doesn't have yet (e.g. poDate from the optional-PO-date feature). - staging-up.sh runs prisma migrate deploy after install. - refresh-test-db.sh re-applies master migrations after each nightly data copy, so the running staging/autofix DB stays at the schema of the code under test. Co-Authored-By: Claude Opus 4.8 --- automation/refresh-test-db.sh | 21 ++++++++++++++++++++- automation/staging-up.sh | 4 ++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/automation/refresh-test-db.sh b/automation/refresh-test-db.sh index bf01590..88f01aa 100644 --- a/automation/refresh-test-db.sh +++ b/automation/refresh-test-db.sh @@ -36,7 +36,7 @@ prod_tables=$(psql -tAc "SELECT count(*) FROM information_schema.tables WHERE ta test_tables=$(psql -tAc "SELECT count(*) FROM information_schema.tables WHERE table_schema='public';" "$TEST_URL") if [ "$test_tables" = "$prod_tables" ] && [ "$test_tables" -gt 0 ]; then - log "Done. $TEST_DB has $test_tables public tables (prod has $prod_tables)." + log "Data copied. $TEST_DB has $test_tables public tables (prod has $prod_tables)." rm -f "$errfile" else log "WARNING: table counts differ (test=$test_tables prod=$prod_tables). Recent errors:" @@ -44,3 +44,22 @@ else rm -f "$errfile" exit 1 fi + +# The test DB now has PROD's schema, which may be behind master. Apply master's +# unreleased migrations so the code under test (staging + autofix) doesn't 500 on +# columns prod doesn't have yet (e.g. poDate). Uses a stable master checkout. +MIG_DIR="" +for d in "$HOME/pelagia-staging/App" "$HOME/pelagia-autofix/App"; do + [ -d "$d/prisma/migrations" ] && { MIG_DIR="$d"; break; } +done +if [ -n "$MIG_DIR" ]; then + export NVM_DIR="$HOME/.nvm"; . "$NVM_DIR/nvm.sh" 2>/dev/null || true + log "Applying master migrations from $MIG_DIR ..." + if ( cd "$MIG_DIR" && DATABASE_URL="$TEST_URL" pnpm db:migrate:deploy ) >/tmp/migrate-test-db.log 2>&1; then + log "Migrations applied." + else + log "WARNING: migrate deploy failed; see /tmp/migrate-test-db.log"; tail -5 /tmp/migrate-test-db.log + fi +else + log "No master checkout with migrations found; skipping migrate (test DB has prod schema only)." +fi diff --git a/automation/staging-up.sh b/automation/staging-up.sh index 79e9e44..ec8a64c 100644 --- a/automation/staging-up.sh +++ b/automation/staging-up.sh @@ -59,6 +59,10 @@ chmod +x "$DIR/App/run-staging.sh" cd "$DIR/App" echo "Installing deps..."; pnpm install --frozen-lockfile echo "Generating Prisma client..."; pnpm db:generate +# Bring the test DB schema up to the code under test. The test DB mirrors prod, +# which may be behind master, so master's unreleased migrations (e.g. poDate) +# must be applied or the new code 500s on the missing columns. +echo "Applying pending migrations to the test DB..."; pnpm db:migrate:deploy if pm2 describe "$NAME" >/dev/null 2>&1; then pm2 restart "$NAME" --update-env From b592358db05d4fe09dc733b4cb9d6b816e62c4e2 Mon Sep 17 00:00:00 2001 From: Hardik Date: Fri, 19 Jun 2026 11:56:34 +0530 Subject: [PATCH 3/6] feat(app): env-gated banner (EnvBanner) for non-prod environments Renders a thin fixed banner only when NEXT_PUBLIC_ENV_LABEL is set; production leaves it unset so nothing shows. Used to mark the staging instance. Co-Authored-By: Claude Opus 4.8 --- App/app/layout.tsx | 6 +++++- App/components/env-banner.tsx | 30 ++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 App/components/env-banner.tsx diff --git a/App/app/layout.tsx b/App/app/layout.tsx index 1a0213d..334d7d0 100644 --- a/App/app/layout.tsx +++ b/App/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import { Inter, JetBrains_Mono } from "next/font/google"; import "./globals.css"; +import { EnvBanner } from "@/components/env-banner"; const inter = Inter({ subsets: ["latin"], @@ -29,7 +30,10 @@ export default function RootLayout({ }) { return ( - {children} + + + {children} + ); } diff --git a/App/components/env-banner.tsx b/App/components/env-banner.tsx new file mode 100644 index 0000000..5957da9 --- /dev/null +++ b/App/components/env-banner.tsx @@ -0,0 +1,30 @@ +// Thin fixed banner shown only when NEXT_PUBLIC_ENV_LABEL is set (e.g. staging). +// Production never sets the var, so it renders nothing there. +export function EnvBanner() { + const label = process.env.NEXT_PUBLIC_ENV_LABEL; + if (!label) return null; + return ( +
+ {label} +
+ ); +} From b472c149b4f881fbe3d98405547930f8ed2bf0cd Mon Sep 17 00:00:00 2001 From: Hardik Date: Fri, 19 Jun 2026 11:59:25 +0530 Subject: [PATCH 4/6] feat(automation): lock staging to SSH tunnel + dev banner + desktop shortcut - staging-up.sh binds the dev server to 127.0.0.1 (tunnel-only, no public access) and sets NEXT_PUBLIC_ENV_LABEL so the 'INTERNAL DEV / STAGING - NOT PRODUCTION' banner shows. - staging-tunnel.cmd: Windows launcher that opens the SSH tunnel + browser (wired to a desktop shortcut). Co-Authored-By: Claude Opus 4.8 --- automation/README.md | 12 ++++++++---- automation/staging-tunnel.cmd | 15 +++++++++++++++ automation/staging-up.sh | 5 ++++- 3 files changed, 27 insertions(+), 5 deletions(-) create mode 100644 automation/staging-tunnel.cmd diff --git a/automation/README.md b/automation/README.md index 1182734..8f938cc 100644 --- a/automation/README.md +++ b/automation/README.md @@ -93,10 +93,14 @@ before a release tag deploys them to prod. (`pelagia_test`), safe dev mode (console email, local storage, SSO disabled). - Refresh to newer master + restart: re-run `~/issue-watcher/staging-up.sh`. - Stop: `pm2 delete ppms-staging`. -- Access: bound to all interfaces, so reachable at `http://:3200`. This is - **plain HTTP with prod-mirror data behind login** — for a private setup, restrict - to localhost (`pnpm dev -p 3200 -H 127.0.0.1` in `run-staging.sh`) and reach it via - `ssh -L 3200:localhost:3200 …` instead. +- **Access is SSH-tunnel only** — the dev server binds to `127.0.0.1:3200`, so it is + not reachable from the public internet. Open a tunnel and browse `http://localhost:3200`: + `ssh -L 3200:localhost:3200 shad0w@`. On Windows, the desktop shortcut + **"Pelagia Staging (tunnel)"** (`automation/staging-tunnel.cmd`) opens the tunnel and + the browser in one click. +- A fixed banner **"INTERNAL DEV / STAGING - NOT PRODUCTION"** is shown (driven by + `NEXT_PUBLIC_ENV_LABEL` in the staging `.env`; the `EnvBanner` component renders nothing + when the var is unset, so production is unaffected). - Log in with a password user (SSO is off here), e.g. `admin@pelagiamarine.com`. ## Issue label lifecycle diff --git a/automation/staging-tunnel.cmd b/automation/staging-tunnel.cmd new file mode 100644 index 0000000..91197da --- /dev/null +++ b/automation/staging-tunnel.cmd @@ -0,0 +1,15 @@ +@echo off +title Pelagia Staging Tunnel (localhost:3200) +echo ============================================================ +echo Pelagia Portal - STAGING (internal dev only) +echo Tunneling pms1 port 3200 to http://localhost:3200 +echo Keep this window OPEN while testing. Close it to disconnect. +echo ============================================================ +echo. +echo Connecting... your browser will open in a few seconds. +REM Open the browser shortly after the tunnel comes up. +start "" cmd /c "ping -n 6 127.0.0.1 >nul & explorer http://localhost:3200" +ssh -i "%USERPROFILE%\.ssh\peliagia_portal_ubuntu22_ed25519" -o StrictHostKeyChecking=accept-new -N -L 3200:localhost:3200 shad0w@87.76.191.133 +echo. +echo Tunnel closed. You can close this window. +pause diff --git a/automation/staging-up.sh b/automation/staging-up.sh index ec8a64c..8625bb9 100644 --- a/automation/staging-up.sh +++ b/automation/staging-up.sh @@ -42,17 +42,20 @@ AZURE_AD_CLIENT_SECRET="dev-placeholder" AZURE_AD_TENANT_ID="dev-placeholder" DATABASE_URL="$TEST_URL" GST_SERVICE_URL="http://localhost:3003" +NEXT_PUBLIC_ENV_LABEL="INTERNAL DEV / STAGING - NOT PRODUCTION" PORT=$PORT EOF chmod 600 "$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 +# (ssh -L 3200:localhost:3200 ...), never directly from the public internet. cat > "$DIR/App/run-staging.sh" < Date: Fri, 19 Jun 2026 12:07:55 +0530 Subject: [PATCH 5/6] 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 6/6] 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" ```