toga-ai 1.0.896 → 1.0.897

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,7 +6,7 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-16
9
+ updated: 2026-09-25
10
10
  owners: ["dfranks", "jcardinal", "bala", "kyalamarthi", "ajean"]
11
11
  files:
12
12
  - _underscore/Component/Api/Netsuite/Netsuite.php
@@ -73,6 +73,10 @@ Shared 2.0 `_underscore` NetSuite REST client (`_Component_Api_Netsuite`) — re
73
73
 
74
74
  Account 1095849 is **shared by every client**, so an external id must be globally unique and prefixed — never a per-client record number. Worked example: [Toga → NetSuite sales-order push](../../worker2/features/netsuite-salesorder-outbound-push.md) (`toga-so-<salesOrder uuid>`).
75
75
 
76
+ ### RESTlets — `sendRestlet(scriptId, deployId, payload)` (added 2026-09-24)
77
+
78
+ Additive. Uses a SEPARATE OAuth2 token with scope `restlets` (widening the shared scope could break every NS call if the integration disallows it); `generateClientAssertion()` takes optional `$scope` (default unchanged). Host = `.suitetalk.api.` swapped to `.restlets.api.`, or `[netsuite] restletHost`. Logging off, TLS verify on. First user: [opportunity documents sync](../../worker2/features/netsuite-opportunity-documents-sync.md).
79
+
76
80
  ### SuiteQL escaping and filtering
77
81
 
