lib/notifier.ts eagerly did `new Resend(process.env.RESEND_API_KEY)` whenever
NODE_ENV !== "development". Resend v4's constructor throws on a missing key, so
in any env without RESEND_API_KEY (CI, non-dev test runs) merely importing the
module crashed — surfaced by crew-records.test.ts once Phase 4c pulled
requisition-service → notifier into the crew actions' import graph.
Construct the client only when a key is present; otherwise fall back to console
logging (the send branches now gate on `!resend` instead of `isDev`). Verified by
running the full integration suite with RESEND_API_KEY unset (195 pass).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Final slice of Phase 4 (the Epic K piece deferred from Phase 2). Ends a tour of
duty and returns the crew member to the candidate pool as an ex-hand. Per
Crewing-Implementation-Spec §5.3. Behind NEXT_PUBLIC_CREWING_ENABLED.
What's in
- Schema: CrewActionType += CREW_SIGNED_OFF (migration crewing_signoff).
- signOffCrew(assignmentId, date, remarks) (crewing/crew/actions.ts, sign_off_crew):
one transaction — assignment → SIGNED_OFF (+ signOffDate); append an internal
ExperienceRecord (rank, on/off dates, computed durationMonths); flip the SAME
CrewMember EMPLOYEE → EX_HAND (type/source EX_HAND), so they reappear in
Candidates as a returning hand; CrewAction CREW_SIGNED_OFF; then auto-raise a
SIGN_OFF backfill requisition via autoRaiseRequisition.
- Screen: a "Sign off" button on the crew-profile header (sign_off_crew holders —
site staff / MPO / Manager); on success redirects to the Crew directory.
Tests & docs
- Integration: signoff.test.ts (3) — SIGNED_OFF + experience + EX_HAND + SIGN_OFF
backfill, already-signed-off guard, permission gating. type-check clean; full
unit (241) + integration (195) green.
- CLAUDE.md updated — completes Phase 4 (E/F/G + K).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Office/admin crewing-management surface behind a new manage_crew permission
(Manager + SuperUser + Admin). Stacks on 4b. Behind NEXT_PUBLIC_CREWING_ENABLED.
What's in
- Permission: manage_crew added to the §6 matrix (MGR/SU/ADMIN).
- Direct placement (placeCrew): a Manager assigns a crew member to a vessel/site
WITHOUT a requisition — creates an ACTIVE CrewAssignment, promotes a candidate to
EMPLOYEE with a CRW- number (generateEmployeeId), blocked if already actively
assigned.
- Admin crew CRUD: createCrewMember / updateCrewMember / deleteCrewMember (delete
blocked when assignments/applications exist).
- Crew strength config: upsert/delete VesselRankRequirement (the minStrength that
drives R6 leave-clash detection).
- Screens under Administration (flag-gated, MGR/SU/ADMIN): /admin/crew (list + add/
edit/delete + Place modal) and /admin/crew-strength (requirement table + form).
Tests & docs
- Unit: permissions-crewing.test.ts gains a manage_crew check. Integration:
crewing-admin.test.ts (9) — CRUD, delete guard, direct placement (+promotion,
+active-assignment guard), strength upsert/delete, manage_crew gating.
type-check clean; full unit (241) + integration (192) green.
- CLAUDE.md updated with the crewing-admin surface.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the implicit "strength = 1" clash rule with a configurable per-vessel,
per-rank requirement (director decision). Adds VesselRankRequirement
{vesselId, rankId, minStrength} (migration crewing_vessel_rank_requirement) and
reworks lib/leave-clash.ts → leaveCausesClash: a leave approval clashes when the
remaining active same-rank cover over the window would fall below minStrength
(default 1 when unconfigured), auto-raising a LEAVE requisition. The requirement
is managed by the office (manage_crew, admin UI in the follow-up).
- Integration: leave-attendance.test.ts gains a configured-strength case
(minStrength 2, one remaining → clash). Full unit (240) + integration (183) green.
- CLAUDE.md updated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Second slice of Phase 4 (stacked on 4a crew records). Leave (site-applied,
Manager-decided) with clash auto-backfill, and the daily attendance calendar,
per Crewing-Implementation-Spec §5.3/§8.9–8.10. Behind NEXT_PUBLIC_CREWING_ENABLED.
What's in
- Schema (crewing_leave_attendance migration): LeaveRequest (LeaveType,
LeaveStatus) + Attendance (AttendanceStatus, unique per assignment+date) on
CrewAssignment; CrewActionType += LEAVE_APPLIED/LEAVE_DECIDED/ATTENDANCE_RECORDED.
- Leave (R1): site staff apply on behalf (apply_leave); Manager decides
(decide_leave) → assignment ON_LEAVE; MPO has no leave role. Leave approvals also
surface in the central /approvals queue (§8.13 Leave kind). Notification
LEAVE_FOR_APPROVAL.
- Clash auto-backfill (R6): lib/leave-clash.ts, required strength = 1 — approving a
leave that leaves the vessel with zero active same-rank cover auto-raises a LEAVE
requisition via the Phase-2 autoRaiseRequisition.
- Attendance (R5): daily month calendar; site staff record (record_attendance),
Manager views (view_attendance) but cannot edit, MPO neither. saveAttendance
bulk-upserts dirty cells.
- Screens: /crewing/leave (apply-on-behalf + Manager Approve/Decline) and
/crewing/attendance (tap-to-cycle calendar + Save). Leave + Attendance added to
the flag-gated nav (Manager + Site staff).
Tests & docs
- Integration: leave-attendance.test.ts (7) — apply/decide, clash auto-raise (and
no-raise when cover remains), MPO/Manager attendance lockout, permission gating.
type-check clean; full unit (240) + integration (182) green.
- CLAUDE.md updated with the Phase 4b surface.
Deferred: the 6-month leave-planner timeline (lightweight list for now); hours/
overtime attendance (A7).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
First slice of Phase 3 (Epics B/C/D shipped as stacked sub-PRs). Adds the
CrewMember talent-pool spine and the Candidates screens. Behind
NEXT_PUBLIC_CREWING_ENABLED; production unchanged. Stacks on the requisitions
branch (Phase 2).
What's in
- Schema (crewing_candidates migration): CrewMember (spine) + CrewStatus,
CandidateType, CandidateSource enums; CrewAction gains a nullable crewMemberId;
CrewActionType += CANDIDATE_ADDED/UPDATED. employeeId is assigned at onboarding
(3c), so it's nullable here.
- Actions (crewing/candidates/actions.ts): addCandidate / updateCandidate —
guard flag + manage_candidates, write a CrewAction, optional CV upload via
buildStorageKey("cv", …) + uploadBuffer (no parsing — A2 deferred). EX_HAND
source ⇒ type/status EX_HAND; edits never downgrade an EMPLOYEE.
- Screens: /crewing/candidates (master list with search/source/rank-applied/
min-experience filters as removable chips + match count + Clear all; Add-candidate
modal) and /crewing/candidates/[id] (profile; pipeline stepper is 3b). Candidates
added to the flag-gated Crewing nav (Manager + MPO).
Tests & docs
- Integration: candidates.test.ts (7) — add/update, ex-hand derivation, employee
no-downgrade, permission gating. type-check clean; full unit (225) + integration
(153) suites green.
- CLAUDE.md "Crewing" section updated with the Phase 3a surface.
Deferred: public careers intake API (A2, §13 open question); CV parsing.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Second slice of the Crewing module per wiki Crewing-Implementation-Spec §12
(build order item 2). Everything stays behind NEXT_PUBLIC_CREWING_ENABLED;
production is unchanged. Schema is added incrementally — this lands the
requisition lifecycle layer.
What's in
- Schema: Requisition (OPEN→SHORTLISTING→PROPOSING→INTERVIEWING→SELECTED→FILLED,
→CANCELLED), ReliefRequest, CrewAction (the POAction mirror) + their enums.
Migration crewing_requisitions.
- State machine: lib/requisition-state-machine.ts mirrors po-state-machine
(selection Manager-only; orthogonal cancel from OPEN/SHORTLISTING by
cancel_requisition holders, §6). Codes REQ-9000… via lib/requisition-number.ts.
- Actions: raise/cancel/transition + requestReliefCover/convertReliefToRequisition,
each guarding flag+permission+state, writing a CrewAction and notifying. Shared
autoRaiseRequisition() (lib/requisition-service.ts) is the backfill entry point
for sign-off / leave-clash (later phases).
- Notifier: notifyCrew() PO-independent path + CrewNotificationEvent.
- Screens: /crewing/requisitions (list + Raise modal + relief convert) and
/crewing/requisitions/[id] (detail). Requisitions added to the flag-gated
Crewing sidebar (Manager + MPO, §7).
Tests & docs
- Unit: requisition-state-machine.test.ts (11).
- Integration: requisitions.test.ts (15) — raise/cancel/transition, relief
request + convert, auto-raise, permission gating.
- CLAUDE.md "Crewing" section updated with the Phase 2 surface.
Deferred: sign-off/experience (Epic K, §12 item 2) depends on the crew/assignment
models from Phase 3/4; autoRaiseRequisition() is ready for it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the per-branch PR-checks trigger ([master, feat/crewing-foundations])
with [master, "feat/crewing-*"] so every branch in the stacked crewing series
(foundations → requisitions → candidates → …) runs the same hard gates without
adding each one by hand. The workflow is evaluated from the branch under test,
so the glob is propagated down the stack.
The crewing module is built as a stack of feature-flagged phases that land on
feat/crewing-foundations (the integration branch) before the whole thing merges
to master. PRs into that branch were skipped because pr-checks only triggered on
`branches: [master]`. Add feat/crewing-foundations so each stacked phase PR runs
the same hard gates (test-presence policy, type-check, unit + integration).
For pull_request events the workflow is read from the base branch, so this must
live on feat/crewing-foundations to take effect for PRs targeting it.
Uploaded signatures/stamps aren't always transparent PNGs, so an opaque stamp
overlapping the signature/name would cover them. Extract the signatory-block
geometry into a tested helper (signatoryLayout): the signature is centred over
the name and the stamp sits to its RIGHT with a 10px gap — never overlapping.
- lib/po-export-layout.ts (signatoryLayout) + unit test
- export route uses it instead of inline overlap math
Verified in a real export: signature 175-328px (centred), stamp 338-405px
(10px gap, no overlap), stamp drawn behind the signature.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
In the XLSX signatory block, place the approver signature centred over the
name and tuck the stamp to its right with a slight overlap. The stamp is now
drawn before the signature so it layers behind it (Excel z-order = add order).
Images are positioned by absolute pixels via native EMU offsets — ExcelJS's
fractional-column anchors don't map cleanly to pixels (the stamp was landing
on top of the signature centre instead of to its right). Verified in a real
export: signature centre 252px in the 503px A-D block (centred), stamp to the
right (305-372px), stamp drawn behind the signature.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The logo, signature, stamp and cancelled watermark were placed with ExcelJS
two-cell (tl/br) anchors, which stretch each image to fill a cell range —
distorting them and making the watermark text small/squished. The PDF looked
fine because CSS sizes by aspect.
- New lib/image-size.ts: getImageSize (PNG/JPEG/WebP header parse) + scaleToBox.
- Export route now places each image with a oneCell `tl` + pixel `ext`,
aspect preserved and matched to the PDF sizes (logo ≤96×52, signature ≤165×44,
stamp ≤80×66, watermark ≤880×720).
- Watermark regenerated as a landscape canvas with the text filling it, so it
spans the page like the PDF instead of sitting small in the centre.
- Unit test for getImageSize + scaleToBox.
Verified structurally: generated XLSX uses oneCellAnchors with fixed pixel
ext sizes (49×52 / 45×44 / 67×66 / 880×629), not stretched cell ranges.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On /inventory/vendors, include vendorId in the search filter and render
it as a muted mono badge beside the vendor name. The vendorId data was
already passed to the client component, so this is a presentation/filter
change only. Unverified vendors (no vendorId) render unchanged.
Fixes#57
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Managers and superusers can cancel a PO from any state via a confirmation modal
that requires typing "cancel" and a mandatory reason. A cancelled PO becomes a
terminal CANCELLED state and drops out of every spend tracker/graph (those filter
on POST_APPROVAL_STATUSES / explicit whitelists, none of which include CANCELLED).
A cancelled PO may optionally be linked to the existing PO that supersedes it
(by PO number); the replacement shows the reciprocal "supersedes" link. No
vessel/account/vendor match is enforced and the link can be added any time.
Cancelled POs remain visible (greyed in history) and exportable, with a diagonal
"CANCELLED" watermark on both the PDF and XLSX exports.
- schema: POStatus CANCELLED; cancelledAt/cancellationReason; self-referential
supersededById relation; ActionType CANCELLED/SUPERSEDED (+ migration)
- state machine canCancel(); cancel_po permission (MANAGER + SUPERUSER)
- cancelPo / supersedePo server actions + PO_CANCELLED notification
- cancel modal + supersede form; cancelled banner with reciprocal links
- exhaustive CANCELLED entries in all status label/variant maps
- diagonal CANCELLED watermark embedded for PDF (CSS) and XLSX (image)
- integration tests (cancel from any state, reason/role guards, supersede)
Inventory reversal on cancel is deferred to #55 (inventory is feature-flagged off).
Closes#53
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds an `integration` job to PR checks: spins up a throwaway postgres:16
container on a random host port (isolated from prod / pelagia_test / staging),
applies migrations, dev-seeds, and runs `pnpm test:integration` (108 tests).
Container is always torn down via an EXIT trap.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- resubmit: updatePo distinguishes intent "resubmit" (from EDITS_REQUESTED)
from "submit" (from DRAFT); test now sends "resubmit" (makePoForm widened).
- payment: MANAGER now holds process_payment, so the "wrong permission"
negative test uses TECHNICAL (which lacks it).
- vendor: provideVendorId rejects on a missing vendorId *code*; seeded
unverified vendors carry codes, so create a genuinely code-less vendor.
Full integration suite: 108/108 passing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The integration suite had rotted against the app. Systematic fixes:
- seed refs: MV Ocean Pride/Sea Breeze/TECH-OPS → current seed entities
- helper appendLineItem set lineItems[i].description; createPo now keys on
lineItems[i].name → zero line items. Fixed to .name.
- vendor gating: lifecycle setups (approval/payment/receipt) now attach the
seeded verified vendor before approval.
- cleanup: POAction has no onDelete:Cascade, so deletePo(sByTitle) now removes
POAction rows before the PO.
- import-api: fixture committed to tests/fixtures/Sample_PO.xlsx (was an
absolute path to a non-existent dir).
- products-search: code search assertion .every → .some (search spans fields).
11 failures remain (behavioral drift — separate commit).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
deploy.yml only triggers on v* tags; bare semver tags (0.2.0/0.2.1/0.2.2) silently
do not deploy. Clarify: push the v* tag specifically (not 'master --tags').
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Satisfies the contribution-policy test gate for the add/edit-page refactor.
Covers: createCompany returns the new id, code upper-casing, duplicate-code
rejection, updateCompany in-place edit, and manage_vessels_accounts gating.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Overhaul the manager dashboard "Total Approved Spend" stat card per the
reporter's request:
- Swap the DollarSign lucide icon for IndianRupee (rupee symbol).
- Render the amount in the Indian short scale (lakh/crore) via a new
`formatCompactINR` helper, e.g. ₹2 Cr, ₹49 L, ₹75 K, instead of the full
₹49,00,000.00.
`formatCompactINR` rounds to at most 2 decimals, trims trailing zeros, keeps
the ₹ prefix and sign. The DollarSign icon is retained for the Accounts
"Payment Queue Value" card; the precise `formatCurrency` is kept for tables.
Adds unit tests covering crore/lakh/thousand/sub-thousand, boundaries, zero,
string input, negatives and non-finite input.
Fixes#50
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The company form outgrew the modal once the branding (logo/stamp) section
was added. Add/edit now live on their own routes:
- /admin/companies/new
- /admin/companies/[id]/edit
- createCompany returns the new id and the create flow lands on the edit
page so logo/stamp can be uploaded immediately
- list "+ Add Company" is a link; row "Edit" navigates to the edit page
- branding is its own card on the edit page (independent uploads)
- list page no longer mints a presigned URL per company (moved to edit)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Companies can upload a logo and a stamp/seal (Admin → Companies → Edit →
Branding); both render on exported PDF and XLSX purchase orders. A fixed
brand-colour bar (#92D050, matching the sample PO) runs along the bottom of
every export.
- Company.logoKey / stampKey + migration
- buildCompanyAssetKey() deterministic storage keys (overwrite-in-place)
- uploadCompanyAsset / removeCompanyAsset server actions (≤4MB PNG/JPG/WebP,
manage_vessels_accounts gated)
- CompanyBrandingUploader in the company edit dialog with live previews
- Export route embeds logo (top-left), stamp (signatory block) and brand bar
in both ExcelJS and print-HTML paths
- Unit test (storage keys) + integration test (branding actions)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New .forgejo/workflows/staging.yml rebuilds ppms-staging to latest master on every
merge (push to master) on the host runner, so staging always mirrors the trunk;
concurrency-coalesced + workflow_dispatch. Also drops --update-env from staging-up.sh
(and unsets FORGEJO_*) so the runner's ephemeral token can't leak into ppms-staging.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>