# PRD — Success Story Module

## 1. Document Metadata

| Field | Value |
|---|---|
| **Title** | PRD — Success Story Module |
| **Module** | Success Story (sales share a customer win on a product — legacy `successstory` module; menu root "Success Story") |
| **Status** | **✅ SHIPPED — faithful legacy port, reachable end-to-end.** Committed `1fd6fca` (2026-07-06/07, fast-forwarded to `main`, not pushed to origin). 4 menus, full create/list/detail/comment/attachment flow, cascading lookups, and queued email notifications all live. Emails were deferred at ship-time then ported (GH #104). |
| **Owner** | IT — PT Colorindo Chemtra |
| **Date** | 2026-07-16 |
| **Stack** | Laravel 13.8 / Inertia.js v3 / React 19.2 / Tailwind v4 / MySQL 8.4 |
| **Document Type** | Shipped-state PRD — documents the **actual merged behavior** of the module (backend, DB writes, and UI verified against live schema + a full proto/legacy parity audit). Unlike the LWR scaffold PRD, nothing here is TBD: every requirement below is implemented. Mirrors the structure of `LWR PRD.md` / `VisitPlan PRD.md`. |

### Construction lineage (deliberate)

- **List** and **Create** pages are faithful ports of the NewUI prototype (`Pages/Proto/SuccessStory/{Index,Create}.jsx`), re-wired from demo state to real server data.
- **Detail has NO proto counterpart** (the `Proto/SuccessStory/` folder holds only `Create.jsx` + `Index.jsx`). `Detail.jsx` was built from the legacy PHP screens `listsuccessstorydetails.php` + `listsuccessstoryhistoryview.php`.
- **Backend** ported from the legacy `successstory` pages (`createsuccessstory.php`, `createsuccessstorysalesadmin.php`, `listsuccessstory*.php`).
- **4 menus reuse the pre-existing legacy ROOT group #137** "Success Story" (icon set to `BarChart`); grants additively mirror the legacy `rolemenu` grants #138–#141.

---

## 2. Overview & Purpose

**Success Story** is where sales users publish a **product win at a customer** so the wider team can see and discuss it. A story records the **division, application, principal, original product name, quantity/month, value/month**, an **optional attachment** (own-create only), and a rich-text **Details** narrative. Once created, any authorized user can open the story and add **comments**, which accumulate into a running **Discussion** thread and a **History** log. Creation and each comment fan out **email notifications** to interested users.

It is one of the transactional modules ported from the legacy PHP app to the Laravel/Inertia/React stack. The port is a **high-fidelity reproduction** of the proto UI and the legacy write behavior, with a handful of deliberate hardening deviations (server-side re-validation of what legacy enforced only in the dropdowns, a required comment, queued post-commit emails, and length clamps for strict MySQL).

Two create modes exist, matching two legacy pages:

- **Create Success Story (own)** — the salesperson files their own story; they are recorded as both inputter (`UserIDInput`) and sales (`UserIDSales`). An attachment upload is available.
- **Create Success Story for Others** — a sales-admin files on behalf of a chosen salesperson (adds a **Sales** picker; the creator stays `UserIDInput`, the picked sales becomes `UserIDSales`). The upload block is dropped, faithful to legacy `createsuccessstorysalesadmin.php`.

Two list views, matching two legacy pages:

- **View Story** — the user's own list, scoped to the **group-divisions of the user's divisions** (NOT own-records).
- **View All Story** — an admin/monitoring list, unscoped across all group divisions.

---

## 3. Scope

### 3.1 Shipped

1. **Four gated menus** — Create Success Story, Create Success Story for Others, View Story, View All Story (§4).
2. **Create (own)** — full form with cascading Division → Application/Sales and Principal → Product → Stock-Barang lookups, thousands-formatted numeric inputs, CKEditor Details, optional 2 MB attachment stored as a DB BLOB, inline validation, submit loading state.
3. **Create (for others)** — same form plus a required Sales picker; attachment block dropped.
4. **View Story list** — server-paginated (Inertia partial reloads), 9 filter controls, Stock-Barang side panel, 12 columns, sortable + drag-reorderable + resizable headers, a customize-columns modal with localStorage persistence, empty-state, pagination footer. Scoped to the user's group divisions.
5. **View All Story list** — same page, unscoped, dual-scope from one component.
6. **Detail** — read-only Story Information, gated attachment download (or "No file attachment"), CKEditor comment box + Submit Comment, Discussion log and History rendered as header icon-popovers.
7. **Comment submit** — appends to `successstory.Discussion` + inserts one `successstoryassignment` history row (§6.5, §10).
8. **Attachment download** — gated route streaming the stored BLOB (404 when empty).
9. **Four cascading AJAX lookup endpoints** — applications-by-division, sales-by-division, products-by-principal, stock-lots-by-product.
10. **Email notifications** — two post-commit queued jobs (created broadcast + comment fan-out), each queuing per-recipient Mailables (GH #104, ported after ship).

### 3.2 Not shipped / deliberately dropped

- **"Save as Draft"** — button retained for design parity but **non-functional**; fires an info toast "Save as Draft — belum diaktifkan." Drafts are not part of the legacy module. **GH #105.**
- **Proto "Manage" mode** — never existed on the proto Create page; nothing to reproduce. **GH #105.**
- **`options/lots` endpoint** — wired and functional, but has **no Pest coverage** (the other three lookup endpoints are tested).
- **Prototype routes** `success-story.*` (singular URI stem, `PrototypeController`) still exist but are **NOT part of the shipped module**.

---

## 4. Users & Permissions

All routes sit inside the `auth` middleware group. Authorization funnels through `User::canAccessMenuLink()` — an active `rolemenu` grant for the user's `RoleID` joined to an active, `IsShow=1` `menu` row whose `LinkLaravel` matches. **No admin bypass exists** — Administrator works only via real grant rows.

`SuccessStoryMenuSeeder` attaches **4 visible leaves under the legacy ROOT group #137** "Success Story" (icon set to `BarChart`). Each is granted to Administrator (RoleID 1) and **additively mirrors the ACTIVE legacy `rolemenu` grants** from the corresponding legacy PHP page (soft-deleted legacy grants stay revoked).

| Menu (ShowName) | `LinkLaravel` | SortNo | Record scope | Grants |
|---|---|---|---|---|
| Create Success Story | `/success-stories/create` | 10 | — (creator = self) | Administrator + active grants mirrored from `createsuccessstory.php` (legacy #138) |
| Create Success Story for Others | `/success-stories/create-for-others` | 20 | — (creator ≠ sales) | Administrator + mirrored from `createsuccessstorysalesadmin.php` (legacy #139) |
| View Story | `/success-stories` | 30 | **group-division of the user's divisions** | Administrator + mirrored from `listsuccessstory.php` (legacy #140) |
| View All Story | `/success-stories/all` | 40 | **unscoped** (any group division) | Administrator + mirrored from `listsuccessstoryall.php` (legacy #141) |

**Gating model:**

- Every controller action calls `canAccessMenuLink` with the relevant link (`LINK_VIEW` `/success-stories`, `LINK_VIEW_ALL` `/success-stories/all`, `LINK_CREATE` `/success-stories/create`, `LINK_CREATE_OTHERS` `/success-stories/create-for-others`) and `abort(403)` on failure.
- `store` uses the FormRequest's `hasMenuGrant` scope check (scope derived from route name: `success-stories.store-for-others` → others, else own).
- `show` / `comment` / `attachment` **additionally** enforce record-level group-division scope via `findStoryOrFail()` — own opens only stories inside the user's group divisions (else 403); view-all grant opens any story. Legacy had **no** record-level gate on these seams; the port tightens it.
- Grant isolation is enforced: a view-own grant does not open view-all, and a create grant does not open for-others.

---

## 5. Data Model

Live schema audited via `SHOW CREATE TABLE` against dev DB `colorindochemtrainertia`. `store()` inserts into **both** tables inside one `DB::transaction`. No strict-mode 1364 risk — every NOT NULL column without a DB default is written on insert.

Both models: `$timestamps = false`, `NotDeletedScope` global scope, `softDelete()`/`restore()`. `ID` and `IsDeleted` are excluded from `$fillable`; `IsDeleted` is emitted as `0` by each model's `booted() creating()` hook (`$model->IsDeleted ??= 0`) before the INSERT.

### 5.1 `successstory` — model `App\Models\SuccessStory`

| Column | Type | Null | Default | Written on insert | Source / value |
|---|---|---|---|---|---|
| `ID` | int | NO | AUTO_INCREMENT | no | DB-generated PK |
| `IsDeleted` | int | NO | — | yes (hook) | `0` via `creating()` hook |
| `Tanggal` | datetime | NO | — | yes | `now('Asia/Jakarta')` → `Y-m-d H:i:s` |
| `UserIDInput` | int | YES | NULL | yes | creator (request user) |
| `UserIDSales` | int | YES | NULL | yes | **creator** (own) / **picked sales** (others) |
| `GroupDivisionID` | int | YES | NULL | yes | **derived** from the chosen `division` row (not the form) |
| `DivisionID` | int | YES | NULL | yes | `(int) DivisionID` |
| `ApplicationID` | int | YES | NULL | yes | `(int) ApplicationID` |
| `PrincipalID` | int | YES | NULL | yes | `(int) PrincipalID` |
| `BarangID` | int | YES | NULL | yes | `(int) BarangID` (form field `InsertProduct` → column `BarangID`) |
| `Quantity` | decimal(20,2) | NO | — | yes | comma-stripped, `?? 0` |
| `Value` | decimal(20,2) | NO | — | yes | comma-stripped, `?? 0` |
| `Details` | text | NO | — | yes | CKEditor HTML, CRLF-stripped |
| `Discussion` | text | NO | — | yes | assembled HTML wrapper (§10) |
| `UploadName` | varchar(200) | NO | — | yes | `''` (no file/others) or `SuccessStory_<ID>.<ext>` clamped `mb_substr` 200 |
| `UploadType` | varchar(30) | NO | — | yes | `''` or client MIME clamped `mb_substr` 30 |
| `UploadSize` | int | NO | — | yes | `0` or `(int) $file->getSize()` |
| `UploadContent` | longblob | NO | — | yes | `''` or `file_get_contents()` (BLOB in DB, faithful to legacy) |

`$fillable`: `Tanggal, UserIDInput, UserIDSales, GroupDivisionID, DivisionID, ApplicationID, PrincipalID, BarangID, Quantity, Value, Details, Discussion, UploadName, UploadType, UploadSize, UploadContent`.

### 5.2 `successstoryassignment` — model `App\Models\SuccessStoryAssignment`

One row is written per story create and per comment (the `<entity>assignment` audit pattern).

| Column | Type | Null | Default | Written on insert | Source / value |
|---|---|---|---|---|---|
| `ID` | int | NO | AUTO_INCREMENT | no | DB-generated PK |
| `IsDeleted` | int | NO | — | yes (hook) | `0` via `creating()` hook |
| `SuccessStoryID` | int | YES | NULL | yes | `$story->ID` |
| `Tanggal` | datetime | NO | — | yes | same `$now` as the story/comment |
| `UserID` | int | YES | NULL | yes | **creator** (even on for-others; the assignment logs the actor, not the picked sales) |
| `Remark` | text | NO | — | yes | CRLF-stripped plain comment/details |

`$fillable`: `SuccessStoryID, Tanggal, UserID, Remark`.

The two commented-out legacy inserts (`payroll` / `payrolldetail`) are dead in legacy and correctly not ported.

---

## 6. Functional Requirements

### 6.1 Create Success Story (own) — `GET/POST /success-stories(/create)`

- Renders `MenuSuccessStories/SuccessStories/Create` with scope `own`.
- **Section 1 "Basic Information"** — Division (no asterisk), Application*, Principal* (approver name line appears below once picked, derived from the principal), Original Product Name*; an `hr` divider; Quantity/month (numeric, Package icon, thousands-formatted), Value/month (numeric, `Rp` prefix, thousands-formatted); a **Choose-File** attachment control (`UploadCloud` icon, hidden input, "No file chosen", "Max. 2MB" hint, "Hapus file" clear link once picked).
- **Stock Barang aside** — once a product is picked the grid switches to `[1fr_340px]`; the panel matches Section 1 height, initially displaying top 10 lots with a "View more/less" toggle; expanding activates internal scrolling (`overflow-y-auto`) without changing the card container height.
- **Section 2 "Details"** — a `CkEditorField` for the story body.
- **Cascade gating** — Application disabled until a Division is chosen; Product disabled until a Principal is chosen (placeholders "Pilih division dulu" / "Pilih principal dulu").
- **Footer** — primary "Create Success Story" (`ArrowRight`, `type=submit`, disabled + `Loader2` "Menyimpan…" while processing) + ghost "Cancel" (`window.history.back`, disabled while processing).
- **Submit** — a `<form onSubmit>` POSTs to `success-stories.store` with `preserveScroll`, inline server-side errors beneath each field, and an `onError` toast.
- **Write** — `store()` inserts one `successstory` row (§5.1) + one `successstoryassignment` row (§5.2) in one transaction; `GroupDivisionID` derived from the division; `Discussion` seeded (§10); dispatches `SendSuccessStoryCreatedEmails` post-commit; redirects.
- **"Save as Draft"** — retained, non-functional (info toast). See §3.2.

### 6.2 Create Success Story for Others — `GET/POST /success-stories(/create-for-others → /for-others)`

Identical to §6.1 **except**:

- Breadcrumb tail "Create for Others" + H1 "Create Success Story for Others".
- Adds a required **Sales*** select (disabled until a Division is chosen, options via the Division cascade).
- **Drops the entire attachment upload block** (faithful to `createsuccessstorysalesadmin.php`); the second field row switches from a 3-col to a 2-col responsive grid.
- Posts to `success-stories.store-for-others`. `store()` writes `UserIDSales` = picked sales, `UserIDInput` = creator; the `successstoryassignment.UserID` still logs the creator.

### 6.3 View Story (own list) — `GET /success-stories`

- Renders `MenuSuccessStories/SuccessStories/Index` with scope `own`; H1 "View Story".
- **Scoped** to the group-divisions of the user's divisions (`whereIn` on `s.GroupDivisionID`).
- **Collapsible search card** with 9 controls: Success Story No (+ InfoHint), Early Date + End Date (+ InfoHint each), Group Division, Division, Sales, Application, Principal (+ inline Approval line when picked), Original Product Name. Sales/Application disabled until a Division is chosen; Product until a Principal (Indonesian placeholders).
- **Stock Barang side panel** — lot list, count pill, View more/less, expired-highlight, empty-state.
- **List card** — 12 columns (SS No, Details, Date, Creator, Sales, Group Division, Division, Application, Principal, Product Name, Quantity, Value). Columns are **sortable** (server `orderby` via `SORT_COLUMNS`, 11 targets, `ChevronUp/Down`/`ArrowUpDown`), **drag-reorderable in place**, and **resizable**. A Customize-Columns modal groups/hides/reorders with localStorage persistence. Quantity/Value are thousands-grouped via `formatIdr(…,{withSymbol:false})`.
- **Server pagination** — Inertia partial reloads (`only:['stories','filters']`) with draft-vs-applied filters echoed back by the server; page sizes `[10,20,50,100]`, default 10; empty-state "No success stories match your search."; `ListFooter` page nav.
- Per-row **Details** action is a real Inertia `<Link>` to `success-stories.show`.

### 6.4 View All Story — `GET /success-stories/all`

Same `Index` component, scope `all`, H1 "View All Story". **Unscoped** (any group division; `IsDeleted=0` only). Same filters, columns, and pagination; a view-all grant opens any detail.

### 6.5 Detail + comment + attachment + history — `GET /success-stories/{id}`

- Renders `MenuSuccessStories/SuccessStories/Detail`; record access gated by `findStoryOrFail()` (own = group-division scope; view-all = open).
- **Story Information** (read-only, two columns): SS No, Tanggal, Creator, Sales, Principal, Quantity/month | Group Div Name, Division, Application, Product Name, Value/month.
- **Attachment** — filename + gated Download link (route `success-stories.attachment`), else "No file attachment". `attachment()` streams the `UploadContent` BLOB with `UploadType` (fallback `application/octet-stream`) + `Content-Disposition filename=UploadName`; **404 when `UploadSize=0`**.
- **Comment** — a `CkEditorField` + **Submit Comment** (`useForm().post`, `Loader2` "Mengirim…", `preserveScroll`, `form.reset()` on success, error toast). Comment is **server-side required** (deviation).
- **Discussion** and **History** rendered as header icon-popovers (`MessagesSquare` / `History` with count). History is a list of entries (User + Tanggal + Remark HTML), dropping the legacy explicit ID column.
- **Write on comment** — `comment()` UPDATEs `successstory.Discussion` (re-read inside the transaction — lost-update fix) with the byte-identical legacy HTML wrapper, and INSERTs one `successstoryassignment` row (`Remark` = CRLF-stripped plain comment). Dispatches `SendSuccessStoryCommentEmails` post-commit; redirects back to the detail page.

### 6.6 Cascading lookups (AJAX) — `GET /success-stories/options/*`

- **applications** — active applications whose group matches the chosen division's group.
- **sales** — users with a `userdivision` row for the chosen division.
- **products** — `barang` belonging to the chosen principal.
- **lots** — stock-barang lots for the chosen product (legacy `getstockbarang`; feeds the Stock Barang panel). **No Pest coverage.**

---

## 7. Validation Rules

### 7.1 `StoreSuccessStoryRequest` (serves both create routes; scope from route name)

- **authorize()** — `hasMenuGrant` of the scope link: own → `/success-stories/create`, others → `/success-stories/create-for-others`.
- `DivisionID` — required, integer, `exists('division','ID' where IsDeleted=0)` **AND** `exists('userdivision','DivisionID' where IsDeleted=0 AND UserID=current user)` (division must belong to the logged-in user — legacy only scoped the dropdown, not the POST).
- `ApplicationID` — required, integer, `exists('application','ID' where IsDeleted=0)`.
- `PrincipalID` — required, integer, `exists('principal','ID' where IsDeleted=0)`.
- `BarangID` — required, integer, `exists('barang','ID' where IsDeleted=0)`.
- `Quantity` / `Value` — nullable, numeric, `min:0` (commas stripped in `prepareForValidation`).
- `Details` — nullable, string, `max:60000` (CKEditor HTML → `Details` TEXT + assignment `Remark`).
- `Attachment` — own scope `sometimes` / others scope `prohibited`; nullable, file, `max:2048` (≈2 MB; own-create only).
- `UserIDSales` — others scope `required` / own scope `prohibited`; nullable, integer, `exists('users','ID' where IsDeleted=0)`.
- **after-hook `validateCascadeScopes()`** — chosen Application must share the division's `GroupDivisionID` ("Application tidak tersedia untuk division terpilih."); chosen Barang must belong to the selected Principal ("Product bukan milik principal terpilih.").
- **after-hook `validateSalesInDivision()`** (others only) — selected sales must have a `userdivision` row (IsDeleted=0) for the chosen division ("Sales tidak terdaftar pada division terpilih.").

### 7.2 `StoreSuccessStoryCommentRequest`

- **authorize()** — `canAccessMenuLink` `/success-stories` OR `/success-stories/all` (record-level group-division scope enforced by the controller's `findStoryOrFail()` before any write).
- `Comment` — **required**, string, `max:60000`. **Deliberate deviation** — legacy accepted an empty comment (and appended an empty Discussion block + empty-Remark row); `max:60000` keeps the nl2br-grown Discussion TEXT from overflowing.

> Known cosmetic note: the request docblock claims `findStoryOrFail` "runs before validation" — it actually runs in the controller body *after* validation; only the menu-grant `authorize()` precedes validation. No security/data impact (the record gate still precedes every DB write).

---

## 8. Routes / API

All 13 named routes on `SuccessStoryController`, inside the `auth` group (`routes/web.php` ~597–626). `{successStory}` is `whereNumber`.

| Name | Method | URI | Action | Notes |
|---|---|---|---|---|
| `success-stories.index` | GET | `success-stories` | `index` | own list (group-division scoped) |
| `success-stories.all` | GET | `success-stories/all` | `all` | view-all list (unscoped) |
| `success-stories.create` | GET | `success-stories/create` | `create` | renders Create, scope own |
| `success-stories.create-for-others` | GET | `success-stories/create-for-others` | `createForOthers` | renders Create, scope others |
| `success-stories.store` | POST | `success-stories` | `store` | own create |
| `success-stories.store-for-others` | POST | `success-stories/for-others` | `store` | same method; scope resolved from route name |
| `success-stories.options.applications` | GET | `success-stories/options/applications` | `optionsApplications` | cascade lookup |
| `success-stories.options.sales` | GET | `success-stories/options/sales` | `optionsSales` | cascade lookup |
| `success-stories.options.products` | GET | `success-stories/options/products` | `optionsProducts` | cascade lookup |
| `success-stories.options.lots` | GET | `success-stories/options/lots` | `optionsLots` | legacy `getstockbarang`; **NO test coverage** |
| `success-stories.show` | GET | `success-stories/{successStory}` | `show` | record-scope gated |
| `success-stories.comment` | POST | `success-stories/{successStory}/comments` | `comment` | record-scope gated |
| `success-stories.attachment` | GET | `success-stories/{successStory}/attachment` | `attachment` | gated BLOB stream, 404 when empty |

The four `options/*` endpoints are the **AJAX cascade lookups** consumed by both Create and the list filters (Division → applications/sales, Principal → products, Product → lots). The separate Prototype routes `success-story.*` (singular stem, `PrototypeController`) are NOT part of the shipped module.

---

## 9. UI / UX Requirements

- Pages resolve by path (Inertia v3): controllers render `MenuSuccessStories/SuccessStories/{Create,Index,Detail}`; every page keeps `Component.layout = [AppLayout]`.
- The app serves a prebuilt Vite bundle — `npm run build` is required after JSX changes; the module was built and browser-verified at ship.

### 9.1 Proto parity note

- **Create — parity: near-full.** Every proto section, field, dropdown, badge, the Stock Barang panel + "View more/less" toggle, the two numbered cards, the CkEditor Details block, and the primary/cancel footer are reproduced with identical styling (shared `PRIMARY_BTN`/`GHOST_BTN`/`DRAFT_BTN` classes + shared `FloatingField`). The port replaces the proto's demo state with real cascading server data, a full `<form>` submit path with inline errors, thousands-formatted numeric inputs, a submit loading state, and the second "for-others" mode. The **one genuine visible text change**: the file-upload hint "PDF, JPG, PNG (Max. 10MB)" becomes **"Max. 2MB"** (legacy `MAX_FILE_SIZE` 2 MB; legacy imposes no type restriction).
- **List — parity: near-full.** Faithfully reproduces the proto's collapsible search card, 9 filters, Stock Barang panel, Columns button, all 12 columns, resizable columns, Customize-Columns modal, empty-state, and footer. It is a strict superset: the port **adds** sortable headers, direct drag-reorder of headers, real cascading data, dual own/all scope from one component, and a real Details link; the proto's client-side `STORIES`/`useMemo`/`slice` engine is replaced by Inertia server pagination with draft-vs-applied filter echo-back. Genuine omissions are decorative only: the trailing `*` on Application/Principal/Original Product Name labels (cosmetic — see §14), and the `data-page-search` auto-focus attribute on the SS No field (minor UX — see §14). Localization restyles (header wording per scope, InfoHint copy EN vs ID, column-group labels EN vs ID) and zebra-opacity tweaks are intended.
- **Detail — no proto counterpart.** Built from legacy `listsuccessstorydetails.php` + `listsuccessstoryhistoryview.php`. Carries every legacy read-only field, the attachment filename + gated Download (else "No file attachment"), the CKEditor Comment + Submit Comment, the Discussion log, and the History. Restyled to the design system: Discussion/History moved from inline sections into header icon-popovers; History rendered as a list (drops the ID column); the native `confirm()` replaced by a spinner + toast; a breadcrumb/back link added; the attachment download hardened to a gated route.

### 9.2 Dropped proto extras

- **Save as Draft** — kept for design parity, non-functional (info toast). **GH #105.**
- **Manage mode** — not present on the proto Create page; nothing to reproduce. **GH #105.**
- Proto rich-text toolbar scaffolding (`ToolBtn`/`ToolDivider` + unused icon imports) omitted — the `CkEditorField` supplies its own toolbar (no visible difference).

---

## 10. Business Rules & Constraints

- **BR-1 — List scope.** View Story = the group-divisions of the user's divisions (NOT own-records); View All = unscoped (`IsDeleted=0` only). Same component, scope prop drives both.
- **BR-2 — `GroupDivisionID` derivation.** Always derived from the chosen `division` row (`division.GroupDivisionID`), NEVER taken from the form. Null-division-group → NULL stored.
- **BR-3 — `UserIDSales`.** Own create → creator; for-others → picked sales. The `successstoryassignment.UserID` (both create and comment) always = the **acting creator**, even on for-others.
- **BR-4 — Discussion HTML format.** Assembled byte-for-byte as legacy:
  `'<div><strong>'.Name.' / '.timestamp.' : </strong>'.comment.'</div>' . "\n\n"` — Name from `users.Nama`, timestamp = the shared `$now`, comment = CRLF-stripped Details.
- **BR-5 — Comment append.** `comment()` sets `Discussion = nl2br(existing) . '<div><strong>Nama / ts : </strong>' . "\n" . comment . '</div>' . "\n\n"`, reproducing legacy's repeated-nl2br-over-prior-content quirk exactly; the current Discussion is **re-read inside the transaction** (lost-update fix vs legacy's stale page-load value). The comment inserts one `successstoryassignment` row sharing the same Jakarta timestamp (`Remark` = CRLF-stripped plain comment, not the HTML wrapper).
- **BR-6 — Upload naming & storage.** `SuccessStory_<ID>.<ext>` (uses the actual auto-increment ID; legacy used `max(ID)+1` pre-insert — identical under non-concurrent operation, race-safer here). File bytes stored **in the DB `UploadContent` LONGBLOB**, faithful to legacy (NOT on the `file_uploads` disk). Own-create + file only; no-file/others paths store `''`/`0` explicitly.
- **BR-7 — Legacy filter-shift bug fixed.** Both legacy list views passed 10 args to a 9-param `getAllUser()`, so the Principal value fell into the `DivisionID` clause, Division into `Tanggal>=`, EarlyDate into `Tanggal<=`, EndDate was dropped, and there was **no `s.PrincipalID` clause at all**. The port applies each of SSNo / GroupDivisionID / DivisionID / SalesID / ApplicationID / **PrincipalID** / BarangID / EarlyDate / EndDate to its own column — reproducing the intended behavior and **adding the Principal filter legacy never applied**.
- **BR-8 — Email broadcast.** On **create**, `SendSuccessStoryCreatedEmails` resolves `users.IsSendSuccessStory=1` recipients within the story's group division (Email non-blank, distinct) and queues one `SuccessStoryCreatedMail` each. On **comment**, `SendSuccessStoryCommentEmails` resolves every DISTINCT commenter of the story (join `successstoryassignment`, IsDeleted=0, Email non-blank) and queues one `SuccessStoryCommentedMail` each. Both jobs dispatch **post-commit** with a scalar `storyId` and re-fetch the fresh row.

---

## 11. Non-Functional Requirements

- **Authorization** — every action gated via `canAccessMenuLink` (`abort 403`), no admin bypass; store additionally uses the FormRequest `hasMenuGrant` scope check; show/comment/attachment additionally enforce record-level group-division scope. Sidebar visibility and 403 gates share identical grant conditions.
- **Soft delete** — both tables use `IsDeleted` (0/1) with `NotDeletedScope`; no Laravel `SoftDeletes` trait, no timestamps. Models expose `softDelete()`/`restore()`.
- **Queue** — `QUEUE_CONNECTION=database`; the two fan-out jobs are `ShouldQueue` + `SerializesModels`, each queuing per-recipient Mailables (independent retry). Delivery requires a running `queue:work` worker; `MAIL_MAILER=log` means mail is only **logged** until SMTP is configured. Test env is `QUEUE_CONNECTION=sync` → jobs run inline and `Mail::assertQueued` records them.
  - `SuccessStoryCreatedMail` — from `do-not-reply@colorindochemtra.com`, display "Success Story Notification || Do Not Reply" (no "CC -" prefix), subject `New Success Story created with No.{rawID}`, view `mail.success-story-document` with `showComments=false`.
  - `SuccessStoryCommentedMail` — from same address, display "CC - Visit Plan Notification || Do Not Reply" (a legacy copy-paste quirk, ported verbatim), subject `{latestCommenter.Nama} added comment on Success Story No.{rawID}`, view `mail.success-story-document` with `showComments=true` + latest comment + full history.
- **Storage** — attachment persisted as a DB LOB (own-create only, ≤2 MB), faithful to legacy.
- **Strict-MySQL safety** — all NOT NULL no-default columns are written explicitly (`''`/`0` on the no-file/others path); `UploadType`/`UploadName` length-clamped via `mb_substr` (30 / 200) to survive strict mode.

---

## 12. Acceptance Criteria / Test Coverage

25 Pest scenarios (`SuccessStoryTest`), all green:

1. Every page 403s a role with no grant (index/all/create/create-for-others/store/store-for-others).
2. Grant isolation — view-own does not open view-all; create does not open for-others.
3. Create page provides the user's divisions + active principals (scope own; foreign division excluded).
4. Create-for-others page renders with the others scope.
5. Store writes `successstory` + assignment with the legacy mapping (own: `UserIDSales`=creator, `GroupDivisionID` derived, commas stripped, Discussion seeded, assignment `UserID`=creator/`Remark`=comment).
6. Store accepts an upload and names it `SuccessStory_<ID>.<ext>` (UploadName/Size/Content set).
7. Store rejects missing/invalid inputs (required FKs, foreign division not owned, dead FK, negatives, cross-principal barang, cross-division-group application).
8. Store truncates an over-long client MIME to the `UploadType` width (varchar(30) `mb_substr`).
9. List date filters ignore well-formed-but-invalid calendar dates (`2026-99-99` / `2026-02-30` dropped, not 500).
10. Store-for-others writes the picked sales as `UserIDSales`, creator as `UserIDInput` (assignment logs the creator).
11. Store-for-others requires a sales pick (UserIDSales error).
12. Own list scoped to the group divisions of the user's divisions (inside/outside).
13. View-all list shows any group division + supports filters (GroupDivisionID + EarlyDate/EndDate range).
14. SS No filter matches ids containing the digits (LIKE on SSNo).
15. Detail shows story/discussion/history; own scope enforces group division (outside → 403).
16. View-all grant opens any detail.
17. Submit comment appends to the discussion and logs an assignment row (nl2br prior text + new block).
18. Attachment downloads the stored blob (`application/pdf`) and 404s when empty.
19. Lookup endpoints cascade: applications + sales by division, products by principal (stray app/barang excluded).
20. Renders the faithful created mailable (subject/from + Details + Discussion, no comment sections).
21. Renders the faithful commented mailable (subject names the latest commenter + History section, raw Remark HTML).
22. Create broadcasts to `IsSendSuccessStory` users in the story group division (`Mail::assertQueued SuccessStoryCreatedMail`).
23. Submit comment emails every distinct commenter (3 distinct recipients: 2 prior + acting user).
24. Create dispatches the broadcast to a background job (`Queue::assertPushed SendSuccessStoryCreatedEmails`).
25. Submit comment dispatches the comment fan-out to a background job (`Queue::assertPushed SendSuccessStoryCommentEmails`).

> Untested surface: `options/lots` (`optionsLots`) is wired and functional but has no Pest scenario.

---

## 13. Edge Cases & Known Constraints

- **`UploadType` varchar(30) truncation** — over-long client MIMEs are `mb_substr`-clamped to 30 chars (strict-mode safe); legacy relied on non-strict MySQL silently truncating. `UploadName` similarly clamped to 200.
- **Extension-less upload** — legacy stored a trailing dot (`SuccessStory_5.`); the port omits it (`SuccessStory_5`) via an `ext !== ''` guard. Cosmetic, affects only extension-less files.
- **Strict-MySQL NOT NULL fills** — every NOT NULL no-default column (`Tanggal`, `Quantity`/`Value` → `?? 0`, `Details`/`Discussion`, the four `Upload*` → `''`/`0`, `IsDeleted` via hook; assignment `Tanggal`/`Remark`/`IsDeleted`) is written on insert; the no-file/others path stores the same `''`/`0` legacy stored under non-strict MySQL — no NULL-vs-empty divergence.
- **Filter echo-back** — the list uses draft-vs-applied filter state echoed back by the server via Inertia partial reloads (`only:['stories','filters']`); a fresh visit resets to defaults.
- **Sort as key + tiebreaker** — sort travels as `sort=<key>&dir=<asc|desc>` (not the legacy qualified column), and the port adds a secondary `ORDER BY s.ID desc` tiebreaker legacy lacked (stable ordering under non-unique sort keys). Legacy deep-link sort URLs do not carry over; the 11 sort targets themselves all match.
- **Per-page (npp) session persistence dropped** — legacy stored `$_SESSION['npp']` and reused it across visits; the port reads `per_page` from the query (allowlist `[10,20,50,100]`, default 10, preserved only via `withQueryString`) so a fresh visit resets to 10.
- **Invalid calendar dates** — well-formed-but-invalid date filters (`2026-99-99`, `2026-02-30`) are dropped, not 500.
- **Detail raw HTML** — `story.Discussion` and history `Remark` are injected via `dangerouslySetInnerHTML` **without** `DOMPurify.sanitize()`, which technically violates CLAUDE.md ABSOLUTE rule #7. Faithful to legacy (`nl2br` raw output), trusted internal CKEditor HTML on an internal-only app, and already catalogued as a known unsanitized call-site. Verified **minor** (not a parity regression); a real hardening item (§14).
- **Post-comment redirect** — redirects to the detail page (legacy redirected to the scoped list). UX-only.

---

## 14. Roadmap / Follow-ups

- **GH #104 — Emails.** Deferred at ship, then **ported** (both fan-out jobs + Mailables + blade + tests). ✅ Done. Outstanding sub-item: **server-side sanitize** the raw-HTML `Discussion`/`Remark` fields in the email blade before SMTP goes live (the merged DOMPurify convention is browser-only).
- **GH #105 — Dropped proto extras.** "Save as Draft" (retained, non-functional) and the proto "Manage" mode remain out of scope; tracked for a future decision.
- **Confirmed follow-up gaps (from the parity audit):**
  1. **Detail DOMPurify** — wrap `story.Discussion` (Detail.jsx) and history `Remark` in `DOMPurify.sanitize()` opportunistically (CLAUDE.md rule #7 hardening). Severity minor.
  2. **List label asterisks** — the trailing `*` on the Application / Principal / Original Product Name filter labels was dropped vs the proto. Cosmetic (they decorate non-required list filters).
  3. **`data-page-search` attribute** — omitted from the SS No filter input, so the page-level auto-focus shortcut no longer lands on it. Minor UX regression vs the proto.
  4. **`options/lots` test coverage** — add a Pest scenario for the stock-lot lookup endpoint (the other three lookups are covered).