# Inquiry Module — Requirements (CR-6)

Authored 2026-08-29. Owner: product (megh@virtualcarry.com). Implementer: follow
`docs/adding-a-module.md` step by step — **Projects is the reference implementation;
when this document is silent, the recipe and the Projects code win.** Wireframes are
the design source of truth: `docs/wireframes-v12/{inquiry,add-inquiry,view-inquiry,follow-up}.html`
(byte-identical to the client's pack). Pixel-perfect means: same sections, same
labels, same classes off the v12 stylesheet, same conditional behaviour — check the
page-local `<head><style>` blocks of each wireframe, not just `css/style.css`.

## 1. Scope

In scope: `brokers` (minimal, no UI), `inquiries` CRUD (list / create / edit / show /
delete), the Inquiry Conclusion capture, `follow_ups` journey (page + history), and
the three Booking Action modals (Tentative Booking, Personal Meeting, Not Interested).

Explicit non-goals (buttons stay visible but inert, styled per wireframe):
- **"Proceed to Booking"** (add-inquiry, view-inquiry, follow-up footers) — render the
  button exactly as designed, no navigation, no handler. Future CR.
- **Booking Action → "Booking"** — option renders in the row dropdown, selecting it does
  nothing (reset the select). Future CR.
- Broker Management CRUD screens — only the table + model + demo seeder land now.

## 2. Database

All tables: `timestamps()` + `softDeletes()`; enum-backed columns are plain `string`
(recipe §1); index what the listing filters/sorts on.

### 2.1 `brokers` (minimal forward table)

| column | type | notes |
|---|---|---|
| id | id | |
| name | string, unique | unique comment re soft-delete consequence, per units migration |
| contact_number | string | |

Model `Broker` uses `HasAudit`. Demo seeder (pattern of `DemoProjectSeeder`) with the
five wireframe brokers: Prime Realty - Rajkot / 98250 11100, Shree Properties -
Ahmedabad / 99090 22200, Saurashtra Estate Agents / 98795 33300, Gujarat Realtors -
Surat / 96010 44400, Patel & Associates / 93740 55500.

### 2.2 `inquiries`

Every add-inquiry.html field **except the Inquiry Conclusion section** is a column,
plus the three product-mandated columns (`is_interested`, `reason`, `status`) and a
generated code.

| column | type | notes |
|---|---|---|
| id | id | |
| code | string, unique | server-generated `INQ-YYYY-NNNN` (e.g. INQ-2026-0142, per wireframe banner/breadcrumb). Generate like `Project::nextCode()`: `lockForUpdate()` inside the insert transaction wrapped in `RetryOnUniqueCollision::once()`. NOT in `$fillable`, NOT in validation rules. Sequence restarts each calendar year (scan max code for current year). |
| inquiry_date | date | required |
| inquiry_type | string | `InquiryType` enum: telephonic, digital, physical. Required. |
| source | string | `InquirySource` enum: existing_nodl_customer, reference, broker, social_media, website, walk_in. Required. Labels exactly as wireframe (uppercase style comes from CSS/text, labels: "EXISTING NODL CUSTOMER", "REFERENCE", "BROKER", "SOCIAL MEDIA", "WEBSITE", "WALK-IN"). walk_in is in the listing filter + view wireframe, so it is a case even though the add form's mock omitted it — the form select iterates the enum. |
| reference_by | string, nullable | required iff source=reference |
| reference_contact | string, nullable | required iff source=reference |
| broker_id | foreignId→brokers, nullable, nullOnDelete | required iff source=broker; `Rule::exists('brokers','id')->whereNull('deleted_at')` |
| broker_contact_number | string, nullable | required iff source=broker; JS prefills from the selected broker (data-phone), stays editable |
| full_name | string | required |
| whatsapp_number | string | required |
| email | string, nullable | `email` rule |
| alternate_phone | string, nullable | |
| occupation | string, nullable | `Occupation` enum: salaried, self_employed, business_owner, retired, homemaker, other |
| current_address | text, nullable | |
| remarks | text, nullable | |
| assigned_to | foreignId→users, nullable, nullOnDelete | "Assigned To" select = all users (product decision 2026-08-29), `Rule::exists('users','id')` |
| project_id | foreignId→projects, nullable, nullOnDelete | "Interested Project" |
| property_type | string, nullable | `PropertyType` enum: office, retail, plot, plot_villa, apartment, ind_land (labels per wireframe; distinct from `ProjectType` — do not reuse it) |
| preferred_unit_id | foreignId→units, nullable, nullOnDelete | must belong to project_id (validate `Rule::exists` scoped by project + after() check) |
| trans_type | string, nullable | `TransType` enum: purchase, lease, pre_leased, resale. Label "Trans Type (Interested In)" on the form. |
| tentative_unit_id | foreignId→units, nullable, nullOnDelete | Tentative Booking "Unit Number"; must belong to project_id |
| hold_until | date, nullable | required together with tentative_unit_id (both-or-neither, `after()` callback) |
| is_interested | unsignedTinyInteger, default 2 | 0 = not interested, 1 = interested, 2 = pending. Backed by `InterestLevel` int enum for casts/labels. Server-derived from the Conclusion tabs — NOT a form field, NOT in `$fillable` writes from raw request. |
| reason | text, nullable | the "Reason / Remarks" textarea of the Not Interested tab / modal |
| status | string, default 'new' | `InquiryStatus` enum: new, follow_up, not_interested, booking_confirm. Server-derived (see §4) — never taken from request input. Index it. |
| created_by | foreignId→users, nullable, nullOnDelete | `forceFill()` with `Auth::id()`, not fillable — unit-table pattern |

Index `inquiry_date`, `status`, `source` (listing filters/sorts).

### 2.3 `follow_ups`

Product-specified column list (CR text is authoritative), plus `deleted_at` because
`HasAudit` requires it:

| column | type | notes |
|---|---|---|
| id | id | |
| inquiry_id | foreignId→inquiries, cascadeOnDelete | index |
| type | unsignedTinyInteger | `FollowUpType` **int-backed** enum (product mandates integers): 1 DigitalBrochure, 2 PhysicalBrochure, 3 PersonalMeeting, 4 SiteVisit, 5 AutoCadPlan, 6 PdfPlan, 7 StructurePlan, 8 OfferRequest, 9 FollowUpCall, **10 NotInterested** (see §5.3 — needed because a Not Interested follow-up must also land in this table). |
| date | date, nullable | |
| time | time, nullable | |
| assigned_to | foreignId→users, nullable, nullOnDelete | |
| location | text, nullable | |
| reason | text, nullable | product says datatype text |
| created_by | foreignId→users, nullable, nullOnDelete | forceFill pattern |
| updated_by | foreignId→users, nullable, nullOnDelete | product-mandated; forceFill on update. (First `updated_by` in the codebase — product requirement overrides the no-updated_by convention here, only here.) |
| timestamps + softDeletes | | |

Per-type required fields (enforced in FormRequest `after()`, mirrored in the enum as
`requiredFields(): array` so the rule lives in one place):
- PersonalMeeting (3): date, time, location required
- SiteVisit (4): date, time, assigned_to required
- FollowUpCall (9): date, time required
- NotInterested (10): reason required
- all others: date optional, everything else null

## 3. Unit dropdown rules (Preferred Unit No + Tentative Unit Number — everywhere they appear)

- Options are the units of the currently selected **Interested Project** only. No
  project selected → empty select with placeholder.
- **Exclude `booked` units.** (Only `booked` — product spec names only booked.)
- A unit shows a **" Hold" suffix** (e.g. `A-101 Hold`) when it is on hold: either
  `units.status = held`, **or** some *other* non-deleted inquiry currently holds it
  (`tentative_unit_id = unit AND hold_until >= today`). Held units remain selectable.
  Note `UnitStatus` has no `on_hold` case — `held` is the mapping of the CR's "on_hold".
- Cascade endpoint: `GET /api/projects/{project}/units?exclude_booked=1` inside the
  auth group's `/api` prefix (recipe §8), returning `[{id, unit_number, on_hold}]`,
  authorised with `inquiries.view`. The edit form and tentative modal must include the
  inquiry's own current selection even if the hold is its own (exclude self from the
  "other inquiry holds it" check via an `?ignore_inquiry=` param parsed with
  `ctype_digit`).
