# CR-15 — Customer Documents (standalone module)

Status: **specified 2026-09-07** — implementation assigned to an Opus agent; BA review to follow.
Wireframes: `docs/wireframes-v12/upload-document.html` (listing) and
`docs/wireframes-v12/add-upload-document.html` (upload screen). Pixel-perfect against those
two pages, minus the client's removals below. There is NO wireframe for the edit screen —
see D14.

This is the module decision D9 (CR-14) predicted: the four `customer_documents.*`
permissions have been seeded since CR-14 and gate nothing. This CR wires them — and, per
the client's answer recorded as D3 below, ALSO re-gates the existing booking Documents tab
onto them.

Read `docs/adding-a-module.md` first; this document only records what differs from the
recipe. Exemplars: **Brokers (CR-12)** for a full module with files, **Roles (CR-14)** for
the newest conventions, `BookingDocumentController` + `BookingDocumentService` +
`StoreCustomerDocRequest` (CR-8) for everything file-shaped — the storage, sniffing,
download and delete mechanics in there are reused, not rebuilt.

---

## §0 Client-mandated overrides — do NOT "fix" back

Verbatim from the client (2026-09-07): "Please don't create test cases and policies. Also
please don't use language file for this module. Add all message and text direct inside
controller and views."

1. **No new test files.** Existing pinned tests (AppShellTest, TwinViewParityTest if it
   applies, code-format literals) may be minimally updated to keep the suite green —
   nothing else. Any pinned-test edit must be an honest move, not a deletion wearing a
   comment (CR-12 lesson).
2. **No policies.** Bare spatie gates: `Gate::authorize('customer_documents.x')`,
   `FormRequest::authorize()`, `@can`. Security stays non-negotiable — every route is
   gated.
3. **No lang files for this module.** All user-facing strings live directly in the
   controllers and views, as CR-8/CR-9 did for bookings/cancellations. Do NOT create
   `lang/en/customer_documents.php`. The sidebar label keeps using the EXISTING
   `lang/en/nav.php` key `nav.customer_documents` — that file belongs to the layout, not
   this module, and the recipe's sidebar ceremony reads from it.
4. House conventions otherwise apply: routes in `routes/modules/customer-documents.php`
   (the client restated no routes override), `declare(strict_types=1)`, final classes,
   forceFill for server-owned columns, `Js::from()` for PHP→JS, no innerHTML
   (createElement/textContent), em-dash `—` blanks, `config('nrems.date_format')`,
   DataTable whitelist = physical columns only, yajra render gate `ajax() && wantsJson()`.

## §1 Client instructions (verbatim, 2026-09-07)

> For customer documents use upload-document.html page design for listing. in listing
> status and All Co-Applicants filter not required. Also ID, File Name, Co-Applicant, Size
> and status column not required to show in listing. Show booking wise customer inside
> customer document listing and make sure atleast one document uploaded for that booking
> then show in customer document list. In Customer column show Drop Down with Customer
> name and customer co-applicant. Default customer selected. If selected customer or
> co-applicant for that perticular customer or co-applicant whatever document available in
> customer_docs table show that document type inside document type column drop down. id
> not exist any document for customer or any co-applicant then show No Document inside
> Document Type drop down. Based on selected customer or co-applicant and Document Type
> user can download document, edit and delete in action column.
>
> Upload New Document screen provide ajax search inside customer dropdown user can search
> using Booking ID, Customer name, customer number or unit number. Show format for
> customer drop down is "Booking Id - Customer name - Mobile Number - Project name - Unit
> Number", once selected any customer then based on that show that booking co-applicant if
> any in customers table. Also show Booking Type whatver booking type selected when
> booking confirm. Based on Booking Type show Document Type inside Document Type * drop
> down. Document Type drop down value come from Customer Document Types. For Selected
> customer any document exist then that document type don't show inside document type drop
> down. Same for co-applicant if select co-applicant and exist document then for that
> document type don't show inside document drop down. If based on booking type all
> document uploaded then just show message after Booking type for this customer or
> co-applicant whatever selected show message for Customer "All document already uploaded
> for customer" and for co-applicant show message "All document already uploaded for
> Co-Applicant". When user uploading document multiple at a time then make sure user not
> selected same document type in document type drop down.

## §2 Decisions (D-list)

