pelagia-portal/Docs/02-architecture.md
Hardik 58a5a00594 docs: bring CLAUDE.md, README, Docs and CHANGELOG up to date with current product
Reflects this iteration's domain/feature changes across the docs set:
- Cost centre = Vessel only (labelled 'Cost Centre'); costCentreRef/Site removed
- Companies (multi-company invoicing) on POs and exports
- 3-level 6-digit accounting-code hierarchy; leaf-only PO selection
- Structured PO numbers COMPANY/VESSEL/ID/FY (ids from 9000)
- Compulsory payment date; editable poDate; export date = approval date
- Submitter vendor creation (unverified until proven); verifyVendor
- Import PO -> CLOSED with auto vendor/product creation
- Inventory flag; inventory added at approval; partial pay/receipt states
- Microsoft Entra SSO (nullable passwordHash); profile reachable by all roles
- README: roles, domain concepts, db:seed:prod, migrate-before-serve callout
- CHANGELOG: Added/Changed/Fixed for the above

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 12:43:24 +05:30

30 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 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
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

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

Source of truth: App/prisma/schema.prisma. The excerpt below is an illustrative overview and may lag the schema. Notable evolutions since the original design:

  • Cost centre is a Vessel only — the PO vesselId is required; the earlier Vessel-or-Site cost-centre design was dropped. Vessel no longer links to a Site. In the UI a Vessel is labelled "Cost Centre".
  • Account is a 3-level hierarchy (self-relation via parentId): Top Category → Sub-Category → Leaf item, with 6-digit numeric codes; only leaf codes are PO-selectable.
  • Company added — the sister company a PO is billed under (PurchaseOrder.companyId).
  • PurchaseOrder gained companyId, siteId?, paidAmount, paymentDate, poDate, and the quotation / requisition / terms-and-conditions fields; currency defaults to INR.
  • Vendor gained gstin, address, pincode, latitude/longitude (geocoded for distance) and a VendorContact[] list. Submitters can create vendors (created unverified); a vendor is verified when a PO closes/pays with it, on import, or by a Manager/Accounts/Admin.
  • Inventory/catalogue models added: Site, ItemInventory, ItemConsumption, ProductVendorPrice. Auth: User.passwordHash is nullable (SSO users) and User has a signatureKey. New statuses PARTIALLY_PAID / PARTIALLY_CLOSED.

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
  PARTIALLY_PAID
  PAID_DELIVERED
  PARTIALLY_CLOSED
  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[]
}

// Cost centre. Surfaced as "Cost Centre" in the UI.
model Vessel {
  id        String   @id @default(cuid())
  name      String
  code      String   @unique
  isActive  Boolean  @default(true)

  purchaseOrders PurchaseOrder[]
}

// 3-level hierarchy via self-relation: Top Category → Sub-Category → Leaf item.
// 6-digit numeric codes; only leaf accounts (no children) are selectable on a PO.
model Account {
  id          String    @id @default(cuid())
  code        String    @unique          // e.g. 100000 / 100100 / 100101
  name        String
  description String?
  isActive    Boolean   @default(true)

  parentId    String?
  parent      Account?  @relation("AccountHierarchy", fields: [parentId], references: [id])
  children    Account[] @relation("AccountHierarchy")

  purchaseOrders PurchaseOrder[]
}

// Sister company a PO is billed under; its details populate the exported PO header.
model Company {
  id             String  @id @default(cuid())
  name           String
  code           String? @unique          // short code used in PO numbers, e.g. PMS
  gstNumber      String?
  address        String?
  telephone      String?
  mobile         String?
  email          String?
  invoiceEmail   String?
  invoiceAddress 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         // COMPANY/VESSEL/ID/FY, formatted in lib/po-number.ts
  title          String
  status         POStatus  @default(DRAFT)
  totalAmount    Decimal   @db.Decimal(12, 2)
  paidAmount     Decimal?  @db.Decimal(12, 2)  // accumulates across partial payments
  currency       String    @default("INR")
  poDate         DateTime?                  // editable PO date (export "Date" = poDate ?? approvedAt ?? createdAt)
  dateRequired   DateTime?
  projectCode    String?
  managerNote    String?
  paymentRef     String?
  paymentDate    DateTime?                  // compulsory when Accounts records a payment; no future dates
  // + piQuotationNo/Date, requisitionNo/Date, placeOfDelivery, tc* (terms & conditions) fields
  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                     // cost centre (required)
  vessel         Vessel    @relation(fields: [vesselId], references: [id])
  siteId         String?                    // optional delivery site (drives inventory)
  site           Site?     @relation(fields: [siteId], references: [id])
  accountId      String
  account        Account   @relation(fields: [accountId], references: [id])
  companyId      String?
  company        Company?  @relation(fields: [companyId], 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

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.

                 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.

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

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


12. Development Conventions

  • 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. On the server they live in ~/pms/App/.env / .env.production; locally in .env.local (git-ignored).