Skip to content

Saving & comparing units

Saving and comparing units is the one cross-persona feature in the platform: the shopper, broker and sales-man apps all ship the same “save a unit” (wishlist/favorite) and “compare units side-by-side” surfaces, backed by the same controllers, actions and pivot table. The behaviour splits into two very different mechanics:

  • Saved units are persisted server-side in a polymorphic pivot (unit_user), one row per (user, unit).
  • Compare is stateless server-side — the selection lives client-side (a sessionStorage-backed compare context, max 6); the API just resolves a list of unit_ids on every call. Only the export turns that into a saved artifact (a spreadsheet).

Verified against current backend code (UnitBaseController, UserSavedUnitsBaseController, the unit_user migrations, and the persona route files).

The saved-units (wishlist) page lists every unit the signed-in user has favorited — here the shopper app and the sales-man app, the same feature rendered per persona:

Shopper app — the wishlist (saved units) page listing the units this shopper has favorited, paginated with a destination filter.

Sales-man app — the saved-units page: the same feature as the shopper wishlist, scoped to the units this sales-man is permitted to see.

sequenceDiagram
participant App as App (shopper/broker/sales-man)
participant API as Backend
participant DB as unit_user pivot
participant CRM as crms-middleware
App->>API: POST user/saved-units/{unit}
API->>DB: syncWithoutDetaching (idempotent)
App->>API: GET user/saved-units
API->>DB: join on user_id + user_type, only available
API-->>App: paginated saved units
Note over App: compare list lives client-side (sessionStorage, max 6)
App->>API: GET units/compare?unit_ids[]=...
API->>DB: load units by id (AccessibleUnit-gated)
API-->>App: side-by-side unit data (local only)
App->>API: GET units/compare/export?unit_ids[]=...&currency_code=...
loop per unit
  API->>CRM: live payment-terms read
end
API-->>App: spreadsheet download (xlsx)
Save → list → compare → export

Saves live in unit_user — a pivot that became polymorphic so a single table can back every persona:

  • The original table (2022_12_05_181944_create_unit_user_table.php:15-19) had just id, user_id (FK → users), unit_id (FK → units).
  • A later migration (2025_01_28_191115_add_morph_user_to_unit_user.php:19-36) dropped the user_id FK, added a user_type string column plus a composite index [user_id, user_type] and timestamps, then backfilled user_type for existing broker/user rows. That turns the plain many-to-many into a morph pivot: shopper, broker and sales-man rows coexist keyed by (user_id, user_type).
  • On User, the relation is savedUnits() = morphToMany(Unit::class, 'user', 'unit_user') (app/Models/User.php:74-77). The pivot-as-model UnitUser has belongsTo(Unit) (app/Models/UnitUser.php:13) and a morphTo user (app/Models/UnitUser.php:18).
  1. Save a unitPOST .../user/saved-units/{unit}UserSavedUnitsController@addSavedUnit (app/Http/Controllers/Api/Base/UserSavedUnitsBaseController.php:14). The action runs $user->savedUnits()->syncWithoutDetaching([$unitId]) (app/Actions/Unit/AddUserSavedUnit.php:10) — idempotent, so saving the same unit twice writes one row and never errors. The {unit} binding must resolve to a published + available unit for that persona (see the endpoint table).

  2. Remove a saveDELETE .../user/saved-units/{unit}UserSavedUnitsController@removeSavedUnit (app/Http/Controllers/Api/Base/UserSavedUnitsBaseController.php:23), running $user->savedUnits()->detach($unitId) (app/Actions/Unit/RemoveUserSavedUnit.php:9). A no-op (no error) if the unit was not saved.

  3. List saved unitsGET .../user/saved-unitsgetSavedUnitsGetUserSavedUnits (app/Actions/Unit/GetUserSavedUnits.php:40-59). Joins unit_user on user_id + user_type, keeps only available() units, applies optional destinations / unit_number filters (app/Http/Requests/Unit/SavedUnitRequest.php:27-32), re-applies the persona’s permission scope (assigned units / allowed projects, app/Actions/Unit/GetUserSavedUnits.php:64-77), and paginates (default 6 per page).

  4. Build a compare set (client-side) — the app keeps the selected unit_ids in a sessionStorage compare context (UNIT_COMPARE_CONTEXT / CompareUnitsProvider), capped at 6. Nothing is written server-side at this stage.

  5. CompareGET .../units/compare?unit_ids[]=...UnitController@compareUnitBaseController::compare (app/Http/Controllers/Api/Base/UnitBaseController.php:46). Validated by CompareRequest: unit_ids required array, min:1, max:6, each id gated by the AccessibleUnit rule (app/Http/Requests/Unit/CompareRequest.php:27-31). GetUnitsById eager-loads images/type/project/destination/country/phase/facilities (app/Actions/Unit/GetUnitsById.php:12-22) and returns CompareUnitResource::collection — a purely local read, no CRM.

  6. Export the comparisonGET .../units/compare/export?unit_ids[]=...&currency_code=...UnitController@compareExport (app/Http/Controllers/Api/Base/UnitBaseController.php:53). CompareExportRequest adds a required currency_code (app/Http/Requests/Unit/CompareExportRequest.php:29-33). ExportCompareUnitsService re-loads the units, then for each unit makes a live CRM payment-terms read and formats it per country (Egypt/Oman/Montenegro), converts every price into currency_code via CurrencyRatesService, and streams an .xlsx back through Maatwebsite\Excel (app/Services/ExportCompareUnitsService.php:16-41), filename Units Compare Details <datetime>.xlsx.