- Do **not** mutate `units.status` when saving a hold — the hold lives on the inquiry
  row; availability is derived. (Deliberate: nothing else writes unit statuses yet.)

## 4. Status & interest lifecycle (server-derived, never client-supplied)

| event | is_interested | status |
|---|---|---|
| create/edit, no conclusion chosen | 2 (pending) | new (unchanged on edit unless a rule below fires) |
| create/edit, Interested tab + ≥1 type selected | 1 | follow_up |
| create/edit, Not Interested tab (+ reason) | 0 | not_interested |
| follow-up page save with ≥1 type (not NotInterested) | 1 | follow_up |
| follow-up page save incl. Not Interested | 0 | not_interested |
| Booking Action → Personal Meeting saved | 1 | follow_up |
| Booking Action → Not Interested / Closed saved | 0 | not_interested |
| Booking Action → Tentative hold saved | unchanged | unchanged |

`booking_confirm` has no writer in this CR (future Booking module). A `not_interested`
inquiry that later gets an Interested follow-up returns to follow_up / is_interested 1
(journeys reopen). Not Interested reason is copied to `inquiries.reason` in the same
transaction as the follow_ups row.

## 5. Screens (pixel-perfect to the wireframes)

### 5.1 Listing — `inquiry.html` → `inquiries/index.blade.php` + `InquiriesDataTable`