78
82
  **String-literal escaping — double the quote, never backslash.** SuiteQL is **ANSI SQL sent to NetSuite as the JSON `q` string** — it is **not** executed against MySQL. It escapes a single quote by **doubling it** (`''`), not with a backslash. So `_Database::escape()` (mysqli `real_escape_string`, backslash-style) is the **wrong tool** and would corrupt values like `O'Brien`. Use `str_replace("'", "''", $value)`. (worker2's `SalesOrder.php` carries this as a private `escapeSuiteQl()` helper; any new SuiteQL caller must do the same.)
@@ -112,8 +116,13 @@ Account 1095849 is **shared by every client**, so an external id must be globall
112
116
  - **Don't escape SuiteQL with `_Database::escape()`** — backslash escaping corrupts `O'Brien`-style values. SuiteQL doubles the quote (`''`).
113
117
  - **204-with-empty-body is success, not failure.** Both create (204 + `Location`) and update (204, no body) return no payload; treat a 2xx with empty body as success and key off the status, not the body.
114
118
 
119
+ - **`_ApiRequest` core default does NOT verify TLS peers** (found 2026-09-24). `sendRestlet` turns it on; the core default needs its own ticket.
120
+
115
121
  ## Related
116
122
 
117
123
  - [NetSuite → Forecast Open-Orders Sync](../../worker2/features/netsuite-salesorder-open-orders-sync.md) — first caller of `createRecord()` (the SalesOrder outbound push).
118
124
  - [NetSuite → TOGA Opportunity Sync](../../worker2/features/netsuite-opportunity-sync.md) — the two-copies gotcha and the `Logs.Api` logging note.
119
125
  - [_underscore Framework Architecture](../architecture.md)
126
+
127
+ ## Change history
128
+ - 2026-09-25 — Added sendRestlet() with separate restlets-scope token (jcardinal)
@@ -31,11 +31,12 @@
31
31
  | [Etilize Catalog Item Import & Refresh](features/etilize-catalog-item-import.md) | Client-generic catalog onboarding: ingest an item catalog from an S3 CSV and enrich each item from the Etilize datasheet, writing directly to `DB_CLIENT`; open |
32
32
  | [Etilize Item Translation Import](features/etilize-item-translation-import.md) | Imports non-English item + feature text from Etilize into the client's `ItemTranslations` (and feature sidecar tables), writing directly to `DB_CLIENT`; open wh |
33
33
  | [Monitoring Framework (Orchestrator + Child Monitors)](features/monitoring-framework.md) | DB-driven monitoring framework: one orchestrator applies anti-flap + notification rules to any registered "is X healthy?" child check; open when adding a monito |
34
- | [Bidirectional NetSuite ↔ Forecast ↔ ClickUp opportunity sync — AGREED DESIGN, not built](features/netsuite-clickup-opportunity-bidirectional-design.md) | Agreed-but-unbuilt design for true bidirectional NetSuite↔Forecast↔ClickUp opportunity sync; open before building anything to replace today's create-only sync s |
34
+ | [Bidirectional NetSuite ↔ Forecast ↔ ClickUp opportunity stage sync — BUILT (stage map, echo guard, claim split)](features/netsuite-clickup-opportunity-bidirectional-design.md) | Two-way NetSuite↔ClickUp opportunity STAGE sync (shared `_Component_Opportunity_StageMap`, actor echo guard, claim split, stale-claim takeover); open before tou |
35
35
  | [NetSuite Integrations Monitor (Monitor/Operations/NetsuiteIntegrations)](features/netsuite-integrations-monitor.md) | Cross-client health check that flags stuck NetSuite↔2.0 integrations; open when a NetSuite integration hangs or when building an all-client OneUptime sweep. |
36
36
  | [NetSuite item → client routing (there is NO client on the NetSuite item record)](features/netsuite-item-client-routing.md) | Evidence that no NetSuite field can route an item to a client; open before designing NetSuite item webhook phase 2 (TRUE-80553) so the "route by a NetSuite fiel |
37
37
  | [ClickUp Opportunity Client vs End-Customer Labels (Stakeholders / End Customer)](features/netsuite-opportunity-client-labels.md) | The NetSuite→ClickUp opportunity task's two customer labels (Stakeholders=client, End Customer=`custbody_end_customer`), their backfills/digest and the fuzzy-ma |
38
- | [NetSuite ↔ ClickUp / TOGA Opportunity Sync (API Message Queue + worker2 webhook)](features/netsuite-opportunity-sync.md) | Outbound NetSuite→Forecast→ClickUp opportunity sync (API Message Queue + worker2 webhook), its create-only ClickUp task claim, triage, and the CU→NS reverse leg |
38
+ | [NetSuite ↔ ClickUp Opportunity Notes & Files Sync (_Worker_Netsuite_OpportunityDocuments + RESTlet)](features/netsuite-opportunity-documents-sync.md) | Polling job syncing NS opportunity User Notes→ClickUp comments and files both ways via a custom RESTlet and the Forecast.OpportunityDocuments ledger; open when |
39
+ | [NetSuite ↔ ClickUp / TOGA Opportunity Sync (API Message Queue + worker2 webhook)](features/netsuite-opportunity-sync.md) | Outbound NetSuite→Forecast→ClickUp opportunity sync (API Message Queue + worker2 webhook), ClickUp task creation claim, triage, and the CU→NS reverse leg; open |
39
40
  | [NetSuite → Forecast Open-Orders Sync (salesOrder webhook → OpenOrderItems)](features/netsuite-salesorder-open-orders-sync.md) | Webhook-driven NetSuite `salesOrder` → `Forecast.OpenOrderItems` sync (inbound half of `SalesOrder.php`); open when touching open-orders import, its locationId/ |
40
41
  | [Toga → NetSuite Sales-Order Push (multi-client, mapping-driven worker)](features/netsuite-salesorder-outbound-push.md) | Outbound half of `SalesOrder.php` (below the `OUTBOUND PUSH (REST)` banner): pushes a Toga sales order into NetSuite over REST; open when creating/updating a NS |
41
42
  | [NetSuite Supporting-Record Backfill Worker (bulk reconciliation recipe)](features/netsuite-supporting-record-backfill-worker.md) | Bulk reconciliation recipe for re-deriving one Forecast column for every row from NetSuite in batches; open when a webhook column needs history fixed or a mass |
@@ -6,7 +6,7 @@ project: Worker
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-16
9
+ updated: 2026-09-25
10
10
  owners: [jcardinal, ajean]
11
11
  files:
12
12
  - worker2/Worker/Clickup/Project.php
@@ -130,6 +130,8 @@ Read-only / setup actions, invoked via `curl -X POST https://worker.togahub.com/
130
130
  - **⚠ `CLICKUP_SERVICE_ACCOUNT_USER_ID` is stale, and that is a CORRECTNESS bug — not housekeeping.** `Project.php:245` still holds `'12633140'`, **a real person's ClickUp user id**. The dedicated service account now exists: `devteam@togatech.com` = user id **87469444** (username "Development Team"), verified via `GET /user`, with read access confirmed to space `90113928591`, folder `90117060687` and the Presales Qualification list. Because `getServiceAccountUserId()` resolves the id dynamically and only *falls back* to the constant, rotation will not break outright — **but leaving the old id makes that person's genuine human edits match the service-account branch and be silently ignored by `enforceStatusOwnership`.** Update the constant as part of the rotation.
131
131
  - **PR #137 also carries a ClickUp rate limiter that is NOT part of this feature — and it was rejected in review.** The open branch `fix/clickup-project-hub-routing` bundles a 533-line `Worker/Clickup/Api.php` limiter (plus dbchanges2 #449) whose premise, coverage, and self-test are all wrong: api2 and tools do **not** call ClickUp (verified by grep), ~24 of ~116 call sites stay unmetered, and its `TestNoDirectApiCalls()` guard cannot see the 18 hand-built `api.clickup.com` sites. Treat rate limiting as a separate subject with its own design — [Outbound third-party API rate budget](../../_underscore/features/outbound-api-rate-budget.md).
132
132
 
133
+ - **Opportunity CU→NS dispatch in `Worker/Clickup.php` is gated by folder `90117060687` and does NOT return early** (since 2026-09-24) — these routing handlers still run for opportunity tasks. Our own writes are dropped upstream by the actor echo guard ([stage sync](./netsuite-clickup-opportunity-bidirectional-design.md)).
134
+
133
135
  ## Related
134
136
 
135
137
  - [Worker (worker2) Architecture](../architecture.md) — always-HTTP-200, commit-before-SQS.
@@ -139,3 +141,6 @@ Read-only / setup actions, invoked via `curl -X POST https://worker.togahub.com/
139
141
  - [ClickUp credential rotation](../workflows/clickup-credential-rotation.md)
140
142
  - [ClickUp Subtask Activity](./clickup-subtask-activity.md)
141
143
  - [Outbound third-party API rate budget](../../_underscore/features/outbound-api-rate-budget.md)
144
+
145
+ ## Change history
146
+ - 2026-09-25 — Noted folder-gated opportunity dispatch, no early return (jcardinal)
@@ -1,101 +1,94 @@
1
1
  ---
2
- title: Bidirectional NetSuite ↔ Forecast ↔ ClickUp opportunity sync — AGREED DESIGN, not built
2
+ title: Bidirectional NetSuite ↔ Forecast ↔ ClickUp opportunity stage sync — BUILT (stage map, echo guard, claim split)
3
3
  framework: "2.0"
4
4
  repo: worker2
5
5
  project: Worker
6
6
  client: shared
7
7
  type: feature
8
- status: draft
9
- updated: 2026-09-16
10
- owners: ["ajean"]
8
+ status: active
9
+ updated: 2026-09-25
10
+ owners: ["ajean", "jcardinal"]
11
11
  files:
12
+ - worker2/Component/Opportunity/StageMap/StageMap.php
13
+ - worker2/Controller/OpportunityStageMapTest.php
12
14
  - worker2/Worker/Netsuite/Opportunity.php
13
15
  - worker2/Worker/Clickup/Opportunity.php
16
+ - worker2/Worker/Clickup.php
14
17
  - worker2/Worker/Clickup/Project.php
18
+ - dbchanges2/Forecast/2026-09-24a - Add dtClickupClaimed to Opportunities.sql
15
19
  - worker/crons/toga2/forecast2/periodic_forecast_discrepancy_fix_opportunities.php
16
20
  related:
17
21
  - ./netsuite-opportunity-sync.md
22
+ - ./netsuite-opportunity-documents-sync.md
18
23
  - ./clickup-project-routing.md
19
24
  - ../workflows/clickup-credential-rotation.md
20
25
  - ../../../../1.0/apps/worker/features/forecast2-netsuite-reconciliation.md
21
26
  ---
22
27
 
23
- Agreed-but-unbuilt design for true bidirectional NetSuite↔Forecast↔ClickUp opportunity sync; open before building anything to replace today's create-only sync so the agreed decisions are not re-litigated.
28
+ Two-way NetSuite↔ClickUp opportunity STAGE sync (shared `_Component_Opportunity_StageMap`, actor echo guard, claim split, stale-claim takeover); open before touching any opportunity stage/close/reopen logic or the stage map rules.
24
29
 
25
30
  ## Summary
26
31
 
27
- **Status: designed and agreed, NOT built. No code from this design has shipped.** Recorded because the decisions cost the most conversation of any part of the work and are the least recoverable from reading code — the code does not exist yet. Provenance: decided in a 2026-09-04 planning session with `ajean`.
32
+ Built 2026-09-24 (`jcardinal`, design by `ajean`). Code on worker2 `_production`; flags `SYNC_STAGE_TO_CLICKUP` (NS→CU) and `SYNC_CLOSE_TO_NETSUITE` (CU→NS close/reopen) now `true`. `UPDATE_CLICKUP` (full-task NS→CU rewrite) stays `false`. Live verification still pending — see Handoff. Notes/files are a separate job: [opportunity documents sync](./netsuite-opportunity-documents-sync.md).
28
33
 
29
- Goal: a genuinely bidirectional opportunity sync between NetSuite, `Forecast.Opportunities`, and the ClickUp Opportunities folder. Today's deployed behaviour is **create-only**: the `clickupTaskId` atomic claim doubles as "should we do anything at all", so NetSuite stage changes never reach ClickUp after task creation, and `UPDATE_CLICKUP` is dead behind the claim. See [NetSuite ↔ ClickUp Opportunity Sync](./netsuite-opportunity-sync.md) for what actually runs.
34
+ ## How it works
30
35
 
31
- ## How it works (the agreed design)
36
+ ### Per-field ownership
37
+ NetSuite owns: Opportunity Stage dropdown, close state. ClickUp owns: description (Fluffer output — never diffed from NS), assignees, presales fields. NS→CU writes ONLY the Opportunity Stage dropdown (`d8fd5819-d8f7-494b-8671-aff7fbdeb985`); the ClickUp Automation moves the task list from it ([routing](./clickup-project-routing.md)).
32
38
 
33
- ### 1. Per-field ownership, not a global on/off switch
39
+ ### Stage map — `_Component_Opportunity_StageMap` (PHP, one file, both directions)
40
+ Replaced `SALES_STAGE_MAP` / `STAGE_OPTION_ORDERINDEX` constants (deleted). Rules from Aaron Jean + Eric, 2026-09-24:
34
41
 
35
- The current `UPDATE_CLICKUP` boolean is the wrong shape — it can only choose "NetSuite clobbers ClickUp" or "NetSuite never updates ClickUp". Replace with **per-field ownership**:
42
+ 1. **Likely to Close (`entityStatus`) always wins.** `124` Closed-Won and `130` Alternate Quote → Opportunity Won; `14` → Closed Lost.
43
+ 2. NS Sales Stage (`custbody_sales_stage` id) ↔ ClickUp:
44
+ - `1` Stage0, `2` Stage1 → leave alone.
45
+ - `3` Stage2 ↔ Pre-Sales Qualification.
46
+ - `4` Stage3 → Quote (unless task is already BU Discovery or Quote).
47
+ - `5` Stage4 ↔ Pending Client Response.
48
+ - `6` Stage5 and `9` Stage6 → Contract Negotiations; CU→NS writes `6` unless NS is already `6`/`9`.
49
+ - `11` Stage7 Waiting for PO ↔ SOW/Contract Signed.
50
+ - `7`/`8` retired.
51
+ 3. **BU Discovery** counts as matching NS `3` and `4`, so PQ→BU→Quote writes nothing. Moving BACK into BU from a later stage writes Stage3 (id `4`) — **interpretation, confirm with Aaron/Eric.**
52
+ 4. **CU close** writes `entityStatus` `124`/`14` (no-op if already `124`/`130`/`14`); stage untouched.
53
+ 5. **CU reopen** writes `entityStatus` `125` (10%) + mapped stage, and posts a ClickUp comment + NetSuite User Note.
54
+ 6. Unknown/future CU stages: never overwritten by open NS stages, never write NS.
36
55
 
37
- | Owner | Fields |
38
- |---|---|
39
- | **NetSuite** | Opportunity Stage dropdown, closing task status, amount, close date |
40
- | **ClickUp** | description, assignees, presales fields |
56
+ Each entry = target value + "already matching" set (the consistency short-circuit: if the other side already matches, write nothing). `Controller/OpportunityStageMapTest.php` walks 1413 round trips and asserts no loops — rerun it after any map edit.
41
57
 
42
- **The description is the Fluffer's output, so NetSuite must not diff it.** That is the concrete reason a global switch fails: one field (stage) needs NS→CU writes while another (description) must never receive them.
58
+ Option orderindex is resolved LIVE from the field's `type_config.options` (fixes the old orderindex-`0` `array_search ?: null` bug; Pre-Sales Qualification now maps back).
43
59
 
44
- ### 2. Actor identity is the primary echo guard; value diff is the backstop
60
+ **Decision (CTO-reviewed): the map stays PHP**, not Forecast lookup tables — rows can't hold the "unless" rules, and PHP avoids a cross-repo deploy. Supersedes the earlier "move to lookup tables" plan.
45
61
 
46
- Today echo prevention is a value difference-check alone. Not sufficient:
47
- - A value guard **cannot distinguish "we wrote it" from "a human wrote the same value."**
48
- - It **cannot see the loop through `_Worker_Clickup_Project::handleOpportunityUpdate`**, which reacts to our own writes.
62
+ ### CU→NS (`Worker/Clickup/Opportunity.php`)
63
+ - Routed from `Worker/Clickup.php` by Opportunities **folder** `90117060687` (all 8 stage lists), no early return — hub routing handlers still run.
64
+ - **Actor echo guard:** drop an event when every history item's actor is our config-token or component-token user (resolved via `GET /user`). Never drop actor `-1` (ClickUp Automation — a human list-move arrives as `taskUpdated` on the dropdown with actor `-1`). Value diff kept as backstop.
65
+ - **Link check (security):** before any PATCH, require `Forecast.Opportunities.clickupTaskId === taskId`. `Opportunity #` is free text; anyone could point a task at another opp.
66
+ - `logged()` commits `DB_LOGS`.
49
67
 
50
- So the guard becomes **actor identity** — drop events authored by our own integration user — with the value diff kept as a backstop rather than the mechanism. Requires the service-account id to be correct; the stale `CLICKUP_SERVICE_ACCOUNT_USER_ID` is a prerequisite fix, not optional cleanup (see [clickup-project-routing](./clickup-project-routing.md) and [credential rotation](../workflows/clickup-credential-rotation.md)).
68
+ ### NS→CU (`Worker/Netsuite/Opportunity.php`) — claim split
69
+ - The `clickupTaskId` claim now guards **creation only**. Linked opportunity → `syncStageToClickup()`: fresh task GET, write dropdown only if the map says so; ClickUp 404 = skip.
70
+ - **Stale PENDING takeover:** `PENDING` older than 30 min (or NULL `dtClickupClaimed`) can be re-claimed. `dtClickupClaimed` is raw-SQL only — deliberately NOT on either ORM model (1.0 or 2.0); code checks `SHOW COLUMNS` first. `endClaim()` / `loadClaimTimestamp()` are fenced on the claim timestamp so a slow job can't overwrite a newer owner.
51
71
 
52
- ### 3. Consistency short-circuit — the answer to the many-to-one map
53
-
54
- **If the current value on a side already maps to the incoming value, write nothing.** This resolves the stage map's many-to-one ambiguity **in both directions**, replacing the previous position that a reverse map can only cover the unambiguous terminal stages. No percentage has to be guessed: if the existing value is already consistent with the incoming one, there is nothing to decide.
55
-
56
- ### 4. The stage map moves out of PHP constants into `Forecast` lookup tables
57
-
58
- Editable DB lookup tables keyed on **`SalesStages.netsuiteInternalId`**, replacing the `SALES_STAGE_MAP` constant. **Preserve "absent = leave alone"** for unmapped NetSuite stages — an unmapped stage must remain a no-op, not a default or an error.
59
-
60
- ### 5. `entityStatus` is evaluated BEFORE Sales Stage
61
-
62
- Precedence matters because the two fields can disagree:
63
-
64
- | NS `entityStatus` | Meaning | ClickUp stage |
65
- |---|---|---|
66
- | `124` | Closed - Won | Opportunity Won |
67
- | `130` | Alternate Quote | Opportunity Won |
68
- | `14` | Closed Lost | Closed Lost |
69
-
70
- `entityStatus` ("Likely to Close") wins; Sales Stage is consulted only when `entityStatus` does not decide. Note `130` **is** mapped here — older docs recorded it as unmapped.
71
-
72
- ### 6. The reconciler stops INSERTing and reports instead
73
-
74
- `periodic_forecast_discrepancy_fix_opportunities.php` **cannot compute `projectedProfit`** — it fetches no line items at all, and profit is Σ(amount − costEstimate). A component that cannot compute a NOT-NULL column should not author rows. It becomes a **reporter**. Evidence, including the `sql_mode`-driven silent `0.00` and the ClickUp-link deletion path, is in [Forecast2 ↔ NetSuite reconciliation](../../../../1.0/apps/worker/features/forecast2-netsuite-reconciliation.md).
75
-
76
- ### 7. Document sync — content-hash ledger plus a mandatory polling backstop
77
-
78
- - **Filename dedup is insufficient**, because **NetSuite dedup-suffixes uploads** — the same document arrives under a different name. Use a **content-hash ledger** in `Forecast`.
79
- - Detection is **event-first with a mandatory polling backstop**, because **ClickUp has no attachment webhook event**. The polling is not an optimization; without it attachments are never detected.
80
-
81
- ### Data model (none built)
82
-
83
- New/changed storage this design implies:
84
- - `Forecast` stage lookup tables keyed on `SalesStages.netsuiteInternalId` (replaces the PHP map).
85
- - A `Forecast` content-hash ledger for document sync.
86
-
87
- Additive columns on `Forecast.Opportunities` are safe — no `SELECT *` over that table exists anywhere. But mind the four concurrent writers and the deliberate `clickupTaskId` omission in the 1.0 model, both mapped in [the sync doc's Data model](./netsuite-opportunity-sync.md).
72
+ ### Reconciler
73
+ The 1.0 `periodic_forecast_discrepancy_fix_opportunities.php` still INSERTs (Aaron's 2026-09-09 session kept and completed it) — the old "reconciler becomes a reporter" plan is dropped.
88
74
 
89
75
  ## Gotchas
76
+ - `taskMoved` is not subscribed for space `90113928591`; stage detection relies on the dropdown `taskUpdated`.
77
+ - `Opportunities` FK children (`OpportunityDocuments`) use ON DELETE CASCADE because the 1.0 reconciler/fixer delete `Opportunities` rows.
78
+ - worker2 has no `?controller=` URL routing — run the map test / probes via CLI runner.
90
79
 
91
- - **The ClickUp Automation is a hard dependency of this design.** A ClickUp opportunity stage is **list membership, not a field**, and only the bidirectional Automation on the "Opportunity Stage" dropdown can move a task (v2 API cannot — `PUT` with `list_id` returns 200 and does nothing). Writing the dropdown *is* the stage write. Details: [clickup-project-routing](./clickup-project-routing.md).
92
- - **`taskMoved` is not subscribed** for space `90113928591`, so the event that most directly signals a human stage change is not currently delivered. Any CU→NS stage detection needs that subscription registered first.
93
- - **The orderindex-`0` reverse-map bug must be fixed before CU→NS stage reads can be trusted** — Pre-Sales Qualification currently never maps back. See the CU→NS gotchas in [the sync doc](./netsuite-opportunity-sync.md).
94
- - **Do not implement this on top of the existing claim-as-gate.** Separating "have we created a task?" from "should we sync this field?" is a precondition for every decision above.
80
+ ## Handoff (open, 2026-09-25)
81
+ 1. Live stage-change test after flag enable (both directions, incl. close + reopen).
82
+ 2. Unverified: NS workflows allow the `entityStatus` PATCH; `addNote` fields.
83
+ 3. 1.0 PRs library#880, worker#1718, tools#15 unmerged — nightly reconciler still unguarded.
84
+ 4. ClickUp webhook migration to the devteam account ([credential rotation](../workflows/clickup-credential-rotation.md)).
85
+ 5. Confirm BU Discovery "move back" rule with Aaron/Eric.
95
86
 
96
87
  ## Related
88
+ - [NetSuite ↔ ClickUp Opportunity Sync](./netsuite-opportunity-sync.md) — transport, creation, triage.
89
+ - [Opportunity documents sync](./netsuite-opportunity-documents-sync.md) — notes/files.
90
+ - [ClickUp routing](./clickup-project-routing.md) — stage-as-list Automation.
97
91
 
98
- - [NetSuite ↔ ClickUp Opportunity Sync](./netsuite-opportunity-sync.md) — what actually runs today (create-only).
99
- - [ClickUp Project & Opportunity Multi-List Routing](./clickup-project-routing.md) — the stage-as-list Automation dependency.
100
- - [ClickUp credential rotation](../workflows/clickup-credential-rotation.md) — the stale service-account id.
101
- - [Forecast2 ↔ NetSuite reconciliation](../../../../1.0/apps/worker/features/forecast2-netsuite-reconciliation.md) — the reconciler data-loss path.
92
+ ## Change history
93
+ - 2026-09-25 — Built: StageMap component, actor guard, link check, claim split, stale takeover; flags on. Map stays PHP; reconciler keeps INSERT (jcardinal)
94
+ - 2026-09-16 — Agreed design recorded, not built (ajean)
@@ -0,0 +1,60 @@
1
+ ---
2
+ title: NetSuite ↔ ClickUp Opportunity Notes & Files Sync (_Worker_Netsuite_OpportunityDocuments + RESTlet)
3
+ framework: "2.0"
4
+ repo: worker2
5
+ project: Worker
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-09-25
10
+ owners: ["jcardinal", "ajean"]
11
+ files:
12
+ - worker2/Worker/Netsuite/OpportunityDocuments.php
13
+ - _underscore/Component/Api/Netsuite/Netsuite.php
14
+ - test/@jeff/netsuite/opportunity-documents/rl_opportunity_documents.js
15
+ - dbchanges2/Forecast/2026-09-24b - Add OpportunityDocuments ledger.sql
16
+ - dbchanges2/Core/2026-09-24a - Insert Opportunity Documents Sync CronJob.sql
17
+ related:
18
+ - ./netsuite-clickup-opportunity-bidirectional-design.md
19
+ - ./netsuite-opportunity-sync.md
20
+ - ../../_underscore/features/netsuite-rest-client.md
21
+ ---
22
+
23
+ Polling job syncing NS opportunity User Notes→ClickUp comments and files both ways via a custom RESTlet and the Forecast.OpportunityDocuments ledger; open when notes/attachments don't appear, duplicate, or when touching the RESTlet.
24
+
25
+ ## Summary
26
+
27
+ Built 2026-09-24. Cron action `Netsuite/OpportunityDocuments/Sync` every 5 min (`dryRun` param available). `SYNC_ENABLED = true`. Directions:
28
+ - NS User Notes → CU comments.
29
+ - NS files → CU attachments.
30
+ - CU attachments → NS files (folder `2008026` "ClickUp Opportunity Uploads", private).
31
+ - CU comments → NS: **not synced** (by design).
32
+
33
+ Go-live forward only: `SYNC_START = '2026-09-24 00:00:00'` UTC (NS side is day-granular). Polling, because NS note/file adds don't fire the opportunity UE and ClickUp has no attachment webhook.
34
+
35
+ ## How it works
36
+
37
+ - Per run: 24h lookback, max 25 items / 50 tasks, 6MB file cap. Blocked script/web extensions → `SKIPPED`.
38
+ - NS access goes through the RESTlet via `_Component_Api_Netsuite::sendRestlet()` ([REST client](../../_underscore/features/netsuite-rest-client.md)).
39
+ - **RESTlet** `rl_opportunity_documents.js` — actions `changes` / `getFile` / `addFile` / `addNote`. `getFile` only serves files attached to the given opportunity. Prod: script `customscript_rl_opportunity_documents` (internal `2831`), deploy `customdeploy_rl_opportunity_documents`, audience Administrator, param `custscript_opp_docs_upload_folder` = `2008026`. Runs as integration "TOGa OAuth 2.0" (Jeffrey Cardinal / Administrator; RESTlets scope on).
40
+ - **Ledger `Forecast.OpportunityDocuments`:** dedup by `(direction, sourceId)`; `targetId` marks our own copies (echo guard); sha256 per opportunity (NS dedup-suffixes filenames, so names can't dedup). `PENDING` re-claimed after 30 min; `FAILED` after 5 attempts. FK to `Opportunities` ON DELETE CASCADE (1.0 reconciler/fixer delete opp rows).
41
+ - Job fails loud at end if any item failed (WorkerJobs `isSuccess=0`).
42
+ - Query cache is disabled inside `Sync` — `_Query` caches SELECTs by SQL text and writes don't clear it, so ledger re-reads went stale.
43
+
44
+ ## Gotchas (NetSuite SuiteScript / API)
45
+ - Opportunity search type has NO `mainline` filter (`SSS_INVALID_SRCH_FILTER`) — use `search.Type.TRANSACTION` + `type anyof Opprtnty`.
46
+ - A datetime string with `after` on `file.created` / `usernotes.notedate` throws `UNEXPECTED_ERROR` — use `format.Type.DATE` with `onorafter`.
47
+ - NS REST record API has no `note` type (404); SuiteQL note title/note/date are `NOT_EXPOSED`. SuiteQL `file` table IS exposed. Hence the RESTlet.
48
+ - When creating script/deploy/param IDs type only the suffix (`_rl_...`) — NetSuite adds the prefix and will double a typed one.
49
+ - Replacing a RESTlet file = File Cabinet edit/re-upload. Verify it took: identical stack line numbers mean the old file is still live.
50
+
51
+ ## Handoff (open, 2026-09-25)
52
+ 1. Live check: task `868k1ytc5` should get the "Sync test" comment; `868jh6x08` should get `TOGA_Technology.065763.v1.18.pdf`.
53
+ 2. Unverified: ClickUp `date_updated` bumps on attachment add (the CU→NS poll depends on it); `addNote` field set.
54
+
55
+ ## Related
56
+ - [Stage sync](./netsuite-clickup-opportunity-bidirectional-design.md)
57
+ - [Opportunity sync transport](./netsuite-opportunity-sync.md)
58
+
59
+ ## Change history
60
+ - 2026-09-25 — Created: polling notes/files sync, RESTlet deployed in prod, ledger + cron (jcardinal)
@@ -6,13 +6,14 @@ project: Worker
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-16
9
+ updated: 2026-09-25
10
10
  owners: ["dfranks", "kyalamarthi", "ajean", "jcardinal"]
11
11
  files:
12
12
  - worker2/Worker/Netsuite.php
13
13
  - worker2/Worker/Netsuite/Opportunity.php
14
14
  - worker2/Worker/Clickup.php
15
15
  - worker2/Worker/Clickup/Opportunity.php
16
+ - worker2/Component/Opportunity/StageMap/StageMap.php
16
17
  - worker2/Controller/Index.php
17
18
  - _underscore/Worker.php
18
19
  - test/@dave/NetSuite/api-message-queue/lib_amq_queue.js
@@ -32,6 +33,7 @@ files:
32
33
  - worker2/Worker/Clickup/Project.md
33
34
  related:
34
35
  - ./netsuite-clickup-opportunity-bidirectional-design.md
36
+ - ./netsuite-opportunity-documents-sync.md
35
37
  - ../workflows/clickup-credential-rotation.md
36
38
  - ./netsuite-opportunity-client-labels.md
37
39
  - ./netsuite-salesorder-open-orders-sync.md
@@ -41,7 +43,7 @@ related:
41
43
  - ../../../../standalone/apps/claude/workflows/mcp-tool-usage.md
42
44
  ---
43
45
 
44
- Outbound NetSuite→Forecast→ClickUp opportunity sync (API Message Queue + worker2 webhook), its create-only ClickUp task claim, triage, and the CU→NS reverse leg; open when opportunity tasks are missing/duplicated or when touching any part of this integration.
46
+ Outbound NetSuite→Forecast→ClickUp opportunity sync (API Message Queue + worker2 webhook), ClickUp task creation claim, triage, and the CU→NS reverse leg; open when opportunity tasks are missing/duplicated or when touching any part of this integration. Stage rules live in the stage-sync doc.
45
47
 
46
48
  ## Summary
47
49
 
@@ -119,15 +121,11 @@ How the claim is race-safe:
119
121
 
120
122
  Required a new `dbchanges2/Forecast/` ALTER adding the `clickupTaskId` column, and a `_underscore/Model/Forecast/Opportunity.php` field addition. Plan: `test/@dave/approach/TRUE-80044.md`.
121
123
 
122
- #### Consequence: `updateTask()` is unreachable — `UPDATE_CLICKUP` alone does NOT revive it
124
+ #### Claim guards creation only (since 2026-09-24)
123
125
 
124
- `maybeCreateClickupTask()` claims the row **before** it ever looks at ClickUp (`Opportunity.php:40`): `UPDATE Opportunities SET clickupTaskId='PENDING' WHERE … AND clickupTaskId IS NULL`. Once `clickupTaskId` holds a real id, **every later webhook for that opportunity returns `skipped (already claimed)` and exits** — it never reaches the find-then-update branch.
126
+ The claim used to double as "do anything at all", making the integration create-only. Now split: a linked opportunity goes to `syncStageToClickup()` (stage dropdown only, behind `SYNC_STAGE_TO_CLICKUP = true`). `updateTask()` is still dead behind `UPDATE_CLICKUP = false` — leave it. A stale `PENDING` (>30 min via `dtClickupClaimed`, or NULL timestamp) is now taken over automatically. Details: [stage sync](./netsuite-clickup-opportunity-bidirectional-design.md).
125
127
 
126
- So `updateTask()` is dead **twice over**: behind `UPDATE_CLICKUP = false` *and* behind the claim. Flipping the constant activates ~180 lines of code that have **never run in production** and still reaches **none** of the 761 existing tasks — only a separate backfill action reaches those (see [client vs end-customer labels](./netsuite-opportunity-client-labels.md#seed-if-empty-backfill-opportunitystakeholderbackfillbackfill)). `UPDATE_CLICKUP` stays `false`. **This is a trap for the next developer:** the constant's own comment says nothing about the claim interaction, so reading the constant alone gives you the wrong model of what enabling it would do — **anyone "just flipping `UPDATE_CLICKUP`" will find it changes nothing.**
127
-
128
- **The claim doubles as "should we do anything at all" (confirmed 2026-09-04).** The claim is not only a dedup guard — because it returns `'skipped (already claimed)'` the moment a real task id exists, it is also the switch that decides whether the handler does any ClickUp work. The durable consequence to state plainly: **NetSuite stage changes never reach ClickUp after task creation.** In deployed behaviour this integration is **create-only**, even though the code reads as bidirectional. Any design that wants live NS→CU updates must separate "have we created a task?" from "should we sync this field?" — see [the bidirectional design](./netsuite-clickup-opportunity-bidirectional-design.md).
129
-
130
- **`clickupTaskId` semantics, and why orphaned sentinels are permanent.** `NULL` = unclaimed; `PENDING` = a claim in flight; anything else = the real ClickUp task id. A `PENDING` left behind by a crashed or timed-out request is **never cleaned up**: there is no `register_shutdown_function` in `worker2/Controller/Index.php`, so nothing releases the claim on an abnormal exit, and the compensating release depends on `_Model::load()`, which succeeds only on an **exactly-one** match (see the `load()` footgun below). A stranded `PENDING` therefore blocks that opportunity from ever getting a task until someone clears the column by hand. One such row was found live on 2026-09-04.
128
+ `clickupTaskId` semantics: `NULL` = unclaimed; `PENDING` = claim in flight; else the real task id.
131
129
 
132
130
  ### Triage: is the opportunity sync working? (`WorkerJobs.output` verdicts)
133
131
 
@@ -239,29 +237,19 @@ Three facts worth not rediscovering:
239
237
  - **No `SELECT *` over `Forecast.Opportunities` exists anywhere**, so **additive columns are safe** — a new column cannot surprise an existing reader.
240
238
  - **`OpportunityItems.line` holds NetSuite line *ids*, not a 1..n sequence.** Max observed in prod is **4196**, with **13,220 rows above 255**, and the column is half of a UNIQUE key. The SOAP-vs-REST semantics of that field **must be verified before the 1.0 cron is ever retired** — if REST numbers lines differently, a cutover creates a **second disjoint item set per opportunity** rather than updating the existing one.
241
239
 
242
- ### CU→NS direction (BUILT 2026-06-30, TRUE-79181): reverse sync
243
-
244
- The integration is now **bidirectional** for **field + stage**. ClickUp task edits flow back to the linked NetSuite opportunity via a new handler, and NS→CU now drives the ClickUp task **status** (not just description text).
245
-
246
- **Handler:** `_Worker_Clickup_Opportunity` (`worker2/Worker/Clickup/Opportunity.php`) — an abstract static-class worker, dispatched by `_Worker_Clickup::Webhook` via `_Worker::runTask('Clickup/Opportunity/Process', {taskId,event,data})`. Routing is gated by the ClickUp **LIST id** (Presales Qualification list `901111987449`) for `taskUpdated`/`taskStatusUpdated` events; non-opportunity lists fall through to the legacy sprint handling in `Clickup.php`.
240
+ ### CU→NS direction (TRUE-79181; reworked 2026-09-24)
247
241
 
248
- **CU→NS is UPDATE-ONLY** (never create/delete):
249
- 1. Resolve the linked NS opportunity from the task's `Opportunity #` custom field (`a5529cdc-8dbb-4165-8bc5-9f5cc691796a`, = NS `tranId`) via a one-row SuiteQL `SELECT id FROM transaction WHERE type='Opprtnty' AND tranid='<n>'`.
250
- 2. GET the opportunity with `expandSubResources=true` — this also supplies the **current** NS values for the diff.
251
- 3. **No match → skip.**
252
- 4. PATCH **only** the NS-owned fields that actually differ (currently `title`, `stage`). A no-op write fires no NS event, so the write cannot re-trigger NS→CU. **Echo prevention is the difference-check alone** — there is no actor-identity / "last-synced-from" suppression field (that hardening was explicitly deferred; see Deferred).
242
+ **Handler:** `_Worker_Clickup_Opportunity` (`worker2/Worker/Clickup/Opportunity.php`), dispatched by `_Worker_Clickup::Webhook` via `_Worker::runTask('Clickup/Opportunity/Process', {taskId,event,data})`. Routed by Opportunities **folder** `90117060687` (all 8 stage lists) for `taskUpdated`/`taskStatusUpdated`, with no early return (hub routing still runs).
253
243
 
254
- **CORRECTED 2026-09-04 — `STAGE_MAP` does not exist; the shipped constant is `SALES_STAGE_MAP`.** This section previously described a `STAGE_MAP` keyed on NS **`entityStatus`** internalId mapping to a ClickUp task **status** on the Presales Qualification list, with a table including `130` Alternate Quote as *(unmapped)*. **That constant and that table were replaced and no longer describe the code.**
244
+ **UPDATE-ONLY** (never create/delete):
245
+ 1. Resolve NS opp from `Opportunity #` (`a5529cdc-8dbb-4165-8bc5-9f5cc691796a`, = `tranId`) via one-row SuiteQL.
246
+ 2. **Require `Forecast.Opportunities.clickupTaskId === taskId`** — else skip (free-text field; security).
247
+ 3. Drop echo events by actor (our token users; never actor `-1`); GET the opp with `expandSubResources=true`.
248
+ 4. PATCH only differing NS-owned fields (`title`, stage/`entityStatus` per `_Component_Opportunity_StageMap`).
255
249
 
256
- What actually ships: **`SALES_STAGE_MAP`**, declared on `_Worker_Netsuite_Opportunity`, keyed on **`custbody_sales_stage`** and mapping to the ClickUp **"Opportunity Stage" dropdown** (`d8fd5819-d8f7-494b-8671-aff7fbdeb985`) — **not** to a task status. That matters because a ClickUp opportunity **stage is list membership, not a field**: writing the dropdown is how NetSuite moves the task, via the bidirectional ClickUp Automation (see *⚠ A healthy sync reads as dead* above and [clickup-project-routing](./clickup-project-routing.md)).
250
+ Stage/close/reopen rules: [stage sync](./netsuite-clickup-opportunity-bidirectional-design.md). Notes/files: [documents sync](./netsuite-opportunity-documents-sync.md).
257
251
 
258
- **The non-1:1 problem is still real, in the other direction.** Several NetSuite stages map to one ClickUp stage, so a reverse map cannot invert cleanly and CU→NS must not guess. The agreed resolution is a **consistency short-circuit** (if the current value already maps to the incoming value, write nothing) plus moving the map out of PHP constants into editable `Forecast` lookup tables — see [the bidirectional design](./netsuite-clickup-opportunity-bidirectional-design.md). Do not invent a percentage reverse-mapping here.
259
-
260
- **`entityStatus` is evaluated BEFORE Sales Stage** in the agreed design: `124` Closed-Won and `130` Alternate Quote → **Opportunity Won**, `14` Closed Lost → **Closed Lost**. `130` is no longer unmapped.
261
-
262
- **NS→CU now drives the ClickUp task STATUS** from the NS stage via `STAGE_MAP` (previously the stage was only rendered in the description text). Applied on both create and update; the update PUT carries **only** the changed fields (`name`/`description`/`status`) to stay echo-safe.
263
-
264
- **Outbound NS calls** use the `Logs.Api` two-save pattern (`_Model_Core_Logs_Api`, `DB_LOGS`, `DIRECTION_OUT`: save the request row, then save the response/failure row), with `\Sentry\captureException($e); throw $e;` on failure so `WorkerJobs` records `isSuccess=0`. Logged payloads are length-capped. NS access is the **2.0 adapter `_Component_Api_Netsuite`** (GET/PATCH + SuiteQL POST with `Prefer: transient`); the **1.0 `App_Api_Netsuite_Rest` / SOAP toolkit are deprecated** for production opportunity code.
252
+ **Outbound NS calls** use the `Logs.Api` two-save pattern (`_Model_Core_Logs_Api`, `DB_LOGS`, `DIRECTION_OUT`), with `\Sentry\captureException($e); throw $e;` on failure. NS access is the 2.0 adapter `_Component_Api_Netsuite`; 1.0 `App_Api_Netsuite_Rest` / SOAP are deprecated for this code.
265
253
 
266
254
  ## Gotchas
267
255
 
@@ -313,16 +301,13 @@ What actually ships: **`SALES_STAGE_MAP`**, declared on `_Worker_Netsuite_Opport
313
301
 
314
302
  ### CU→NS gotchas
315
303
 
316
- - **⚠ LIVE BUG (found 2026-09-04, NOT fixed): orderindex `0` never maps back, so the most common stage silently never reaches NetSuite.** `worker2/Worker/Clickup/Opportunity.php:207` does `array_search($orderIndex, STAGE_OPTION_ORDERINDEX, true) ?: null`. `array_search` returns the **key**, and Pre-Sales Qualification's key is `0` — which `?:` converts to `null`. So a task sitting in Pre-Sales Qualification (the most common stage) never writes NetSuite stage 3; the write is dropped with no error. Fix is the strict form: `=== false ? null :`. This is the classic `array_search`-with-`?:` trap — any falsy key (`0`, `''`) is indistinguishable from "not found". A prototype fix was written and **deliberately reverted** on 2026-09-04, so the bug is still live.
317
304
  - **SuiteQL over `_Component_Api_Netsuite` has NO placeholder binding.** When a SuiteQL filter value comes from an **external** source (here a ClickUp custom-field `tranId`, not a local PK), sanitize with an **allowlist-and-REJECT-on-mismatch**, not strip-and-use: `$safe = preg_replace('/[^A-Za-z0-9_-]/','',$v); if ($safe!==$v) return null;`. Reject-on-mismatch is the firewall; silently stripping characters is not enough.
318
305
  - **Validate `task_id` format at the webhook entry.** ClickUp native task ids are alphanumeric. The legacy `_Worker_Clickup::Webhook` interpolates the raw `$payload->task_id` into Team-DB SQL at ~20 sites (a **pre-existing** SQL-injection exposure, NOT introduced here). A single input-boundary format guard at the top of `Webhook()` closes that class for the raw payload id without rewriting each query. **FLAG:** the broader legacy `$taskId`/`$customTaskId` raw-SQL interpolation throughout `Clickup.php` is real pre-existing debt that warrants a dedicated security ticket.
319
- - **ClickUp opportunity field ids:** list `901111987449` ("Presales Qualification"); `Opportunity #` = `a5529cdc-8dbb-4165-8bc5-9f5cc691796a`, `Customer #` = `170dc118-b3fa-413b-826b-2753e9405851`.
306
+ - **ClickUp opportunity ids:** folder `90117060687` (routing gate); creation list `901111987449` ("Presales Qualification"); `Opportunity #` = `a5529cdc-8dbb-4165-8bc5-9f5cc691796a`, `Customer #` = `170dc118-b3fa-413b-826b-2753e9405851`.
320
307
 
321
- ### Deferred (documented follow-up)
308
+ ### Deferred
322
309
 
323
- - **Notes & attachments (CU→NS)** were intentionally deferred — they require NetSuite custom fields (`custentity_cu_synced_notes` / `custentity_cu_synced_files`) + a File Cabinet folder that **do not exist yet**, plus unconfirmed NS REST note/file shapes. Field + stage sync is bidirectional; notes/attachments remain a documented follow-up.
324
- - **Actor-identity echo hardening** deferred — the difference-check is the current (and sufficient for field/stage) echo-prevention mechanism. A "drop events authored by our own integration user" gate is the planned hardening if/when no-op-safe writes can't fully cover a future field.
325
- - **Percentage reverse-mapping** (which NS percentage stage a CU `in progress` should map back to) is blocked on the Aaron stakeholder decision noted above.
310
+ - Percentage reverse-mapping is no longer needed — the StageMap "already matching" sets replace it.
326
311
 
327
312
  ## Related
328
313
 
@@ -335,3 +320,6 @@ What actually ships: **`SALES_STAGE_MAP`**, declared on `_Worker_Netsuite_Opport
335
320
  - [ClickUp credential rotation](../workflows/clickup-credential-rotation.md).
336
321
  - [1.0 webhook: NetSuite → ClickUp opportunity sync](../../../../1.0/apps/webhook/features/netsuite-clickup-opportunity-sync.md) — NetSuite scheduled script 2412, which auto-assigns the Presales Lead that gates task creation.
337
322
  - [MCP Tool Usage](../../../../standalone/apps/claude/workflows/mcp-tool-usage.md) — the `toga-db` query traps hit while running the verdict census.
323
+
324
+ ## Change history
325
+ - 2026-09-25 — Claim guards creation only; CU→NS routed by folder, link check, actor guard; orderindex-0 bug fixed (jcardinal)
@@ -20,7 +20,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
20
20
  ## 2.0 framework
21
21
 
22
22
  - **_underscore** (_Underscore) _(framework core)_ — 90 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
23
- - **worker2** (Worker) — 77 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
23
+ - **worker2** (Worker) — 78 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
24
24
  - **api2** (API) — 26 doc(s) → [2.0/apps/api2/INDEX.md](2.0/apps/api2/INDEX.md)
25
25
  - **dbchanges2** (Database Changes) _(framework core)_ — 19 doc(s) → [2.0/apps/dbchanges2/INDEX.md](2.0/apps/dbchanges2/INDEX.md)
26
26
  - **toga2-supply** (TOGa Supply) — 9 doc(s) → [2.0/apps/toga2-supply/INDEX.md](2.0/apps/toga2-supply/INDEX.md)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.896",
3
+ "version": "1.0.897",
4
4
  "description": "TOGA Technology Team Claude Knowledge System — shared AI coding harness with skills, knowledge base CLI, and project installer for Claude Code.",
5
5
  "keywords": [
6
6
  "claude",