pelagia-portal/Docs/02-architecture.md

566 lines
25 KiB
Markdown

# 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<POStatus, Record<string, Transition>> = {
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<PurchaseOrder> { ... }
```
### 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/<key> │
│ │ │
│── PUT /api/files/dev/<key>►│ │
│ │── write to disk ───────►│
│ │ │
│── Server Action: link ───►│ │
│ { poId, key, meta } │── INSERT PODocument ──►│ (DB)
```
Downloads follow the same pattern: `generateDownloadUrl` returns a `/api/files/dev/<key>` 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).