Follow `ProjectsDataTable` + `projects/index.blade.php` verbatim (whitelist trap,
filter-bar-as-ajax-params, dom string, script order, debounce 300ms).

Columns in order: Actions (view + edit icon buttons, per-ability `@can`), **Booking
Action** (raw partial, see below), Date (`inquiry_date`, "14 Mar 2026" format,
orderable, default order desc), Customer Name (`full_name`, `fw-500`), Phone
(`whatsapp_number`), Property Type (enum label, em-dash when null), Interested Project
(via `->with('project:id,name')`, never a join), Status (badge partial via
`<x-status-badge>` — extend `StatusBadge::STATUSES` with the four inquiry statuses:
new→info, follow_up→primary, not_interested→danger, booking_confirm→success), Assigned
To (via `->with('assignee:id,name')`, uppercase styling comes from data not CSS — print
the user's name as-is).

Filter bar (exact wireframe order): search input ("Search by name, phone, ID..." —
searches full_name, whatsapp_number, code), status select ("All Status" + the four
`InquiryStatus` labels + **"TENTATIVE BOOKING / HOLD / BLOCKED"** which is not a status
but a derived filter: `tentative_unit_id IS NOT NULL AND hold_until >= today` — parse
it as a sentinel value, everything else through `InquiryStatus::tryFrom`), source
select (enum), date-from, date-to (validate as dates server-side; ignore garbage).

**Booking Action column** (raw partial, XSS rules of `projects/partials/actions.blade.php`
apply — everything dynamic through `Js::from()` or a single JSON `data-` attribute):
a `.booking-action-select` per row with the five wireframe options. Behaviour:
- "Add Follow up journey" → navigate to `inquiries.follow_up` page
- "TENTATIVE BOOKING / HOLD / BLOCKED" → open the Tentative modal, prefilled from the
  row's inquiry (project_id, current tentative_unit_id, hold_until as data payload);
  unit options fetched from the cascade endpoint on open
- "Personal Meeting" → open Meeting modal
- "Booking" → no-op, reset select
- "Not Interested / Closed" → open Not Interested modal
After any modal opens, reset the select to placeholder (wireframe does this with a
100ms timeout). One instance of each modal per page (the wireframe markup, rebuilt with
`<x-modal>` + `<x-form.*>`), re-targeted per row via JS; each modal form posts to its
member route with per-form error bags so a failed submit reopens the right modal with
its errors (DatatableModalTest pattern).

Pagination text "Showing X to Y of Z inquiries" comes from the DataTables i18n config
like Projects. "Add New Inquiry" button top-right above the card.

### 5.2 Create + Edit — `add-inquiry.html` → twin views (recipe §9, TwinViewParityTest)

Six `.form-section`s exactly as the wireframe: Inquiry Date & Source (3-col row +
the two conditional `.conditional-field` blocks — Reference Details, Broker Details —
including the dashed-border style from the page-local `<style>`), Prospective Customer
Information, Assigned To, Inquiry Details, Tentative Booking, Inquiry Conclusion.
Card footer: Cancel → index, Proceed to Booking (inert, success-styled), Save Inquiry
primary. All fields via `<x-form.*>` components; jsvalidation binding with
`clientRuleExclusions()` covering the conditional/conclusion fields (they are
cross-field rules enforced server-side in `after()`).

Conditional behaviour (JS module `public/assets/js/nrems-inquiry-form.js`, patterned
on `nrems-unit-import.js` — config via one JSON data-attribute, no innerHTML):
- source=REFERENCE shows Reference Details; source=BROKER shows Broker Details;
  switching away clears the hidden group's inputs (wireframe behaviour).
- Broker select carries `data-phone` per option (escaped server-side); selecting
  prefills the contact input.
- Interested Project change reloads both unit selects from the cascade endpoint and
  clears selections (options built via `document.createElement`/`.textContent`).

**Inquiry Conclusion section** (wireframe: tab pair + option list):
- Two clickable tabs: "Interested" / "Not Interested" (`conclusion-tab` styling).
  Interested active → the nine `conclusion-option` checkbox rows, each expanding its
  dashed sub-panel with the wireframe's exact sub-fields (see §2.3 required matrix;
  types 1,2,5,6,7,8 = optional Date only; 3 = Date*+Time*+Location*; 4 = Site Visit
  Date*+Time*+Assigned To* select; 9 = Date*+Time*).
- Not Interested active → single "Reason / Remarks" textarea (the wireframe's red
  preview block shows this state — build it as the real tab content, not the preview).
- Field naming: `conclusion[<type-int>][selected|date|time|location|assigned_to]`,
  `conclusion_tab` (interested|not_interested), `reason`. Validated in the
  StoreInquiryRequest via array rules + `after()`; server derives §4 outcomes.
