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.
- package/knowledge/2.0/apps/_underscore/features/netsuite-rest-client.md +10 -1
- package/knowledge/2.0/apps/worker2/INDEX.md +3 -2
- package/knowledge/2.0/apps/worker2/features/clickup-project-routing.md +6 -1
- package/knowledge/2.0/apps/worker2/features/netsuite-clickup-opportunity-bidirectional-design.md +57 -64
- package/knowledge/2.0/apps/worker2/features/netsuite-opportunity-documents-sync.md +60 -0
- package/knowledge/2.0/apps/worker2/features/netsuite-opportunity-sync.md +22 -34
- package/knowledge/INDEX.md +1 -1
- package/package.json +1 -1
|
@@ -6,7 +6,7 @@ project: _Underscore
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-09-
|
|
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 —
|
|
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
|
|
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-
|
|
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)
|
package/knowledge/2.0/apps/worker2/features/netsuite-clickup-opportunity-bidirectional-design.md
CHANGED
|
@@ -1,101 +1,94 @@
|
|
|
1
1
|
---
|
|
2
|
-
title: Bidirectional NetSuite ↔ Forecast ↔ ClickUp opportunity sync —
|
|
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:
|
|
9
|
-
updated: 2026-09-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
34
|
+
## How it works
|
|
30
35
|
|
|
31
|
-
|
|
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
|
-
###
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
47
|
-
-
|
|
48
|
-
-
|
|
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
|
-
|
|
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
|
-
###
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
99
|
-
-
|
|
100
|
-
-
|
|
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-
|
|
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),
|
|
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
|
-
####
|
|
124
|
+
#### Claim guards creation only (since 2026-09-24)
|
|
123
125
|
|
|
124
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
**
|
|
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
|
-
**
|
|
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
|
-
|
|
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
|
-
**
|
|
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
|
|
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
|
|
308
|
+
### Deferred
|
|
322
309
|
|
|
323
|
-
-
|
|
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)
|
package/knowledge/INDEX.md
CHANGED
|
@@ -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) —
|
|
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