The client answered three AskUserQuestion rounds; D1–D3 restate those answers. The rest
are BA decisions consistent with them.

- **D1 — Edit is a FULL edit**: document type (changeable), file (replaceable), remarks
  (editable). A type change is refused when the target type already has a live document
  for the same person on the same booking. The person (customer/co-applicant) and the
  booking are NOT editable — a document belongs to who it was uploaded for; wrong person
  means delete + re-upload.
- **D2 — Confirmed bookings only, everywhere in this module.** The listing shows only
  bookings with `status = confirmed` (and ≥1 live document); the upload search offers only
  confirmed bookings. A cancelled booking's documents disappear from this module — they
  remain visible/downloadable on the booking view and cancellation screens exactly as
  CR-8/CR-9 left them.
- **D3 — One permission scheme for documents everywhere** (client, verbatim: "Use Customer
  Documents permission for this module as well as inside bookings"). The booking Documents
  tab's gates CHANGE: upload form + store `bookings.edit` → `customer_documents.create`;
  download `bookings.view` → `customer_documents.view`; destroy `bookings.delete` →
  `customer_documents.delete`. Consequences the implementer must handle, §4.6.
- **D4 — Listing row = one CONFIRMED booking** with at least one live (non-soft-deleted)
  `customer_docs` row. Not one row per document: the Customer and Document Type cells are
  dropdowns that navigate within the booking's documents.
- **D5 — Uploaded Date and Uploaded By cells are DYNAMIC**: they always describe the
  document currently selected by the row's two dropdowns (there is no other honest
  reading once the row is a booking). When the selected person has no documents
  (Document Type shows "No Document"), Date/By show the em-dash and the Actions cell
  shows the em-dash.
- **D6 — Listing columns** (in order): Customer (dropdown), Document Type (dropdown),
  Uploaded Date, Uploaded By, Actions. Removed vs wireframe per client: ID, Co-Applicant,
  File Name, Size, Status.
- **D7 — Filters kept**: search box + All Customers + All Document Types. Removed per
  client: All Co-Applicants, All Status. The Customers filter lists the MAIN customer
  (name, value = customers.id) of each qualifying booking; the Document Types filter lists
  active doc types (value = id) and keeps a booking when any of its live documents has
  that type. The search box matches the booking code OR any customer/co-applicant name on
  the booking (the wireframe's "file name" half is dropped with its column). Filter params
  must survive array tampering (`filter_x[]=`) — use the stringParam guard pattern from
  BrokersDataTable.
- **D8 — Actions per selected person + type**: Download (`customer_documents.view`), Edit
  (`customer_documents.edit`), Delete (`customer_documents.delete`) — each icon rendered
  only when the viewer holds its permission, matching wireframe `.btn-icon` styling
  (download icon `fa-download`, pen, trash with `.text-danger`). Delete is a POST form
  with `confirm()` (never a GET), targeting the EXISTING `bookings.documents.destroy`
  route; Download targets the EXISTING `bookings.documents.download` route. Both routes
  now carry this module's gates (D3), so reuse — do not build a second streaming path.
  Because the selected document changes client-side, the actions cell is (re)built by JS
  from the row payload — createElement/textContent, URLs from the payload verbatim, no
  client-side URL assembly beyond what the payload carries.
- **D9 — "No Document"** is a literal, disabled-select single option (verbatim text
  `No Document`) shown when the selected person has zero live documents.
- **D10 — Upload screen person scope**: one batch uploads for ONE person — the customer,
  or one co-applicant if selected (same rule CR-8's screen has). Switching the selected
  booking or person RESETS the Document Details section to one empty row (a chosen file
  belongs to the person context it was picked in).
- **D11 — "All uploaded" state**: when every mapped doc type already has a live document
  for the selected person, show — directly after the Booking Type chip — the verbatim
  message `All document already uploaded for customer` (main customer selected) or
  `All document already uploaded for Co-Applicant` (co-applicant selected), hide the
  Document Details + Remark sections, and disable the submit button.
- **D12 — Duplicate-type prevention in one batch** is BOTH client-side (a type chosen in
  one row is removed from the other rows' selects; re-added when freed) and server-side
  (`distinct` per person — tamper-proof).
- **D13 — Documents become editable ⇒ `customer_docs` gains `updated_by`** (nullable FK
  users nullOnDelete) via a NEW migration. The original create-migration's comment
  ("a document is uploaded and deleted, never edited") is now false — update that COMMENT
  in place (comment-only edit, no schema change to a shipped migration) and the model
  docblock. `updated_by` is server-owned (forceFill), never fillable.
- **D14 — Edit screen design** (no wireframe page exists): follow
  `add-upload-document.html`'s form language — a Reference section showing read-only
  context (Booking code, Customer/Co-Applicant name, Booking Type chip), one Document
  Details block (Document Type select + current-file panel styled like the wireframe's
  "Row 2" uploaded-file state, with a replace-file drop zone), the Remark Notes section,
  Cancel/footer per the twins convention. No Add More on edit.
- **D15 — File replace on edit is OPTIONAL.** No new file → bytes untouched, only
  type/remarks change. New file → stored first (outside the transaction, CR-8 rule), row
  updated inside it, OLD bytes deleted via `DB::afterCommit` (same asymmetry as delete).
  A failed transaction must clean up the NEW orphan file, mirroring
  BookingDocumentController::store()'s error path.
- **D16 — Audit**: `customer_doc_type_id` and `remarks` are fillable so
  HasAudit/logFillable covers them; `file_path`/`original_name`/`updated_by` are NOT
  fillable, so a file replacement writes an EXPLICIT `activity()` row with
  `->event('file_replaced')` recording old/new original names (the CR-9 lesson —
  logFillable is blind to forceFill'd columns).
- **D17 — Ajax search** is this app's first remote select2. Endpoint under `/api` inside
  the web group (401 JSON on auth failure, same as `bookings.unit_options`), gated
  `customer_documents.create`, minimum-data JSON (scalars only, no markup). select2 is
  already shipped and initialised via `data-control="select2"`; the ajax config is added
  by this module's form JS for this one select. Query `q` validated as a plain string
  (reject arrays), trimmed, min 1 char, results limited to 20, matched with `LIKE %q%`
  against booking code, main-customer name, main-customer mobile_number, and unit number
  — confirmed bookings only. Label format verbatim:
  `{code} - {main customer name} - {mobile number} - {project name} - {unit number}`
  (em-dash-free; a NULL mobile renders as `—` inside the label).
- **D18 — Second cascade endpoint** returns, for one confirmed booking (route-model bound,
  404 when not confirmed): the booking type name, the person list (main customer first,
  then co-applicants, each `{id, name, is_co_owner}`), the mapped doc types
  (`{id, name}`, active only, pivot-scoped to the booking's type), and per-person the
  ALREADY-UPLOADED live type ids. Gated `customer_documents.create`. The form JS computes
  availability client-side from this one payload; the server re-derives it independently
  at store time (never trust the client's arithmetic).
- **D19 — Sidebar**: Customer Documents moves OUT of `$previewMenu` into a live
  `@can('customer_documents.view')` entry (recipe §10 ceremony: routeIs active-state
  variable, Document Uploads section heading gets a real gate arm — the heading shows for
  `customer_documents.view` OR the `$previewMenu` case while Legal Files & Agreements
  remains preview). `$previewMenu` machinery STAYS (3 preview entries left: Possession,
  Maintenance, Legal Files & Agreements).
- **D20 — Permissions ripple accepted, matrix untouched**: after D3, `bookings.delete`
  gates NOTHING (it existed only for tab doc-deletion). RolePermissionMatrix rule 3
  (bookings.edit → auto-grant bookings.delete) and decision D4 of CR-14 stay AS-IS —
  harmless dormant grant; removing it would churn reviewed CR-14 code for zero behavior.
  Every stale comment that says otherwise must be updated (§4.6). Seeded roles are NOT
  changed: Admin already holds all 40; Sales/Operations do NOT get `customer_documents.*`
  seeded — the client grants via Role Management (flagged to the client in the report:
  seeded Sales/Operations lose booking-tab upload/delete until granted).
- **D21 — Uploaded Date default order**: newest activity first — the listing's default
  order is by each booking's LATEST live document `created_at`, DESC. The Uploaded Date
  header is the ONLY orderable column (aggregate `MAX(customer_docs.created_at)` via
  orderColumn, CR-12's aggregate pattern); Customer/Document Type/Uploaded By are
  orderable(false) + searchable(false) (computed cells). Default order applied via the
  CR-12.1 pattern: `'order' => []` in html() + conditional query-side order gated on
  `Yajra\DataTables\Utilities\Request::isOrderable()`.

## §3 Screens

### 3.1 Listing — `GET /customer-documents` (`customer_documents.index`)

`upload-document.html` minus removals (D6/D7). Header: page title "Upload Documents",
breadcrumb Home / Upload Documents. Top action bar: right-aligned
`Upload New Document` primary button (`fa-plus`) → create screen, rendered only
`@can('customer_documents.create')`.

Card: filter bar (search input with `fa-search`, Customers select, Document Types select)
+ DataTable + standard pagination. Row cells per D4–D9. The two in-row selects are plain
`.form-control` selects sized to the cell (match wireframe select styling); changing
Customer re-populates Document Type (first option selected), changing either re-renders
Date/By/Actions from the payload. Payload per row rides a `data-` attribute JSON-encoded
with `Js::from`-equivalent escaping (yajra raw column → build the payload in PHP with
`e(json_encode(...))` semantics; the JS reads it via `JSON.parse` of the attribute —
follow nrems-inquiry-actions.js's existing payload pattern).

Row payload shape (per booking):

```json
{
  "persons": [
    {
      "id": 12, "name": "Rajesh Patel", "co": false,
      "docs": [
        {"id": 7, "type": "PAN Card", "date": "14 Mar 2026", "by": "Amit Shah",
         "urls": {"download": "...", "edit": "...", "delete": "..."}}
      ]
    }
  ]
}
```

`urls.edit` present only when the viewer holds `customer_documents.edit`; `urls.delete`
only with `.delete`; `urls.download` only with `.view` (always true on this page, kept
for shape honesty). JS renders only the icons whose url exists. Dates pre-formatted
server-side with `config('nrems.date_format')`; `by` = creator name (`creator()` is
withTrashed) or em-dash.

### 3.2 Upload — `GET /customer-documents/create` + `POST /customer-documents`

`add-upload-document.html` verbatim, with these live behaviors:

- **Customer select** = the ajax select2 (D17). Placeholder "Select Customer". Until a
  booking is chosen: Co-Applicant select disabled with placeholder, Booking Type row
  hidden (wireframe hides it via inline style — keep that), Document Details + Remarks
  visible with one row but submit blocked by client validation (booking required).
- On selection: fetch D18 payload → populate Co-Applicant select ("Select Co-Applicant
  (optional)" + each co-applicant name; helper text per wireframe), show Booking Type
  chip with the booking's type name, populate each Document Type select with the
  AVAILABLE types for the currently-selected person (mapped minus that person's uploaded
  minus other rows' selections, D12).
- Person switch (co-applicant chosen/cleared) → D10 reset + availability recompute → D11
  message state when empty.
- Document rows: Add More appends a row (max 10 — the CR-8 `MAX_ROWS` constant — hide the
  button at the cap), each row after the first has the remove ✕. File drop-zone identical
  to wireframe incl. the selected-file state of "Row 2" (filename + size + remove);
  accept list and "PDF, JPG, PNG, DOC — Max 5 MB" caption verbatim, sourced from
  `BookingDocumentService::acceptAttribute()` / constants.
- Remark Notes: one textarea, stored to `remarks` on every row of the batch (existing
  CR-8 semantics).
- Footer: Cancel (link to listing) + `Upload Document` primary with `fa-upload`.
- Server: new `StoreCustomerDocumentRequest` (namespace
  `App\Http\Requests\CustomerDocuments`), `authorize()` = `customer_documents.create`.
  Rules: `booking_id` required + exists among CONFIRMED, non-deleted bookings;
  `customer_id` nullable integer resolved exactly like
  `BookingDocumentController::resolveCustomer()` (blank = main customer; supplied id must
  be a co-owner of THAT booking); `documents` required array 1..10; per row
  `customer_doc_type_id` required + exists non-deleted + mapped to the booking's type
  (CR-8's whereHas tamper check) + **no live document of that type for that person**
  (`Rule::unique`-style closure over customer_docs whereNull deleted_at) + distinct
  within the request; `documents.*.file` identical to CR-8 (file, mimetypes list, the
  OLE-extension closure, max 5120). `remarks` nullable string max 5000. Client rules:
  the whole request goes on `clientRuleExclusions()` where remote/wildcard traps apply
  (wildcard array keys, exists rules) — re-arm the plain `required`s via
  `nrems-client-rules.js` data-attributes where the closed SAFE_RULES set covers them;
  everything else is the form JS's live validation + server truth.
- Storage/rows: REUSE `BookingDocumentService::store()` + the controller store
  choreography from CR-8 (files outside tx, rows inside, orphan cleanup on failure,
  explicit `->event('documents_uploaded')`-style activity row if CR-8 writes one — match
  what exists). Success: redirect to the listing with the existing flash pattern, message
  text inline (§0.3), e.g. "Documents uploaded successfully."

### 3.3 Edit — `GET /customer-documents/{document}/edit` + `PUT /customer-documents/{document}`

D1/D14/D15. Route-model binds `CustomerDoc`; 404 unless its booking is confirmed and the
row is live. Gate `customer_documents.edit` (controller + FormRequest).
`UpdateCustomerDocumentRequest`: `customer_doc_type_id` required + exists + mapped to the
booking's type + no OTHER live document of that type for the same person (ignore self);
`file` NULLABLE + the CR-8 file rules when present; `remarks` nullable string max 5000.
Service work in a new `App\Services\CustomerDocuments\CustomerDocumentService` (final,
composes/reuses BookingDocumentService's storage helpers rather than duplicating sniff
logic — extract-and-share only where the recipe allows, additive changes to
BookingDocumentService are acceptable and must keep CR-8 call sites byte-compatible):
transaction, `lockForUpdate` re-read, type-collision re-check under the lock, forceFill
for server-owned columns, `updated_by` stamped, D16 activity row on file replace, D15
bytes choreography.

### 3.4 API — in `routes/modules/customer-documents.php`

- `GET /api/customer-documents/bookings` → invokable
  `CustomerDocumentBookingSearchController` (D17), route name
  `customer_documents.booking_search`.
- `GET /api/customer-documents/bookings/{booking}` → invokable
  `CustomerDocumentBookingOptionsController` (D18), route name
  `customer_documents.booking_options`.

Both `Gate::authorize('customer_documents.create')`, both follow the
BookingUnitOptionsController docblock rules (ctype_digit/plain-string param hygiene,
data-only JSON).

## §4 Cross-cutting

### 4.1 Routes

`routes/modules/customer-documents.php` (8th module file, loaded like the others):
listing (index), create, store, edit, update + the two api routes. NO show, NO destroy of
its own — download and destroy reuse the existing `bookings.documents.download` /
`bookings.documents.destroy` routes (D8), which stay declared inline in `routes/web.php`
(CR-8 override — do not move them).

### 4.2 Gate switch (D3)

In `BookingDocumentController`: `create()`/`StoreCustomerDocRequest::authorize()` →
`customer_documents.create`; `show()` → `customer_documents.view`; `destroy()` →
`customer_documents.delete`. In the booking views, every `@can` guarding the Documents
tab's upload/delete controls switches to the matching `customer_documents.*`; the tab
itself (read-only table) stays visible with the booking screens as today — but its
Download buttons render only `@can('customer_documents.view')` and its Delete buttons
only `@can('customer_documents.delete')`. Sweep: `rg "bookings\.(edit|view|delete)"`
across the documents-related controllers/views/partials and switch ONLY document-scoped
checks; booking-scoped checks (the screens themselves) are untouched.

### 4.3 Migration

One new migration adding `customer_docs.updated_by` (nullable FK users nullOnDelete),
plus the comment-honesty edits of D13.

### 4.4 Sidebar + pinned tests

D19 ceremony. AppShellTest: move the Customer Documents label from the absent/preview
expectations into a live-entry dataset row gated both directions (grant → visible, no
grant → absent), following the CR-12 dataset shape. RolePermissionSeedTest: unchanged
(still 40). TwinViewParityTest: NOT extended — this module's create and edit are
different shapes (repeater vs single-doc), pairing them would be dishonest; state this in
a comment only if the test's structure otherwise invites the pair.

### 4.5 JS

New `public/assets/js/nrems-customer-document-form.js` (upload + edit screens: select2
ajax wiring, cascade fetch, availability arithmetic, row repeater, D11/D12 behaviors,
file-zone state — reuse/extend `nrems-upload-guard.js` if that is what CR-8's zones use)
and `public/assets/js/nrems-customer-document-list.js` (listing row dropdown/cell/action
rendering from payload). ES5, createElement/textContent, no innerHTML, all URLs from
server payloads.

### 4.6 Docblock/comment hygiene (D3/D20 ripple — the truth sweep)

Update every comment made false by the gate switch, at minimum: RolePermissionSeeder
lines ~189–203 ("checked by nothing yet" — now wired, and the bookings.edit/delete
sentence), `StoreRoleRequest`'s bookings.delete rationale sentence (still auto-granted,
now dormant — say so), `BookingDocumentController`/`web.php` route comments naming the
old gates, `RolePermissionMatrix` docblock D9 note, sidebar docblock preview-entry list
(4 → 3). Do not touch CR-14 CODE (matrix/normalize/seeder logic) — comments only.

## §5 Review-round amendments (BA, 2026-09-07, after adversarial review)

- **D22 (fixes review B1)** — the edit screen's Document Type list is the mapped-and-active
  set **plus the row's own current type, always**, pre-selected. When the own type is
  inactive or no longer mapped its option carries an honest suffix (CR-12's broker
  precedent: " (Inactive)" / " (No longer applicable)"). Server side,
  UpdateCustomerDocumentRequest gains the matching unchanged-arm: the row's current type
  is always acceptable; only a CHANGE must land in the mapped-and-active set. A document
  must never become uneditable because an administrator flipped a master flag.
- **D23 (resolves the CR-8/CR-15 `is_active` asymmetry)** — the booking tab's
  `mappedDocTypes()` gains the same `is_active` filter as CR-15: inactive means "do not
  offer for NEW uploads" everywhere. (The tab has no edit, so no own-type arm is needed
  there. §6 E1 later deleted `mappedDocTypes()` outright — the tab now draws from the
  shared cascade payload — and E3 extended `BookingController::mappedDocTypeCount()`
  with the same `is_active` reading, so the post-save redirect cannot point at a screen
  with nothing to offer.)
- **D24 (fixes review S2)** — CR-15's store re-checks the not-already-uploaded rule
  INSIDE its transaction under a `lockForUpdate` over the person's `customer_docs` rows
  before inserting, the same check-then-act closure the edit path already has. The
  booking tab's store originally kept its pre-existing race (flagged in a comment);
  §6 E3 closed it the next day with the same lock.
- **D25 (fixes review S3)** — the `uploaded_date` orderColumn handler carries the
  `bookings.id` tiebreaker too (direction following the header), so user-initiated sorts
  are as pagination-stable as the default order.
- **D26 (fixes review S1)** — the search endpoint escapes LIKE metacharacters (`%`, `_`,
  `\`) in the term before interpolation, honouring its own "no roster without a real
  search" contract; the listing's filterColumn does the same for consistency.
- **D27 (fixes review S8)** — a person switch on the upload screen also clears each
  remaining row's Document Type selection back to the placeholder (D10's reasoning
  applies to the type, not only the file).

## §6 CR-15.1 — booking-tab fix round (client, 2026-09-08)

Verbatim: "Bookings -> Customer Document upload time show only pending document types.
Also Validate Document type and file client side. if document type and file not selected
till form will not submit and show error message whatever field is not fill." Overrides
restated by the client: no test cases, no policies. (The bookings module is no-lang
already — CR-8 §0.)

- **E1 — Pending types only on the booking tab's upload screen**
  (`bookings/documents.blade.php` / BookingDocumentController::create()). A row's
  Document Type select offers the PENDING set for the currently selected person:
  mapped-and-active (the shared `StoreCustomerDocumentRequest::mappedTypeIds()`) minus
  that person's already-uploaded live types (`uploadedTypeIds()`), minus types chosen in
  sibling rows (D12 parity). Main customer is the default person; choosing a co-applicant
  recomputes every row's options client-side from a per-person payload embedded in the
  page (escaped-attribute JSON, the CR-15 standalone pattern — no new endpoint: the tab
  is booking-scoped, so the data is all known at render time).
- **E2 — Exhausted state**: when the selected person has no pending types, the tab shows
  the D11 verbatim message ("All document already uploaded for customer" /
  "All document already uploaded for Co-Applicant"), hides/disables the document rows and
  disables the submit — the standalone screen's behavior, on the tab.
- **E3 — Server parity + the race closes**: the tab's `StoreCustomerDocRequest` gains
  `distinct` and the not-already-uploaded-per-person check (reusing the shared helpers),
  and the tab's store gains the same `lockForUpdate` re-check inside its transaction that
  CR-15's store has (D24). This closes the pre-existing tab race S2 left flagged — update
  the comment that flags it, and any docblock the change dates.
- **E4 — Client-side required validation** (the client's explicit ask): on the tab's
  upload screen, Document Type and File are required per row IN THE BROWSER — the form
  does not submit while any row is missing either, and each unfilled control shows its own
  error message naming the field (inline strings, the app's existing invalid-feedback
  styling; wildcard-keyed rules can't ride proengsoft, so this is the form JS's job, as
  on the standalone screen). ACCEPTANCE: with an empty type and no file, Upload does
  nothing but mark both fields; filling them submits. Verify the CR-15 standalone create
  screen already meets this same acceptance and align it if it does not.
- **E5 — Scope guard**: no schema, route, or permission changes; no new tests, no
  policies; pinned tests only minimally updated if one pins the old behavior.

## §7 CR-15.2 — Booking Entity Type change vs. uploaded documents (client, 2026-09-08)

Verbatim: "User edit booking and change Booking Entity Type * then on save asked
confirmation below message with popup confirm button and cancel plesae don't use alert
message. / Do you want to continue? / Changing the Booking Entity Type may affect the
documents uploaded for the customer and co-applicant. / - Documents that are still
required for the new Booking Entity Type will be retained. / - Documents that are no
longer required will be deleted. / if user confirm then delete extra document type
document will delete and if cancel then stay on same screen". Overrides restated: no
test cases, no policies. (Bookings module is no-lang — strings inline.)

- **G1 — Client-side confirmation, edit twin only.** When the booking EDIT form is
  submitted with a `booking_type_id` different from the booking's current one, a real
  MODAL opens (house pattern — CR-10's complete-task modal is the precedent; NOT
  `window.confirm`/`alert`) carrying the client's text verbatim: title
  "Do you want to continue?", body sentence + the two bullet lines, buttons Confirm and
  Cancel. Confirm ⇒ the submit proceeds; Cancel (or closing the modal) ⇒ nothing is
  submitted, the user stays on the screen with their edits intact. The original type id
  reaches the JS via a data attribute; the interception must coexist with jsvalidation
  (an invalid form is still blocked first — measure the ordering, state it in the
  docblock). An unchanged type never shows the modal.
- **G2 — The server is the authority; the modal is only a warning.** On ANY update that
  changes `booking_type_id` — modal-confirmed or hand-rolled — BookingService's update,
  inside its existing transaction and after its locked re-read, deletes every LIVE
  `customer_docs` row of the booking (customer and co-applicants alike) whose type is
  NOT mapped (CR-7 pivot) to the NEW booking type. **Retention = mapped to the new
  type, regardless of `is_active`** — "still required" is the mapping; `is_active` only
  governs NEW uploads (D22/D23 spirit: an existing document of an inactive-but-mapped
  type is retained). Rows soft-delete; bytes go via the established
  `DB::afterCommit` asymmetry (reuse `BookingDocumentService::delete()`'s choreography
  or the method itself). No confirmation token is posted or trusted.
- **G3 — Audit.** The soft-deletes' automatic trail is blind to the server-owned columns
  and tells no story; write ONE explicit `activity()` row on the booking with
  `->event()` set (e.g. `documents_deleted`), listing per deleted document its type
  name, original filename and person — the CR-9 lesson, Booking's own explicit-row
  precedent.
- **G4 — No extra permission.** The deletion is a consequence of an edit the
  `bookings.edit` holder is making; it is NOT gated on `customer_documents.delete`
  (blocking the save or silently skipping the rule would both be wrong). Flagged to the
  client as a note, not asked.
- **G5 — Scope guard.** No schema, route, or permission changes; no new tests, no
  policies; pinned tests minimally updated only if one pins the old behavior. Modal and
  message strings inline in the view (no lang).
- **G6 — Do not touch the client's hand-edited block** at
  `nrems-booking-form.js:612-614`. The modal wiring may live in that file's form half
  (the edit twin has `#nrems_booking_form`, so the block is harmless there) or in a
  small separate module — implementer's choice, stated with reasons.

## §8 CR-15.3 — upload error messages + ajax delete with popup (client, 2026-09-08)

Verbatim: "From Customer Documents Upload New Document error message missing for
document type and File Upload." / "when delete document from Customer Documents show
popup instead of alert for confirmation and confirm then delete the document using ajax
and remove that document type from dropdown options. Please don't redirect on booking
view or edit screen stay same page without reload. don't create new destroy method in
controller use same code just stop redirection and reload. Same this delete popup
implement in documents tab inside booking edit and view screen and don't reload page
once delete just remove that row from table and show success message as a toaster
message getting error then show error toaster." Overrides restated: no tests, no
policies.

- **H1 — The missing messages are a BUG against E4's own acceptance.** The client sees
  no per-row error for Document Type / File Upload on the standalone Upload New
  Document screen. The CR-15.1 probes passed in jsdom, so the failure is
  real-page-specific: REPRODUCE on the actually rendered page first (artisan serve or a
  full-stack render), find the root cause (candidate suspects: `data-strings` keys
  missing from the standalone view, guard not binding on that root, script
  ordering/absence, CSS hiding the message node), fix it, and state the root cause with
  evidence. Acceptance is E4's, on the real page, both screens re-verified.
- **H2 — Delete confirmation becomes the house modal** (x-modal, CR-15.2's precedent —
  never `window.confirm`/`alert`) on BOTH surfaces: the Customer Documents listing's
  per-row delete and the Documents tab inside booking edit AND view. Title verbatim
  from the client (2026-09-08 follow-up): **"Are you sure you want to delete?"**; the
  body names the document (filename in the sentence, as the confirm() text did);
  buttons Confirm/Cancel; Cancel closes with nothing sent.
- **H3 — Same server code, new answer shape.** Confirm sends the DELETE via ajax to the
  EXISTING `bookings.documents.destroy` route. `BookingDocumentController::destroy()`
  gains a `wantsJson()` arm (the `ajax() && wantsJson()` gate convention) answering
  JSON success instead of the redirect; the redirect arm stays for any non-ajax caller.
  NO new route, NO new controller method (client instruction). Failures surface as
  their HTTP status (403/404/419/500) — the client JS turns any non-OK answer into the
  error toaster and removes nothing.
- **H4 — Standalone listing after a confirmed delete** (no reload): the document leaves
  the row's payload; its type disappears from that row's Document Type dropdown; the
  next remaining document becomes selected (cells re-render). A person left with
  nothing shows "No Document" + em-dashes. A BOOKING left with zero live documents
  leaves the listing — remove the row client-side (the listing's own membership rule;
  the next draw is the authority and will agree). Success toaster on success.
- **H5 — Booking tab (edit + view)** after a confirmed delete: the row leaves the table
  without a reload, success toaster; if the tab's table has an empty state, deleting
  the last row shows it, else the header row stands alone honestly.
- **H6 — Toaster**: search the shipped assets for an existing toast mechanism (Metronic
  bundle/toastr) FIRST and reuse it; only if none exists, add one small shared module
  (createElement/textContent, strings from Blade, no new CDN/vendor files). Success and
  error variants, auto-dismissing, non-blocking.
- **H7 — Scope guard**: no schema/route/permission changes, no new tests, no policies;
  strings inline (both modules are no-lang); pinned tests minimally updated only if one
  pins the old redirect.

### 4.7 Gates before handoff

`php artisan test` (full suite green apart from the 4 pre-existing skips), `composer
analyse` (larastan level 6 = 0), pint on the CR-15 footprint, `php artisan migrate` on a
seeded DB, and a manual smoke of: listing render + both filters + search, row dropdown
cascade incl. a no-document co-applicant, download/edit/delete per-permission visibility,
upload happy path (multi-row, co-applicant), D11 both messages, duplicate-type block
(client + hand-rolled POST), already-uploaded tamper POST, edit with and without file
replace, type-collision on edit, cancelled-booking 404s on this module's routes. Use
`C:\wamp64\bin\php\php8.3.28\php.exe` — PATH php is 8.2.29.