{p} is the persona API prefix. All routes require the persona’s auth guard.

Capability Shopper Broker Sales-man
Compare GET {p}/units/compare (shopper.php:56) GET {p}/units/compare (broker.php:89) GET {p}/units/compare (sales-man.php:28)
Compare export (.xlsx) GET {p}/units/compare/export (shopper.php:58) GET {p}/units/compare/export (broker.php:90) not available
List saved GET {p}/user/saved-units (shopper.php:131) GET {p}/user/saved-units (broker.php:92) GET {p}/user/saved-units (sales-man.php:42)
Save POST {p}/user/saved-units/{publishedAvailableUnitShopper} (shopper.php:132) POST {p}/user/saved-units/{publishedAvailableUnitBroker} (broker.php:120) POST {p}/user/saved-units/{publishedAvailableUnitSalesMan} (sales-man.php:44)
Remove DELETE {p}/user/saved-units/{publishedUnitShopper} (shopper.php:133) DELETE {p}/user/saved-units/{savedUnit} (broker.php:144) DELETE {p}/user/saved-units/{savedUnit} (sales-man.php:49)
Auth guard Passport + user-provider:shoppers Sanctum + user-provider:brokers Sanctum + ability:ACCESS_SALES_MAN_API

The {published…Unit…} route-model bindings enforce persona visibility before the controller runs; broker and sales-man add a resource-visibility / salesman-resource-visibility middleware on save/remove (broker.php:118-146, sales-man.php:46-51).

  • Sales-man has compare but not compare-export. Only shopper and broker expose units/compare/export; the sales-man route file has no export route (sales-man.php:28 has compare only). The sales-man units/export route (sales-man.php:27) is a different feature — a whole-listing units report, not the compare spreadsheet.

  • Saved is persisted, compare is not. A save is a durable unit_user row. The compare selection is client-only state (sessionStorage) — refresh or a new device starts empty. Do not look for a “compare list” table; there isn’t one.

  • A save can silently vanish from the list. getSavedUnits filters to available() and re-applies permissions (GetUserSavedUnits.php:48,64-77). If a unit goes unavailable or leaves the user’s project/unit scope, it drops out of the wishlist response even though the unit_user row still exists. It is filtered, not deleted.

  • Export is N live CRM calls. compareExport calls payment-terms once per unit (ExportCompareUnitsService.php:28-38), so exporting 6 units is 6 CRM round-trips — slower and more failure-prone than compare (which is local-only). A unit whose payment-terms read is non-200 is written with an empty payment_terms: [] rather than failing the whole export (ExportCompareUnitsService.php:59-61).

  • It is .xlsx, not CSV. The export streams an Excel workbook via Maatwebsite\Excel (UnitBaseController.php:63,68), named Units Compare Details <datetime>.xlsx. Prices are converted into the requested currency_code; a missing/invalid currency_code fails validation (CompareExportRequest.php:32).

  • Max 6 is enforced twice. Both the client compare context and the server CompareRequest / CompareExportRequest cap unit_ids at 6 (CompareRequest.php:28). A 7th id is rejected server-side even if the client somehow sends it.

  • user_type is the isolation key. Never query unit_user by user_id alone — the same numeric id belongs to a different persona in a different table. Always pair it with user_type (GetUserSavedUnits.php:49-50).