Skip to content

feat: cruise library search redesign + bookings tab fixes#9

Merged
Systemsaholic merged 2 commits intomainfrom
feature/cruise-library-search-redesign
Feb 16, 2026
Merged

feat: cruise library search redesign + bookings tab fixes#9
Systemsaholic merged 2 commits intomainfrom
feature/cruise-library-search-redesign

Conversation

@Systemsaholic
Copy link
Copy Markdown
Owner

@Systemsaholic Systemsaholic commented Feb 16, 2026

Summary

  • Cruise library search redesign: 3-tier filter layout with collapsible sections, improved UX for searching and filtering cruises
  • Bookings tab fixes: Computed payment status from actual transactions, enriched unlinked activities with real booking/payment data, audit events for link/unlink operations, traveler validation before payments, corrected overview totals

Test plan

  • Preview deployment verified — all services green
  • Cruise library: filters expand/collapse, search returns results, pagination works
  • Bookings tab: overview totals include all top-level items, payment status reflects actual transactions
  • Unlinked activities show real supplier, confirmation, booking/payment status
  • Link/unlink operations appear in trip activity feed
  • Payment blocked when trip has no travelers; refunds/adjustments still allowed

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Redesigned cruise filters with live search, tiered organization, and multi-port selection capability
    • Activities and packages now display booking status, confirmation numbers, payment status, and supplier information
  • Bug Fixes

    • Fixed pricing calculation during custom cruise form data initialization
  • Improvements

    • Enhanced financial displays to show "Booked" amounts instead of authorized amounts
    • Added audit trail for activity linking operations
    • Improved payment validation to verify trips have travelers before processing payments

Systemsaholic and others added 2 commits February 16, 2026 13:24
Restructure filters into 3 tiers for faster cruise discovery:
- Tier 1 (Quick Find): live debounced search, Ship combobox with
  type-ahead, date range pickers — always visible
- Tier 2 (Common): cruise line, region, duration, departure port
  combobox, cabin type tabs — always visible
- Tier 3 (More Filters): return port, ports of call, price range
  — collapsed by default

Also adds:
- Active filter badges with per-filter clear and Clear All
- Expanded filter cascading (6 params instead of 2)
- Auto-clear orphaned filters on cruise line change
- URL sync for disembarkPortId, portOfCallIds, price range

Validated by Codex (2 passes, all findings addressed).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
… events, traveler guard

- Compute package payment status from actual transactions (expected_payment_items)
  instead of stale manual enum; populate totalPaidCents/totalUnpaidCents
- Enrich unlinked activities with isBooked, confirmationNumber, paymentStatus,
  paidCents, currency — replace hardcoded badges in packages-table UI
- Invalidate trip overview queries (activities, itinerary-days, tripTotals) on
  booking/payment mutations so changes reflect immediately
- Emit audit events for link/unlink package operations with actorId and metadata
- Require at least one traveler before recording payments (skip for refunds/adjustments)
- Fix overview totals to include all top-level items (standalone + packages)
- Fix overview fields: show bookedTotalCents, correct outstanding calculation
- Fix cruise supplier fallback and custom-cruise-form hydration recompute

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@vercel
Copy link
Copy Markdown

vercel Bot commented Feb 16, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
tailfire Error Error Feb 16, 2026 8:43pm
tailfire-admin Ready Ready Preview, Comment Feb 16, 2026 8:43pm
tailfire-client Ready Ready Preview, Comment Feb 16, 2026 8:43pm
tailfire-ota Ready Ready Preview, Comment Feb 16, 2026 8:43pm

Request Review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Feb 16, 2026

📝 Walkthrough

Walkthrough

This PR introduces significant updates across the cruise search interface, package display metrics, activity enrichment, and API audit/validation capabilities. Changes include a tiered-filter UI restructure with debounced search, new query parameters for cruise library filtering, enriched activity data with supplier and payment details, audit logging for link/unlink operations, expanded cache invalidations, traveler validation for payments, and replacement of "Authorized" with "Booked" metrics in package displays.

Changes

Cohort / File(s) Summary
Cruise Filter UI Restructure
apps/admin/src/app/library/cruises/_components/cruise-filters.tsx
Replaced inline search with debounced live search using useDebounce; restructured filters into tiered sections (Quick Find, Common Filters, More Filters) with Combobox-based ship/port selection, date pickers for departure range, dynamic badge generation for active filters, and multi-select port-of-call popover with Clear selection option.
Cruise Library Page State & URL
apps/admin/src/app/library/cruises/page.tsx
Added parsing and state management for new query parameters: disembarkPortId, portOfCallIds, priceMinCents, priceMaxCents; included orphan-clearing logic to reset dependent filters when cruiseLineId changes; expanded URL synchronization and filter propagation to backend.
Trip Cruise Form Hydration
apps/admin/src/app/trips/[id]/_components/custom-cruise-form.tsx
Forced recomputation of memoized pricing data by incrementing changeCounter during server hydration to ensure pricingData is recalculated using hydrated form values.
Package Display Metrics
apps/admin/src/components/packages/packages-overview.tsx
Replaced "Authorized" metric label and value with "Booked" metric from totals.bookedTotalCents; removed explicit authorized variable placeholder.
Package Table Enrichment & Display
apps/admin/src/components/packages/packages-table.tsx
Added supplierName, isBooked, confirmationNumber, paymentStatus, and currency fields to package and activity rows with conditional badge rendering; updated UnifiedBookingRow and UnlinkedActivity types to include enriched data.
Booking & Payment Hook Invalidations
apps/admin/src/hooks/use-bookings.ts, apps/admin/src/hooks/use-payment-schedules.ts
Expanded cache invalidations in useMarkAsBooked to refresh tripTotals and unlinkedActivities; added cache invalidations in payment transaction creation for bookings list, tripTotals, and unlinkedActivities.
API Activity Enrichment
apps/api/src/trips/activities.controller.ts, apps/api/src/trips/activities.service.ts
Added optional actorId parameter to linkChildrenToPackage and unlinkChildrenFromPackage methods for audit logging; enriched activity and package responses with supplierName, isBooked, confirmationNumber, paymentStatus, paidCents, and currency fields; integrated audit event emission on link/unlink operations.
API Trip Totals & Validation
apps/api/src/trips/payment-schedules.service.ts, apps/api/src/trips/trips.controller.ts
Added validateTripHasTravelersForPayment validation method to ensure trip has travelers before recording payment; expanded getUnlinkedActivities response with enriched activity fields (supplierName, isBooked, confirmationNumber, paymentStatus, paidCents, currency).
Shared Type Definitions
packages/shared-types/src/api/activities.types.ts
Extended TripPackageTotalsDto with bookedTotalCents and pendingCommissionCents fields; extended UnlinkedActivityDto with supplierName, isBooked, confirmationNumber, paymentStatus, paidCents, and currency fields.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 With tiered filters neat and debounce so true,
Booked totals gleam where authorized once grew,
Audit trails whisper of who linked and when,
Rich data flows dance through our API again!

🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 40.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the two main changes: cruise library search redesign and bookings tab fixes, matching the substantial work described across multiple files.
Merge Conflict Detection ✅ Passed ✅ No merge conflicts detected when merging into main

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feature/cruise-library-search-redesign

Tip

Issue Planner is now in beta. Read the docs and try it out! Share your feedback on Discord.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@Systemsaholic Systemsaholic merged commit b0e92f1 into main Feb 16, 2026
4 of 6 checks passed
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
apps/api/src/trips/activities.service.ts (3)

1852-1955: ⚠️ Potential issue | 🟡 Minor

Avoid defaulting currency to CAD when pricing is missing

When pricing isn’t present, returning a default currency can mislead downstream consumers. Prefer null or the activity’s own currency.

🔧 Proposed fix
-        currency: pricing?.currency ?? 'CAD',
+        currency: pricing?.currency ?? baseResponse.currency ?? null,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/trips/activities.service.ts` around lines 1852 - 1955, The code
defaults currency to 'CAD' when pricing is missing (in the activities.map return
block), which can mislead consumers; update the returned currency field to
prefer the activity's own currency then null instead of 'CAD' (use
pricing?.currency ?? r.activity.currency ?? null) and ensure any related
consumers handle nulls; check the returned object assembly in the activities.map
callback where pricing is read from pricingMap and the field named currency is
set.

1760-1797: ⚠️ Potential issue | 🟠 Major

Audit metadata may overstate unlinked activities

The audit entry uses the requested activityIds even if some weren’t actually linked to the package. That can make audit logs inaccurate. Consider deriving the list/count from rows that are actually unlinked.

🔧 Proposed fix
-    const activities = await this.db.client
-      .select({ id: this.db.schema.itineraryActivities.id, name: this.db.schema.itineraryActivities.name })
-      .from(this.db.schema.itineraryActivities)
-      .where(inArray(this.db.schema.itineraryActivities.id, activityIds))
+    const activitiesToUnlink = await this.db.client
+      .select({ id: this.db.schema.itineraryActivities.id, name: this.db.schema.itineraryActivities.name })
+      .from(this.db.schema.itineraryActivities)
+      .where(
+        and(
+          inArray(this.db.schema.itineraryActivities.id, activityIds),
+          eq(this.db.schema.itineraryActivities.parentActivityId, packageId)
+        )
+      )
...
-    if (pkg) {
+    if (pkg && activitiesToUnlink.length > 0) {
       const tripId = pkg.tripId ?? (pkg.itineraryDayId ? await this.getTripIdFromDayId(pkg.itineraryDayId) : null)
       if (tripId) {
-        const unlinkedNames = activities.map(a => a.name).join(', ')
+        const unlinkedNames = activitiesToUnlink.map(a => a.name).join(', ')
+        const count = activitiesToUnlink.length
         this.eventEmitter.emit(
           'audit.updated',
           new AuditEvent(
             'activity',
             packageId,
             'updated',
             tripId,
             actorId ?? null,
-            `Unlinked ${activityIds.length} activit${activityIds.length === 1 ? 'y' : 'ies'} from ${pkg.name}`,
-            { childrenUnlinked: activityIds, unlinkedNames, count: activityIds.length, subType: 'package' }
+            `Unlinked ${count} activit${count === 1 ? 'y' : 'ies'} from ${pkg.name}`,
+            { childrenUnlinked: activitiesToUnlink.map(a => a.id), unlinkedNames, count, subType: 'package' }
           )
         )
       }
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/trips/activities.service.ts` around lines 1760 - 1797, The audit
currently reports using the input activityIds even if some weren’t actually
linked; in unlinkChildrenFromPackage, query the DB for the subset of activities
that both match activityIds and have parentActivityId === packageId (e.g.,
select id and name where inArray(id, activityIds) AND eq(parentActivityId,
packageId)) before performing the update, then use that resulting list (ids and
names) and its length for the audit payload (unlinkedNames, count,
childrenUnlinked) instead of the original activityIds so the audit reflects only
actually-unlinked rows.

2348-2402: ⚠️ Potential issue | 🟠 Major

Add activity_type filter to package totals query

The method getTripPackageTotals claims to return "aggregated financial data for all packages in a trip" but currently selects all top-level activities without filtering by activity_type = 'package'. This includes tours, lodging, and other activity types. Parallel code in activity-totals.service.ts shows the correct filtering pattern. Add the activity_type filter to ensure package-only scope matches the documented intent.

Suggested fix
         WHERE ia.parent_activity_id IS NULL
