pelagia-portal/App/prisma/schema.prisma
Hardik 37b1debc9d feat(crewing): Phase 4a — crew records & profile + PPE (flagged)
First slice of Phase 4 (stacked on 3c onboarding). The Crew directory and tabbed
crew profile with documents, bank/EPF (role-masked), next of kin, PPE and
experience, per Crewing-Implementation-Spec §8.7–8.8. Behind
NEXT_PUBLIC_CREWING_ENABLED; production unchanged.

What's in
- Schema (crewing_crew_records migration): SeafarerDocument, NextOfKin
  (isEmergency), ExperienceRecord, PpeIssue (PpeItem enum) — all on CrewMember;
  CrewActionType += DOCUMENT_UPLOADED/RECORD_UPDATED/PPE_ISSUED/PPE_RETURNED/
  EXPERIENCE_ADDED.
- PII masking (lib/crew-pii.ts, §6/§8.8): bank account + Aadhaar full only for
  Accounts/SuperUser, masked otherwise; salary hidden from site staff. Applied
  server-side before crossing to the client.
- Actions (crewing/crew/actions.ts): uploadDocument/deleteDocument, saveBankEpf,
  addNextOfKin/deleteNextOfKin, issuePpe/returnPpe, addExperience — guarded by
  upload_crew_records / issue_ppe, each writes a CrewAction.
- Screens: /crewing/crew (directory, search + vessel filter, ex-hands excluded)
  and /crewing/crew/[id] (tabbed profile: Documents · Bank & EPF · Next of kin ·
  PPE · Experience · Pay status). Crew added to the flag-gated nav (MGR/MPO/Site/
  Accounts).

Tests & docs
- Unit: crew-pii.test.ts (6). Integration: crew-records.test.ts (7) — documents,
  bank/EPF upsert, NoK, PPE issue/return, experience + permission gating.
  type-check clean; full unit (240) + integration (175) green.
- CLAUDE.md updated with the Phase 4a surface.

Deferred: site-staff own-site scoping (needs a User↔Site link); the records verify
queue (§8.11, Phase 5); Pay-status shows the salary structure only until payroll
(Phase 6).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 19:27:21 +05:30

961 lines
31 KiB
Text
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum Role {
TECHNICAL
MANNING
ACCOUNTS
MANAGER
SUPERUSER
AUDITOR
ADMIN
SITE_STAFF
}
enum POStatus {
DRAFT
SUBMITTED
MGR_REVIEW
VENDOR_ID_PENDING
EDITS_REQUESTED
REJECTED
MGR_APPROVED
SENT_FOR_PAYMENT
PARTIALLY_PAID
PAID_DELIVERED
PARTIALLY_CLOSED
CLOSED
CANCELLED
}
enum ActionType {
CREATED
SUBMITTED
APPROVED
APPROVED_WITH_NOTE
REJECTED
EDITS_REQUESTED
VENDOR_ID_REQUESTED
VENDOR_ID_PROVIDED
PAYMENT_SENT
PARTIAL_PAYMENT_CONFIRMED
RECEIPT_CONFIRMED
PARTIAL_RECEIPT_CONFIRMED
CLOSED
REASSIGNED
PRODUCT_PRICE_UPDATED
MANAGER_LINE_EDIT
CANCELLED
SUPERSEDED
}
enum RequestStatus {
PENDING
APPROVED
DENIED
}
// ─── Crewing (feature-flagged: NEXT_PUBLIC_CREWING_ENABLED) ──────────────────
// Phase 1 (Foundations) lands only the reference-data layer. The lifecycle
// models/enums (Requisition, Application, Assignment, …) arrive in later phases.
// See wiki Crewing-Implementation-Spec §12.
// Org-chart grouping for a Rank. Drives reporting/segmentation, not login.
enum RankCategory {
OPERATIONAL
SUPPORT
}
// The seafarer/crew document set a rank may be required to hold. Drives
// candidate vetting and crew uploads via RankDocRequirement.
enum SeafarerDocType {
STCW
AADHAAR
PAN
PASSPORT
CDC
COC
PHOTOGRAPH
DRIVING_LICENSE
MEDICAL_FITNESS
CONTRACT_LETTER
}
// ─── Crewing lifecycle (Phase 2: Requisitions + relief) ─────────────────────
// Requisition lifecycle — Crewing-Implementation-Spec §5.2. The intermediate
// stages (SHORTLISTING…SELECTED) are advanced by the recruitment pipeline that
// lands in Phase 3; Phase 2 wires OPEN, CANCELLED and the FILLED terminal.
enum RequisitionStatus {
OPEN
SHORTLISTING
PROPOSING
INTERVIEWING
SELECTED
FILLED
CANCELLED
}
// Why a vacancy exists. LEAVE / SIGN_OFF / END_OF_CONTRACT are the system
// auto-raise reasons (§5.2/§5.3); the rest are raised manually by MPO/Manager.
enum RequisitionReason {
NEW_VACANCY
REPLACEMENT
LEAVE
SIGN_OFF
END_OF_CONTRACT
OTHER
}
// A foreseen-gap flag raised by site staff (§8.2 "Relief requests from sites").
// The office converts an OPEN relief request into a real requisition.
enum ReliefRequestStatus {
OPEN
CONVERTED
CANCELLED
}
// Crewing audit-trail action types — the CrewAction mirror of ActionType for
// POAction (§4.5/§11). Extended per phase; Phase 2 covers requisition + relief,
// Phase 3a adds candidate intake.
enum CrewActionType {
REQUISITION_RAISED
REQUISITION_ADVANCED
REQUISITION_FILLED
REQUISITION_CANCELLED
RELIEF_REQUESTED
RELIEF_CONVERTED
RELIEF_CANCELLED
CANDIDATE_ADDED
CANDIDATE_UPDATED
APPLICATION_CREATED
GATE_PASSED
GATE_FAILED
REFERENCE_RECORDED
SALARY_AGREED
SALARY_APPROVED
CANDIDATE_PROPOSED
INTERVIEW_RECORDED
WAIVER_REQUESTED
WAIVER_APPROVED
CANDIDATE_SELECTED
APPLICATION_REJECTED
CREW_ONBOARDED
DOCUMENT_UPLOADED
RECORD_UPDATED
PPE_ISSUED
PPE_RETURNED
EXPERIENCE_ADDED
}
// PPE kit items issued to crew (Phase 4a, Epic F). See Crewing-Data-Model §1.
enum PpeItem {
BOILER_SUIT
SAFETY_SHOES
HELMET
VEST
GLOVES
MASK
GOGGLES
TIFFIN
TORCH
WALKIE_TALKIE
}
// ─── Crewing recruitment pipeline (Phase 3b: Epic C) ────────────────────────
// The gated 7-stage application pipeline (Crewing-Implementation-Spec §5.1).
// ONBOARDED is the terminal system state set at onboarding (Phase 3c);
// REJECTED is the branch reachable from any active stage.
enum ApplicationStage {
SHORTLISTED
COMPETENCY_AND_REFERENCES
DOC_VERIFICATION
SALARY_AGREEMENT
PROPOSED
INTERVIEW
SELECTED
REJECTED
ONBOARDED
}
// A vetting gate on an application. SALARY / SELECTION / WAIVER are the
// Manager-decided gates that surface in the central Approvals queue (§8.13).
enum ApplicationGateType {
COMPETENCY_REFERENCE
DOCUMENT
SALARY
INTERVIEW
WAIVER
SELECTION
}
enum GateResult {
PENDING
VERIFIED
REJECTED
}
// MPO's recorded interview outcome (Manager then approves selection).
enum InterviewOutcome {
PENDING
ACCEPTED
REJECTED
}
// Salary capture basis — the other is derived (R10/A4). Effective-dated.
enum SalaryRateBasis {
MONTHLY
DAILY
}
// A crew member's tour of duty (Phase 3c, Epic D). Created at onboarding; the
// leave/sign-off transitions land in Phase 4. See Crewing-Data-Model §4.
enum AssignmentStatus {
ACTIVE
ON_LEAVE
SIGNED_OFF
}
// ─── Crewing candidates (Phase 3a: Epic B) ──────────────────────────────────
// A CrewMember is the talent-pool spine: a row exists from first contact and
// persists through CANDIDATE → EMPLOYEE → EX_HAND. `employeeId` is assigned only
// at onboarding (Phase 3c). See Crewing-Data-Model §4 + Implementation-Spec §8.6.
enum CrewStatus {
PROSPECT
CANDIDATE
EMPLOYEE
EX_HAND
BLACKLISTED
}
// NEW applicants vs returning EX_HAND crew (drives the ex-hand affordances).
enum CandidateType {
NEW
EX_HAND
}
// Where the candidate came from (the §8.6 "Source" column; ex-hand renders purple).
enum CandidateSource {
CAREERS
EX_HAND
WALK_IN
REFERRAL
OTHER
}
model User {
id String @id @default(cuid())
employeeId String @unique
email String @unique
name String
passwordHash String?
role Role
isActive Boolean @default(true)
signatureKey String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
submittedPOs PurchaseOrder[] @relation("Submitter")
actions POAction[]
notifications Notification[]
consumption ItemConsumption[]
superUserRequests SuperUserRequest[] @relation("Requester")
resolvedRequests SuperUserRequest[] @relation("RequestResolver")
requisitionsRaised Requisition[] @relation("RequisitionRaiser")
reliefRequested ReliefRequest[] @relation("ReliefRequester")
crewActions CrewAction[]
}
model SuperUserRequest {
id String @id @default(cuid())
userId String
user User @relation("Requester", fields: [userId], references: [id])
reason String?
status RequestStatus @default(PENDING)
createdAt DateTime @default(now())
resolvedAt DateTime?
resolvedById String?
resolvedBy User? @relation("RequestResolver", fields: [resolvedById], references: [id])
}
model Site {
id String @id @default(cuid())
name String
code String @unique
address String?
latitude Float?
longitude Float?
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
purchaseOrders PurchaseOrder[]
inventory ItemInventory[]
consumption ItemConsumption[]
requisitions Requisition[]
reliefRequests ReliefRequest[]
assignments CrewAssignment[]
}
model Vessel {
id String @id @default(cuid())
name String
code String @unique
isActive Boolean @default(true)
purchaseOrders PurchaseOrder[]
requisitions Requisition[]
reliefRequests ReliefRequest[]
assignments CrewAssignment[]
}
model Company {
id String @id @default(cuid())
name String
code String? @unique
gstNumber String?
address String?
telephone String?
mobile String?
email String?
invoiceEmail String?
invoiceAddress String?
logoKey String? // storage key for uploaded logo image (top of exported POs)
stampKey String? // storage key for uploaded company stamp/seal (signatory block of exported POs)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
purchaseOrders PurchaseOrder[]
}
model Account {
id String @id @default(cuid())
code String @unique
name String
description String?
isActive Boolean @default(true)
parentId String?
parent Account? @relation("AccountHierarchy", fields: [parentId], references: [id])
children Account[] @relation("AccountHierarchy")
purchaseOrders PurchaseOrder[]
lineItems POLineItem[]
}
model VendorContact {
id String @id @default(cuid())
name String
role String?
mobile String?
email String?
isPrimary Boolean @default(false)
createdAt DateTime @default(now())
vendorId String
vendor Vendor @relation(fields: [vendorId], references: [id], onDelete: Cascade)
}
model Vendor {
id String @id @default(cuid())
name String
vendorId String? @unique
address String?
pincode String?
gstin String?
latitude Float?
longitude Float?
isVerified Boolean @default(false)
isActive Boolean @default(true)
createdAt DateTime @default(now())
contacts VendorContact[]
purchaseOrders PurchaseOrder[]
products Product[] @relation("ProductLastVendor")
vendorPrices ProductVendorPrice[]
}
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[]
vendorPrices ProductVendorPrice[]
inventory ItemInventory[]
consumption ItemConsumption[]
}
model ProductVendorPrice {
id String @id @default(cuid())
price Decimal @db.Decimal(12, 2)
updatedAt DateTime @updatedAt
productId String
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
vendorId String
vendor Vendor @relation(fields: [vendorId], references: [id])
@@unique([productId, vendorId])
}
model ItemInventory {
id String @id @default(cuid())
quantity Decimal @db.Decimal(10, 3)
updatedAt DateTime @updatedAt
productId String
product Product @relation(fields: [productId], references: [id])
siteId String
site Site @relation(fields: [siteId], references: [id])
@@unique([productId, siteId])
}
model ItemConsumption {
id String @id @default(cuid())
date DateTime @db.Date
quantity Decimal @db.Decimal(10, 3)
note String?
productId String
product Product @relation(fields: [productId], references: [id])
siteId String
site Site @relation(fields: [siteId], references: [id])
recordedById String
recordedBy User @relation(fields: [recordedById], references: [id])
@@unique([productId, siteId, date])
}
model PurchaseOrder {
id String @id @default(cuid())
poNumber String @unique
title String
status POStatus @default(DRAFT)
totalAmount Decimal @db.Decimal(12, 2)
currency String @default("INR")
dateRequired DateTime?
projectCode String?
managerNote String?
paymentRef String?
paymentDate DateTime?
paidAmount Decimal? @db.Decimal(12, 2)
piQuotationNo String?
piQuotationDate DateTime?
requisitionNo String?
requisitionDate DateTime?
placeOfDelivery String?
tcDelivery String?
tcDispatch String?
tcInspection String?
tcTransitInsurance String?
tcPaymentTerms String?
tcOthers String?
poDate DateTime?
submittedAt DateTime?
approvedAt DateTime?
paidAt DateTime?
closedAt DateTime?
cancelledAt DateTime?
cancellationReason String?
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])
companyId String?
company Company? @relation(fields: [companyId], references: [id])
vendorId String?
vendor Vendor? @relation(fields: [vendorId], references: [id])
siteId String?
site Site? @relation(fields: [siteId], references: [id])
// Supersede: a cancelled PO may be linked to the existing PO that replaces it.
// `supersededBy` is that replacement; `supersedes` is the reciprocal list.
supersededById String?
supersededBy PurchaseOrder? @relation("Supersede", fields: [supersededById], references: [id])
supersedes PurchaseOrder[] @relation("Supersede")
lineItems POLineItem[]
documents PODocument[]
actions POAction[]
receipt Receipt?
notifications Notification[]
}
model POLineItem {
id String @id @default(cuid())
name String
description String?
quantity Decimal @db.Decimal(10, 3)
unit String
unitPrice Decimal @db.Decimal(12, 2)
totalPrice Decimal @db.Decimal(12, 2)
gstRate Decimal @default(0.18) @db.Decimal(5, 4)
sortOrder Int @default(0)
size String?
deliveredQuantity Decimal? @db.Decimal(10, 3)
productId String?
product Product? @relation(fields: [productId], references: [id])
accountId String?
account Account? @relation(fields: [accountId], 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
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?
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
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
link String?
isRead Boolean @default(false)
sentAt DateTime @default(now())
status String @default("sent")
poId String?
po PurchaseOrder? @relation(fields: [poId], references: [id])
userId String
user User @relation(fields: [userId], references: [id])
}
// ─── Crewing reference data ──────────────────────────────────────────────────
// The crew org hierarchy. A self-referential tree (parent/children), exactly
// like the Account accounting-code hierarchy. Reference data managed at
// /admin/ranks. `grantsLogin` is true only for the three management ranks
// (PM, Assistant PM, Site In-charge) — every other rank is a crew member /
// data subject with no portal account. See Crewing-Data-Model §2.
model Rank {
id String @id @default(cuid())
code String @unique
name String
description String?
category RankCategory @default(OPERATIONAL)
isSeafarer Boolean @default(false)
grantsLogin Boolean @default(false)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
parentId String?
parent Rank? @relation("RankHierarchy", fields: [parentId], references: [id])
children Rank[] @relation("RankHierarchy")
docRequirements RankDocRequirement[]
requisitions Requisition[]
reliefRequests ReliefRequest[]
crewCurrentRank CrewMember[] @relation("CrewCurrentRank")
crewAppliedRank CrewMember[] @relation("CrewAppliedRank")
assignments CrewAssignment[]
experienceRecords ExperienceRecord[]
}
// Which documents a rank is required (or conditionally required) to hold.
// `isMandatory = false` is the "conditional" tag in the UI.
model RankDocRequirement {
id String @id @default(cuid())
rankId String
rank Rank @relation(fields: [rankId], references: [id], onDelete: Cascade)
docType SeafarerDocType
isMandatory Boolean @default(true)
note String?
createdAt DateTime @default(now())
@@unique([rankId, docType])
}
// ─── Crewing lifecycle models (Phase 2) ──────────────────────────────────────
// A vacancy to be filled for a rank on a vessel/site. Raised manually by
// MPO/Manager, or auto-raised by the system on a leave clash / sign-off / EOC
// (autoRaised = true). The recruitment pipeline (Phase 3) attaches candidates
// and drives the intermediate stages. See Crewing-Implementation-Spec §5.2/§8.
model Requisition {
id String @id @default(cuid())
code String @unique // mono id, e.g. REQ-9000
status RequisitionStatus @default(OPEN)
reason RequisitionReason @default(NEW_VACANCY)
autoRaised Boolean @default(false)
neededBy DateTime?
notes String?
cancelledAt DateTime?
cancellationReason String?
filledAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
rankId String
rank Rank @relation(fields: [rankId], references: [id])
vesselId String?
vessel Vessel? @relation(fields: [vesselId], references: [id])
siteId String?
site Site? @relation(fields: [siteId], references: [id])
// Null when the system auto-raised it.
raisedById String?
raisedBy User? @relation("RequisitionRaiser", fields: [raisedById], references: [id])
// The site relief request this requisition was converted from, if any.
sourceReliefRequest ReliefRequest? @relation("ReliefConversion")
actions CrewAction[]
applications Application[]
assignment CrewAssignment?
}
// A foreseen-gap flag from a site (site staff), pending office conversion into a
// Requisition. Complementary, proactive channel to the auto-raised LEAVE
// requisition. See Crewing-Implementation-Spec §8.2 (R3/R6).
model ReliefRequest {
id String @id @default(cuid())
status ReliefRequestStatus @default(OPEN)
note String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
rankId String
rank Rank @relation(fields: [rankId], references: [id])
vesselId String?
vessel Vessel? @relation(fields: [vesselId], references: [id])
siteId String?
site Site? @relation(fields: [siteId], references: [id])
requestedById String
requestedBy User @relation("ReliefRequester", fields: [requestedById], references: [id])
// Set when an MPO/Manager converts it; one relief request → one requisition.
convertedRequisitionId String? @unique
convertedRequisition Requisition? @relation("ReliefConversion", fields: [convertedRequisitionId], references: [id])
}
// Crewing audit trail — one row per transition / verification (mirror of
// POAction). Entity relations are added per phase; Phase 2 links requisitions,
// Phase 3a adds candidates. A row references at most one entity (the rest null).
model CrewAction {
id String @id @default(cuid())
actionType CrewActionType
note String?
metadata Json?
createdAt DateTime @default(now())
// Null for system-performed actions (auto-raise).
actorId String?
actor User? @relation(fields: [actorId], references: [id])
requisitionId String?
requisition Requisition? @relation(fields: [requisitionId], references: [id])
crewMemberId String?
crewMember CrewMember? @relation(fields: [crewMemberId], references: [id])
applicationId String?
application Application? @relation(fields: [applicationId], references: [id])
}
// The talent-pool spine (Phase 3a, Epic B). One row per person, created the
// moment they enter the pool and kept through CANDIDATE → EMPLOYEE → EX_HAND, so
// an ex-hand's history/documents are already on file. `employeeId` is assigned
// at onboarding (Phase 3c). The recruitment pipeline (Applications, Phase 3b)
// and crew records (Phase 4) hang off this model. See Crewing-Data-Model §4.
model CrewMember {
id String @id @default(cuid())
employeeId String? @unique // assigned at onboarding (Phase 3c)
name String
status CrewStatus @default(CANDIDATE)
type CandidateType @default(NEW)
source CandidateSource @default(CAREERS)
email String?
phone String?
dob DateTime?
experienceMonths Int @default(0)
vesselTypeExperience String? // free-text "vessel type" from the Add-candidate modal
cvKey String? // storage key for an uploaded CV (no parsing yet — A2 deferred)
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Rank held / last held (ex-hands) and the rank being applied for.
currentRankId String?
currentRank Rank? @relation("CrewCurrentRank", fields: [currentRankId], references: [id])
appliedRankId String?
appliedRank Rank? @relation("CrewAppliedRank", fields: [appliedRankId], references: [id])
actions CrewAction[]
applications Application[]
bankDetail BankDetail?
epfDetail EpfDetail?
assignments CrewAssignment[]
documents SeafarerDocument[]
nextOfKin NextOfKin[]
experienceRecords ExperienceRecord[]
ppeIssues PpeIssue[]
}
// ─── Crewing recruitment pipeline models (Phase 3b) ─────────────────────────
// A candidate's application against one requisition — the gated pipeline spine
// (spec §5.1/§8.48.5). One application per (requisition, candidate).
model Application {
id String @id @default(cuid())
stage ApplicationStage @default(SHORTLISTED)
type CandidateType @default(NEW)
interviewResult InterviewOutcome @default(PENDING)
interviewWaived Boolean @default(false) // set true only on Manager-approved waiver (R2)
rejectedReason String?
rejectedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
requisitionId String
requisition Requisition @relation(fields: [requisitionId], references: [id])
crewMemberId String
crewMember CrewMember @relation(fields: [crewMemberId], references: [id])
gates ApplicationGate[]
referenceChecks ReferenceCheck[]
salaryStructures SalaryStructure[]
actions CrewAction[]
@@unique([requisitionId, crewMemberId])
}
// One row per vetting gate. SALARY / SELECTION / WAIVER gates with result PENDING
// are the Manager's central Approvals-queue items (§8.13). `decidedById` is a
// denormalised actor id — the audited actor lives on the CrewAction.
model ApplicationGate {
id String @id @default(cuid())
applicationId String
application Application @relation(fields: [applicationId], references: [id], onDelete: Cascade)
gate ApplicationGateType
result GateResult @default(PENDING)
note String?
decidedById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([applicationId, gate])
}
// Competency & reference checks recorded by the MPO at the COMPETENCY_AND_REFERENCES gate.
model ReferenceCheck {
id String @id @default(cuid())
applicationId String
application Application @relation(fields: [applicationId], references: [id], onDelete: Cascade)
refereeName String
refereeContact String?
outcome String? // free-text / "positive" | "negative"
note String?
recordedById String?
createdAt DateTime @default(now())
}
// The salary agreed at SALARY_AGREEMENT, sent for Manager approval. Effective-dated
// (R10/A4) and attached to the Application in 3b; onboarding (3c) binds it to the
// CrewAssignment. `approvedById` is set when the Manager approves the SALARY gate.
model SalaryStructure {
id String @id @default(cuid())
applicationId String
application Application @relation(fields: [applicationId], references: [id], onDelete: Cascade)
rateBasis SalaryRateBasis @default(MONTHLY)
basic Decimal @db.Decimal(12, 2)
victualingPerDay Decimal @default(0) @db.Decimal(12, 2)
allowances Json?
currency String @default("INR")
effectiveFrom DateTime?
effectiveTo DateTime?
approvedById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Bound to the assignment at onboarding (Phase 3c); null while still a proposal.
assignmentId String?
assignment CrewAssignment? @relation(fields: [assignmentId], references: [id])
}
// Bank details captured at DOC_VERIFICATION (needed downstream for payroll).
// NOTE: PII — field-level encryption/masking is a Phase-4 task (§11); stored
// plainly for now behind the crewing flag.
model BankDetail {
id String @id @default(cuid())
crewMemberId String @unique
crewMember CrewMember @relation(fields: [crewMemberId], references: [id], onDelete: Cascade)
accountName String?
accountNumber String?
ifsc String?
bankName String?
verificationStatus GateResult @default(PENDING) // verified by Accounts in a later phase
verifiedById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// EPF / identity details captured at DOC_VERIFICATION. PII note as BankDetail.
model EpfDetail {
id String @id @default(cuid())
crewMemberId String @unique
crewMember CrewMember @relation(fields: [crewMemberId], references: [id], onDelete: Cascade)
uan String?
aadhaarLast4 String?
pfNumber String?
verificationStatus GateResult @default(PENDING)
verifiedById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// ─── Crewing onboarding (Phase 3c: Epic D) ──────────────────────────────────
// A single tour of duty, created at onboarding. Flips the requisition to FILLED
// and the crew member to EMPLOYEE. Leave/sign-off transitions arrive in Phase 4.
model CrewAssignment {
id String @id @default(cuid())
status AssignmentStatus @default(ACTIVE)
signOnDate DateTime
signOffDate DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
crewMemberId String
crewMember CrewMember @relation(fields: [crewMemberId], references: [id])
rankId String
rank Rank @relation(fields: [rankId], references: [id])
vesselId String?
vessel Vessel? @relation(fields: [vesselId], references: [id])
siteId String?
site Site? @relation(fields: [siteId], references: [id])
// The requisition this assignment fills (one assignment per requisition).
requisitionId String? @unique
requisition Requisition? @relation(fields: [requisitionId], references: [id])
salaryStructures SalaryStructure[]
contractLetter ContractLetter?
}
// The signed contract for an assignment. `salaryRestricted` hides salary from
// site staff on the crew profile (Phase 4 display gating).
model ContractLetter {
id String @id @default(cuid())
assignmentId String @unique
assignment CrewAssignment @relation(fields: [assignmentId], references: [id], onDelete: Cascade)
fileKey String
salaryRestricted Boolean @default(true)
createdAt DateTime @default(now())
}
// ─── Crewing crew records (Phase 4a, Epics E + F) ───────────────────────────
// A held document on the crew profile (medical, passport, CDC, STCW, …). The
// verify queue (MPO/Accounts) lands in Phase 5; here we capture + display, with
// `verificationStatus` carried and "expired" derived from expiryDate in the UI.
model SeafarerDocument {
id String @id @default(cuid())
crewMemberId String
crewMember CrewMember @relation(fields: [crewMemberId], references: [id], onDelete: Cascade)
docType SeafarerDocType
number String? // PII — masked in the UI for non-privileged roles
fileKey String?
issueDate DateTime?
expiryDate DateTime?
verificationStatus GateResult @default(PENDING)
verifiedById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// Next of kin / emergency contacts (§8.8). `isEmergency` marks the emergency row.
model NextOfKin {
id String @id @default(cuid())
crewMemberId String
crewMember CrewMember @relation(fields: [crewMemberId], references: [id], onDelete: Cascade)
name String
relationship String?
phone String?
address String?
isEmergency Boolean @default(false)
createdAt DateTime @default(now())
}
// A tour-of-duty experience row — added manually or auto-appended at sign-off
// (Phase 4c). `source` is "internal" (a PPMS assignment) or "declared".
model ExperienceRecord {
id String @id @default(cuid())
crewMemberId String
crewMember CrewMember @relation(fields: [crewMemberId], references: [id], onDelete: Cascade)
vesselType String?
rankId String?
rank Rank? @relation(fields: [rankId], references: [id])
fromDate DateTime?
toDate DateTime?
durationMonths Int?
source String @default("declared")
createdAt DateTime @default(now())
}
// PPE issued to a crew member (§8.8). A reissue is a new row; `returnedDate`
// marks a returned item. Optional ItemInventory draw-down is a later refinement.
model PpeIssue {
id String @id @default(cuid())
crewMemberId String
crewMember CrewMember @relation(fields: [crewMemberId], references: [id], onDelete: Cascade)
item PpeItem
size String?
quantity Int @default(1)
issuedDate DateTime @default(now())
returnedDate DateTime?
issuedById String?
comment String?
createdAt DateTime @default(now())
}