sizmo 0.4.1 → 0.5.0
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/README.md +38 -0
- package/commands/crm.mjs +258 -0
- package/commands/noshow.mjs +46 -5
- package/commands/pipeline.mjs +87 -14
- package/commands/reconcile.mjs +46 -4
- package/commands/snapshot.mjs +67 -9
- package/commands/sync.mjs +84 -0
- package/docs/how-to/crm-model.md +105 -0
- package/lib/context.mjs +36 -1
- package/lib/model.mjs +213 -0
- package/lib/registry.mjs +3 -1
- package/lib/resolver.mjs +137 -0
- package/package.json +3 -3
package/commands/snapshot.mjs
CHANGED
|
@@ -2,8 +2,11 @@
|
|
|
2
2
|
// Trust-fix #1: LOC from ctx.cfg.loc (no baked default).
|
|
3
3
|
// Trust-fix #2: leads() and revenue() paginate to completion.
|
|
4
4
|
// Trust-fix #3: revenue tracks per-currency (never cross-sums).
|
|
5
|
+
// v0.5.0: calendar list from CRM model; location currency from model.
|
|
6
|
+
// v0.6.0 (C2): modelMeta emitted in JSON envelope; TTY staleness note.
|
|
5
7
|
import { paginate } from '../lib/paginate.mjs';
|
|
6
8
|
import { mapLimit } from '../lib/pool.mjs';
|
|
9
|
+
import { ENTITY_SPECS } from '../lib/model.mjs';
|
|
7
10
|
|
|
8
11
|
export const meta = {
|
|
9
12
|
name: 'snapshot',
|
|
@@ -31,6 +34,35 @@ export async function collect(args, ctx) {
|
|
|
31
34
|
return Number.isFinite(t) && t >= START && t <= NOW;
|
|
32
35
|
};
|
|
33
36
|
|
|
37
|
+
// Location currency from CRM model (fallback PHP if model missing/blocked)
|
|
38
|
+
let locationCurrency = 'PHP';
|
|
39
|
+
let _snapshotModelLoaded = null;
|
|
40
|
+
let modelMeta = null;
|
|
41
|
+
if (ctx.ensureModel) {
|
|
42
|
+
try {
|
|
43
|
+
_snapshotModelLoaded = await ctx.ensureModel();
|
|
44
|
+
const locCur = _snapshotModelLoaded?.entities?.location?.item?.business?.currency
|
|
45
|
+
|| _snapshotModelLoaded?.entities?.location?.item?.currency;
|
|
46
|
+
if (locCur) locationCurrency = locCur.toUpperCase();
|
|
47
|
+
} catch { /* use default */ }
|
|
48
|
+
} else if (ctx.cfg.currency) {
|
|
49
|
+
locationCurrency = ctx.cfg.currency;
|
|
50
|
+
}
|
|
51
|
+
// Build modelMeta for the JSON envelope (C2)
|
|
52
|
+
if (_snapshotModelLoaded) {
|
|
53
|
+
const specMap = Object.fromEntries(ENTITY_SPECS.map(s => [s.name, s]));
|
|
54
|
+
// Check calendars staleness (the main model-sourced entity in snapshot)
|
|
55
|
+
const calEnt = _snapshotModelLoaded.entities?.calendars;
|
|
56
|
+
const calSpec = specMap.calendars;
|
|
57
|
+
const calStale = calEnt && calSpec ? (NOW - (calEnt.fetchedAt ?? 0)) > calSpec.ttlMs : false;
|
|
58
|
+
modelMeta = {
|
|
59
|
+
syncedAt: _snapshotModelLoaded.syncedAt,
|
|
60
|
+
ageMs: NOW - _snapshotModelLoaded.syncedAt,
|
|
61
|
+
stale: calStale,
|
|
62
|
+
offline: !!(_snapshotModelLoaded.offline),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
34
66
|
// ── LEADS: paginate contacts newest→older, stop past window ──
|
|
35
67
|
async function leads() {
|
|
36
68
|
let count = 0, pages = 0, oldest = null;
|
|
@@ -78,12 +110,27 @@ export async function collect(args, ctx) {
|
|
|
78
110
|
// Full fix = date-window splitting; tracked as follow-up. Cheap mitigation: warn + degrade.
|
|
79
111
|
const EVENTS_CAP = 100;
|
|
80
112
|
async function bookings() {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
113
|
+
// Get calendar list from the CRM model if available; fall back to live fetch.
|
|
114
|
+
// Use _snapshotModelLoaded already loaded above — no second ensureModel call.
|
|
115
|
+
let cals = null;
|
|
116
|
+
if (_snapshotModelLoaded?.entities?.calendars && !_snapshotModelLoaded.entities.calendars.blocked && !_snapshotModelLoaded.entities.calendars.networkError) {
|
|
117
|
+
cals = _snapshotModelLoaded.entities.calendars.items ?? [];
|
|
118
|
+
} else if (!_snapshotModelLoaded && ctx.ensureModel) {
|
|
119
|
+
try {
|
|
120
|
+
const model = await ctx.ensureModel();
|
|
121
|
+
if (model?.entities?.calendars && !model.entities.calendars.blocked && !model.entities.calendars.networkError) {
|
|
122
|
+
cals = model.entities.calendars.items ?? [];
|
|
123
|
+
}
|
|
124
|
+
} catch { /* fall through to live fetch */ }
|
|
125
|
+
}
|
|
126
|
+
if (cals === null) {
|
|
127
|
+
const cr = await ctx.http.get('/calendars/', { query: { locationId: LOC }, version: '2021-04-15' });
|
|
128
|
+
if (!cr.ok) return [
|
|
129
|
+
metric('Bookings', null, { blocked: true, blocker: `calendars list HTTP ${cr.code}` }),
|
|
130
|
+
metric('Show rate', null, { blocked: true, blocker: 'no calendars' }),
|
|
131
|
+
];
|
|
132
|
+
cals = cr.j.calendars || [];
|
|
133
|
+
}
|
|
87
134
|
let booked = 0, showed = 0, noshow = 0, calsHit = 0, skippedCalendars = 0;
|
|
88
135
|
// Parallel fan-out, capped at 5 concurrent (GHL rate-limit-safe: 100 req/10s; 5 concurrent is well under).
|
|
89
136
|
// ONLY the independent per-calendar fetches are parallelized — pagination pages stay sequential.
|
|
@@ -152,7 +199,7 @@ export async function collect(args, ctx) {
|
|
|
152
199
|
const when = t.createdAt || t.created_at || t.dateAdded;
|
|
153
200
|
const ok = (t.status || t.paymentStatus || '').toLowerCase();
|
|
154
201
|
if (inWindow(when) && (ok === 'succeeded' || ok === 'success' || ok === 'paid' || ok === 'completed' || ok === 'captured')) {
|
|
155
|
-
const cur = (t.currency ||
|
|
202
|
+
const cur = (t.currency || locationCurrency).toUpperCase();
|
|
156
203
|
byCur[cur] = byCur[cur] || { sum: 0, n: 0 };
|
|
157
204
|
byCur[cur].sum += Number(t.amount) || 0;
|
|
158
205
|
byCur[cur].n++;
|
|
@@ -163,7 +210,7 @@ export async function collect(args, ctx) {
|
|
|
163
210
|
// If only one currency, match original format; multi-currency → list all
|
|
164
211
|
const entries = Object.entries(byCur);
|
|
165
212
|
if (entries.length === 0)
|
|
166
|
-
return metric('Collected', money(0,
|
|
213
|
+
return metric('Collected', money(0, locationCurrency), { note: `0 payment(s) · ${totalScanned} txns scanned` });
|
|
167
214
|
if (entries.length === 1) {
|
|
168
215
|
const [cur, { sum, n }] = entries[0];
|
|
169
216
|
return metric('Collected', money(sum, cur), { note: `${n} payment(s) · ${totalScanned} txns scanned` });
|
|
@@ -195,7 +242,7 @@ export async function collect(args, ctx) {
|
|
|
195
242
|
sum += Number(o.monetaryValue || o.monetary_value || 0) || 0;
|
|
196
243
|
n++;
|
|
197
244
|
}
|
|
198
|
-
return metric('Pipeline value', money(sum), { note: `${n} open deal(s)` });
|
|
245
|
+
return metric('Pipeline value', money(sum, locationCurrency), { note: `${n} open deal(s)` });
|
|
199
246
|
}
|
|
200
247
|
|
|
201
248
|
// ── REPLY RATE ──
|
|
@@ -223,6 +270,7 @@ export async function collect(args, ctx) {
|
|
|
223
270
|
location: LOC,
|
|
224
271
|
window: { days: DAYS, startISO, endISO: new Date(NOW).toISOString() },
|
|
225
272
|
metrics: rows,
|
|
273
|
+
...(modelMeta ? { modelMeta } : {}),
|
|
226
274
|
};
|
|
227
275
|
}
|
|
228
276
|
|
|
@@ -239,6 +287,16 @@ export async function run(args, ctx) {
|
|
|
239
287
|
const line = (l, v) => '│ ' + l.padEnd(16) + ' ' + String(v).padEnd(W - 20) + '│';
|
|
240
288
|
ctx.out.line('┌' + '─'.repeat(W - 2) + '┐');
|
|
241
289
|
ctx.out.line(line('SNAPSHOT', winLabel.slice(0, W - 20)));
|
|
290
|
+
// C2: model staleness note
|
|
291
|
+
if (data.modelMeta) {
|
|
292
|
+
const mm = data.modelMeta;
|
|
293
|
+
if (mm.offline) {
|
|
294
|
+
ctx.out.line(line('· model', 'OFFLINE — calendar list from cache'));
|
|
295
|
+
} else if (mm.stale) {
|
|
296
|
+
const ageD = Math.round(mm.ageMs / 86400000);
|
|
297
|
+
ctx.out.line(line('· model', `${ageD}d old — run sizmo sync`));
|
|
298
|
+
}
|
|
299
|
+
}
|
|
242
300
|
ctx.out.line('├' + '─'.repeat(W - 2) + '┤');
|
|
243
301
|
for (const m of data.metrics) {
|
|
244
302
|
if (m.blocked) {
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// commands/sync.mjs — force-refresh the local CRM model.
|
|
2
|
+
// Fetches all 6 structure entities (or a subset) and stores the blob.
|
|
3
|
+
// READ-ONLY. Only writes to the local model file; never writes to GoHighLevel.
|
|
4
|
+
import { syncModel, ENTITY_SPECS, DEFAULT_MODEL_DIR } from '../lib/model.mjs';
|
|
5
|
+
|
|
6
|
+
export const meta = {
|
|
7
|
+
name: 'sync',
|
|
8
|
+
summary: 'Refresh the local CRM model (pipelines, calendars, tags, fields, users, location)',
|
|
9
|
+
flags: [],
|
|
10
|
+
readOnly: true,
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export async function run(args, ctx) {
|
|
14
|
+
const dir = ctx._modelDir ?? DEFAULT_MODEL_DIR;
|
|
15
|
+
const loc = ctx.cfg.loc;
|
|
16
|
+
const now = typeof ctx.now === 'function' ? ctx.now : () => ctx.now;
|
|
17
|
+
|
|
18
|
+
// Optional: sync only one entity — `sizmo sync tags`
|
|
19
|
+
const entityArg = args._?.[0];
|
|
20
|
+
const validNames = ENTITY_SPECS.map(s => s.name);
|
|
21
|
+
// Also accept 'fields' as alias for 'customFields'
|
|
22
|
+
const alias = { fields: 'customFields' };
|
|
23
|
+
const resolvedArg = entityArg ? (alias[entityArg] ?? entityArg) : null;
|
|
24
|
+
const only = resolvedArg ? [resolvedArg] : null;
|
|
25
|
+
|
|
26
|
+
if (resolvedArg && !validNames.includes(resolvedArg)) {
|
|
27
|
+
ctx.out.warn(`unknown entity "${entityArg}" — valid: ${validNames.join(', ')}`);
|
|
28
|
+
return 1;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let model;
|
|
32
|
+
try {
|
|
33
|
+
model = await syncModel({ http: ctx.http, loc, dir, now, only });
|
|
34
|
+
} catch (e) {
|
|
35
|
+
if (e.offline) {
|
|
36
|
+
ctx.out.warn("⚠ OFFLINE — can't reach GoHighLevel — check your connection; run `sizmo sync` when online");
|
|
37
|
+
return 1;
|
|
38
|
+
}
|
|
39
|
+
// Write failure (disk full, permissions, etc.)
|
|
40
|
+
ctx.out.warn(`sync failed — model not written: ${e.message}`);
|
|
41
|
+
return 1;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Count results
|
|
45
|
+
let synced = 0, blocked = 0, networkErrors = 0;
|
|
46
|
+
for (const [, ent] of Object.entries(model.entities)) {
|
|
47
|
+
if (ent.networkError) networkErrors++;
|
|
48
|
+
else if (ent.blocked) blocked++;
|
|
49
|
+
else synced++;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
ctx.out.data({ synced, blocked, networkErrors: networkErrors || undefined, offline: model.offline || undefined,
|
|
53
|
+
locationId: loc, syncedAt: model.syncedAt, entities: Object.fromEntries(
|
|
54
|
+
Object.entries(model.entities).map(([name, ent]) => [
|
|
55
|
+
name,
|
|
56
|
+
ent.networkError
|
|
57
|
+
? { networkError: true, error: ent.error }
|
|
58
|
+
: ent.blocked
|
|
59
|
+
? { blocked: true, scope: ent.scope }
|
|
60
|
+
: { fetchedAt: ent.fetchedAt, count: ent.items ? ent.items.length : (ent.item ? 1 : 0) },
|
|
61
|
+
])
|
|
62
|
+
)});
|
|
63
|
+
|
|
64
|
+
ctx.out.card(() => {
|
|
65
|
+
const blockedNote = blocked > 0 ? ` (${blocked} scope-blocked)` : '';
|
|
66
|
+
const netNote = networkErrors > 0 ? ` (${networkErrors} network-error — check connection)` : '';
|
|
67
|
+
ctx.out.line(`synced ${synced} of ${synced + blocked + networkErrors} entities${blockedNote}${netNote} · loc ${loc}`);
|
|
68
|
+
for (const [name, ent] of Object.entries(model.entities)) {
|
|
69
|
+
if (ent.networkError) {
|
|
70
|
+
ctx.out.line(` ⚠ ${name.padEnd(14)} couldn't reach GHL`);
|
|
71
|
+
} else if (ent.blocked) {
|
|
72
|
+
ctx.out.line(` ✖ ${name.padEnd(14)} needs ${ent.scope}`);
|
|
73
|
+
} else {
|
|
74
|
+
const count = ent.items ? ent.items.length : (ent.item ? 1 : 0);
|
|
75
|
+
ctx.out.line(` ✔ ${name.padEnd(14)} ${count} item(s)`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (model.offline) {
|
|
79
|
+
ctx.out.line(' ⚠ some entities could not be reached — model may be partially stale');
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
return 0;
|
|
84
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# CRM model — how it works
|
|
2
|
+
|
|
3
|
+
`sizmo` keeps a local copy of your CRM's structure — pipelines + stages, calendars, tags, custom fields, users, and location info. Recipes read from this cache instead of re-fetching on every run.
|
|
4
|
+
|
|
5
|
+
## What is cached
|
|
6
|
+
|
|
7
|
+
| Entity | Endpoint | TTL | Key fields |
|
|
8
|
+
|--------|----------|-----|-----------|
|
|
9
|
+
| pipelines + stages | `GET /opportunities/pipelines` | 24h | pipeline `{id,name}`, stages `[{id,name,position}]` |
|
|
10
|
+
| calendars | `GET /calendars/` | 24h | `{id,name,calendarType,isActive}` |
|
|
11
|
+
| tags | `GET /locations/{loc}/tags` | 12h | `{id,name}` |
|
|
12
|
+
| customFields | `GET /locations/{loc}/customFields` | 12h | `{id,name,fieldKey,dataType}` |
|
|
13
|
+
| users | `GET /users/` | 24h | `{id,firstName,lastName,email}` |
|
|
14
|
+
| location | `GET /locations/{loc}` | 24h | `{name,timezone,currency,country}` |
|
|
15
|
+
|
|
16
|
+
Stored at `~/.config/sizmo/model/<locationId>.json`. Written atomically (temp + rename), permissions 0600.
|
|
17
|
+
|
|
18
|
+
## First run
|
|
19
|
+
|
|
20
|
+
The model is synced automatically the first time a command needs it. Nothing to do.
|
|
21
|
+
|
|
22
|
+
## Keeping the model fresh
|
|
23
|
+
|
|
24
|
+
Run `sizmo sync` after:
|
|
25
|
+
- Adding or renaming a pipeline stage
|
|
26
|
+
- Adding or removing a calendar
|
|
27
|
+
- Adding custom fields or tags
|
|
28
|
+
- Onboarding a new user
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
sizmo sync # full refresh (6 single-page calls, rate-safe)
|
|
32
|
+
sizmo sync tags # refresh one entity
|
|
33
|
+
sizmo sync customFields
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Valid entity names: `pipelines`, `calendars`, `tags`, `customFields` (or `fields`), `users`, `location`.
|
|
37
|
+
|
|
38
|
+
## Querying the model
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
sizmo crm # overview: count + age per entity
|
|
42
|
+
sizmo crm pipelines # list pipelines + stages
|
|
43
|
+
sizmo crm calendars # list calendars
|
|
44
|
+
sizmo crm tags # list tags (first 20)
|
|
45
|
+
sizmo crm tags --all # list all tags
|
|
46
|
+
sizmo crm fields # list custom fields
|
|
47
|
+
sizmo crm users # list users
|
|
48
|
+
sizmo crm location # timezone / currency / country
|
|
49
|
+
|
|
50
|
+
# Machine output:
|
|
51
|
+
sizmo crm pipelines --json # includes _meta with source/syncedAt/ageMs/stale
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Honest staleness
|
|
55
|
+
|
|
56
|
+
Every model read shows the age of the data. Stale means the entity has passed its TTL (24h or 12h).
|
|
57
|
+
|
|
58
|
+
- Fresh (under TTL): no special marker
|
|
59
|
+
- Stale (past TTL): `⚠ STALE — run sizmo sync` banner; served anyway
|
|
60
|
+
- Blocked (scope missing): `✖ needs <scope>`; other entities still served
|
|
61
|
+
|
|
62
|
+
The CLI **never** auto-syncs mid-recipe when stale — it serves the cache and warns. This avoids surprise network calls and rate-limit hits. Use `sizmo sync` to force a refresh.
|
|
63
|
+
|
|
64
|
+
The `--json` output includes `_meta` so agents can branch without parsing prose:
|
|
65
|
+
|
|
66
|
+
```json
|
|
67
|
+
{
|
|
68
|
+
"_meta": {
|
|
69
|
+
"source": "cache",
|
|
70
|
+
"syncedAt": 1718000000000,
|
|
71
|
+
"ageMs": 3600000,
|
|
72
|
+
"stale": false,
|
|
73
|
+
"offline": false
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Name resolution
|
|
79
|
+
|
|
80
|
+
Recipes use names (not IDs) in output when the model is available. If an ID is not found in the model (renamed stage, deleted calendar), the output shows:
|
|
81
|
+
|
|
82
|
+
```
|
|
83
|
+
<unknown:<id> — run sizmo sync>
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
This is the resolver's miss path. It never fabricates a name. Run `sizmo sync` to refresh the model after structural changes.
|
|
87
|
+
|
|
88
|
+
## Partial sync (missing scopes)
|
|
89
|
+
|
|
90
|
+
A 401 or 403 on one entity marks it blocked and stores what succeeded. `sizmo crm` shows blocked entities with `✖ needs <scope>`. Other entities are unaffected.
|
|
91
|
+
|
|
92
|
+
Required scopes for a full sync:
|
|
93
|
+
|
|
94
|
+
```
|
|
95
|
+
opportunities.readonly
|
|
96
|
+
calendars.readonly
|
|
97
|
+
locations/tags.readonly
|
|
98
|
+
locations/customFields.readonly
|
|
99
|
+
users.readonly
|
|
100
|
+
locations.readonly
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Schema versioning
|
|
104
|
+
|
|
105
|
+
The model blob has a `schemaVersion` field. A format change bumps this version and invalidates the cached file (treated as missing, triggering a re-sync on next use).
|
package/lib/context.mjs
CHANGED
|
@@ -3,6 +3,8 @@ import { makeHttp } from './http.mjs';
|
|
|
3
3
|
import { makeOut } from './output.mjs';
|
|
4
4
|
import { makeCache } from './cache.mjs';
|
|
5
5
|
import { GhlError, EXIT } from './errors.mjs';
|
|
6
|
+
import { loadModel, syncModel, DEFAULT_MODEL_DIR } from './model.mjs';
|
|
7
|
+
import { makeResolver } from './resolver.mjs';
|
|
6
8
|
import { homedir } from 'node:os';
|
|
7
9
|
import { join } from 'node:path';
|
|
8
10
|
import { env as processEnv } from 'node:process';
|
|
@@ -29,5 +31,38 @@ export function buildCtx({ creds, globals, now = Date.now(), httpFactory = makeH
|
|
|
29
31
|
return r;
|
|
30
32
|
},
|
|
31
33
|
};
|
|
32
|
-
|
|
34
|
+
|
|
35
|
+
// CRM model — lazy, loaded once per ctx. Auto-syncs if missing.
|
|
36
|
+
// Recipes read ctx.model (the raw blob) and ctx.resolve (the resolver).
|
|
37
|
+
// We expose a lazy getter so commands that don't need the model pay nothing.
|
|
38
|
+
let _model = undefined; // undefined = not yet loaded; null = load attempted, none found + sync ran
|
|
39
|
+
let _resolver = undefined;
|
|
40
|
+
|
|
41
|
+
async function ensureModel() {
|
|
42
|
+
if (_model !== undefined) return _model;
|
|
43
|
+
_model = loadModel(creds.loc, DEFAULT_MODEL_DIR);
|
|
44
|
+
if (!_model) {
|
|
45
|
+
// Auto-sync on first use (model missing)
|
|
46
|
+
try {
|
|
47
|
+
_model = await syncModel({ http: rawHttp, loc: creds.loc, now: () => now });
|
|
48
|
+
} catch {
|
|
49
|
+
_model = null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
_resolver = makeResolver(_model, { now: () => now });
|
|
53
|
+
return _model;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
http,
|
|
58
|
+
cfg: creds,
|
|
59
|
+
out,
|
|
60
|
+
now,
|
|
61
|
+
// Expose model access for recipes
|
|
62
|
+
get model() { return _model; },
|
|
63
|
+
// ensureModel() → async lazy-loads + returns model
|
|
64
|
+
ensureModel,
|
|
65
|
+
// resolve(kind, id) → sync resolver call (after ensureModel was awaited)
|
|
66
|
+
get resolve() { return _resolver; },
|
|
67
|
+
};
|
|
33
68
|
}
|
package/lib/model.mjs
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// lib/model.mjs — per-profile CRM structure store + sync.
|
|
2
|
+
// Caches 6 slow-changing GHL entities (pipelines, calendars, tags, customFields, users, location)
|
|
3
|
+
// in a single JSON blob per location at ~/.config/sizmo/model/<loc>.json.
|
|
4
|
+
// Atomic write (temp+rename, same-dir to avoid EXDEV), 0600, per-entity age tracked, partial-sync-safe.
|
|
5
|
+
// READ-ONLY. No writes to GoHighLevel.
|
|
6
|
+
import { mkdirSync, writeFileSync, readFileSync, renameSync, unlinkSync } from 'node:fs';
|
|
7
|
+
import { homedir } from 'node:os';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { env as processEnv } from 'node:process';
|
|
10
|
+
import { mapLimit } from './pool.mjs';
|
|
11
|
+
|
|
12
|
+
const XDG = processEnv.XDG_CONFIG_HOME || join(homedir(), '.config');
|
|
13
|
+
export const DEFAULT_MODEL_DIR = join(XDG, 'sizmo', 'model');
|
|
14
|
+
export const SCHEMA_VERSION = 1;
|
|
15
|
+
|
|
16
|
+
// TTLs: pipelines/calendars/users/location 24h; tags/customFields 12h
|
|
17
|
+
const H24 = 24 * 60 * 60 * 1000;
|
|
18
|
+
const H12 = 12 * 60 * 60 * 1000;
|
|
19
|
+
|
|
20
|
+
// Entity specs — each describes how to fetch + parse one CRM entity.
|
|
21
|
+
// buildPath(loc) → the API path segment; version → GHL API Version header;
|
|
22
|
+
// scope → human-readable scope name for blocked messages;
|
|
23
|
+
// ttlMs → staleness threshold; extract(json) → items[] or item{}.
|
|
24
|
+
export const ENTITY_SPECS = [
|
|
25
|
+
{
|
|
26
|
+
name: 'pipelines',
|
|
27
|
+
buildPath: (loc) => `/opportunities/pipelines?locationId=${loc}`,
|
|
28
|
+
version: '2021-07-28',
|
|
29
|
+
scope: 'opportunities.readonly',
|
|
30
|
+
ttlMs: H24,
|
|
31
|
+
extract: (j) => ({ items: j?.pipelines ?? [] }),
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
name: 'calendars',
|
|
35
|
+
buildPath: (loc) => `/calendars/?locationId=${loc}`,
|
|
36
|
+
version: '2021-04-15',
|
|
37
|
+
scope: 'calendars.readonly',
|
|
38
|
+
ttlMs: H24,
|
|
39
|
+
extract: (j) => ({ items: j?.calendars ?? [] }),
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
name: 'tags',
|
|
43
|
+
buildPath: (loc) => `/locations/${loc}/tags`,
|
|
44
|
+
version: '2021-07-28',
|
|
45
|
+
scope: 'locations/tags.readonly',
|
|
46
|
+
ttlMs: H12,
|
|
47
|
+
extract: (j) => ({ items: j?.tags ?? [] }),
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
name: 'customFields',
|
|
51
|
+
buildPath: (loc) => `/locations/${loc}/customFields?model=all`,
|
|
52
|
+
version: '2021-07-28',
|
|
53
|
+
scope: 'locations/customFields.readonly',
|
|
54
|
+
ttlMs: H12,
|
|
55
|
+
extract: (j) => ({ items: j?.customFields ?? [] }),
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
name: 'users',
|
|
59
|
+
buildPath: (loc) => `/users/?locationId=${loc}`,
|
|
60
|
+
version: '2021-07-28',
|
|
61
|
+
scope: 'users.readonly',
|
|
62
|
+
ttlMs: H24,
|
|
63
|
+
extract: (j) => ({ items: j?.users ?? [] }),
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
name: 'location',
|
|
67
|
+
buildPath: (loc) => `/locations/${loc}`,
|
|
68
|
+
version: '2021-07-28',
|
|
69
|
+
scope: 'locations.readonly',
|
|
70
|
+
ttlMs: H24,
|
|
71
|
+
extract: (j) => ({ item: j?.location ?? {} }),
|
|
72
|
+
},
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* syncModel — fetch (up to) 6 entities and write the blob atomically.
|
|
77
|
+
* @param {object} opts
|
|
78
|
+
* @param {object} opts.http ctx.http (get returns {code,ok,j})
|
|
79
|
+
* @param {string} opts.loc locationId
|
|
80
|
+
* @param {string} [opts.dir] override default model dir (for tests)
|
|
81
|
+
* @param {Function} [opts.now] injectable clock () => ms
|
|
82
|
+
* @param {string[]} [opts.only] subset of entity names (partial sync; for `sync <entity>`)
|
|
83
|
+
* @returns {object} the written model blob, with an `offline` boolean property:
|
|
84
|
+
* true = at least one entity hit a network/transport error (couldn't reach GHL at all).
|
|
85
|
+
* false = all entities either succeeded or were blocked by an HTTP 401/403 scope error.
|
|
86
|
+
* @throws {Error} if the model is missing AND all fetches hit network errors (cold+offline).
|
|
87
|
+
* Caller must show a real error — do NOT display a fresh-looking empty model in this case.
|
|
88
|
+
*/
|
|
89
|
+
export async function syncModel({ http, loc, dir = DEFAULT_MODEL_DIR, now = Date.now, only = null } = {}) {
|
|
90
|
+
const specs = only ? ENTITY_SPECS.filter(s => only.includes(s.name)) : ENTITY_SPECS;
|
|
91
|
+
const syncedAt = now();
|
|
92
|
+
|
|
93
|
+
// Start from any existing model (keep entities not in this sync run)
|
|
94
|
+
const base = loadModel(loc, dir) || {};
|
|
95
|
+
const hadExistingModel = !!(base.entities && Object.keys(base.entities).length > 0);
|
|
96
|
+
const entities = base.entities ? { ...base.entities } : {};
|
|
97
|
+
|
|
98
|
+
let networkErrorCount = 0;
|
|
99
|
+
|
|
100
|
+
await mapLimit(specs, 5, async (spec) => {
|
|
101
|
+
const path = spec.buildPath(loc);
|
|
102
|
+
let r;
|
|
103
|
+
try {
|
|
104
|
+
r = await http.get(path, spec.version !== '2021-07-28' ? { version: spec.version } : undefined);
|
|
105
|
+
} catch (e) {
|
|
106
|
+
// Transport/network failure — couldn't reach GHL at all. Distinct from auth errors.
|
|
107
|
+
networkErrorCount++;
|
|
108
|
+
entities[spec.name] = { networkError: true, error: e?.message ?? 'network error', fetchedAt: now() };
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
// http.get returned code:0 signals a network-level failure (no response from server)
|
|
112
|
+
if (r.code === 0) {
|
|
113
|
+
networkErrorCount++;
|
|
114
|
+
entities[spec.name] = { networkError: true, error: r.message ?? 'no response', fetchedAt: now() };
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (r.code === 401 || r.code === 403) {
|
|
118
|
+
// Scope/auth blocked — this is NOT a network error. Clearly distinguished.
|
|
119
|
+
entities[spec.name] = { blocked: true, scope: spec.scope, fetchedAt: now() };
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (!r.ok) {
|
|
123
|
+
// Other HTTP errors (5xx, 404, etc.) — mark blocked with code, NOT networkError.
|
|
124
|
+
entities[spec.name] = { blocked: true, scope: spec.scope, httpCode: r.code, fetchedAt: now() };
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const extracted = spec.extract(r.j);
|
|
128
|
+
entities[spec.name] = { fetchedAt: now(), ...extracted };
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const offline = networkErrorCount > 0;
|
|
132
|
+
|
|
133
|
+
// Cold + offline: no existing model AND all/some fetches hit network errors.
|
|
134
|
+
// Do NOT write a fresh-looking empty blob. Throw so the caller shows a real error.
|
|
135
|
+
if (!hadExistingModel && offline) {
|
|
136
|
+
const err = new Error(
|
|
137
|
+
"can't reach GoHighLevel — check your connection; run `sizmo sync` when online"
|
|
138
|
+
);
|
|
139
|
+
err.offline = true;
|
|
140
|
+
err.networkErrorCount = networkErrorCount;
|
|
141
|
+
throw err;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const model = {
|
|
145
|
+
schemaVersion: SCHEMA_VERSION,
|
|
146
|
+
locationId: loc,
|
|
147
|
+
syncedAt,
|
|
148
|
+
entities,
|
|
149
|
+
offline,
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
writeModelAtomic(loc, model, dir);
|
|
153
|
+
return model;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* loadModel — read the blob for a location. Returns null if missing, corrupt,
|
|
158
|
+
* or schemaVersion mismatch (caller must re-sync).
|
|
159
|
+
*/
|
|
160
|
+
export function loadModel(loc, dir = DEFAULT_MODEL_DIR) {
|
|
161
|
+
const path = join(dir, `${loc}.json`);
|
|
162
|
+
try {
|
|
163
|
+
const raw = readFileSync(path, 'utf8');
|
|
164
|
+
const parsed = JSON.parse(raw);
|
|
165
|
+
if (!parsed || parsed.schemaVersion !== SCHEMA_VERSION) return null;
|
|
166
|
+
return parsed;
|
|
167
|
+
} catch {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* isStale — true if the entity's fetchedAt is older than ttlMs relative to now.
|
|
174
|
+
* @param {object} entity model.entities[name]
|
|
175
|
+
* @param {number} nowMs current time in ms
|
|
176
|
+
* @param {number} ttlMs TTL for this entity type
|
|
177
|
+
*/
|
|
178
|
+
export function isStale(entity, nowMs, ttlMs) {
|
|
179
|
+
if (!entity || entity.blocked || entity.networkError || typeof entity.fetchedAt !== 'number') return true;
|
|
180
|
+
return (nowMs - entity.fetchedAt) > ttlMs;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* ageMs — ms since this entity was last fetched.
|
|
185
|
+
*/
|
|
186
|
+
export function ageMs(entity, nowMs) {
|
|
187
|
+
if (!entity || typeof entity.fetchedAt !== 'number') return null;
|
|
188
|
+
return nowMs - entity.fetchedAt;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ── internal ──────────────────────────────────────────────────────────────────
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* writeModelAtomic — write model to disk with a same-directory temp+rename pattern.
|
|
195
|
+
* Same-dir temp avoids EXDEV (cross-filesystem rename failure) that occurs when tmpdir()
|
|
196
|
+
* is on a different mount than the model dir. Throws on any write failure so callers can
|
|
197
|
+
* surface the error (M1 + M2 fix).
|
|
198
|
+
*/
|
|
199
|
+
function writeModelAtomic(loc, model, dir) {
|
|
200
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
201
|
+
const dest = join(dir, `${loc}.json`);
|
|
202
|
+
// Same directory as dest — no cross-FS EXDEV risk.
|
|
203
|
+
const tmp = join(dir, `.${loc}.json.tmp.${process.pid}`);
|
|
204
|
+
try {
|
|
205
|
+
writeFileSync(tmp, JSON.stringify(model, null, 2), { mode: 0o600 });
|
|
206
|
+
renameSync(tmp, dest);
|
|
207
|
+
} catch (e) {
|
|
208
|
+
// Clean up orphaned temp file on failure; ignore cleanup error.
|
|
209
|
+
try { unlinkSync(tmp); } catch { /* ignore */ }
|
|
210
|
+
// Re-throw so the caller (sync command) can surface "sync failed — nothing written".
|
|
211
|
+
throw e;
|
|
212
|
+
}
|
|
213
|
+
}
|
package/lib/registry.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// lib/registry.mjs — name → lazy loader. All
|
|
1
|
+
// lib/registry.mjs — name → lazy loader. All 12 commands run in-process (v0.5 importable-core).
|
|
2
2
|
export const registry = {
|
|
3
3
|
snapshot: () => import('../commands/snapshot.mjs'),
|
|
4
4
|
triage: () => import('../commands/triage.mjs'),
|
|
@@ -10,4 +10,6 @@ export const registry = {
|
|
|
10
10
|
'booked-not-paid': () => import('../commands/booked-not-paid.mjs'),
|
|
11
11
|
brief: () => import('../commands/brief.mjs'),
|
|
12
12
|
focus: () => import('../commands/focus.mjs'),
|
|
13
|
+
crm: () => import('../commands/crm.mjs'),
|
|
14
|
+
sync: () => import('../commands/sync.mjs'),
|
|
13
15
|
};
|