+          AND ia.activity_type = 'package'
           AND (
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/trips/activities.service.ts` around lines 2348 - 2402, The query
in getTripPackageTotals selects top-level itinerary_activities without
restricting to packages, causing non-package activities to be included; update
the trip_packages CTE WHERE clause to also require ia.activity_type = 'package'
(same filter pattern used in activity-totals.service.ts) so only package
activities are included, and verify downstream CTEs (package_totals, payments)
continue to reference tp.id as before.
🤖 Fix all issues with AI agents
Verify each finding against the current code and only fix it if needed.


In `@apps/admin/src/app/library/cruises/_components/cruise-filters.tsx`:
- Around line 61-77: The local state searchInput initialized with
useState(filters.q ?? '') doesn't update when filters.q changes externally; add
a short sync effect that watches filters.q and calls setSearchInput(filters.q ??
'') so the input reflects external updates (e.g., URL/back-nav). Locate the
state variables searchInput and setSearchInput and add a useEffect that compares
filters.q to searchInput (or previous value) and updates via setSearchInput when
different to avoid clobbering user typing while keeping external sync.
- Around line 604-608: The duration badge currently builds the label as `Nights:
${min}-${max}` which yields awkward output like `Nights: 15-any`; update the
badge creation in the block that pushes the duration badge (the badges.push call
for key 'duration') to instead: if both nightsMin and nightsMax are present keep
the "Nights: min-max" format, but when one end is open-ended (e.g., nightsMax
=== undefined or nightsMin === undefined) look up the human-friendly label from
DURATION_OPTIONS (match by value) and use that label for the badge; keep the
same onClear behavior (onChange({ nightsMin: undefined, nightsMax: undefined
})). Ensure you reference filters.nightsMin, filters.nightsMax, DURATION_OPTIONS
and the badges.push for key 'duration' when making the change.
- Around line 278-331: Add explicit Label elements for the Tier 2 controls
(Cruise Line, Region, Duration) to match Tier 1 accessibility and visual
alignment: insert a <Label> for each control (e.g., "Cruise Line", "Region",
"Duration") and associate it with the corresponding Select by giving the
SelectTrigger a stable id (or the Select a generated id) and using that id as
the Label's htmlFor; alternatively set an aria-label on the SelectTrigger if you
prefer not to add an id. Update the JSX around the
Select/SelectTrigger/SelectValue for cruiseLineId, regionId and duration to
include these Labels and ensure their text matches existing placeholders for
screen readers. Ensure keys remain unchanged and no logic in onValueChange or
value props is modified.

In `@apps/admin/src/app/library/cruises/page.tsx`:
- Around line 200-202: The Biome rule flags the implicit return in the forEach
arrow callback; update the callback used on filters.portOfCallIds.forEach to use
a statement body instead of an expression so it does not implicitly return a
value — i.e. change the callback for filters.portOfCallIds.forEach(id =>
params.append('portOfCallIds', id)) to use braces (id => {
params.append('portOfCallIds', id); }) so params.append remains a statement and
the lint rule is satisfied.
- Around line 53-56: The URL integer query parsing for nightsMin, nightsMax,
priceMinCents, and priceMaxCents currently uses parseInt directly and can yield
NaN for malformed values; modify the parsing to return undefined on missing or
invalid input by using a small helper (e.g., parseIntOrUndefined) or inline
logic that checks for null and Number.isNaN(result) and only assigns the numeric
value when valid, otherwise sets the field to undefined so the filter state and
downstream API calls never receive NaN.

In `@apps/admin/src/components/packages/packages-table.tsx`:
- Around line 1158-1176: Header label "Commission" is mismatched with
activity-row content that renders a Booked/Not Booked badge using row.isBooked
while package rows show commissionStatus; update the table to be consistent by
either renaming the header text from "Commission" to a neutral label like
"Status" or "Booking Status" where the header string is defined, or change the
activity-row cell to render a commission-related value (e.g., use
row.commissionStatus) instead of the isBooked badge; locate the header string
and the cell rendering code referencing row.isBooked and commissionStatus to
apply the chosen fix so header semantics match row content.
- Around line 364-369: The UnlinkedActivity property paidCents is declared but
never used; decide whether it should flow into UnifiedBookingRow or be removed:
if it’s required downstream, add paidCents to the UnifiedBookingRow shape and
include it in the mapping code that converts UnlinkedActivity ->
UnifiedBookingRow (the mapping logic around the code that constructs
UnifiedBookingRow from UnlinkedActivity), and then add a column/cell in the
packages-table rendering to display paidCents (formatted via currency using
currency/cents fields); otherwise remove paidCents from the UnlinkedActivity
type and any API mapping that populates it, and add a short comment documenting
the removal if needed.
- Line 1070: Remove the unsafe "as any" casts on the Badge's variant prop and
make the types align: either change getPaymentStatusVariant to explicitly return
the Badge component's Variant union (or export/alias Badge's Variant type and
use it as the return type of getPaymentStatusVariant) or map the function's
returned union to the Badge's expected union before passing it; update the four
Badge usages that currently use getPaymentStatusVariant(... ) as any (the
instances around the Badge components) so they pass a value whose type matches
Badge's variant prop without casting.

In `@apps/admin/src/hooks/use-payment-schedules.ts`:
- Around line 384-391: The delete hook useDeletePaymentTransaction is missing
the same cache invalidations as useCreatePaymentTransaction so deleting a
transaction leaves bookings/activities stale; update useDeletePaymentTransaction
to accept/traverse the tripId (like useCreatePaymentTransaction) and call
queryClient.invalidateQueries for ['bookings','list'], ['bookings','tripTotals',
tripId], ['bookings','unlinkedActivities', tripId], ['activities'], and
['itinerary-days'] after a successful delete, ensuring you locate the
invalidation logic near the current invalidateQueries calls in
useCreatePaymentTransaction and mirror it in the delete success handler.
- Around line 385-391: The invalidation calls in use-payment-schedules.ts are
using hardcoded query key arrays (e.g., ['bookings','list'],
['bookings','tripTotals', tripId], ['activities'], ['itinerary-days']) which
risks drift and unnecessary refetches; refactor to use a centralized query-key
factory or constants (exported from your queries/keys module) and replace the
inline arrays in the queryClient.invalidateQueries calls with those named keys
(e.g., BookingQueryKeys.list(), BookingQueryKeys.tripTotals(tripId),
ActivityQueryKeys.forTrip(tripId), ItineraryDayQueryKeys.forTrip(tripId)) so
keys stay consistent across hooks and scope the broad ['activities'] and
['itinerary-days'] invalidations by tripId to avoid unneeded global refetches.

In `@apps/api/src/trips/activities.service.ts`:
- Around line 1852-1955: The code defaults currency to 'CAD' when pricing is
missing (in the activities.map return block), which can mislead consumers;
update the returned currency field to prefer the activity's own currency then
null instead of 'CAD' (use pricing?.currency ?? r.activity.currency ?? null) and
ensure any related consumers handle nulls; check the returned object assembly in
the activities.map callback where pricing is read from pricingMap and the field
named currency is set.
- Around line 1760-1797: The audit currently reports using the input activityIds
even if some weren’t actually linked; in unlinkChildrenFromPackage, query the DB
for the subset of activities that both match activityIds and have
parentActivityId === packageId (e.g., select id and name where inArray(id,
activityIds) AND eq(parentActivityId, packageId)) before performing the update,
then use that resulting list (ids and names) and its length for the audit
payload (unlinkedNames, count, childrenUnlinked) instead of the original
activityIds so the audit reflects only actually-unlinked rows.
- Around line 2348-2402: The query in getTripPackageTotals selects top-level
itinerary_activities without restricting to packages, causing non-package
activities to be included; update the trip_packages CTE WHERE clause to also
require ia.activity_type = 'package' (same filter pattern used in
activity-totals.service.ts) so only package activities are included, and verify
downstream CTEs (package_totals, payments) continue to reference tp.id as
before.

In `@apps/api/src/trips/payment-schedules.service.ts`:
- Around line 821-867: The validateTripHasTravelersForPayment function currently
returns early when the expectedPaymentItem → activity → trip chain can’t be
resolved (checks for result[0], activity, and tripId), which allows payments to
bypass traveler validation; update those early-return branches to throw a
BadRequestException (or another appropriate HTTP error) with a clear message
referencing the missing link (include expectedPaymentItemId and which step
failed) so the caller is blocked instead of silently skipping validation; ensure
you update the checks at the top of validateTripHasTravelersForPayment (the
result[0] check), the activity resolution block (the activity missing check),
and the tripId resolution block (the !tripId check) to throw, not return.

In `@apps/api/src/trips/trips.controller.ts`:
- Around line 482-498: The activities.map callback in trips.controller.ts uses a
generic "a: any" which loses type-safety and uses a generic identifier; change
the parameter to a domain-specific name (e.g., "activity" or "unlinkedActivity")
and give it a proper type instead of any (for example UnlinkedActivity or the
appropriate DTO/interface used by findUnlinkedByTrip) so the mapped fields
remain type-checked; update the mapping expression (activities.map(...)) to use
the new typed identifier and adjust any downstream property access accordingly.

In `@packages/shared-types/src/api/activities.types.ts`:
- Around line 333-349: Change the paymentStatus field on UnlinkedActivityDto
from a plain string to a typed union (e.g., PaymentStatus | null) to lock
allowed values; update the UnlinkedActivityDto definition to use the
PaymentStatus type (or import it if defined elsewhere) and ensure PaymentStatus
is exported/defined as the union of valid values ('paid' | 'deposit_paid' |
'unpaid') so consumers and type-checking align with valid statuses; then update
any imports/usages of UnlinkedActivityDto to compile against the new
PaymentStatus type.
🧹 Nitpick comments (8)
🤖 Fix all nitpicks with AI agents
Verify each finding against the current code and only fix it if needed.


In `@apps/admin/src/app/library/cruises/_components/cruise-filters.tsx`:
- Around line 604-608: The duration badge currently builds the label as `Nights:
${min}-${max}` which yields awkward output like `Nights: 15-any`; update the
badge creation in the block that pushes the duration badge (the badges.push call
for key 'duration') to instead: if both nightsMin and nightsMax are present keep
the "Nights: min-max" format, but when one end is open-ended (e.g., nightsMax
=== undefined or nightsMin === undefined) look up the human-friendly label from
DURATION_OPTIONS (match by value) and use that label for the badge; keep the
same onClear behavior (onChange({ nightsMin: undefined, nightsMax: undefined
})). Ensure you reference filters.nightsMin, filters.nightsMax, DURATION_OPTIONS
and the badges.push for key 'duration' when making the change.
- Around line 278-331: Add explicit Label elements for the Tier 2 controls
(Cruise Line, Region, Duration) to match Tier 1 accessibility and visual
alignment: insert a <Label> for each control (e.g., "Cruise Line", "Region",
"Duration") and associate it with the corresponding Select by giving the
SelectTrigger a stable id (or the Select a generated id) and using that id as
the Label's htmlFor; alternatively set an aria-label on the SelectTrigger if you
prefer not to add an id. Update the JSX around the
Select/SelectTrigger/SelectValue for cruiseLineId, regionId and duration to
include these Labels and ensure their text matches existing placeholders for
screen readers. Ensure keys remain unchanged and no logic in onValueChange or
value props is modified.

In `@apps/admin/src/app/library/cruises/page.tsx`:
- Around line 53-56: The URL integer query parsing for nightsMin, nightsMax,
priceMinCents, and priceMaxCents currently uses parseInt directly and can yield
NaN for malformed values; modify the parsing to return undefined on missing or
invalid input by using a small helper (e.g., parseIntOrUndefined) or inline
logic that checks for null and Number.isNaN(result) and only assigns the numeric
value when valid, otherwise sets the field to undefined so the filter state and
downstream API calls never receive NaN.

In `@apps/admin/src/components/packages/packages-table.tsx`:
- Around line 364-369: The UnlinkedActivity property paidCents is declared but
never used; decide whether it should flow into UnifiedBookingRow or be removed:
if it’s required downstream, add paidCents to the UnifiedBookingRow shape and
include it in the mapping code that converts UnlinkedActivity ->
UnifiedBookingRow (the mapping logic around the code that constructs
UnifiedBookingRow from UnlinkedActivity), and then add a column/cell in the
packages-table rendering to display paidCents (formatted via currency using
currency/cents fields); otherwise remove paidCents from the UnlinkedActivity
type and any API mapping that populates it, and add a short comment documenting
the removal if needed.
- Line 1070: Remove the unsafe "as any" casts on the Badge's variant prop and
make the types align: either change getPaymentStatusVariant to explicitly return
the Badge component's Variant union (or export/alias Badge's Variant type and
use it as the return type of getPaymentStatusVariant) or map the function's
returned union to the Badge's expected union before passing it; update the four
Badge usages that currently use getPaymentStatusVariant(... ) as any (the
instances around the Badge components) so they pass a value whose type matches
Badge's variant prop without casting.

In `@apps/admin/src/hooks/use-payment-schedules.ts`:
- Around line 385-391: The invalidation calls in use-payment-schedules.ts are
using hardcoded query key arrays (e.g., ['bookings','list'],
['bookings','tripTotals', tripId], ['activities'], ['itinerary-days']) which
risks drift and unnecessary refetches; refactor to use a centralized query-key
factory or constants (exported from your queries/keys module) and replace the
inline arrays in the queryClient.invalidateQueries calls with those named keys
(e.g., BookingQueryKeys.list(), BookingQueryKeys.tripTotals(tripId),
ActivityQueryKeys.forTrip(tripId), ItineraryDayQueryKeys.forTrip(tripId)) so
keys stay consistent across hooks and scope the broad ['activities'] and
['itinerary-days'] invalidations by tripId to avoid unneeded global refetches.

In `@apps/api/src/trips/trips.controller.ts`:
- Around line 482-498: The activities.map callback in trips.controller.ts uses a
generic "a: any" which loses type-safety and uses a generic identifier; change
the parameter to a domain-specific name (e.g., "activity" or "unlinkedActivity")
and give it a proper type instead of any (for example UnlinkedActivity or the
appropriate DTO/interface used by findUnlinkedByTrip) so the mapped fields
remain type-checked; update the mapping expression (activities.map(...)) to use
the new typed identifier and adjust any downstream property access accordingly.

In `@packages/shared-types/src/api/activities.types.ts`:
- Around line 333-349: Change the paymentStatus field on UnlinkedActivityDto
from a plain string to a typed union (e.g., PaymentStatus | null) to lock
allowed values; update the UnlinkedActivityDto definition to use the
PaymentStatus type (or import it if defined elsewhere) and ensure PaymentStatus
is exported/defined as the union of valid values ('paid' | 'deposit_paid' |
'unpaid') so consumers and type-checking align with valid statuses; then update
any imports/usages of UnlinkedActivityDto to compile against the new
PaymentStatus type.
apps/admin/src/hooks/use-payment-schedules.ts (1)

385-391: Consider centralizing query key definitions to prevent silent breakage.

The invalidation keys (['bookings', 'list'], ['bookings', 'tripTotals', tripId], ['activities'], ['itinerary-days']) are hardcoded as inline arrays. If these keys are also defined in other hooks or service utilities, hardcoding them here creates drift risk—changes to the actual query key structure elsewhere won't automatically reflect in invalidations.

The broad prefix invalidations (['activities'] and ['itinerary-days']) correctly refetch all trips but will become a performance concern if the app scales. Consider scoping by tripId if unnecessary refetches become bottlenecks.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/admin/src/hooks/use-payment-schedules.ts` around lines 385 - 391, The
invalidation calls in use-payment-schedules.ts are using hardcoded query key
arrays (e.g., ['bookings','list'], ['bookings','tripTotals', tripId],
['activities'], ['itinerary-days']) which risks drift and unnecessary refetches;
refactor to use a centralized query-key factory or constants (exported from your
queries/keys module) and replace the inline arrays in the
queryClient.invalidateQueries calls with those named keys (e.g.,
BookingQueryKeys.list(), BookingQueryKeys.tripTotals(tripId),
ActivityQueryKeys.forTrip(tripId), ItineraryDayQueryKeys.forTrip(tripId)) so
keys stay consistent across hooks and scope the broad ['activities'] and
['itinerary-days'] invalidations by tripId to avoid unneeded global refetches.
apps/admin/src/app/library/cruises/page.tsx (1)

53-56: parseInt can produce NaN from malformed URL params.

If someone manually edits the URL to ?priceMinCents=abc, parseInt returns NaN, which propagates into the filter state and API calls. This is a pre-existing pattern (same for nightsMin/nightsMax), but worth hardening now that more integer params are added.

🛡️ Suggested guard (apply to all four integer params)
-    priceMinCents: searchParams.get('priceMinCents') ? parseInt(searchParams.get('priceMinCents')!, 10) : undefined,
-    priceMaxCents: searchParams.get('priceMaxCents') ? parseInt(searchParams.get('priceMaxCents')!, 10) : undefined,
+    priceMinCents: searchParams.get('priceMinCents') ? (Number.isNaN(parseInt(searchParams.get('priceMinCents')!, 10)) ? undefined : parseInt(searchParams.get('priceMinCents')!, 10)) : undefined,
+    priceMaxCents: searchParams.get('priceMaxCents') ? (Number.isNaN(parseInt(searchParams.get('priceMaxCents')!, 10)) ? undefined : parseInt(searchParams.get('priceMaxCents')!, 10)) : undefined,

Or extract a small helper for readability:

const parseIntOrUndefined = (v: string | null): number | undefined => {
  if (!v) return undefined
  const n = parseInt(v, 10)
  return Number.isNaN(n) ? undefined : n
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/admin/src/app/library/cruises/page.tsx` around lines 53 - 56, The URL
integer query parsing for nightsMin, nightsMax, priceMinCents, and priceMaxCents
currently uses parseInt directly and can yield NaN for malformed values; modify
the parsing to return undefined on missing or invalid input by using a small
helper (e.g., parseIntOrUndefined) or inline logic that checks for null and
Number.isNaN(result) and only assigns the numeric value when valid, otherwise
sets the field to undefined so the filter state and downstream API calls never
receive NaN.
apps/admin/src/app/library/cruises/_components/cruise-filters.tsx (2)

604-608: Duration badge label can read awkwardly for open-ended ranges.

When nightsMax is undefined (e.g., the "15+ Nights" option), the badge renders as Nights: 15-any. Consider using the matching DURATION_OPTIONS label instead for a more user-friendly display:

Suggested improvement
   if (filters.nightsMin !== undefined || filters.nightsMax !== undefined) {
-    const min = filters.nightsMin ?? 'any'
-    const max = filters.nightsMax ?? 'any'
-    badges.push({ key: 'duration', label: `Nights: ${min}-${max}`, onClear: () => onChange({ nightsMin: undefined, nightsMax: undefined }) })
+    const match = DURATION_OPTIONS.find(o => o.min === filters.nightsMin && o.max === filters.nightsMax)
+    const durationLabel = match?.label ?? `${filters.nightsMin ?? '?'}–${filters.nightsMax ?? '?'} Nights`
+    badges.push({ key: 'duration', label: durationLabel, onClear: () => onChange({ nightsMin: undefined, nightsMax: undefined }) })
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/admin/src/app/library/cruises/_components/cruise-filters.tsx` around
lines 604 - 608, The duration badge currently builds the label as `Nights:
${min}-${max}` which yields awkward output like `Nights: 15-any`; update the
badge creation in the block that pushes the duration badge (the badges.push call
for key 'duration') to instead: if both nightsMin and nightsMax are present keep
the "Nights: min-max" format, but when one end is open-ended (e.g., nightsMax
=== undefined or nightsMin === undefined) look up the human-friendly label from
DURATION_OPTIONS (match by value) and use that label for the badge; keep the
same onClear behavior (onChange({ nightsMin: undefined, nightsMax: undefined
})). Ensure you reference filters.nightsMin, filters.nightsMax, DURATION_OPTIONS
and the badges.push for key 'duration' when making the change.

278-331: Tier 2 selects lack <Label> elements, unlike Tier 1 controls.

Tier 1 filters have explicit <Label> elements for each control (Search, Ship, Departs From/To), but Cruise Line, Region, and Duration in Tier 2 do not. For consistent accessibility (screen reader association) and visual alignment, consider adding labels here as well.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/admin/src/app/library/cruises/_components/cruise-filters.tsx` around
lines 278 - 331, Add explicit Label elements for the Tier 2 controls (Cruise
Line, Region, Duration) to match Tier 1 accessibility and visual alignment:
insert a <Label> for each control (e.g., "Cruise Line", "Region", "Duration")
and associate it with the corresponding Select by giving the SelectTrigger a
stable id (or the Select a generated id) and using that id as the Label's
htmlFor; alternatively set an aria-label on the SelectTrigger if you prefer not
to add an id. Update the JSX around the Select/SelectTrigger/SelectValue for
cruiseLineId, regionId and duration to include these Labels and ensure their
text matches existing placeholders for screen readers. Ensure keys remain
unchanged and no logic in onValueChange or value props is modified.
apps/api/src/trips/trips.controller.ts (1)

482-498: Avoid any + generic identifier in unlinked-activities mapping

Leaning on inference keeps the new fields type-checked and the mapping clearer.

♻️ Suggested change
-      activities: activities.map((a: any) => ({
-        id: a.id,
-        name: a.name,
-        activityType: a.activityType,
+      activities: activities.map((activity) => ({
+        id: activity.id,
+        name: activity.name,
+        activityType: activity.activityType,
         itineraryId: '', // Not returned by findUnlinkedByTrip - frontend doesn't use this
-        itineraryDayId: a.itineraryDayId || '',
+        itineraryDayId: activity.itineraryDayId || '',
         dayNumber: null, // Would require additional query - frontend doesn't use this
         date: null, // Would require additional query - frontend doesn't use this
-        sequenceOrder: a.sequenceOrder,
-        totalPriceCents: a.pricing?.totalPriceCents ?? null,
-        parentActivityId: a.parentActivityId,
-        supplierName: a.supplierName ?? null,
-        isBooked: a.isBooked ?? false,
-        confirmationNumber: a.confirmationNumber ?? null,
-        paymentStatus: a.paymentStatus ?? null,
-        paidCents: a.paidCents ?? null,
-        currency: a.currency ?? null,
+        sequenceOrder: activity.sequenceOrder,
+        totalPriceCents: activity.pricing?.totalPriceCents ?? null,
+        parentActivityId: activity.parentActivityId,
+        supplierName: activity.supplierName ?? null,
+        isBooked: activity.isBooked ?? false,
+        confirmationNumber: activity.confirmationNumber ?? null,
+        paymentStatus: activity.paymentStatus ?? null,
+        paidCents: activity.paidCents ?? null,
+        currency: activity.currency ?? null,
       })),

As per coding guidelines: Prefer explicit, domain-focused names (trip, booking, itinerary) over generic terms.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/trips/trips.controller.ts` around lines 482 - 498, The
activities.map callback in trips.controller.ts uses a generic "a: any" which
loses type-safety and uses a generic identifier; change the parameter to a
domain-specific name (e.g., "activity" or "unlinkedActivity") and give it a
proper type instead of any (for example UnlinkedActivity or the appropriate
DTO/interface used by findUnlinkedByTrip) so the mapped fields remain
type-checked; update the mapping expression (activities.map(...)) to use the new
typed identifier and adjust any downstream property access accordingly.
packages/shared-types/src/api/activities.types.ts (1)

333-349: Type paymentStatus instead of using string

Using a shared union keeps consumers aligned with valid values and prevents typos.

♻️ Suggested change
-  paymentStatus: string | null // 'paid' | 'deposit_paid' | 'unpaid' | null (no schedule)
+  paymentStatus: PackagePaymentStatus | null // 'paid' | 'deposit_paid' | 'unpaid' | ...
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/shared-types/src/api/activities.types.ts` around lines 333 - 349,
Change the paymentStatus field on UnlinkedActivityDto from a plain string to a
typed union (e.g., PaymentStatus | null) to lock allowed values; update the
UnlinkedActivityDto definition to use the PaymentStatus type (or import it if
defined elsewhere) and ensure PaymentStatus is exported/defined as the union of
valid values ('paid' | 'deposit_paid' | 'unpaid') so consumers and type-checking
align with valid statuses; then update any imports/usages of UnlinkedActivityDto
to compile against the new PaymentStatus type.
apps/admin/src/components/packages/packages-table.tsx (2)

364-369: paidCents is declared but never read.

paidCents is included in the UnlinkedActivity type and presumably populated from the API, but it isn't mapped into UnifiedBookingRow (lines 788-792) or rendered anywhere in this component. If it's carried solely for downstream consumers, consider documenting that intent; otherwise it's dead data in this context.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/admin/src/components/packages/packages-table.tsx` around lines 364 -
369, The UnlinkedActivity property paidCents is declared but never used; decide
whether it should flow into UnifiedBookingRow or be removed: if it’s required
downstream, add paidCents to the UnifiedBookingRow shape and include it in the
mapping code that converts UnlinkedActivity -> UnifiedBookingRow (the mapping
logic around the code that constructs UnifiedBookingRow from UnlinkedActivity),
and then add a column/cell in the packages-table rendering to display paidCents
(formatted via currency using currency/cents fields); otherwise remove paidCents
from the UnlinkedActivity type and any API mapping that populates it, and add a
short comment documenting the removal if needed.

1070-1070: as any casts on Badge variant prop bypass type safety.

Four instances cast the variant to any. The helper getPaymentStatusVariant returns 'default' | 'secondary' | 'outline' | 'destructive', which likely already matches the Badge component's variant prop. If it does, the cast is unnecessary; if it doesn't, a proper union type or a mapped type would be safer.

Also applies to: 1165-1165, 1229-1229, 1237-1237

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/admin/src/components/packages/packages-table.tsx` at line 1070, Remove
the unsafe "as any" casts on the Badge's variant prop and make the types align:
either change getPaymentStatusVariant to explicitly return the Badge component's
Variant union (or export/alias Badge's Variant type and use it as the return
type of getPaymentStatusVariant) or map the function's returned union to the
Badge's expected union before passing it; update the four Badge usages that
currently use getPaymentStatusVariant(... ) as any (the instances around the
Badge components) so they pass a value whose type matches Badge's variant prop
without casting.

Comment on lines 61 to +77
const [searchInput, setSearchInput] = useState(filters.q ?? '')
const [portsPopoverOpen, setPortsPopoverOpen] = useState(false)

// Count active filters (excluding search and sort)
const activeFilterCount = [
filters.cruiseLineId,
filters.shipId,
filters.regionId,
filters.embarkPortId,
// Live debounced search
const debouncedSearch = useDebounce(searchInput, 300)
const prevDebouncedRef = useRef(debouncedSearch)

useEffect(() => {
// Only fire when debounced value actually changes
if (debouncedSearch === prevDebouncedRef.current) return
prevDebouncedRef.current = debouncedSearch

const newQ = debouncedSearch || undefined
if (newQ !== filters.q) {
onChange({ q: newQ })
}
}, [debouncedSearch, filters.q, onChange])
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

searchInput won't sync if filters.q is updated externally (e.g., URL/back-navigation).

useState(filters.q ?? '') captures the initial value only. If the parent ever updates filters.q from outside (URL sync, browser back), the text input will show stale text. Consider adding a sync effect:

Proposed sync effect
+ // Keep local input in sync when filters.q changes externally
+ useEffect(() => {
+   const externalQ = filters.q ?? ''
+   if (externalQ !== searchInput && externalQ !== debouncedSearch) {
+     setSearchInput(externalQ)
+     prevDebouncedRef.current = externalQ
+   }
+ }, [filters.q])
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/admin/src/app/library/cruises/_components/cruise-filters.tsx` around
lines 61 - 77, The local state searchInput initialized with useState(filters.q
?? '') doesn't update when filters.q changes externally; add a short sync effect
that watches filters.q and calls setSearchInput(filters.q ?? '') so the input
reflects external updates (e.g., URL/back-nav). Locate the state variables
searchInput and setSearchInput and add a useEffect that compares filters.q to
searchInput (or previous value) and updates via setSearchInput when different to
avoid clobbering user typing while keeping external sync.

Comment on lines +200 to +202
if (filters.portOfCallIds && filters.portOfCallIds.length > 0) {
filters.portOfCallIds.forEach(id => params.append('portOfCallIds', id))
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Biome flags implicit return from forEach callback.

The arrow function id => params.append(...) implicitly returns the result of append (void). While harmless at runtime, Biome's useIterableCallbackReturn rule flags this. Add braces to make the callback a statement body.

🔧 Proposed fix
     if (filters.portOfCallIds && filters.portOfCallIds.length > 0) {
-      filters.portOfCallIds.forEach(id => params.append('portOfCallIds', id))
+      filters.portOfCallIds.forEach(id => { params.append('portOfCallIds', id) })
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (filters.portOfCallIds && filters.portOfCallIds.length > 0) {
filters.portOfCallIds.forEach(id => params.append('portOfCallIds', id))
}
if (filters.portOfCallIds && filters.portOfCallIds.length > 0) {
filters.portOfCallIds.forEach(id => { params.append('portOfCallIds', id) })
}
🧰 Tools
🪛 Biome (2.3.14)

[error] 201-201: This callback passed to forEach() iterable method should not return a value.

Either remove this return or remove the returned value.

(lint/suspicious/useIterableCallbackReturn)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/admin/src/app/library/cruises/page.tsx` around lines 200 - 202, The
Biome rule flags the implicit return in the forEach arrow callback; update the
callback used on filters.portOfCallIds.forEach to use a statement body instead
of an expression so it does not implicitly return a value — i.e. change the
callback for filters.portOfCallIds.forEach(id => params.append('portOfCallIds',
id)) to use braces (id => { params.append('portOfCallIds', id); }) so
params.append remains a statement and the lint rule is satisfied.

Comment on lines 1158 to 1176
<td className="px-4 py-3 text-sm text-gray-900">
{formatCurrency(row.totalPriceCents ?? 0, currency)}
</td>
<td className="px-4 py-3 text-sm text-gray-500"></td>
<td className="px-4 py-3 text-sm text-gray-500"></td>
<td className="px-4 py-3 text-sm text-gray-500">{row.supplierName || '–'}</td>
<td className="px-4 py-3 text-sm text-gray-500">{row.confirmationNumber || '–'}</td>
<td className="px-4 py-3">
<Badge variant="outline">Unpaid</Badge>
{row.paymentStatus ? (
<Badge variant={getPaymentStatusVariant(row.paymentStatus) as any}>
{getPaymentStatusLabel(row.paymentStatus)}
</Badge>
) : (
<span className="text-sm text-gray-400">–</span>
)}
</td>
<td className="px-4 py-3">
<Badge variant="outline">Not Booked</Badge>
<Badge variant={row.isBooked ? 'default' : 'outline'}>
{row.isBooked ? 'Booked' : 'Not Booked'}
</Badge>
</td>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Column semantics: "Commission" header now shows booking status for activity rows.

The table header at line 994 reads "Commission", but the activity-row cell (lines 1173-1175) now renders a Booked / Not Booked badge driven by row.isBooked. Package rows still render commissionStatus (which can be "Booked" / "Upcoming" / "Not Booked"). This mismatch between the header label and the actual cell content for activity rows could confuse users — consider renaming the header to something more inclusive like "Status" or "Booking Status", or keeping a commission-relevant value in the activity rows.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/admin/src/components/packages/packages-table.tsx` around lines 1158 -
1176, Header label "Commission" is mismatched with activity-row content that
renders a Booked/Not Booked badge using row.isBooked while package rows show
commissionStatus; update the table to be consistent by either renaming the
header text from "Commission" to a neutral label like "Status" or "Booking
Status" where the header string is defined, or change the activity-row cell to
render a commission-related value (e.g., use row.commissionStatus) instead of
the isBooked badge; locate the header string and the cell rendering code
referencing row.isBooked and commissionStatus to apply the chosen fix so header
semantics match row content.

Comment on lines +384 to +391
// Refresh Bookings tab (payment status computed from transactions)
queryClient.invalidateQueries({ queryKey: ['bookings', 'list'] })
queryClient.invalidateQueries({ queryKey: ['bookings', 'tripTotals', tripId] })
queryClient.invalidateQueries({ queryKey: ['bookings', 'unlinkedActivities', tripId] })
}
// Refresh Trip Overview (activities may show updated payment status)
queryClient.invalidateQueries({ queryKey: ['activities'] })
queryClient.invalidateQueries({ queryKey: ['itinerary-days'] })
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

useDeletePaymentTransaction is missing symmetric cache invalidations.

Creating a transaction now correctly invalidates bookings and activity caches, but useDeletePaymentTransaction (Line 411) does not perform the same invalidations. Deleting a transaction also changes payment status, so the bookings tab totals, unlinked activities, and activity/itinerary-day views will show stale data until the user manually triggers a refetch.

Proposed fix

Add matching invalidations in useDeletePaymentTransaction. This requires threading tripId into the hook (similar to useCreatePaymentTransaction):

-export function useDeletePaymentTransaction(activityPricingId: string, expectedPaymentItemId: string) {
+export function useDeletePaymentTransaction(activityPricingId: string, expectedPaymentItemId: string, tripId?: string) {
   const queryClient = useQueryClient()
   const { toast } = useToast()
 
   return useMutation({
     mutationFn: async (transactionId: string) => {
       return api.delete(`/payment-schedules/transactions/${transactionId}`)
     },
     onSuccess: () => {
       queryClient.invalidateQueries({
         queryKey: paymentScheduleKeys.byActivityPricing(activityPricingId),
       })
       queryClient.invalidateQueries({
         queryKey: paymentScheduleKeys.transactions(expectedPaymentItemId),
       })
+      if (tripId) {
+        queryClient.invalidateQueries({
+          queryKey: paymentScheduleKeys.tripExpectedPayments(tripId),
+        })
+        queryClient.invalidateQueries({
+          queryKey: paymentScheduleKeys.tripTransactions(tripId),
+        })
+        queryClient.invalidateQueries({ queryKey: ['bookings', 'list'] })
+        queryClient.invalidateQueries({ queryKey: ['bookings', 'tripTotals', tripId] })
+        queryClient.invalidateQueries({ queryKey: ['bookings', 'unlinkedActivities', tripId] })
+      }
+      queryClient.invalidateQueries({ queryKey: ['activities'] })
+      queryClient.invalidateQueries({ queryKey: ['itinerary-days'] })
 
       toast({
         title: 'Transaction deleted',
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Refresh Bookings tab (payment status computed from transactions)
queryClient.invalidateQueries({ queryKey: ['bookings', 'list'] })
queryClient.invalidateQueries({ queryKey: ['bookings', 'tripTotals', tripId] })
queryClient.invalidateQueries({ queryKey: ['bookings', 'unlinkedActivities', tripId] })
}
// Refresh Trip Overview (activities may show updated payment status)
queryClient.invalidateQueries({ queryKey: ['activities'] })
queryClient.invalidateQueries({ queryKey: ['itinerary-days'] })
export function useDeletePaymentTransaction(activityPricingId: string, expectedPaymentItemId: string, tripId?: string) {
const queryClient = useQueryClient()
const { toast } = useToast()
return useMutation({
mutationFn: async (transactionId: string) => {
return api.delete(`/payment-schedules/transactions/${transactionId}`)
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: paymentScheduleKeys.byActivityPricing(activityPricingId),
})
queryClient.invalidateQueries({
queryKey: paymentScheduleKeys.transactions(expectedPaymentItemId),
})
if (tripId) {
queryClient.invalidateQueries({
queryKey: paymentScheduleKeys.tripExpectedPayments(tripId),
})
queryClient.invalidateQueries({
queryKey: paymentScheduleKeys.tripTransactions(tripId),
})
queryClient.invalidateQueries({ queryKey: ['bookings', 'list'] })
queryClient.invalidateQueries({ queryKey: ['bookings', 'tripTotals', tripId] })
queryClient.invalidateQueries({ queryKey: ['bookings', 'unlinkedActivities', tripId] })
}
queryClient.invalidateQueries({ queryKey: ['activities'] })
queryClient.invalidateQueries({ queryKey: ['itinerary-days'] })
toast({
title: 'Transaction deleted',
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/admin/src/hooks/use-payment-schedules.ts` around lines 384 - 391, The
delete hook useDeletePaymentTransaction is missing the same cache invalidations
as useCreatePaymentTransaction so deleting a transaction leaves
bookings/activities stale; update useDeletePaymentTransaction to accept/traverse
the tripId (like useCreatePaymentTransaction) and call
queryClient.invalidateQueries for ['bookings','list'], ['bookings','tripTotals',
tripId], ['bookings','unlinkedActivities', tripId], ['activities'], and
['itinerary-days'] after a successful delete, ensuring you locate the
invalidation logic near the current invalidateQueries calls in
useCreatePaymentTransaction and mirror it in the delete success handler.

Comment on lines +821 to +867
private async validateTripHasTravelersForPayment(expectedPaymentItemId: string): Promise<void> {
const result = await this.db.client
.select({
activityId: this.db.schema.activityPricing.activityId,
})
.from(this.db.schema.expectedPaymentItems)
.innerJoin(
this.db.schema.paymentScheduleConfig,
eq(this.db.schema.expectedPaymentItems.paymentScheduleConfigId, this.db.schema.paymentScheduleConfig.id)
)
.innerJoin(
this.db.schema.activityPricing,
eq(this.db.schema.paymentScheduleConfig.activityPricingId, this.db.schema.activityPricing.id)
)
.where(eq(this.db.schema.expectedPaymentItems.id, expectedPaymentItemId))
.limit(1)

if (!result[0]) return // Can't resolve chain — skip validation

// Resolve tripId from the activity (direct trip_id or via itinerary chain)
const [activity] = await this.db.client
.select({
directTripId: this.db.schema.itineraryActivities.tripId,
itineraryDayId: this.db.schema.itineraryActivities.itineraryDayId,
})
.from(this.db.schema.itineraryActivities)
.where(eq(this.db.schema.itineraryActivities.id, result[0].activityId))
.limit(1)

if (!activity) return

let tripId = activity.directTripId
if (!tripId && activity.itineraryDayId) {
const [dayResult] = await this.db.client
.select({ tripId: this.db.schema.itineraries.tripId })
.from(this.db.schema.itineraryDays)
.innerJoin(
this.db.schema.itineraries,
eq(this.db.schema.itineraryDays.itineraryId, this.db.schema.itineraries.id)
)
.where(eq(this.db.schema.itineraryDays.id, activity.itineraryDayId))
.limit(1)
tripId = dayResult?.tripId ?? null
}

if (!tripId) return // Can't resolve trip — skip validation

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Fail closed when trip resolution fails in traveler validation

The early return paths skip validation entirely if any link in the chain is missing. That allows payments to be recorded without travelers, which undermines the guard. Consider throwing a BadRequestException (or at least logging and blocking) when the chain can’t be resolved.

🔧 Proposed fix
-    if (!result[0]) return // Can't resolve chain — skip validation
+    if (!result[0]) {
+      this.logger.warn(`Payment item ${expectedPaymentItemId} could not resolve activity for traveler validation`)
+      throw new BadRequestException('Cannot record payment: unable to resolve trip for this payment item.')
+    }
...
-    if (!activity) return
+    if (!activity) {
+      throw new BadRequestException('Cannot record payment: activity not found for this payment item.')
+    }
...
-    if (!tripId) return // Can't resolve trip — skip validation
+    if (!tripId) {
+      throw new BadRequestException('Cannot record payment: trip not found for this payment item.')
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/trips/payment-schedules.service.ts` around lines 821 - 867, The
validateTripHasTravelersForPayment function currently returns early when the
expectedPaymentItem → activity → trip chain can’t be resolved (checks for
result[0], activity, and tripId), which allows payments to bypass traveler
validation; update those early-return branches to throw a BadRequestException
(or another appropriate HTTP error) with a clear message referencing the missing
link (include expectedPaymentItemId and which step failed) so the caller is
blocked instead of silently skipping validation; ensure you update the checks at
the top of validateTripHasTravelersForPayment (the result[0] check), the
activity resolution block (the activity missing check), and the tripId
resolution block (the !tripId check) to throw, not return.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant