Merge branch 'master' into claude/issue-31
This commit is contained in:
commit
b3e6f6181a
16 changed files with 391 additions and 59 deletions
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Inter, JetBrains_Mono } from "next/font/google";
|
import { Inter, JetBrains_Mono } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
import { EnvBanner } from "@/components/env-banner";
|
||||||
|
|
||||||
const inter = Inter({
|
const inter = Inter({
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
|
|
@ -29,7 +30,10 @@ export default function RootLayout({
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" className={`${inter.variable} ${jetbrainsMono.variable}`}>
|
<html lang="en" className={`${inter.variable} ${jetbrainsMono.variable}`}>
|
||||||
<body>{children}</body>
|
<body>
|
||||||
|
<EnvBanner />
|
||||||
|
{children}
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
30
App/components/env-banner.tsx
Normal file
30
App/components/env-banner.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
zIndex: 9999,
|
||||||
|
height: 18,
|
||||||
|
lineHeight: "18px",
|
||||||
|
textAlign: "center",
|
||||||
|
background: "#b45309",
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: "0.06em",
|
||||||
|
pointerEvents: "none",
|
||||||
|
fontFamily: "var(--font-sans), system-ui, sans-serif",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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
96
App/lib/attachments.ts
Normal 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 }]
|
||||||
|
: [];
|
||||||
|
});
|
||||||
|
}
|
||||||
67
App/tests/unit/attachments.test.ts
Normal file
67
App/tests/unit/attachments.test.ts
Normal 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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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 | — |
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -93,10 +93,14 @@ before a release tag deploys them to prod.
|
||||||
(`pelagia_test`), safe dev mode (console email, local storage, SSO disabled).
|
(`pelagia_test`), safe dev mode (console email, local storage, SSO disabled).
|
||||||
- Refresh to newer master + restart: re-run `~/issue-watcher/staging-up.sh`.
|
- Refresh to newer master + restart: re-run `~/issue-watcher/staging-up.sh`.
|
||||||
- Stop: `pm2 delete ppms-staging`.
|
- Stop: `pm2 delete ppms-staging`.
|
||||||
- Access: bound to all interfaces, so reachable at `http://<pms1-ip>:3200`. This is
|
- **Access is SSH-tunnel only** — the dev server binds to `127.0.0.1:3200`, so it is
|
||||||
**plain HTTP with prod-mirror data behind login** — for a private setup, restrict
|
not reachable from the public internet. Open a tunnel and browse `http://localhost:3200`:
|
||||||
to localhost (`pnpm dev -p 3200 -H 127.0.0.1` in `run-staging.sh`) and reach it via
|
`ssh -L 3200:localhost:3200 shad0w@<pms1>`. On Windows, the desktop shortcut
|
||||||
`ssh -L 3200:localhost:3200 …` instead.
|
**"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`.
|
- Log in with a password user (SSO is off here), e.g. `admin@pelagiamarine.com`.
|
||||||
|
|
||||||
## Issue label lifecycle
|
## Issue label lifecycle
|
||||||
|
|
|
||||||
|
|
@ -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")
|
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
|
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"
|
rm -f "$errfile"
|
||||||
else
|
else
|
||||||
log "WARNING: table counts differ (test=$test_tables prod=$prod_tables). Recent errors:"
|
log "WARNING: table counts differ (test=$test_tables prod=$prod_tables). Recent errors:"
|
||||||
|
|
@ -44,3 +44,22 @@ else
|
||||||
rm -f "$errfile"
|
rm -f "$errfile"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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
|
||||||
|
|
|
||||||
15
automation/staging-tunnel.cmd
Normal file
15
automation/staging-tunnel.cmd
Normal file
|
|
@ -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
|
||||||
|
|
@ -42,23 +42,30 @@ AZURE_AD_CLIENT_SECRET="dev-placeholder"
|
||||||
AZURE_AD_TENANT_ID="dev-placeholder"
|
AZURE_AD_TENANT_ID="dev-placeholder"
|
||||||
DATABASE_URL="$TEST_URL"
|
DATABASE_URL="$TEST_URL"
|
||||||
GST_SERVICE_URL="http://localhost:3003"
|
GST_SERVICE_URL="http://localhost:3003"
|
||||||
|
NEXT_PUBLIC_ENV_LABEL="INTERNAL DEV / STAGING - NOT PRODUCTION"
|
||||||
PORT=$PORT
|
PORT=$PORT
|
||||||
EOF
|
EOF
|
||||||
chmod 600 "$DIR/App/.env"
|
chmod 600 "$DIR/App/.env"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# pm2-run wrapper so the dev server always gets nvm on PATH and the right port.
|
# 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" <<EOF
|
cat > "$DIR/App/run-staging.sh" <<EOF
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
export NVM_DIR="\$HOME/.nvm"; . "\$NVM_DIR/nvm.sh"
|
export NVM_DIR="\$HOME/.nvm"; . "\$NVM_DIR/nvm.sh"
|
||||||
cd "$DIR/App"
|
cd "$DIR/App"
|
||||||
exec pnpm dev -p $PORT
|
exec pnpm dev -p $PORT -H 127.0.0.1
|
||||||
EOF
|
EOF
|
||||||
chmod +x "$DIR/App/run-staging.sh"
|
chmod +x "$DIR/App/run-staging.sh"
|
||||||
|
|
||||||
cd "$DIR/App"
|
cd "$DIR/App"
|
||||||
echo "Installing deps..."; pnpm install --frozen-lockfile
|
echo "Installing deps..."; pnpm install --frozen-lockfile
|
||||||
echo "Generating Prisma client..."; pnpm db:generate
|
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
|
if pm2 describe "$NAME" >/dev/null 2>&1; then
|
||||||
pm2 restart "$NAME" --update-env
|
pm2 restart "$NAME" --update-env
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue