@nbt-dev/nbt 0.0.10 → 0.1.1

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.
Files changed (37) hide show
  1. package/package.json +1 -1
  2. package/stdlib/workflows/schema.nbt +69 -17
  3. package/vendor/linux-x64/cartridges/workflows/schema.nbt +69 -17
  4. package/vendor/linux-x64/console +0 -0
  5. package/vendor/linux-x64/nbt +0 -0
  6. package/stdlib/crm/adapters/gohighlevel/README.md +0 -85
  7. package/stdlib/crm/adapters/gohighlevel/tests/README.md +0 -159
  8. package/stdlib/crm/adapters/gohighlevel/tests/fixtures/webhooks/contact_138fields.json +0 -222
  9. package/stdlib/crm/adapters/gohighlevel/tests/fixtures/webhooks/contact_140fields.json +0 -219
  10. package/stdlib/crm/adapters/gohighlevel/tests/fixtures/webhooks/contact_alt.json +0 -212
  11. package/stdlib/crm/adapters/gohighlevel/tests/fixtures/webhooks/contact_changed.json +0 -102
  12. package/stdlib/crm/adapters/gohighlevel/tests/fixtures/webhooks/contact_created.json +0 -95
  13. package/stdlib/crm/adapters/gohighlevel/tests/fixtures/webhooks/contact_full.json +0 -213
  14. package/stdlib/crm/adapters/gohighlevel/tests/fixtures/webhooks/contact_sparse.json +0 -161
  15. package/stdlib/crm/adapters/gohighlevel/tests/fixtures/webhooks/contact_update_a.json +0 -197
  16. package/stdlib/crm/adapters/gohighlevel/tests/fixtures/webhooks/contact_update_b.json +0 -197
  17. package/stdlib/crm/adapters/gohighlevel/tests/fixtures/webhooks/opportunity_changed.json +0 -85
  18. package/stdlib/crm/adapters/gohighlevel/tests/fixtures/webhooks/opportunity_created.json +0 -85
  19. package/stdlib/crm/adapters/gohighlevel/tests/fixtures/webhooks/v2_contact_pilot.json +0 -43
  20. package/stdlib/crm/adapters/gohighlevel/tests/fixtures/webhooks/v2_contact_with_price_closed.json +0 -7
  21. package/stdlib/crm/adapters/gohighlevel/tests/fixtures/webhooks/v2_contact_with_price_open.json +0 -7
  22. package/stdlib/crm/adapters/gohighlevel/tests/fixtures/webhooks/v2_event_appointment_delete.json +0 -1
  23. package/stdlib/crm/adapters/gohighlevel/tests/fixtures/webhooks/v2_event_calendar_update.json +0 -1
  24. package/stdlib/crm/adapters/gohighlevel/tests/fixtures/webhooks/v2_event_contact_create.json +0 -1
  25. package/stdlib/crm/adapters/gohighlevel/tests/fixtures/webhooks/v2_event_opp_status_update.json +0 -1
  26. package/stdlib/crm/adapters/gohighlevel/tests/fixtures/webhooks/v2_opportunity_pilot.json +0 -16
  27. package/stdlib/crm/adapters/gohighlevel/tests/fixtures/webhooks/v2_pipelines_pilot.json +0 -137
  28. package/stdlib/design/migrations/20260501210107_initial/migration.nbt +0 -19
  29. package/stdlib/design/migrations/20260501210107_initial/schema_snapshot.nbt +0 -21
  30. package/stdlib/design/migrations/20260610130000_design_system/migration.nbt +0 -50
  31. package/stdlib/design/migrations/20260610130000_design_system/schema_snapshot.nbt +0 -80
  32. package/stdlib/design/schema.nbt +0 -140
  33. package/vendor/linux-x64/cartridges/design/migrations/20260501210107_initial/migration.nbt +0 -19
  34. package/vendor/linux-x64/cartridges/design/migrations/20260501210107_initial/schema_snapshot.nbt +0 -21
  35. package/vendor/linux-x64/cartridges/design/migrations/20260610130000_design_system/migration.nbt +0 -50
  36. package/vendor/linux-x64/cartridges/design/migrations/20260610130000_design_system/schema_snapshot.nbt +0 -80
  37. package/vendor/linux-x64/cartridges/design/schema.nbt +0 -140
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nbt-dev/nbt",
3
- "version": "0.0.10",
3
+ "version": "0.1.1",
4
4
  "description": "The nbt CLI and console daemon for the nbt-dev console — a fixed-target OS for web development. Linux/x64 prebuilt binaries.",
5
5
  "bin": {
6
6
  "nbt": "dist/nbt.js"
@@ -1,40 +1,92 @@
1
- enum WorkerStatus {
2
- WAITING
3
- RUNNING
4
- TERMINATED
5
- }
6
-
7
- entity Worker {
1
+ # An immutable, versioned workflow bundle (the JS artifact). Deploying new code
2
+ # registers a new version; in-flight executions keep replaying against the version
3
+ # they started on (the journal-header pin), so a code change can never corrupt a
4
+ # running instance. A version's source is retained while ≥1 execution pins it and
5
+ # dropped once the last drains (refcount drain-GC, in-memory). Durable so a pinned
6
+ # version survives restart.
7
+ entity WorkflowBundle {
8
8
  id: ulid
9
9
  createdAt: DateTime @default(now())
10
10
  updatedAt: DateTime @updatedAt
11
- source: blob
11
+ version: u32
12
+ source: string
12
13
  contentHash: string
13
- queue?: string
14
14
  }
15
15
 
16
- # Stub flesh out execution lifecycle fields later.
16
+ # A single durable workflow instance. The journal is the WorkflowExecutionEvent
17
+ # rows owned by this row (ordered by seq); status + result/error are the
18
+ # materialized terminal state. cursor = number of resolved host commands
19
+ # (== count of HOST_CALL_RESULT events) — the replay ordinal high-water mark.
17
20
  entity WorkflowExecution {
18
21
  id: ulid
19
22
  createdAt: DateTime @default(now())
20
23
  updatedAt: DateTime @updatedAt
21
- status: string
24
+ status: string # RUNNING (incl. async host call in flight) | SUSPENDED | COMPLETED | FAILED | CANCELLED
25
+ workflowName: string
26
+ args?: string # JSON arguments passed to runWorkflow
27
+ result?: string # JSON return value (COMPLETED)
28
+ error?: string # message + stack (FAILED)
29
+ cursor: u32
30
+ lane?: string # fairness lane; per-lane concurrency is capped so a burst of one lane can't starve others (default "default")
31
+ deployVersion: u32 # the bundle version this instance pins for its entire life (journal-header pin); replay always runs against this exact version
22
32
  events: WorkflowExecutionEvent[]
23
33
  }
24
34
 
25
- # Do we want this to be a string?
35
+ # A recurring trigger: fire `workflowName` whenever the cron expression matches.
36
+ # Idempotent per minute-bucket via a deterministic execution id (schedule + bucket),
37
+ # so a tick repeating within the same minute — or a restart mid-minute — never
38
+ # double-fires. Evaluated leader-only by the executor tick.
39
+ entity WorkflowSchedule {
40
+ id: ulid
41
+ createdAt: DateTime @default(now())
42
+ updatedAt: DateTime @updatedAt
43
+ workflowName: string
44
+ cron: string # 5-field: minute hour day-of-month month day-of-week
45
+ args?: string # JSON arguments passed to each fired execution
46
+ lane?: string # lane the fired executions run on
47
+ enabled: bool
48
+ lastFiredBucket: u32 # highest minute-bucket already fired; the durable idempotency watermark (no double-fire across ticks/restart)
49
+ }
50
+
51
+ # A durable record that a named event fired. Emissions ride the WAL (replicated
52
+ # like any row); the leader-only executor reconcile drains the unprocessed ones,
53
+ # looks up the event's triggers in the storage event registry, and fires each
54
+ # triggered workflow exactly once cluster-wide (only the leader drains; the fired
55
+ # id is derived from emission+index so a crash mid-drain re-fires idempotently).
56
+ entity WorkflowEvent {
57
+ id: ulid
58
+ createdAt: DateTime @default(now())
59
+ updatedAt: DateTime @updatedAt
60
+ event: string # "<slug>:<event>" — the event-registry key
61
+ payload?: string # JSON passed as args to each fired workflow
62
+ processed: bool # set true once the leader has fired this emission's triggers
63
+ }
64
+
26
65
  enum WorkflowExecutionEventKind {
27
- WORKFLOW_EXECUTION_STARTED
28
- WORKFLOW_STEP_SCHEDULED
29
- WORKFLOW_STEP_STARTED
30
- WORKFLOW_STEP_COMPLETED
31
- WORKFLOW_EXECUTION_COMPLETED
66
+ HOST_CALL_INTENT # recorded BEFORE a side effect is performed (durability)
67
+ HOST_CALL_RESULT # the resolved value of a host command (replay source)
68
+ SUSPENDED # parked awaiting an external command (e.g. a signal)
69
+ RESUMED # an external command arrived and re-drove the instance
70
+ COMPLETED # workflow function returned
71
+ FAILED # workflow function threw
72
+ CANCELLED # terminated by an explicit cancel request (cooperative)
73
+ RETRY # an async host call failed transiently and was retried (counter; payload = attempt)
74
+ CONTINUED # ended via continue-as-new; payload = the child execution id carrying the work forward
32
75
  }
33
76
 
77
+ # One journal entry. For HOST_CALL_* events seq is the command ordinal and
78
+ # (capability, target, op) is the descriptor; payload carries the args (INTENT)
79
+ # or the result JSON (RESULT). Lifecycle events (SUSPENDED/RESUMED/COMPLETED/
80
+ # FAILED/CANCELLED/RETRY) carry payload only.
34
81
  entity WorkflowExecutionEvent {
35
82
  id: ulid
36
83
  createdAt: DateTime @default(now())
37
84
  updatedAt: DateTime @updatedAt
38
85
  execution: WorkflowExecution @relation(onDelete: Cascade) # events are owned by their execution
86
+ seq: u32
39
87
  kind: WorkflowExecutionEventKind
88
+ capability?: string
89
+ target?: string
90
+ op?: string
91
+ payload?: string
40
92
  }
@@ -1,40 +1,92 @@
1
- enum WorkerStatus {
2
- WAITING
3
- RUNNING
4
- TERMINATED
5
- }
6
-
7
- entity Worker {
1
+ # An immutable, versioned workflow bundle (the JS artifact). Deploying new code
2
+ # registers a new version; in-flight executions keep replaying against the version
3
+ # they started on (the journal-header pin), so a code change can never corrupt a
4
+ # running instance. A version's source is retained while ≥1 execution pins it and
5
+ # dropped once the last drains (refcount drain-GC, in-memory). Durable so a pinned
6
+ # version survives restart.
7
+ entity WorkflowBundle {
8
8
  id: ulid
9
9
  createdAt: DateTime @default(now())
10
10
  updatedAt: DateTime @updatedAt
11
- source: blob
11
+ version: u32
12
+ source: string
12
13
  contentHash: string
13
- queue?: string
14
14
  }
15
15
 
16
- # Stub flesh out execution lifecycle fields later.
16
+ # A single durable workflow instance. The journal is the WorkflowExecutionEvent
17
+ # rows owned by this row (ordered by seq); status + result/error are the
18
+ # materialized terminal state. cursor = number of resolved host commands
19
+ # (== count of HOST_CALL_RESULT events) — the replay ordinal high-water mark.
17
20
  entity WorkflowExecution {
18
21
  id: ulid
19
22
  createdAt: DateTime @default(now())
20
23
  updatedAt: DateTime @updatedAt
21
- status: string
24
+ status: string # RUNNING (incl. async host call in flight) | SUSPENDED | COMPLETED | FAILED | CANCELLED
25
+ workflowName: string
26
+ args?: string # JSON arguments passed to runWorkflow
27
+ result?: string # JSON return value (COMPLETED)
28
+ error?: string # message + stack (FAILED)
29
+ cursor: u32
30
+ lane?: string # fairness lane; per-lane concurrency is capped so a burst of one lane can't starve others (default "default")
31
+ deployVersion: u32 # the bundle version this instance pins for its entire life (journal-header pin); replay always runs against this exact version
22
32
  events: WorkflowExecutionEvent[]
23
33
  }
24
34
 
25
- # Do we want this to be a string?
35
+ # A recurring trigger: fire `workflowName` whenever the cron expression matches.
36
+ # Idempotent per minute-bucket via a deterministic execution id (schedule + bucket),
37
+ # so a tick repeating within the same minute — or a restart mid-minute — never
38
+ # double-fires. Evaluated leader-only by the executor tick.
39
+ entity WorkflowSchedule {
40
+ id: ulid
41
+ createdAt: DateTime @default(now())
42
+ updatedAt: DateTime @updatedAt
43
+ workflowName: string
44
+ cron: string # 5-field: minute hour day-of-month month day-of-week
45
+ args?: string # JSON arguments passed to each fired execution
46
+ lane?: string # lane the fired executions run on
47
+ enabled: bool
48
+ lastFiredBucket: u32 # highest minute-bucket already fired; the durable idempotency watermark (no double-fire across ticks/restart)
49
+ }
50
+
51
+ # A durable record that a named event fired. Emissions ride the WAL (replicated
52
+ # like any row); the leader-only executor reconcile drains the unprocessed ones,
53
+ # looks up the event's triggers in the storage event registry, and fires each
54
+ # triggered workflow exactly once cluster-wide (only the leader drains; the fired
55
+ # id is derived from emission+index so a crash mid-drain re-fires idempotently).
56
+ entity WorkflowEvent {
57
+ id: ulid
58
+ createdAt: DateTime @default(now())
59
+ updatedAt: DateTime @updatedAt
60
+ event: string # "<slug>:<event>" — the event-registry key
61
+ payload?: string # JSON passed as args to each fired workflow
62
+ processed: bool # set true once the leader has fired this emission's triggers
63
+ }
64
+
26
65
  enum WorkflowExecutionEventKind {
27
- WORKFLOW_EXECUTION_STARTED
28
- WORKFLOW_STEP_SCHEDULED
29
- WORKFLOW_STEP_STARTED
30
- WORKFLOW_STEP_COMPLETED
31
- WORKFLOW_EXECUTION_COMPLETED
66
+ HOST_CALL_INTENT # recorded BEFORE a side effect is performed (durability)
67
+ HOST_CALL_RESULT # the resolved value of a host command (replay source)
68
+ SUSPENDED # parked awaiting an external command (e.g. a signal)
69
+ RESUMED # an external command arrived and re-drove the instance
70
+ COMPLETED # workflow function returned
71
+ FAILED # workflow function threw
72
+ CANCELLED # terminated by an explicit cancel request (cooperative)
73
+ RETRY # an async host call failed transiently and was retried (counter; payload = attempt)
74
+ CONTINUED # ended via continue-as-new; payload = the child execution id carrying the work forward
32
75
  }
33
76
 
77
+ # One journal entry. For HOST_CALL_* events seq is the command ordinal and
78
+ # (capability, target, op) is the descriptor; payload carries the args (INTENT)
79
+ # or the result JSON (RESULT). Lifecycle events (SUSPENDED/RESUMED/COMPLETED/
80
+ # FAILED/CANCELLED/RETRY) carry payload only.
34
81
  entity WorkflowExecutionEvent {
35
82
  id: ulid
36
83
  createdAt: DateTime @default(now())
37
84
  updatedAt: DateTime @updatedAt
38
85
  execution: WorkflowExecution @relation(onDelete: Cascade) # events are owned by their execution
86
+ seq: u32
39
87
  kind: WorkflowExecutionEventKind
88
+ capability?: string
89
+ target?: string
90
+ op?: string
91
+ payload?: string
40
92
  }
Binary file
Binary file
@@ -1,85 +0,0 @@
1
- # GHL Adapter
2
-
3
- Webhook ingest → CRM persistence as composable tasks. The adapter classifies
4
- inbound GHL Workflow webhook payloads and upserts the corresponding Contact
5
- or Deal directly. Appointment persistence lives in the calendar cart's
6
- parallel adapter (`cartridges/core/calendar/adapters/gohighlevel/`) because
7
- Calendar/Appointment are calendar-cart entities.
8
-
9
- No transformation, normalization, geocoding, AI scoring, or write-back to
10
- GHL. Those are downstream client-cart concerns. Read-side tasks (backfill,
11
- sync recovery) come back when there's a real consumer.
12
-
13
- ## Tasks (queue names = `crm.<task>`)
14
-
15
- | Task | Inputs | Outputs |
16
- |---|---|---|
17
- | `crm.ghl_classify_event_type` | `body_json` | `event_type`, `tenant_org`, `tenant_project` |
18
- | `crm.ghl_persist_contact` | `body_json` | `contact_id`, `created`, `ok`, `error` |
19
- | `crm.ghl_persist_opportunity` | `body_json` | `deal_id`, `created`, `ok`, `error` |
20
-
21
- Plus, in the calendar cart:
22
-
23
- | Task | Inputs | Outputs |
24
- |---|---|---|
25
- | `calendar.ghl_persist_appointment` | `body_json` | `appointment_id`, `calendar_id`, `ok`, `error` |
26
-
27
- `event_type` outputs from classify: `ContactCreate`, `ContactChange`,
28
- `OpportunityCreate`, `OpportunityChange`, `AppointmentCreate`,
29
- `AppointmentChange`, `Unknown`. The 7th GHL workflow "Pipeline Stage Changed
30
- Sync" aliases onto `OpportunityChange` because the payload shape is
31
- identical.
32
-
33
- ## Webhook entry
34
-
35
- GHL POSTs to ingest:
36
-
37
- ```
38
- POST /api/ingest/endpoint/receive?e=mylocalpro-ghl
39
- ```
40
-
41
- The `Endpoint` row for slug `mylocalpro-ghl` carries `systemId` pointing at
42
- the per-client System graph. After Payload persistence, ingest fires
43
- `queue_exec_start("system.<systemId>", { payloadId, endpointSlug, endpointId, body_json })`.
44
-
45
- The first task in the per-client graph is `crm.ghl_classify_event_type`,
46
- followed by a `branch` on `event_type` to one of the persist tasks
47
- (`ghl_persist_contact`, `ghl_persist_opportunity`,
48
- `calendar.ghl_persist_appointment`).
49
-
50
- ## Files
51
-
52
- ```
53
- main.nbt # composes the persist tasks
54
- native/persist.jai # ghl_apply_contact_payload, ghl_apply_opportunity_payload
55
- tasks/classify_event_type.nbt
56
- tasks/persist_contact.nbt
57
- tasks/persist_opportunity.nbt
58
- tests/fixtures/webhooks/ # 4 real-shape fixtures (contact_*/opportunity_*)
59
- tests/README.md # fixture provenance + classifier expectations
60
- ```
61
-
62
- Native imports are declared at the cart root (`crm/cartridge.nbt`) because
63
- the codegen's native-file copy step assumes paths are cart-root-relative.
64
-
65
- The calendar cart mirrors this layout for appointment persistence:
66
- `cartridges/core/calendar/adapters/gohighlevel/`.
67
-
68
- ## External-PK = local-PK
69
-
70
- GHL ids are written into entity `id` directly: a Contact for
71
- `contact_id="ctc_xyz"` lives at `Contact.id="ctc_xyz"`, an Opportunity for
72
- `id="opp_001"` lives at `Deal.id="opp_001"`, an Appointment for
73
- `calendar.appointmentId="apt_xyz"` lives at `Appointment.id="apt_xyz"`. No
74
- `externalId` indirection — the next webhook update keys the same row.
75
-
76
- ## Adding a persist branch
77
-
78
- 1. Add the helper to `native/persist.jai` (or a calendar/agent/etc. cart's
79
- `native/persist.jai` if the target entity lives there).
80
- 2. Create `tasks/persist_<name>.nbt` with `input body_json: string` and the
81
- handler delegating to the helper.
82
- 3. Import the task in `main.nbt`.
83
- 4. Update `tasks/classify_event_type.nbt` if a new `event_type` is needed.
84
- 5. `jai first.jai` — task appears in the cart's `contract.json` and at
85
- `/_console/task-catalog`.
@@ -1,159 +0,0 @@
1
- # GHL Adapter Tests
2
-
3
- Two fixture sets, two purposes. Both describe the same GHL tenant
4
- (`org=mlp, project=default, locationId=loc_test`) so a replay that uses
5
- either source converges on the same CRM entity graph.
6
-
7
- | Set | Role | Producer | Consumer | Discriminator |
8
- | --- | --- | --- | --- | --- |
9
- | `fixtures/webhooks/*.json` | Inbound delta stream | GHL workflow engine (or v2 native subscription) | `tasks/classify_event_type.nbt` + downstream user-cart systems | `body.workflow.name` (workflow shape) → fall back to `body.type` (v2 native) |
10
- | `fixtures/api/*.json` | Backfill / sync recovery snapshot | Mocked GHL REST replies (when `GHL_MOCK_DIR` is set, `native/mock.jai` reads from disk instead of curl) | All `tasks/fetch_*` / `tasks/list_*` tasks | URL-derived filename (see "Mock key derivation" below) |
11
-
12
- The two sets carry **different shapes** because GHL emits different shapes:
13
- the workflow engine flattens custom fields onto the top-level payload (with
14
- `customFields[]` redundantly nested), while the REST API returns them under
15
- a typed envelope (`{contact: {...}}`, `{opportunities: [...], meta: {...}}`,
16
- etc.). The flattening logic and field classification live in the portal at
17
- `packages/api/src/services/ghl/field-classification.ts`.
18
-
19
- ## Source of truth for shapes
20
-
21
- - **Webhook payload shapes** — derived from the portal handler at
22
- `apps/portal/app/api/v1/[...route]/routes/webhooks/ghl.ts` (the consumer is
23
- authoritative; GHL workflow webhook payloads are user-configurable and not
24
- formally schema'd).
25
- - **API response shapes** — the GHL marketplace API docs are authoritative;
26
- cross-check each API fixture against the corresponding endpoint page:
27
- - `GET /contacts/:id` — https://marketplace.gohighlevel.com/docs/ghl/contacts/get-contact
28
- - `GET /contacts/` — https://marketplace.gohighlevel.com/docs/ghl/contacts/get-contacts
29
- - `GET /opportunities/:id` — https://marketplace.gohighlevel.com/docs/ghl/opportunities/get-opportunity
30
- - `GET /opportunities/search` — https://marketplace.gohighlevel.com/docs/ghl/opportunities/search-opportunity
31
- - `GET /opportunities/pipelines` — https://marketplace.gohighlevel.com/docs/ghl/opportunities/get-pipelines
32
- - `GET /calendars/` — https://marketplace.gohighlevel.com/docs/ghl/calendars/get-calendars
33
- - `GET /calendars/events` — https://marketplace.gohighlevel.com/docs/ghl/calendars/get-calendar-events
34
- - `GET /calendars/blocked-slots` — https://marketplace.gohighlevel.com/docs/ghl/calendars/get-blocked-slots
35
- - `GET /users/` — https://marketplace.gohighlevel.com/docs/ghl/users/get-user-by-location
36
- - `GET /locations/:id/customFields` — https://marketplace.gohighlevel.com/docs/ghl/custom-fields/get-custom-fields
37
-
38
- ## Shared CRM graph (referenced by both sets)
39
-
40
- | Entity | ID | Notes |
41
- | --- | --- | --- |
42
- | Contact | `ctc_alice_001` | Alice Anderson; full address + property custom fields. After `contact_changed`, last name → `Anderson-Smith`, address → `456 Updated Ave`, email → `alice.smith@example.com`. |
43
- | Contact | `ctc_bob_002` | Bob Builder; minimal — exists so `contacts__page1` is non-trivial. |
44
- | Opportunity | `opp_001` | Alice's 8 kW solar deal. `Created` lands at `pip_solar / ps_qualified` @ $24 500. `Changed` advances to `ps_proposal_sent` @ $28 500 with an `Appointment Outcome=showed`. |
45
- | Pipeline | `pip_solar` | 5 stages: `ps_lead → ps_qualified → ps_proposal_sent → ps_won / ps_lost`. |
46
- | Calendar | `cal_solar_consult` | `America/Toronto`, team `usr_001`/`usr_002`. |
47
- | Appointment | `apt_001` | Alice + cal_solar_consult. `Created` confirms for 2026-05-12 14:00 EDT. `Changed` reschedules to 2026-05-15 10:00 (timezone-naive) and transitions to `showed`. |
48
- | Users | `usr_001..003` | Bob Brown / Charlie Chen / Diana Davis — match the `Setter`/`Closer` strings in webhook payloads. |
49
- | Custom fields | `cf_utility`, `cf_avg_bill`, `cf_shade`, `cf_owner` (contact model); `cf_proposal`, `cf_inspection_date` (opportunity model); plus the deal-tracking projection (`cf_setter`, `cf_closer`, `cf_appt_outcome`, `cf_total_contract`, `cf_date_sold`). |
50
-
51
- ## Webhook fixtures (`fixtures/webhooks/`)
52
-
53
- One fixture per real published GHL Workflow shape. Every payload mirrors
54
- something GHL actually posts; synthetic probes (unknown event, wrong
55
- tenant, v2 native subscriptions) have been dropped — they tested code
56
- paths that don't fire for the real tenant configuration.
57
-
58
- | File | `workflow.name` | `body.type` | Expected classifier output |
59
- | --- | --- | --- | --- |
60
- | `contact_created.json` | `Contact Created Sync` | `ContactCreate` | `event_type=ContactCreate, tenant_org=mlp, tenant_project=default` |
61
- | `contact_changed.json` | `Contact Changed Sync` | `ContactUpdate` | `event_type=ContactChange` |
62
- | `opportunity_created.json` | `Opportunity Created Sync` | `OpportunityCreate` | `event_type=OpportunityCreate` |
63
- | `opportunity_changed.json` | `Opportunity Changed Sync` | `OpportunityUpdate` | `event_type=OpportunityChange` |
64
- | `appointment_created.json` | `Appointment Created Sync` | `AppointmentCreate` | `event_type=AppointmentCreate` |
65
- | `appointment_changed.json` | `Appointment Changed Sync` | `AppointmentUpdate` | `event_type=AppointmentChange` |
66
-
67
- The 7th GHL workflow shown in the published list — **Pipeline Stage Changed
68
- Sync** — carries the same opportunity payload shape as Opportunity Changed
69
- Sync. The classifier aliases it onto `event_type=OpportunityChange`, so
70
- `opportunity_changed.json` covers it (replay with `workflow.name` swapped to
71
- verify).
72
-
73
- ### Real-payload shape and quirks
74
-
75
- - **Custom fields are flat top-level keys, not a `customFields[]` array.**
76
- Real GHL workflow webhooks emit every property field as a top-level body
77
- key by display name (`"Closer": "..."`, `"Average Electric Bill": "245"`,
78
- `"Shade?": "minimal"`). The `customFields[]` array shape only appears on
79
- REST API responses (`fixtures/api/`), not on webhook bodies. Most
80
- property keys come through as empty strings even when unset.
81
- - **Cross-domain spillage is normal.** Contact webhooks carry deal-tracking
82
- fields (`Setter`, `Closer`, `Total Contract Price`); opportunity webhooks
83
- carry property fields (`Average Electric Bill`, `Shade?`); appointment
84
- webhooks carry both. Don't assume Contact-side keys imply contact intent.
85
- - **HVAC twin keys.** Many fields have `(HVAC)` variants
86
- (`Closer` + `Closer (HVAC)`, `Setter Name` + `Setter Name (HVAC)`,
87
- `# Touches` + `# Touches HVAC`). Both ship even when only one branch is
88
- active.
89
- - **Numeric values can be raw JSON numbers**, not always strings
90
- (e.g. `"Outbound Dials": 8`, `"# Touches": 3`).
91
- - `customData` carries `{org, project, type}` — `type` is one of
92
- `"Contact"` / `"Opportunity"` / `"Appointment"`.
93
- - Top-level `attributionSource: {}` is typically empty; real attribution
94
- lives under `contact.attributionSource` and `contact.lastAttributionSource`.
95
- - `appoinmentStatus` (GHL typo) is the canonical key on appointment
96
- payloads. `appointment_changed.json` carries both `appoinmentStatus` and
97
- `appointmentStatus` with conflicting values to exercise typo precedence.
98
- - `pipleline_stage` (GHL typo) — `opportunity_changed.json` uses it
99
- instead of `pipeline_stage` to exercise handler/cart fallback
100
- (ghl.ts:1308, 1313, 1392).
101
- - Naive vs offset timestamps — `appointment_created.json` uses
102
- `-04:00`-offset times; `appointment_changed.json` uses naive
103
- `"2026-05-15T10:00:00"` (no offset) so `parseGhlTime` falls into its
104
- `fromZonedTime` branch.
105
- - `tags` on workflow webhooks is a comma-separated CSV string (with
106
- possible spaces, apostrophes, special chars); v2-native shape is a JSON
107
- array.
108
-
109
- API fixtures (`fixtures/api/`) keep the structured `customFields[]` array
110
- shape — that's the format the GHL REST API actually returns. The webhook
111
- flattening happens server-side inside GHL's workflow engine.
112
-
113
- ## API fixtures (`fixtures/api/`)
114
-
115
- Filename is derived from URL path + selected query keys (see `native/mock.jai`).
116
-
117
- ### Mock key derivation
118
-
119
- 1. Strip leading `/`, replace remaining `/` with `_`, drop the query string.
120
- 2. If query has `skip` + `limit`, append `_pageN` where `N = skip/limit + 1`.
121
- 3. If query has `startTime` + `endTime`, append `_window`.
122
- 4. Other query params (e.g. `model=contact|opportunity` on
123
- `customFields`) **do not** affect the key — a single fixture serves all
124
- variants of those query shapes.
125
-
126
- | Fixture | Endpoint | Notes |
127
- | --- | --- | --- |
128
- | `users_.json` | `GET /users/` | 3 users matching the `Setter`/`Closer` names in the webhook fixtures. |
129
- | `opportunities_pipelines.json` | `GET /opportunities/pipelines` | One pipeline (`pip_solar`) with 5 stages. |
130
- | `locations_loc_test_customFields.json` | `GET /locations/loc_test/customFields` | Union of contact-model and opportunity-model fields (mock dispatcher conflates the `?model=` query). |
131
- | `contacts__page1.json` | `GET /contacts/?skip=0&limit=100` | Alice + Bob with full scalars, `attributionSource`, `customFields[]`. |
132
- | `contacts__page2.json` | `GET /contacts/?skip=100&limit=100` | Empty terminator. |
133
- | `contacts_ctc_alice_001.json` | `GET /contacts/ctc_alice_001` | Single-contact envelope `{contact: {...}}`. |
134
- | `opportunities_search_page1.json` | `GET /opportunities/search?skip=0&limit=100` | One opp with custom fields and assignment metadata. |
135
- | `opportunities_search_page2.json` | `GET /opportunities/search?skip=100&limit=100` | Empty terminator. |
136
- | `opportunities_opp_001.json` | `GET /opportunities/opp_001` | Single-opp envelope `{opportunity: {...}}`. |
137
- | `calendars_.json` | `GET /calendars/` | One calendar with team members. |
138
- | `calendars_events_window.json` | `GET /calendars/events?calendarId=…&startTime=…&endTime=…` | One event matching `apt_001`'s **created** state, so an API-backfill replay sees the same starting point as the webhook stream's first appointment fixture. |
139
- | `calendars_blocked-slots_window.json` | `GET /calendars/blocked-slots?userId=…&startTime=…&endTime=…` | Empty `{slots: []}` so the task has a fixture instead of `mock_missing:`. |
140
-
141
- ## Replay loop (smoke target — harness still pending)
142
-
143
- ```sh
144
- GHL_MOCK_DIR=$(pwd)/cartridges/core/crm/adapters/gohighlevel/tests/fixtures \
145
- GHL_PIT_TOKEN=test GHL_LOCATION_ID=loc_test \
146
- ./console &
147
- for f in cartridges/core/crm/adapters/gohighlevel/tests/fixtures/webhooks/*.json; do
148
- curl -fsS -X POST -H 'Content-Type: application/json' --data-binary "@$f" \
149
- "http://localhost:8080/api/ingest/endpoint/receive?e=mylocalpro-ghl"
150
- done
151
- ```
152
-
153
- Assertion targets:
154
- - One `Payload` row per webhook fixture.
155
- - One `Execution` per `Payload`.
156
- - Classifier outputs match the table in "Webhook fixtures" above.
157
- - After running the API-fetch path against the same `GHL_MOCK_DIR`, the
158
- resulting CRM entity graph is congruent with the webhook-replay state
159
- (Alice's contact + Alice's opp at `ps_proposal_sent` + `apt_001` showed).