564 lines
25 KiB
Markdown
564 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
|
|
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.
|
|
|
|
```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).
|