@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.
- package/package.json +1 -1
- package/stdlib/workflows/schema.nbt +69 -17
- package/vendor/linux-x64/cartridges/workflows/schema.nbt +69 -17
- package/vendor/linux-x64/console +0 -0
- package/vendor/linux-x64/nbt +0 -0
- package/stdlib/crm/adapters/gohighlevel/README.md +0 -85
- package/stdlib/crm/adapters/gohighlevel/tests/README.md +0 -159
- package/stdlib/crm/adapters/gohighlevel/tests/fixtures/webhooks/contact_138fields.json +0 -222
- package/stdlib/crm/adapters/gohighlevel/tests/fixtures/webhooks/contact_140fields.json +0 -219
- package/stdlib/crm/adapters/gohighlevel/tests/fixtures/webhooks/contact_alt.json +0 -212
- package/stdlib/crm/adapters/gohighlevel/tests/fixtures/webhooks/contact_changed.json +0 -102
- package/stdlib/crm/adapters/gohighlevel/tests/fixtures/webhooks/contact_created.json +0 -95
- package/stdlib/crm/adapters/gohighlevel/tests/fixtures/webhooks/contact_full.json +0 -213
- package/stdlib/crm/adapters/gohighlevel/tests/fixtures/webhooks/contact_sparse.json +0 -161
- package/stdlib/crm/adapters/gohighlevel/tests/fixtures/webhooks/contact_update_a.json +0 -197
- package/stdlib/crm/adapters/gohighlevel/tests/fixtures/webhooks/contact_update_b.json +0 -197
- package/stdlib/crm/adapters/gohighlevel/tests/fixtures/webhooks/opportunity_changed.json +0 -85
- package/stdlib/crm/adapters/gohighlevel/tests/fixtures/webhooks/opportunity_created.json +0 -85
- package/stdlib/crm/adapters/gohighlevel/tests/fixtures/webhooks/v2_contact_pilot.json +0 -43
- package/stdlib/crm/adapters/gohighlevel/tests/fixtures/webhooks/v2_contact_with_price_closed.json +0 -7
- package/stdlib/crm/adapters/gohighlevel/tests/fixtures/webhooks/v2_contact_with_price_open.json +0 -7
- package/stdlib/crm/adapters/gohighlevel/tests/fixtures/webhooks/v2_event_appointment_delete.json +0 -1
- package/stdlib/crm/adapters/gohighlevel/tests/fixtures/webhooks/v2_event_calendar_update.json +0 -1
- package/stdlib/crm/adapters/gohighlevel/tests/fixtures/webhooks/v2_event_contact_create.json +0 -1
- package/stdlib/crm/adapters/gohighlevel/tests/fixtures/webhooks/v2_event_opp_status_update.json +0 -1
- package/stdlib/crm/adapters/gohighlevel/tests/fixtures/webhooks/v2_opportunity_pilot.json +0 -16
- package/stdlib/crm/adapters/gohighlevel/tests/fixtures/webhooks/v2_pipelines_pilot.json +0 -137
- package/stdlib/design/migrations/20260501210107_initial/migration.nbt +0 -19
- package/stdlib/design/migrations/20260501210107_initial/schema_snapshot.nbt +0 -21
- package/stdlib/design/migrations/20260610130000_design_system/migration.nbt +0 -50
- package/stdlib/design/migrations/20260610130000_design_system/schema_snapshot.nbt +0 -80
- package/stdlib/design/schema.nbt +0 -140
- package/vendor/linux-x64/cartridges/design/migrations/20260501210107_initial/migration.nbt +0 -19
- package/vendor/linux-x64/cartridges/design/migrations/20260501210107_initial/schema_snapshot.nbt +0 -21
- package/vendor/linux-x64/cartridges/design/migrations/20260610130000_design_system/migration.nbt +0 -50
- package/vendor/linux-x64/cartridges/design/migrations/20260610130000_design_system/schema_snapshot.nbt +0 -80
- package/vendor/linux-x64/cartridges/design/schema.nbt +0 -140
package/package.json
CHANGED
|
@@ -1,40 +1,92 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
entity
|
|
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
|
-
|
|
11
|
+
version: u32
|
|
12
|
+
source: string
|
|
12
13
|
contentHash: string
|
|
13
|
-
queue?: string
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
#
|
|
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
|
-
#
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
entity
|
|
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
|
-
|
|
11
|
+
version: u32
|
|
12
|
+
source: string
|
|
12
13
|
contentHash: string
|
|
13
|
-
queue?: string
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
#
|
|
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
|
-
#
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
}
|
package/vendor/linux-x64/console
CHANGED
|
Binary file
|
package/vendor/linux-x64/nbt
CHANGED
|
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).
|