# 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).
