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 | 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} +
+ ); +} 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

-
    - {po.documents.map((doc, i) => ( -
  • - - {doc.fileName} - - - {(doc.fileSize / 1024).toFixed(0)} KB · {formatDate(doc.uploadedAt)} - -
  • +
    + {attachmentGroups.map((group) => ( +
    +

    + {group.meta.label} + ({group.items.length}) +

    + {group.meta.description && ( +

    {group.meta.description}

    + )} +
      + {group.items.map((doc) => ( +
    • + + {doc.fileName} + + + {(doc.fileSize / 1024).toFixed(0)} KB · {formatDate(doc.uploadedAt)} + +
    • + ))} +
    +
    ))} -
+
)} 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"]); + }); +}); 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" ``` 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/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-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 79e9e44..8625bb9 100644 --- a/automation/staging-up.sh +++ b/automation/staging-up.sh @@ -42,23 +42,30 @@ 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" </dev/null 2>&1; then pm2 restart "$NAME" --update-env