Broadens the feature-flagged attachment affordance (same flag,
NEXT_PUBLIC_CLOSED_PO_ATTACHMENTS_ENABLED) from CLOSED-only to **any PO state
except REJECTED / CANCELLED**, for the same roles: the PO's own submitter plus
Accounts / Manager / SuperUser.
- lib/permissions.ts: canAddClosedPoAttachment → canAddPoAttachment(role,
status, { isSubmitter }); allows the submitter + ACCOUNTS/MANAGER/SUPERUSER
in any non-voided state. REJECTED/CANCELLED are always refused.
- uploadPoDocuments: voided POs are refused regardless of the flag; with the
flag on, uploads are restricted to those roles in any live state (the normal
create/receipt actors qualify, so those flows keep working); with the flag
off, the legacy behaviour stands (closed POs immutable).
- po-detail.tsx: the Attachments card now shows the uploader for any non-voided
state when permitted (not just CLOSED).
- Renamed ClosedPoAttachmentUploader → PoAttachmentUploader and the test file
to po-attachment-permissions.test.ts (flag-on matrix now covers live states +
rejected/cancelled refusal). Docs updated (feature-flags, .env.example,
CLAUDE.md).
Full unit + integration suites green; tsc clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
308 lines
9.4 KiB
TypeScript
308 lines
9.4 KiB
TypeScript
import type { Role, POStatus } from "@prisma/client";
|
|
import { SUBMITTER_VIEW_ALL_ENABLED, CLOSED_PO_ATTACHMENTS_ENABLED } from "./feature-flags";
|
|
|
|
export type Permission =
|
|
| "create_po"
|
|
| "submit_po"
|
|
| "edit_own_draft_po"
|
|
| "view_own_pos"
|
|
| "view_all_pos"
|
|
| "approve_po"
|
|
| "reject_po"
|
|
| "cancel_po"
|
|
| "request_edits"
|
|
| "request_vendor_id"
|
|
| "process_payment"
|
|
| "confirm_receipt"
|
|
| "view_analytics"
|
|
| "export_reports"
|
|
| "manage_users"
|
|
| "manage_vendors"
|
|
| "create_vendor"
|
|
| "manage_vessels_accounts"
|
|
| "manage_products"
|
|
| "manage_sites"
|
|
| "manage_delivery_locations"
|
|
| "manage_project_codes"
|
|
| "manage_terms"
|
|
// ── Crewing (feature-flagged) — mirrors Crewing-Implementation-Spec §6 ──────
|
|
| "raise_requisition"
|
|
| "request_relief_cover"
|
|
| "convert_relief_to_requisition"
|
|
| "cancel_requisition"
|
|
| "view_requisitions"
|
|
| "manage_candidates"
|
|
| "record_reference_check"
|
|
| "record_interview_result"
|
|
| "request_interview_waiver"
|
|
| "approve_interview_waiver"
|
|
| "approve_salary_structure"
|
|
| "select_candidate"
|
|
| "onboard_crew"
|
|
| "sign_off_crew"
|
|
| "view_crew_records"
|
|
| "upload_crew_records"
|
|
| "issue_ppe"
|
|
| "apply_leave"
|
|
| "decide_leave"
|
|
| "record_attendance"
|
|
| "view_attendance"
|
|
| "verify_site_records"
|
|
| "verify_bank_epf"
|
|
| "raise_appraisal"
|
|
| "verify_appraisal"
|
|
| "approve_appraisal"
|
|
| "generate_wage_report"
|
|
| "approve_wage_report"
|
|
| "view_wage_report"
|
|
| "manage_ranks"
|
|
// Office/admin crew management — direct placement (no requisition), crew CRUD,
|
|
// and per-vessel rank-strength config. Held by Manager + Admin (+ SuperUser).
|
|
| "manage_crew";
|
|
|
|
// Purchasing / admin permissions (the original PPMS matrix). SITE_STAFF is a
|
|
// crewing-only role and holds no purchasing permissions.
|
|
const PO_ROLE_PERMISSIONS: Record<Role, Permission[]> = {
|
|
TECHNICAL: ["create_po", "submit_po", "edit_own_draft_po", "view_own_pos", "confirm_receipt", "create_vendor"],
|
|
MANNING: ["create_po", "submit_po", "edit_own_draft_po", "view_own_pos", "confirm_receipt", "create_vendor"],
|
|
ACCOUNTS: ["view_all_pos", "process_payment", "manage_vendors", "create_vendor"],
|
|
MANAGER: [
|
|
"create_po",
|
|
"submit_po",
|
|
"edit_own_draft_po",
|
|
"view_own_pos",
|
|
"view_all_pos",
|
|
"approve_po",
|
|
"reject_po",
|
|
"cancel_po",
|
|
"request_edits",
|
|
"request_vendor_id",
|
|
"view_analytics",
|
|
"export_reports",
|
|
"manage_vendors",
|
|
"create_vendor",
|
|
"manage_vessels_accounts",
|
|
"manage_products",
|
|
"manage_sites",
|
|
"manage_delivery_locations",
|
|
"manage_project_codes",
|
|
"manage_terms",
|
|
"confirm_receipt",
|
|
"process_payment"
|
|
],
|
|
SUPERUSER: [
|
|
"create_po",
|
|
"submit_po",
|
|
"edit_own_draft_po",
|
|
"view_own_pos",
|
|
"view_all_pos",
|
|
"approve_po",
|
|
"reject_po",
|
|
"cancel_po",
|
|
"request_edits",
|
|
"request_vendor_id",
|
|
"process_payment",
|
|
"confirm_receipt",
|
|
"view_analytics",
|
|
"export_reports",
|
|
"create_vendor",
|
|
"manage_delivery_locations",
|
|
"manage_project_codes",
|
|
"manage_terms",
|
|
],
|
|
AUDITOR: ["view_own_pos", "view_all_pos", "view_analytics", "export_reports"],
|
|
ADMIN: [
|
|
"view_own_pos",
|
|
"view_all_pos",
|
|
"view_analytics",
|
|
"export_reports",
|
|
"manage_users",
|
|
"manage_vendors",
|
|
"create_vendor",
|
|
"manage_vessels_accounts",
|
|
"manage_products",
|
|
"manage_sites",
|
|
"manage_delivery_locations",
|
|
"manage_project_codes",
|
|
"manage_terms",
|
|
],
|
|
SITE_STAFF: [],
|
|
};
|
|
|
|
// Crewing permissions — a verbatim transcription of the §6 grant matrix in
|
|
// wiki Crewing-Implementation-Spec. Gating these is harmless until the screens
|
|
// land (the module is behind NEXT_PUBLIC_CREWING_ENABLED). Notes from the spec:
|
|
// MPO (MANNING) has NO attendance/leave; decide_leave/approve_* and selection are
|
|
// Manager-only; manage_ranks is Manager + Admin (not SuperUser).
|
|
const CREWING_ROLE_PERMISSIONS: Record<Role, Permission[]> = {
|
|
TECHNICAL: [],
|
|
SITE_STAFF: [
|
|
"request_relief_cover",
|
|
"sign_off_crew",
|
|
"view_crew_records",
|
|
"upload_crew_records",
|
|
"issue_ppe",
|
|
"apply_leave",
|
|
"record_attendance",
|
|
"view_attendance",
|
|
"raise_appraisal",
|
|
],
|
|
MANNING: [
|
|
"raise_requisition",
|
|
"convert_relief_to_requisition",
|
|
"cancel_requisition",
|
|
"view_requisitions",
|
|
"manage_candidates",
|
|
"record_reference_check",
|
|
"record_interview_result",
|
|
"request_interview_waiver",
|
|
"onboard_crew",
|
|
"sign_off_crew",
|
|
"view_crew_records",
|
|
"upload_crew_records",
|
|
"issue_ppe",
|
|
"verify_site_records",
|
|
"verify_appraisal",
|
|
],
|
|
ACCOUNTS: ["view_crew_records", "verify_bank_epf", "view_wage_report"],
|
|
MANAGER: [
|
|
"raise_requisition",
|
|
"convert_relief_to_requisition",
|
|
"cancel_requisition",
|
|
"view_requisitions",
|
|
"manage_candidates",
|
|
"record_reference_check",
|
|
"record_interview_result",
|
|
"approve_interview_waiver",
|
|
"approve_salary_structure",
|
|
"select_candidate",
|
|
"onboard_crew",
|
|
"sign_off_crew",
|
|
"view_crew_records",
|
|
"upload_crew_records",
|
|
"issue_ppe",
|
|
"apply_leave",
|
|
"decide_leave",
|
|
"view_attendance",
|
|
"verify_site_records",
|
|
"raise_appraisal",
|
|
"verify_appraisal",
|
|
"approve_appraisal",
|
|
"generate_wage_report",
|
|
"approve_wage_report",
|
|
"view_wage_report",
|
|
"manage_ranks",
|
|
"manage_crew",
|
|
],
|
|
SUPERUSER: [
|
|
"raise_requisition",
|
|
"request_relief_cover",
|
|
"convert_relief_to_requisition",
|
|
"cancel_requisition",
|
|
"view_requisitions",
|
|
"manage_candidates",
|
|
"record_reference_check",
|
|
"record_interview_result",
|
|
"request_interview_waiver",
|
|
"approve_interview_waiver",
|
|
"approve_salary_structure",
|
|
"select_candidate",
|
|
"onboard_crew",
|
|
"sign_off_crew",
|
|
"view_crew_records",
|
|
"upload_crew_records",
|
|
"issue_ppe",
|
|
"apply_leave",
|
|
"decide_leave",
|
|
"record_attendance",
|
|
"view_attendance",
|
|
"verify_site_records",
|
|
"verify_bank_epf",
|
|
"raise_appraisal",
|
|
"verify_appraisal",
|
|
"approve_appraisal",
|
|
"generate_wage_report",
|
|
"approve_wage_report",
|
|
"view_wage_report",
|
|
"manage_crew",
|
|
],
|
|
AUDITOR: ["view_requisitions", "view_crew_records", "view_attendance", "view_wage_report"],
|
|
ADMIN: ["view_requisitions", "view_crew_records", "view_wage_report", "manage_ranks", "manage_crew"],
|
|
};
|
|
|
|
const ROLE_PERMISSIONS: Record<Role, Permission[]> = Object.fromEntries(
|
|
(Object.keys(PO_ROLE_PERMISSIONS) as Role[]).map((role) => [
|
|
role,
|
|
[...PO_ROLE_PERMISSIONS[role], ...CREWING_ROLE_PERMISSIONS[role]],
|
|
])
|
|
) as Record<Role, Permission[]>;
|
|
|
|
export function hasPermission(role: Role, permission: Permission): boolean {
|
|
return ROLE_PERMISSIONS[role]?.includes(permission) ?? false;
|
|
}
|
|
|
|
export function requirePermission(role: Role, permission: Permission): void {
|
|
if (!hasPermission(role, permission)) {
|
|
throw new Error(`Forbidden: role ${role} lacks permission ${permission}`);
|
|
}
|
|
}
|
|
|
|
export function getPermissions(role: Role): Permission[] {
|
|
return ROLE_PERMISSIONS[role] ?? [];
|
|
}
|
|
|
|
// ── Submitter roles & feature-flagged view-all ────────────────────────────────
|
|
// Submitters raise and track their own POs. The two "submitter" roles below hold
|
|
// `view_own_pos` but not `view_all_pos`.
|
|
|
|
export const SUBMITTER_ROLES: Role[] = ["TECHNICAL", "MANNING"];
|
|
|
|
export function isSubmitterRole(role: Role): boolean {
|
|
return SUBMITTER_ROLES.includes(role);
|
|
}
|
|
|
|
/**
|
|
* Feature-flagged: when NEXT_PUBLIC_SUBMITTER_VIEW_ALL_ENABLED=true, submitters may
|
|
* read & export every PO (not just their own) and reach the History page. This is a
|
|
* read-only widening — it does not grant approval, payment, or edit rights.
|
|
*/
|
|
export function submitterCanViewAll(role: Role): boolean {
|
|
return SUBMITTER_VIEW_ALL_ENABLED && isSubmitterRole(role);
|
|
}
|
|
|
|
/**
|
|
* Whether a role may view/export any PO, not just the ones they submitted.
|
|
* True for `view_all_pos` holders (ACCOUNTS, MANAGER, SUPERUSER, AUDITOR, ADMIN) and,
|
|
* when the feature flag is on, for submitters too.
|
|
*/
|
|
export function canViewAllPos(role: Role): boolean {
|
|
return hasPermission(role, "view_all_pos") || submitterCanViewAll(role);
|
|
}
|
|
|
|
// ── PO attachments (feature-flagged) ──────────────────────────────────────────
|
|
// Roles that may attach to a PO (besides the PO's own submitter, who is always
|
|
// allowed) when NEXT_PUBLIC_CLOSED_PO_ATTACHMENTS_ENABLED=true.
|
|
const PO_ATTACHMENT_ROLES: Role[] = ["ACCOUNTS", "MANAGER", "SUPERUSER"];
|
|
|
|
// A PO in one of these terminal/voided states never accepts new attachments.
|
|
const NO_ATTACHMENT_STATUSES: POStatus[] = ["REJECTED", "CANCELLED"];
|
|
|
|
/**
|
|
* Feature-flagged: whether the current user may add attachments to a PO.
|
|
*
|
|
* When `NEXT_PUBLIC_CLOSED_PO_ATTACHMENTS_ENABLED` is on, the PO's own submitter
|
|
* — plus Accounts / Manager / SuperUser — may attach documents to a PO in **any
|
|
* state except `REJECTED` / `CANCELLED`** (a voided PO is never editable). This
|
|
* is the remediation path for the upload bug where documents never persisted, and
|
|
* the general "add a document after the fact" affordance. Off by default ⇒ no
|
|
* post-hoc attachment UI and closed POs stay immutable.
|
|
*/
|
|
export function canAddPoAttachment(
|
|
role: Role,
|
|
status: POStatus,
|
|
opts: { isSubmitter: boolean }
|
|
): boolean {
|
|
if (!CLOSED_PO_ATTACHMENTS_ENABLED) return false;
|
|
if (NO_ATTACHMENT_STATUSES.includes(status)) return false;
|
|
return opts.isSubmitter || PO_ATTACHMENT_ROLES.includes(role);
|
|
}
|