- On save: one `follow_ups` row **per selected type** inside the same transaction as
  the inquiry write.
- **Edit semantics: the journey is append-only.** The edit form's conclusion section
  loads unchecked/empty; anything selected on save appends new follow_ups rows.
  Existing journey is visible on the Follow Up page and show page, not re-editable
  here. (BA note: prevents silent mutation of historical journey entries.)
- Edit prefills every inquiries-table field including the conditional source blocks
  and tentative section; unit selects load the project's units with self-hold ignored.

### 5.3 Follow Up page — `follow-up.html` → `inquiries/follow-up.blade.php`

Route `GET inquiries/{inquiry}/follow-up`, permission `inquiries.edit`.
- Top info banner exactly per wireframe: Inquiry code, Customer (name · whatsapp),
  Project Interest (project name · property type label), current status badge on the
  right.
- Follow Up Details: the **eleven** `followup-option` rows in wireframe order —
  the nine types PLUS **Tentative Booking** (writes `tentative_unit_id`/`hold_until`
  on the inquiry, unit select scoped per §3) PLUS **Not Interested** (reason textarea;
  saves a type-10 follow_ups row AND flips the inquiry per §4). Copy the page-local
  `<style>` block (followup-option, sub-fields, fu-history-*) into the module CSS or
  the page's `@push('styles')`.
- Follow Up History below the form: timeline entries from `follow_ups` for this
  inquiry, newest first — dot column, type label as title, "by {creator name}",
  date · time meta, and the detail chips (location for meetings, unit + hold-until
  for holds, reason for not-interested, assignee for site visits) as `badge` /
  `fu-action-detail` blocks per the wireframe. All user data escaped.
