pelagia-portal/Design/02-architecture.md
2026-05-18 23:18:58 +05:30

25 KiB

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/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
  imoNumber String?  @unique
  isActive  Boolean  @default(true)

  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.

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