# Proposed action items — insight → approval → task

> Part of [Channel Insights](readMe.md). Shipped 2026-09-19.

## What it does

Every one of the nine lead readings (WhatsApp, Zoom meetings, Zoom webinars, Phone calls, AI Caller, Showroom,
Sales, Property Portal, Overall) now ends in **up to 3 proposed action items**. Each one arrives with the step
itself (a verb-first line plus 1–4 points), a **topic**, **how** it is done, **who** should do it, a **priority**
and a **due date**. They are **proposals, not tasks**: nobody is assigned, alerted or counted until a colleague
**approves** one on the reading's panel. Only then does it become a real item on the lead's **Action Items** tab
(`?tab=actions`) — assigned, alerted through Notify, badged `AI · {channel} insight`.

Founder, 2026-09-19: *"each channel when they generate the insights, need to have action item (pls improve the
prompt) … with details like date, who to do, priority, due date etc. then all these would capture in both action
item list and insight tab. then when ai generate, it require human to approve. after human approve, then only show
up. then can manual generate too the action item."*

- **Insight tab** (every channel's Insights panel, and Intelligence → Insight): a *Proposed action items* card,
  right after *Do now*. Each pending proposal has Owner / Priority / Due pickers pre-filled with the AI's answer,
  **Edit text**, **Approve**, **Reject** (optional reason). *Recently decided* keeps the last 10 approvals and
  rejections, each approved one linking to the task.
- **Action item list**: an approved proposal is an ordinary `lead_action_items` row, so it shows wherever items do.
- **Manual**: *Add one yourself* opens the lead's Action Items tab, where the composer (and its ✨ Improve with AI)
  lives. There is no second composer here.

## How it works

1. **The prompts ask for it.** All nine main prompts (`resources/prompts/*_insights.md`) carry the same
   *Action items* section: at most 3; every promise WE still owe plus the step that moves the person forward most;
   never a step already open on the lead, never one a colleague rejected (unless the source changed); topic and
   type from `LeadActionItem`'s own codes; priority and due-date rules copied from `lead_action_item_draft.md` (the
   composer's AI), so both AI doors decide the same way. `owner` is the staff name **as the source spells it**, or
   null. The `_part` prompts are unchanged — notes already carry the promises.
2. **The user turn tells it what exists.** `ChannelInsightsAnalyzer::userMessage()` adds `TODAY: YYYY-MM-DD
   (weekday)` (due dates are worked out from it), then `OPEN TASKS ALREADY ON THIS LEAD` (open items, first line +
   due, max 15) and `STEPS A COLLEAGUE REJECTED` (last 60 days, with the reason, max 10) — `taskBlock()`, JSON-encoded
   with `JSON_HEX_TAG` like the thread facts. Without it every re-analysis re-proposes the task somebody already owns.
   The fingerprint does not include these lines, so a new task does not by itself make a reading stale.
3. **One clamp for all nine schemas.** `analyze()` runs the reading's own `normalize()` and then sets
   `insights.action_items = InsightActionItems::normalize($decoded['action_items'])` — so no schema class had to
   learn it. Unknown codes → null topic / `other` type / `medium`; a past date → **today** (an overdue promise is
   due now); no readable date → the priority's default (high 1 day, medium 3, low 7); a Sunday → Monday; cap 3;
   `record_id` (Sales / Portal) accepted as the citation.
4. **Saving a reading files its proposals.** `LeadChannelInsightRepository::saveForLead()` — the one writer every
   channel controller already calls — invokes `LeadActionProposalRepository::syncFromInsight()` **inside the same
   transaction**: that channel's still-PENDING proposals become SUPERSEDED, the new ones are inserted PENDING.
   Approved / rejected rows are never touched. A reused reading (same fingerprint) is not saved, so nothing churns.
5. **Who.** `owner` is matched to a manage-role user by exact, case-blind full name — only when exactly one matches
   (a near miss assigns nobody rather than the wrong person). Otherwise the lead's `assigned_admin_id`, if that
   person still holds a manage role. The raw name stays in `owner_hint`, and the card says when it matched nobody.
   Approving with no owner assigns **the approver**: an approved task nobody owns is the dropped ball this exists to
   prevent (and it keeps peta-bf's "only an assignee may close an insight item" rule reachable).
6. **Approve.** `LeadActionProposalRepository::approve()` locks the row, applies the reviewer's last edits, and calls
   the SAME `LeadActionItemRepository::create()` the composer uses, with `source_channel` (the readable key —
   `whatsapp`, `zoom-meetings`, `calls`, `ai-caller`, `f2f`, `zoom-webinars`, `sales`, `portal`, `overall`; the
   item badge prints it as-is) and `source_proposal_id`. `priority_source` / `scheduled_source` are `HUMAN` only when
   the reviewer **changed** the AI's value — approving untouched is not a person choosing it. The controller then
   alerts the assignee via `ActionItemAssignees::notify()` (never the approver themself). A second click → 409.
7. **Reject** records `reject_reason`, which the next reading of ANY channel for this lead is shown.

**Who may see / decide.** `permission:viewLeadsAny` + `LeadVisibility::allows()` + the channel's own read
permission (`LeadChannelInsight::READ_PERMISSIONS` — the map the Overall controller now reads too): a proposal is
that channel's words. Overall proposals follow the Overall panel's rule — withheld whole when any channel with a
stored reading is one this viewer cannot open.

**Existing readings have no proposals.** Nothing is backfilled: a reading made before 2026-09-19 has no
`action_items`, and the card says *Nothing waiting for approval* until someone presses Analyse again (which is a
paid call — deliberately left to a person).

## Reference usage

```php
// Nothing to call: saving a reading files its proposals.
app(LeadChannelInsightRepository::class)->saveForLead($lead, LeadChannelInsight::CHANNEL_WHATSAPP, [
    'lead_channel_insight' => [/* … 'ai_insights' => $result['insights'] (from ChannelInsightsAnalyzer) */],
]);

// A NEW reading (a tenth channel): route it through ChannelInsightsAnalyzer and saveForLead(), add its id to
// LeadActionProposal::CHANNEL_KEYS (+ READ_PERMISSIONS if its words need a permission), paste the Action items
// section into its prompt, and mount the card on its panel:
// <InsightActionProposals :lead-uuid="leadUuid" channel="my-channel" :refresh-key="state?.generated_at || null" />
```

## Related files

- [database/migrations/2026_09_19_220000_create_lead_action_proposals_table.php](/database/migrations/2026_09_19_220000_create_lead_action_proposals_table.php) — `lead_action_proposals`; why it is its own table (no list of real work has to remember to exclude AI guesses).
- [src/Lead/LeadActionProposal.php](/src/Lead/LeadActionProposal.php) — `STATUS_*` (pending / approved / rejected / superseded), `CHANNEL_KEYS`, `toPanelArray()`.
- [src/Lead/Repositories/LeadActionProposalRepository.php](/src/Lead/Repositories/LeadActionProposalRepository.php) — `syncFromInsight()`, `update()`, `approve()`, `reject()`, owner matching.
- [src/Conversation/InsightActionItems.php](/src/Conversation/InsightActionItems.php) — the clamp.
- [src/Conversation/ChannelInsightsAnalyzer.php](/src/Conversation/ChannelInsightsAnalyzer.php) — applies the clamp; `TODAY` + `taskBlock()` in the user turn.
- [src/Lead/Repositories/LeadChannelInsightRepository.php](/src/Lead/Repositories/LeadChannelInsightRepository.php) — calls `syncFromInsight()` in the reading's transaction.
- [app/Http/Controllers/Manage/Leads/LeadActionProposalsController.php](/app/Http/Controllers/Manage/Leads/LeadActionProposalsController.php) + [app/Http/Requests/Manage/Leads/ActionProposals/](/app/Http/Requests/Manage/Leads/ActionProposals/) — `GET|PUT|POST manage/leads/{id}/action-proposals[/{proposal}[/approve|/reject]]` (`manage.leads.action-proposals.*`), JSON.
- [resources/js/Pages/Manage/Leads/Partials/Tabs/Channel/InsightActionProposals.vue](/resources/js/Pages/Manage/Leads/Partials/Tabs/Channel/InsightActionProposals.vue) — the card; mounted in `ChannelInsightsPanel` (5 channels, key from the endpoint), `WebinarInsightsPanel`, `OverallInsightsPanel`, `SalesInsightsTab`, `PortalInsightsTab`.
- [src/Lead/Services/ActionItemAssignees.php](/src/Lead/Services/ActionItemAssignees.php) — shared uuid → id resolution and the assignee alert (peta-bf, a23342e6f).
- Tests: [tests/Feature/Leads/InsightActionProposalsTest.php](/tests/Feature/Leads/InsightActionProposalsTest.php) (clamp, supersede, owner match, approve/reject, AI-vs-human source, the task block) and [InsightActionProposals.test.js](/resources/js/Pages/Manage/Leads/Partials/Tabs/Channel/InsightActionProposals.test.js) (only changed fields are sent). The five panel tests stub the card.
