# Pelagia Portal — Architecture Document ## 1. Technology Stack ### 1.1 Decision Summary The portal is an internal line-of-business app with a well-defined data model, multi-role access, and transactional workflows. The stack below optimises for **developer velocity**, **type safety end-to-end**, and **operational simplicity** (minimal infrastructure to manage). | Layer | Choice | Rationale | |---|---|---| | **Framework** | Next.js 15 (App Router) | Full-stack React; server components reduce client JS; built-in API routes; excellent TypeScript support | | **Language** | TypeScript 5 (strict mode) | Shared types between frontend and backend; catches contract mismatches at compile time | | **UI Library** | React 19 | Concurrent rendering, Server Components | | **Component Library** | shadcn/ui + Radix UI primitives | Accessible, unstyled primitives; copy-owned source, no black-box upgrade surprises | | **Styling** | Tailwind CSS v4 | Utility-first; consistent design tokens; no CSS specificity battles | | **ORM** | Prisma 5 | Type-safe DB client; schema-first migrations; Prisma Studio for admin data inspection | | **Database** | PostgreSQL 16 | ACID transactions; JSON columns for flexible line-item metadata; mature RBAC at row level | | **Auth** | NextAuth.js v5 (Auth.js) | Session-cookie auth; credentials provider for internal accounts; easy SSO adapter upgrade path | | **File Storage** | Cloudflare R2 (S3-compatible) in production; local filesystem in development | Cheap egress; S3 API compatibility; presigned URLs keep uploads off the app server; dev mode avoids paid services | | **Email** | Resend + React Email in production; console log in development | Transactional email with React-rendered templates; generous free tier; reliable deliverability; dev mode requires no API key | | **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 | --- ## 2. High-Level System Architecture ``` ┌─────────────────────────────────────────────────────────┐ │ Browser │ │ React 19 + shadcn/ui + Tailwind │ │ Server Components (read) + Client Components (forms) │ └──────────────────┬──────────────────────────────────────┘ │ HTTPS ┌──────────────────▼──────────────────────────────────────┐ │ Next.js 15 App Server │ │ │ │ ┌─────────────────────┐ ┌─────────────────────────┐ │ │ │ App Router Pages │ │ Server Actions / API │ │ │ │ (RSC rendering) │ │ Route Handlers │ │ │ └─────────────────────┘ └──────────┬──────────────┘ │ │ │ │ │ ┌────────────────────────────────────▼──────────────┐ │ │ │ Business Logic Layer │ │ │ │ (PO state machine, permission checks, notifier) │ │ │ └──────────────────────┬────────────────────────────┘ │ └─────────────────────────┼────────────────────────────────┘ │ ┌───────────────┼───────────────┐ │ │ │ ┌─────────▼────┐ ┌───────▼──────┐ ┌────▼──────────┐ │ PostgreSQL │ │ Cloudflare R2│ │ Resend │ │ (Prisma) │ │ (documents, │ │ (transact- │ │ │ │ receipts) │ │ ional email) │ └──────────────┘ └──────────────┘ └───────────────┘ ``` --- ## 3. Application Layer Structure ``` pelagia-portal/ ├── app/ # Next.js App Router │ ├── (auth)/ │ │ └── login/ │ ├── (portal)/ # Authenticated shell │ │ ├── layout.tsx # Sidebar + header shell │ │ ├── dashboard/ │ │ ├── po/ │ │ │ ├── new/ │ │ │ ├── [id]/ │ │ │ │ ├── page.tsx # Detail view │ │ │ │ └── edit/ │ │ ├── approvals/ │ │ ├── payments/ │ │ ├── history/ │ │ └── admin/ │ │ ├── users/ │ │ ├── vessels/ │ │ ├── accounts/ │ │ └── vendors/ │ └── api/ │ ├── auth/[...nextauth]/ │ └── files/ │ ├── sign/ # Generate presigned upload URL (production) │ └── dev/[...key]/ # Local file upload/download handler (dev only) │ ├── components/ │ ├── ui/ # shadcn/ui primitives (owned copies) │ ├── po/ # PO-specific composite components │ ├── dashboard/ │ └── layout/ │ ├── lib/ │ ├── db.ts # Prisma client singleton │ ├── auth.ts # NextAuth config │ ├── po-state-machine.ts # State transition logic + guards │ ├── permissions.ts # Role → allowed-action map │ ├── notifier.ts # Email dispatch (wraps Resend) │ ├── storage.ts # R2 presigned URL helpers │ └── validations/ # Zod schemas │ ├── emails/ # React Email templates │ ├── po-submitted.tsx │ ├── po-approved.tsx │ ├── po-rejected.tsx │ ├── edits-requested.tsx │ ├── vendor-id-needed.tsx │ ├── payment-processed.tsx │ └── receipt-confirmed.tsx │ ├── prisma/ │ ├── schema.prisma │ └── migrations/ │ └── tests/ ├── unit/ ├── integration/ └── e2e/ ``` --- ## 4. Data Model ### 4.1 Entity Relationship (Prisma Schema) ```prisma // prisma/schema.prisma enum Role { TECHNICAL MANNING ACCOUNTS MANAGER SUPERUSER AUDITOR ADMIN } enum POStatus { DRAFT SUBMITTED MGR_REVIEW VENDOR_ID_PENDING EDITS_REQUESTED REJECTED MGR_APPROVED SENT_FOR_PAYMENT PAID_DELIVERED CLOSED } enum ActionType { CREATED SUBMITTED APPROVED APPROVED_WITH_NOTE REJECTED EDITS_REQUESTED VENDOR_ID_REQUESTED VENDOR_ID_PROVIDED PAYMENT_SENT RECEIPT_CONFIRMED CLOSED REASSIGNED PRODUCT_PRICE_UPDATED } model User { id String @id @default(cuid()) employeeId String @unique email String @unique name String passwordHash String role Role isActive Boolean @default(true) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt submittedPOs PurchaseOrder[] @relation("Submitter") actions POAction[] notifications Notification[] } model Vessel { id String @id @default(cuid()) name String isActive Boolean @default(true) siteId String? site Site? @relation(fields: [siteId], references: [id]) purchaseOrders PurchaseOrder[] } model Account { id String @id @default(cuid()) code String @unique name String description String? isActive Boolean @default(true) purchaseOrders PurchaseOrder[] } model Vendor { id String @id @default(cuid()) name String vendorId String? @unique contactName String? contactEmail String? isVerified Boolean @default(false) isActive Boolean @default(true) createdAt DateTime @default(now()) purchaseOrders PurchaseOrder[] products Product[] @relation("ProductLastVendor") } model Product { id String @id @default(cuid()) code String @unique name String description String? lastPrice Decimal? @db.Decimal(12, 2) lastVendorId String? lastVendor Vendor? @relation("ProductLastVendor", fields: [lastVendorId], references: [id]) isActive Boolean @default(true) updatedAt DateTime @updatedAt createdAt DateTime @default(now()) lineItems POLineItem[] } model PurchaseOrder { id String @id @default(cuid()) poNumber String @unique @default(cuid()) // formatted in app layer title String status POStatus @default(DRAFT) totalAmount Decimal @db.Decimal(12, 2) currency String @default("USD") dateRequired DateTime? projectCode String? managerNote String? paymentRef String? submittedAt DateTime? approvedAt DateTime? paidAt DateTime? closedAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt submitterId String submitter User @relation("Submitter", fields: [submitterId], references: [id]) vesselId String vessel Vessel @relation(fields: [vesselId], references: [id]) accountId String account Account @relation(fields: [accountId], references: [id]) vendorId String? vendor Vendor? @relation(fields: [vendorId], references: [id]) lineItems POLineItem[] documents PODocument[] actions POAction[] receipt Receipt? notifications Notification[] } model POLineItem { id String @id @default(cuid()) description String quantity Decimal @db.Decimal(10, 3) unit String unitPrice Decimal @db.Decimal(12, 2) totalPrice Decimal @db.Decimal(12, 2) sortOrder Int @default(0) productId String? product Product? @relation(fields: [productId], references: [id]) poId String po PurchaseOrder @relation(fields: [poId], references: [id], onDelete: Cascade) } model PODocument { id String @id @default(cuid()) fileName String fileSize Int mimeType String storageKey String // R2 object key uploadedAt DateTime @default(now()) poId String po PurchaseOrder @relation(fields: [poId], references: [id], onDelete: Cascade) } model POAction { id String @id @default(cuid()) actionType ActionType note String? metadata Json? // flexible: payment ref, vendor ID, etc. createdAt DateTime @default(now()) poId String po PurchaseOrder @relation(fields: [poId], references: [id]) actorId String actor User @relation(fields: [actorId], references: [id]) } model Receipt { id String @id @default(cuid()) storageKey String // R2 object key fileName String notes String? confirmedAt DateTime @default(now()) poId String @unique po PurchaseOrder @relation(fields: [poId], references: [id]) } model Notification { id String @id @default(cuid()) subject String body String sentAt DateTime @default(now()) status String @default("sent") // sent | failed | bounced poId String? po PurchaseOrder? @relation(fields: [poId], references: [id]) userId String user User @relation(fields: [userId], references: [id]) } ``` --- ## 5. Authentication & Authorisation ### 5.1 Authentication - Session-cookie based via NextAuth.js v5, `CredentialsProvider`. - Passwords hashed with bcrypt (cost factor 12). - Sessions stored server-side (database adapter); JWT not used to avoid stale role tokens. - Session contains: `userId`, `role`, `name`, `email`. ### 5.2 Authorisation Model Role permissions are enforced in a central `lib/permissions.ts` module and checked in Server Actions / Route Handlers before any data mutation. React Server Components also gate entire page segments server-side. ``` Action | Technical | Manning | Accounts | Manager | SuperUser | Auditor | Admin ----------------------------|-----------|---------|----------|---------|-----------|---------|------- create_po | ✓ | ✓ | | | ✓ | | submit_po | ✓ | ✓ | | | ✓ | | edit_own_draft_po | ✓ | ✓ | | | ✓ | | view_own_pos | ✓ | ✓ | | | ✓ | ✓ | ✓ view_all_pos | | | ✓ | ✓ | ✓ | ✓ | ✓ approve_po | | | | ✓ | ✓ | | reject_po | | | | ✓ | ✓ | | request_edits | | | | ✓ | ✓ | | request_vendor_id | | | | ✓ | ✓ | | process_payment | | | ✓ | | | | confirm_receipt | ✓ | ✓ | | | ✓ | | view_analytics | | | | ✓ | ✓ | ✓ | ✓ export_reports | | | | ✓ | ✓ | ✓ | ✓ manage_users | | | | | | | ✓ manage_vendors | | | | | | | ✓ manage_vessels_accounts | | | | | | | ✓ ``` --- ## 6. PO State Machine Implementation The state machine lives entirely in `lib/po-state-machine.ts`. No state transition may be performed without going through this module, ensuring the graph is enforced in one place. ```typescript // lib/po-state-machine.ts (illustrative) export type POStatus = | 'DRAFT' | 'SUBMITTED' | 'MGR_REVIEW' | 'VENDOR_ID_PENDING' | 'EDITS_REQUESTED' | 'REJECTED' | 'MGR_APPROVED' | 'SENT_FOR_PAYMENT' | 'PAID_DELIVERED' | 'CLOSED'; interface Transition { to: POStatus; allowedRoles: Role[]; requiresNote?: boolean; sideEffects: SideEffect[]; } const transitions: Record> = { DRAFT: { submit: { to: 'SUBMITTED', allowedRoles: ['TECHNICAL','MANNING','SUPERUSER'], sideEffects: ['EMAIL_MANAGER'] }, }, MGR_REVIEW: { approve: { to: 'MGR_APPROVED', allowedRoles: ['MANAGER','SUPERUSER'], sideEffects: ['EMAIL_SUBMITTER','EMAIL_ACCOUNTS'] }, approve_note: { to: 'MGR_APPROVED', allowedRoles: ['MANAGER','SUPERUSER'], requiresNote: true, sideEffects: ['EMAIL_SUBMITTER','EMAIL_ACCOUNTS'] }, reject: { to: 'REJECTED', allowedRoles: ['MANAGER','SUPERUSER'], requiresNote: true, sideEffects: ['EMAIL_SUBMITTER'] }, request_edits: { to: 'EDITS_REQUESTED', allowedRoles: ['MANAGER','SUPERUSER'], requiresNote: true, sideEffects: ['EMAIL_SUBMITTER'] }, request_vendor: { to: 'VENDOR_ID_PENDING', allowedRoles: ['MANAGER','SUPERUSER'], sideEffects: ['EMAIL_SUBMITTER'] }, }, SENT_FOR_PAYMENT: { confirm_payment: { to: 'PAID_DELIVERED', allowedRoles: ['ACCOUNTS'], sideEffects: ['EMAIL_SUBMITTER','EMAIL_MANAGER','UPDATE_PRODUCT_PRICES'] }, }, // ... }; export function canTransition(from: POStatus, action: string, role: Role): boolean { ... } export async function applyTransition(poId: string, action: string, actor: User, note?: string): Promise { ... } ``` ### Product Price Auto-Update (`UPDATE_PRODUCT_PRICES` side effect) When `confirm_payment` fires on a `SENT_FOR_PAYMENT` PO, `applyTransition` iterates every line item that carries a `productId`. For each one it sets `Product.lastPrice = lineItem.unitPrice` and `Product.lastVendorId = po.vendorId`. A `PRODUCT_PRICE_UPDATED` `POAction` is logged per updated product. Line items without a `productId` are skipped. --- ## 7. File Upload Flow To avoid routing large files through the app server, uploads use **presigned URLs** in production. Development uses a local equivalent to avoid requiring Cloudflare credentials. **Production (`NODE_ENV=production`) — Cloudflare R2:** ``` Client App Server Cloudflare R2 │ │ │ │── POST /api/files/sign ──►│ │ │ { fileName, mimeType } │ │ │ │── generate presigned ─►│ │ │◄─── presigned URL ─────│ │◄── { uploadUrl, key } ────│ │ │ │ │ │─────── PUT uploadUrl ──────────────────────────────►│ │ │ │ │── Server Action: link ───►│ │ │ { poId, key, meta } │── INSERT PODocument ──►│ (DB) ``` **Development (`NODE_ENV=development`) — local filesystem:** ``` Client App Server .dev-uploads/ │ │ │ │── POST /api/files/sign ──►│ │ │ { fileName, mimeType } │ │ │◄── { uploadUrl, key } ────│ │ │ uploadUrl = /api/files/dev/ │ │ │ │ │── PUT /api/files/dev/►│ │ │ │── write to disk ───────►│ │ │ │ │── Server Action: link ───►│ │ │ { poId, key, meta } │── INSERT PODocument ──►│ (DB) ``` Downloads follow the same pattern: `generateDownloadUrl` returns a `/api/files/dev/` GET URL in development and an R2 presigned URL in production. The `app/api/files/dev/[...key]/route.ts` route is auth-gated and returns 404 in production. --- ## 8. Notification System `lib/notifier.ts` is the single point for dispatching emails. It is called exclusively from within state-machine side-effects, never directly from UI handlers. ``` notifier.notify({ event: 'PO_APPROVED', po: PurchaseOrder, // full PO with relations recipients: User[], // resolved from event matrix }) ``` **In production**, email templates live in `/emails/` as React Email components, rendered server-side with `@react-email/render` and sent via the Resend SDK. **In development**, the email content (recipient, subject, body) is printed to the terminal instead of being sent. No Resend API key is required. In both modes, all notification events are persisted in the `Notification` table for audit purposes. --- ## 9. API Surface All data mutations are implemented as **Next.js Server Actions** (no separate REST endpoints for mutations). Queries use React Server Components where possible; client components call `fetch` against route handlers only for dynamic/paginated data. | Route Handler | Method | Purpose | |---|---|---| | `/api/auth/[...nextauth]` | GET/POST | Auth.js session endpoints | | `/api/files/sign` | POST | Generate R2 presigned upload URL | | `/api/po/[id]/export` | GET | Export single PO as PDF | | `/api/reports/export` | GET | Export history report as CSV/PDF | All other data operations (create PO, approve, reject, etc.) are Server Actions in `app/(portal)/*/actions.ts` co-located with their page. --- ## 10. Deployment Architecture ``` ┌────────────────────────────────────────────────┐ │ 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) │ └───────────────┘ ``` ### 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`). | Variable | Dev Required | Prod Required | Notes | |---|---|---|---| | `NEXTAUTH_SECRET` | Yes | Yes | 32-char random secret | | `NEXTAUTH_URL` | Yes | Yes | Full app URL | | `DATABASE_URL` | Yes | Yes | PostgreSQL connection string | | `R2_ACCOUNT_ID` | No | Yes | Cloudflare account ID | | `R2_ACCESS_KEY_ID` | No | Yes | R2 access key | | `R2_SECRET_ACCESS_KEY` | No | Yes | R2 secret key | | `R2_BUCKET_NAME` | No | Yes | R2 bucket name | | `R2_PUBLIC_URL` | No | Yes | Public R2 bucket URL | | `RESEND_API_KEY` | No | Yes | Resend API key | | `EMAIL_FROM` | No | Yes | Sender address | | `EMAIL_FROM_NAME` | No | No | Display name (default: "Pelagia Portal") | In development, uploaded files are stored in `.dev-uploads/` at the project root and emails are printed to the terminal. --- ## 11. Testing Strategy | Layer | Tool | What is tested | |---|---|---| | Unit | Vitest | State machine transitions, permission checks, Zod validators, utility functions | | Integration | Vitest + Prisma test DB | Server Actions against a real test database; seeded with fixture data | | 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. --- ## 12. Development Conventions - **Branch strategy**: `main` (production) ← `staging` ← feature branches (`feat/`, `fix/`, `chore/`). - **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).