- Footer: Cancel → index, Proceed to Booking (inert), Save Follow Up primary.
- `POST inquiries/{inquiry}/follow-up` (`StoreFollowUpRequest`): same
  `conclusion[...]`-style payload; at least one option must be selected; per-type
  required matrix; everything in one transaction; flash success; redirect back to the
  listing (wireframe's Save goes to inquiry.html).

### 5.4 Booking Action modals (listing page)

- **Tentative Booking modal** (wireframe `#tentativeModal`): Unit Number* (scoped per
  §3, prefilled from the inquiry, form-text "Only available units shown"), Hold
  Until* (prefilled). `POST inquiries/{inquiry}/hold` updates the two inquiry columns
  only. Validation: both required, unit belongs to the inquiry's project, not booked,
  hold_until today-or-future.
- **Personal Meeting modal** (`#meetingModal`): Date*, Time*, Location* →
  `POST inquiries/{inquiry}/meeting` inserts a type-3 follow_ups row; §4 status rule.
- **Not Interested modal** (`#notInterestedModal`): Reason/Remarks* →
  `POST inquiries/{inquiry}/not-interested` inserts type-10 row, copies reason to the
  inquiry, flips status/is_interested per §4. Danger-styled confirm button per
  wireframe.
- An inquiry with a live hold shows its Booking Action select normally (no special
  state in this CR).

### 5.5 Show — `view-inquiry.html` → `inquiries/show.blade.php`

Two-column `grid-2`: left card with detail-sections (Customer Information, Inquiry
Details, Assignment, Tentative Booking, Occupation & Address, Inquiry Conclusion) —
**omit the wireframe's "Budget Range" and "City" items: those fields are not captured
by the form and have no column** (deviation noted; do not invent columns). "Source
Reference" in Assignment shows reference_by/broker name when source warrants, else
em-dash. Inquiry Conclusion section shows one success badge per distinct follow-up
type recorded plus the wireframe's detail strip for the latest dated entry.
Right card: Follow Up History timeline (same partial as §5.3 — extract a shared
`inquiries/partials/history.blade.php`) with "Add Follow Up" button → follow-up page.
Top action bar: status badge + code · date on the left; Edit Inquiry (outline) +
Proceed to Booking (inert, success) on the right. Back to Inquiries button below.
Copy the page-local detail-section/fu-* styles.

## 6. Backend architecture (recipe steps 2–8)

- Enums: `app/Enums/{InquiryType,InquirySource,Occupation,PropertyType,TransType,InquiryStatus,InterestLevel,FollowUpType}.php` —
  string-backed except `InterestLevel`/`FollowUpType` (int-backed, product mandate);
  `label()` match with no default; `FollowUpType::requiredFields()` per §2.3.
- Models: `Inquiry`, `FollowUp`, `Broker` — `HasAudit`, full larastan `@property`
  docblocks, typed relation generics, fillable excludes code/status/is_interested/
  created_by/updated_by (all `forceFill`/service-set).
- `app/Services/Inquiries/FollowUpRecorder.php`: the single writer for follow_ups
  rows + the §4 status/interest transitions, called by every entry point (store,
  update, follow-up page, meeting modal, not-interested modal) inside the caller's
  transaction. Keeps the lifecycle in one reviewable place.
- `app/Services/Inquiries/UnitOptions.php` (or equivalent): the single query
  implementing §3 (exclude booked, compute on-hold suffix, ignore-self) used by the
  cascade endpoint and the form/edit prefill.
- FormRequests in `app/Http/Requests/Inquiries/`: `StoreInquiryRequest`,
  `UpdateInquiryRequest extends Store`, `StoreFollowUpRequest`, `StoreHoldRequest`,
  `StoreMeetingRequest`, `StoreNotInterestedRequest`. Policy-backed `authorize()`,
  `prepareForValidation()` for blank selects/absent checkbox arrays,
  `clientRuleExclusions()`, `attributes()` mapping every field to
  `__('inquiries.fields.*')`.
- `InquiryPolicy`: five one-liners onto `inquiries.{view,create,edit,delete}`;
  follow-up/hold/meeting/not-interested actions authorize as `update`.
- `RolePermissionSeeder`: add `inquiries.*` to `PERMISSIONS` + grant in `ROLES`
  mirroring how projects permissions are distributed (Sales gets the sales-side
  grants; check the seeder's own comment about inquiries separating the roles).
- Routes `routes/modules/inquiries.php` required from the auth group:
  `Route::resource('inquiries', ...)` + the four member POSTs + follow-up GET +
  the `/api/projects/{project}/units` cascade route. Names snake_case.
- `lang/en/inquiries.php`: title, fields, flash sentences, options, table headings.
- Sidebar: move Inquiries out of `$previewMenu` behind `@can('inquiries.view')`;
  update `AppShellTest`.

## 7. Security requirements (blocking — review gate)

Everything in recipe §11 plus, specifically for this module:
1. `status`, `is_interested`, `code`, `created_by`, `updated_by` must be impossible to
   set from any request payload (tamper tests posting them).
2. DataTable whitelist = physical columns only; project/assignee names order via
   explicit `orderColumn`; booking-action/status/actions rawColumns carry no
   unescaped user data (XSS probes: `full_name` = `<script>alert(1)</script>` and
   `');alert(1)//` asserted escaped in listing HTML + DataTables JSON + modals +
   history timeline + show page).
3. All five write endpoints: guest→login, unpermitted→403, cross-inquiry tampering
   (posting another inquiry's unit / a unit of another project / a booked unit /
   a soft-deleted broker) → 422, never 500.
4. Cascade endpoint authorised, project id via route model binding, `ignore_inquiry`
   parsed with `ctype_digit`, response is data-only JSON.
5. Dates validated (`hold_until` >= today on new holds; follow-up dates sane),
   `email` rule on email, string maxima matching column sizes everywhere.
6. No new `{!! !!}` without a safety comment; `Js::from()` for every JS-context
   interpolation; JS reads config from one JSON data-attribute; options built via
   DOM APIs, never string HTML.

## 8. Tests (Pest, style of ProjectCrudTest / ProjectsDataTableTest)

`tests/Feature/Inquiries/`: `InquiryCrudTest`, `InquiriesDataTableTest`,
`FollowUpTest` (page + all three modal endpoints + lifecycle table of §4),
`UnitOptionsTest` (§3 matrix: booked excluded; held-status suffix; other-inquiry hold
suffix; own hold ignored on edit; project scoping), `InquiryJsValidationTest`.
Plus: TwinViewParityTest dataset entry, AppShellTest sidebar update, factories for
Inquiry/FollowUp/Broker, permission matrix for every route, N+1 pin on show page,
`tools/responsive-smoke.mjs` walk() additions (index, create, show, follow-up).
Gates: `composer test && composer analyse && composer lint` all green.

## 9. Resolved decisions & flagged assumptions

Decided with product 2026-08-29: minimal brokers table + demo seeder (dropdown works
as wireframed); Assigned To lists all users; show page is in scope.

Assumptions taken by the BA (flag to product if wrong):
1. `FollowUpType` case 10 NotInterested — required so Not Interested journeys land in
   follow_ups as the CR demands, despite the CR's 1–9 list.
2. Edit's Conclusion section is append-only (§5.2) — historical journey rows are
   never mutated by the edit form.
3. Saving a hold does not change `units.status`; "Hold" visibility is derived (§3).
4. Any interested-side follow-up reopens a not_interested inquiry to follow_up.
5. `walk_in` is a selectable source on the form (needed by filter + view wireframe).
6. Wireframe's "Budget Range"/"City" on view-inquiry are dropped (no such fields).
7. `follow_ups.updated_by` lands as specified even though no other table has one.

---

# CR-6.1 — Wire the Booking Action "Booking" option (client instruction, 2026-09-07)

Client ask, verbatim: "If user select Booking from Booking Action in
inquiry listing then redirect to create booking screen."

CR-6 §1 left the "Booking" option deliberately inert because the Booking
module did not exist. It does now, and BookingController::create()
already prefills from `?inquiry=<id>` (inquiry/project/tentative-unit/
customer). The change is wiring only:

- **C1** — `inquiries/partials/booking-action.blade.php`: add
  `'booking' => route('bookings.create', ['inquiry' => $model->getKey()])`
  to the payload's `urls`, **only when the viewer holds
  `bookings.create`** (`$model` cell is gated on inquiry `update`; the
  two permissions are separate, and an option that navigates a
  no-permission user to a 403 is worse than one that stays inert for
  them). Same server-generated-URL rule as the four existing entries.
- **C2** — `nrems-inquiry-actions.js`: on `value === 'booking'`,
  navigate (`window.location.assign`) to `payload.urls.booking` when it
  is a non-empty string; otherwise fall through to the reset exactly as
  today. No URL construction client-side, ever.
- **C3** — Docblock hygiene: the partial's "two inert options" section
  and the JS's "booking falls through" comment now describe removed
  behavior — rewrite both truthfully (including the remaining inert
  state for users without `bookings.create`, if C1's gate leaves one).
- **C4** — Pinned tests: tests/Feature/Inquiries/InquiriesDataTableTest
  asserts on the booking_action cell (rendering + em-dash for view-only).
  Check whether any assertion pins the payload's `urls` key set; update
  MINIMALLY and honestly if so, no new test files.

DoD: full suite (baseline 1102/4 skips), phpstan level 6 = 0, pint
--dirty clean, run with C:\wamp64\bin\php\php8.3.28\php.exe FOREGROUND,
commit nothing. Verify by rendering: an inquiries listing draw as a user
with both permissions carries the booking URL in data-inquiry; as an
inquiry-updater WITHOUT bookings.create it does not; GET the generated
URL → 200 create screen with the inquiry's data prefilled.

---

## CR-6.2 — booking-confirmed inquiries are LOCKED (client, 2026-09-08)

Verbatim: "Once Booking Confirm for any inquiry then no one can edit, delete
that inquiry and also Booking action can do it once inquiry booking confirm."
Read with the standing open question this closes (the Booking Action still
offered on booked inquiries), the third clause means the Booking Action can
NOT be used either. Overrides restated: no test cases, no policies (the
EXISTING InquiryPolicy stays exactly as it is — see K2 for why it is not the
enforcement point).

- **K1 — The predicate is the STATUS**: `status === booking_confirm`
  (`Inquiry::STATUS_*` / the CR-6 int enum value BookingService writes on
  confirm and reverts on cancellation). Free per listing row — no query. Add
  one honest helper on the model (e.g. `isLockedByBooking(): bool`) so every
  check reads the same and the docblock explains the lifecycle (confirm
  locks, cancellation unlocks — CR-9's revert makes the controls come back,
  which is correct and stated). Do NOT use `hasLiveBooking()` here: it is a
  query per row and the status is the column BookingService maintains for
  exactly this question.
- **K2 — "No one" includes Super Admin, so the permission layer is the WRONG
  enforcement point**: `Gate::before` grants Super Admin every ability, so a
  policy arm returning false would lock everyone EXCEPT the one account the
  client's "no one" most needs to cover. Enforcement is a STATE check, like
  RoleService::refuseSuperAdmin: (a) UI — the listing actions partial, the
  show page's edit control, and any other edit/delete entry point render
  nothing (existing gates AND `! $model->isLockedByBooking()`); (b) server —
  `InquiryController::edit()`, `update()`, `destroy()` and every
  `InquiryActionController` record action (the four POSTs + one GET member
  routes; NOT `follow_ups.complete`, which is CR-10's task path and stays)
  refuse a locked inquiry BEFORE doing anything, via one shared private
  helper per controller (or a small shared concern) — do not scatter seven
  copies of the comparison.
- **K3 — Refusal shapes**: destroy() ajax arm (CR-16) → **422 JSON** with the
  sentence below, so the error toaster reads the reason; destroy() non-ajax →
  `back()->with('error', ...)`; edit()/update() → redirect to
  `inquiries.show` with the error flash (the screen that still works);
  action-controller posts → their existing error shape for a refused action
  (match whatever they answer today for a failed guard — recon it, keep it).
  Messages via the inquiries LANG file (house module, client did not forbid
  lang here): e.g. `inquiries.locked.edit` "This inquiry has a confirmed
  booking and can no longer be edited." / `.delete` "…deleted." /
  `.action` "…updated." — final wording implementer's, plain and consistent.
- **K4 — The Booking Action CELL** on a locked row renders the em-dash (the
  app's blank) instead of the select — nothing to disable, nothing inert to
  explain. The `data-inquiry` payload for locked rows carries no action URLs
  (server-side omission, the CR-6.1/CR-15 payload rule: what you may not do
  has no address). The follow-up/show screens' action affordances follow the
  same predicate if they render any (recon and cover them — the client's
  sentence is about every door, not just the listing column).
- **K5 — Scope guard**: no schema/route/permission changes; no policy edits;
  no new tests. Pinned tests: InquiriesDataTableTest pins booking_action
  cell rendering and the CR-16 delete re-pins — check whether any fixture
  inquiry is booking-confirmed (unlikely; minimal honest updates only if a
  pin breaks). InquiryCrudTest's destroy pins use unbooked inquiries —
  should pass untouched; if one books first, update honestly and quote.
- **K6 — Docblock hygiene**: the CR-6.1 payload comment ("option not
  rendered without bookings.create"), the actions partial header, and any
  "booking falls through" JS prose that this lockdown dates — rewrite
  truthfully. The standing open product question about the Booking option on
  already-booked inquiries is CLOSED by this CR; say so where it was flagged.

DoD: full suite (baseline 1112/1108/4/4318), phpstan 6 = 0, pint clean,
probes on the REAL app: locked inquiry's row shows em-dash in Booking Action
+ no edit/delete controls (as Super Admin TOO); direct GET /inquiries/{id}/edit,
PUT update, ajax DELETE, and each record POST all refused with the K3 shapes;
cancellation of the booking restores the controls; unlocked inquiries
unchanged. C:\wamp64\bin\php\php8.3.28\php.exe. No commits.

---

## CR-6.3 — client-side required for the conditional source blocks (client, 2026-09-08)

Verbatim: "if user select Inquiry Source REFERENCE or BROKER inside Inquiry create or
edit form then that related column is required so please add client side validation for
that required field." Overrides restated: no test cases, no policies.

- **L1 — Server truth is already right and is the contract**:
  `StoreInquiryRequest::validateSourceDetails()` requires, given the source,
  REFERENCE → `reference_by` + `reference_contact`, BROKER → `broker_id` +
  `broker_contact_number` — the same fields the wireframe stars inside the visible
  block. The client half must mirror EXACTLY that mapping (both fields per block, since
  both are starred and server-required), and its docblock/comments must cite the server
  method as the source of the mapping. No server change.
- **L2 — Mechanism: arm/disarm on the BOUND validator, not hand-built nodes.** The
  inquiry twins bind jsvalidation, so conditional requiredness is jQuery Validation
  rules added and removed as the source changes: on source-select change AND on initial
  load (edit/old() arrives with a source pre-picked), the VISIBLE block's fields get
  `required` via `.rules('add', ...)` (messages from `data-validate-message-*`
  attributes, the nrems-client-rules.js layer — extend that module or the inquiry form
  module, implementer's choice with reasons), and the hidden block's fields get
  `.rules('remove', 'required')` plus their stale error state cleared — a hidden field
  must NEVER hold a required rule (the exact reason the server avoided `required_if`:
  an invisible error that blocks submit with nothing on screen to fix). Letting the
  validator render its own messages sidesteps the CR-15.3 div-adoption trap entirely —
  if any node IS hand-built anyway, it is a `span.invalid-feedback` (the measured
  rule).
- **L3 — Acceptance** (real page, both twins): source REFERENCE + empty
  reference fields → submit blocked, each empty field shows its message naming the
  field; same for BROKER (`broker_id` select + contact); switching source away clears
  the requirement AND any shown message, and the form submits without touching the
  now-hidden fields; WALK-IN/other sources unchanged; edit twin arriving with
  REFERENCE pre-selected is armed without any select interaction; a rejected server
  submit re-renders with the correct block armed. Existing behavior preserved: the
  block show/hide + input-emptying the form JS already does, the CR-6.2 lock (a locked
  inquiry has no edit form to validate), and every other field's validation.
- **L4 — Scope guard**: no schema/route/permission/policy/server-rule changes; no new
  tests; message wording from the existing lang layer (inquiries is a lang module —
  reuse `validation.required` + `inquiries.fields.*` attribute names so client and
  server sentences match); pinned tests minimally updated only if one pins the old
  markup (unlikely — check InquiriesDataTableTest is untouched by form-only changes).

DoD: full suite (baseline 1112/1108/4/4318), phpstan 6 = 0, pint clean, node --check;
probes on the REAL app per L3. C:\wamp64\bin\php\php8.3.28\php.exe. No commits.

---

## CR-6.4 — expired-hold release command (client, 2026-09-08)

Verbatim: "Create one command for hold unit status changed to available If any one
Select Tentative booking unit for hold until date if that date pass then released hold
to available." Overrides restated: no test cases, no policies.

- **M1 — The app's FIRST artisan command**: `app/Console/Commands/` (new directory),
  e.g. `ReleaseExpiredHolds`, signature `nrems:release-expired-holds`. Console output =
  honest counts (released N / skipped M), exit 0 either way. Its docblock must state
  the derived-hold context: units.status was NEVER written by holds (CR-6 §3 —
  availability is derived, `scopeHoldingAUnit` already excludes past dates), so an
  expired hold already DISPLAYS as available everywhere; this command is the explicit
  release that makes the ROW agree — clearing the stale pair and leaving a trail — not
  the thing that frees the unit.
- **M2 — Expiry predicate** = `tentative_unit_id` NOT NULL AND `hold_until` NOT NULL
  AND `hold_until` strictly before today — the exact complement of `holdsAUnit()`
  (a hold is valid THROUGH its date; today's hold is live). Release = set BOTH
  `tentative_unit_id` and `hold_until` to null.
- **M3 — Skip booking-confirmed inquiries** (status `booking_confirm`): their unit
  state belongs to the booking, and BookingService owns their lifecycle. Verify
  nothing reads a booked inquiry's stale tentative pair in a way that skipping breaks
  (recon `tentative_unit_id` readers); state the finding in the docblock. Soft-deleted
  inquiries are excluded by the scope as usual.
- **M4 — Per-row safety**: iterate candidate ids, then per inquiry one transaction with
  `lockForUpdate` re-read and the expiry + status predicates RE-CHECKED under the lock
  (a user may be booking or editing this second); write one explicit `activity()` row
  with `->event('hold_released')` and properties naming the released unit and the
  expired `hold_until` (console has no auth user — `causedBy` null is honest; if the
  automatic logFillable 'updated' row would double-log the same change, suppress it the
  CR-10 way, `disableLogging()`, and say so). If the inquiry history tab renders by
  `event` match arms, add the `hold_released` arm so the row reads properly.
- **M5 — Scheduled daily** in `routes/console.php` (Laravel 12 `Schedule::command`
  style), early morning, `withoutOverlapping()`. The command stays manually runnable.
  REPORT NOTE for the client: the schedule only fires where the Laravel scheduler runs
  (cron / `php artisan schedule:work` in dev) — say so plainly in the report.
- **M6 — Scope guard**: no schema/route/permission/policy changes, no new tests, no
  edits to the hold action or the derived-display predicates (`holdsAUnit`,
  `scopeHoldingAUnit` stay byte-identical). Lang: console strings inline (console is
  not a lang surface in this app); any history-tab label follows the module's lang
  convention.

Client follow-up (same day, verbatim): "Also for Tentative booking anyone changed unit
or tentative date then manage that history inside follow_ups table."

- **M7 — Tentative-booking CHANGES become journey history in `follow_ups`.** Whenever
  `tentative_unit_id` or `hold_until` changes through a user action — the inquiry edit
  form's update() and the hold action are the writers; recon for any other — append ONE
  `follow_ups` row recording the change in words (old unit → new unit by label, old
  date → new date via `config('nrems.date_format')`; a first-time set and a clearing
  are changes too). Use the journey's own conventions (type/remarks/conclusion shapes —
  recon how hold() writes its row today and match the house shape).
  **HARD CONSTRAINT (CR-10):** a history row must NEVER read as a pending task — the
  pending predicates are type-driven (doc types pending while `date` NULL, activity
  types pending until `conclusion`), so shape the row (existing type + filled fields,
  or whatever honest shape recon supports) such that the dashboard and pending-task
  queries cannot pick it up. Prove it with a probe (dashboard count unchanged after a
  tentative change).
- **M8 — The M4 release writes the SAME kind of history**: the command's release
  appends a `follow_ups` row too ("hold expired and released", unit + date in the
  text; `created_by` NULL if the column allows — it is a system act; verify
  nullability and the journey partial's tolerance for a missing author). With the
  follow_ups row carrying the story, keep the M4 activity row ONLY if the inquiry's
  screens render an activity-based history that would otherwise miss the event —
  recon which surfaces render what, avoid double-telling, and state what you chose.
  Append-only discipline holds: follow_ups rows are never edited by this CR, only
  added (FollowUpRecorder::complete() stays the one sanctioned mutation).

DoD: full suite (baseline 1112/1108/4/4318), phpstan 6 = 0, pint clean; probes on the
real app: seed an expired hold, a live (today/future) hold, a booking-confirmed
inquiry with a stale pair, run the command — expired released with its follow_ups
history row + correct console counts, live hold untouched, booked inquiry skipped;
run TWICE — second run releases 0 (idempotent); `schedule:list` shows the entry;
change the tentative unit then the hold date through the edit form and the hold
action — each writes one journey row with old→new, visible on the inquiry timeline,
and the Pending Tasks dashboard count is UNCHANGED by all of them.
C:\wamp64\bin\php\php8.3.28\php.exe. No commits.
