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 CHANGED
@@ -78,6 +78,8 @@ Command list generated from `sizmo schema` (authoritative — pulled directly fr
78
78
  | `sizmo booked-not-paid` | Sessions with no invoice or payment — the money leak | `--days N` (default 30), `--top N` (default 15) |
79
79
  | `sizmo focus` | One ranked to-do queue by money at stake | `--top N` (default 15), `--stuck-days N` (default 7) |
80
80
  | `sizmo segment` | Find contacts by criteria — tag, phone, age, etc. | `--tag X`, `--without-tag X`, `--no-tags`, `--created-days N`, `--has-phone`, `--no-phone`, `--top N` (default 20) |
81
+ | `sizmo crm` | Query the local CRM model — counts, lists, staleness | `--all` (show all items) |
82
+ | `sizmo sync` | Refresh the local CRM model (pipelines, calendars, tags, fields, users, location) | `[entity]` (sync one) |
81
83
 
82
84
  ### Utility commands
83
85
 
@@ -119,6 +121,42 @@ Every command supports `--json`. The envelope shape is stable:
119
121
 
120
122
  `degraded: true` means at least one data source was blocked (scope or auth). Read `warnings`. A blocked source is not zero — treat it as unknown.
121
123
 
124
+ ## Your CRM model
125
+
126
+ `sizmo` caches the slow-changing structure of your CRM — pipelines + stages, calendars, tags, custom fields, users, and location — in a local file (`~/.config/sizmo/model/<locationId>.json`). Recipes read from this cache instead of re-fetching structure on every run.
127
+
128
+ **What it stores:** pipeline/stage names + IDs, calendar list, tag list, custom fields, user roster, and location info (timezone, currency, country). Structure only — no contacts, no conversations, no payments.
129
+
130
+ **Sync once, read fast.** The model is synced automatically on first use. After that, recipes use the cached copy. Run `sizmo sync` after you change your pipeline stages or add calendars:
131
+
132
+ ```sh
133
+ sizmo sync # full refresh (all 6 entities)
134
+ sizmo sync tags # refresh one entity only
135
+ ```
136
+
137
+ **Age is always shown.** `sizmo crm` shows how old each entity is. Stale entries (past TTL: 24h for pipelines/calendars/users/location; 12h for tags/fields) show a warning. The CLI never silently serves stale structure as current.
138
+
139
+ **Model never auto-syncs when stale.** It serves the cached data with a loud age banner. This avoids surprise network calls mid-recipe. Use `sizmo sync` or `--fresh` to force a refresh.
140
+
141
+ ```sh
142
+ sizmo crm # overview: counts + age per entity
143
+ sizmo crm pipelines # list pipelines + stages
144
+ sizmo crm calendars # list calendars
145
+ sizmo crm tags [--all] # list tags (truncated at 20 by default)
146
+ sizmo crm fields # list custom fields
147
+ sizmo crm users # list users
148
+ sizmo crm location # timezone / currency / country
149
+ sizmo crm pipelines --json # machine output with _meta.source/syncedAt/ageMs/stale
150
+ ```
151
+
152
+ The JSON `_meta` block in every `crm` response lets agents branch on staleness without parsing prose:
153
+
154
+ ```json
155
+ "_meta": { "source": "cache", "syncedAt": 1718000000000, "ageMs": 3600000, "stale": false, "offline": false }
156
+ ```
157
+
158
+ **Scope requirements** for a full sync: `opportunities.readonly`, `calendars.readonly`, `locations/tags.readonly`, `locations/customFields.readonly`, `users.readonly`, `locations.readonly`. A 401/403 on one entity marks it blocked; the rest still store. `sizmo crm` shows `✖ needs <scope>` for blocked entities.
159
+
122
160
  ## Read-only + safety promise
123
161
 
124
162
  - **Never writes to GoHighLevel.** No contacts created, no messages sent, no invoices issued, no payments charged.
@@ -0,0 +1,258 @@
1
+ // commands/crm.mjs — query surface for the local CRM model.
2
+ // Overview counts + per-entity lists. Honest staleness on every read.
3
+ // Missing model → auto-sync once (first run). Stale → serve + banner, no auto-sync.
4
+ // READ-ONLY. No writes to GoHighLevel.
5
+ import { loadModel, syncModel, isStale, ageMs, ENTITY_SPECS, DEFAULT_MODEL_DIR } from '../lib/model.mjs';
6
+
7
+ export const meta = {
8
+ name: 'crm',
9
+ summary: 'Query the local CRM model — counts, lists, staleness',
10
+ flags: [
11
+ { name: '--all', type: 'bool', desc: 'show all items (overrides high-cardinality truncation)' },
12
+ ],
13
+ readOnly: true,
14
+ };
15
+
16
+ const TRUNCATE_ABOVE = 20; // default max items for high-cardinality entities (tags, fields)
17
+
18
+ // Alias map for subcommands
19
+ const ALIAS = { fields: 'customFields' };
20
+ const VALID_SUBS = ['pipelines', 'calendars', 'tags', 'fields', 'users', 'location'];
21
+
22
+ export async function run(args, ctx) {
23
+ const dir = ctx._modelDir ?? DEFAULT_MODEL_DIR;
24
+ const loc = ctx.cfg.loc;
25
+ const nowMs = typeof ctx.now === 'function' ? ctx.now() : ctx.now;
26
+ const showAll = !!(args.all || args['--all']);
27
+
28
+ // Sub-command from positional args
29
+ const sub = args._?.[0] || null;
30
+
31
+ // 1. Load model (auto-sync if missing)
32
+ let model = loadModel(loc, dir);
33
+ if (!model) {
34
+ // First run — auto-sync. syncModel throws if cold+offline.
35
+ ctx.out.warn('model not found — running first-time sync...');
36
+ try {
37
+ model = await syncModel({ http: ctx.http, loc, dir, now: typeof ctx.now === 'function' ? ctx.now : () => ctx.now });
38
+ } catch (e) {
39
+ if (e.offline) {
40
+ ctx.out.warn("⚠ OFFLINE — can't reach GoHighLevel — check your connection; run `sizmo sync` when online");
41
+ return 1;
42
+ }
43
+ throw e;
44
+ }
45
+ }
46
+
47
+ // 2. Build overall model meta
48
+ const modelAgeMs = nowMs - model.syncedAt;
49
+ // Determine overall staleness: any entity past its TTL
50
+ const specMap = Object.fromEntries(ENTITY_SPECS.map(s => [s.name, s]));
51
+ let anyStale = false;
52
+ for (const [name, ent] of Object.entries(model.entities)) {
53
+ if (!ent.blocked && !ent.networkError && specMap[name] && isStale(ent, nowMs, specMap[name].ttlMs)) {
54
+ anyStale = true;
55
+ }
56
+ }
57
+
58
+ // Determine offline: model was last sync'd while offline, OR the model itself has the offline flag.
59
+ // Also detect if a model exists but a refresh just failed (model.offline = true from the last sync).
60
+ const offline = !!(model.offline);
61
+ const meta = { source: 'cache', syncedAt: model.syncedAt, ageMs: modelAgeMs, stale: anyStale, offline };
62
+
63
+ // 3. Warn if offline (showing stale/cached data) or just stale
64
+ if (offline) {
65
+ const cacheAge = fmtAge(modelAgeMs);
66
+ ctx.out.warn(`⚠ OFFLINE — showing cache from ${new Date(model.syncedAt).toISOString()} (${cacheAge} old) — run \`sizmo sync\` when online`);
67
+ } else if (anyStale) {
68
+ ctx.out.warn(`⚠ model is stale (${fmtAge(modelAgeMs)} old) — run sizmo sync to refresh`);
69
+ }
70
+
71
+ // 4. Dispatch sub-command or overview
72
+ if (!sub) {
73
+ return overview(model, meta, nowMs, specMap, ctx);
74
+ }
75
+
76
+ const resolved = ALIAS[sub] ?? sub;
77
+ if (!VALID_SUBS.includes(sub) && !VALID_SUBS.includes(resolved)) {
78
+ ctx.out.warn(`unknown crm subcommand "${sub}" — valid: ${VALID_SUBS.join(', ')}`);
79
+ return 1;
80
+ }
81
+
82
+ if (resolved === 'location') return locationCmd(model, meta, ctx);
83
+ return entityList(resolved, model, meta, nowMs, specMap, showAll, ctx);
84
+ }
85
+
86
+ // ── overview ──────────────────────────────────────────────────────────────────
87
+
88
+ function overview(model, modelMeta, nowMs, specMap, ctx) {
89
+ const counts = {};
90
+ const blocked = {};
91
+ const networkErrors = {};
92
+ for (const spec of ENTITY_SPECS) {
93
+ const ent = model.entities[spec.name];
94
+ if (!ent) { counts[spec.name] = 0; continue; }
95
+ if (ent.networkError) {
96
+ counts[spec.name] = null;
97
+ networkErrors[spec.name] = ent.error ?? 'network error';
98
+ } else if (ent.blocked) {
99
+ counts[spec.name] = null;
100
+ blocked[spec.name] = ent.scope;
101
+ } else if (spec.name === 'location') {
102
+ counts[spec.name] = ent.item ? 1 : 0;
103
+ } else {
104
+ counts[spec.name] = Array.isArray(ent.items) ? ent.items.length : 0;
105
+ }
106
+ }
107
+
108
+ // Build human-friendly output structure
109
+ const data = {
110
+ pipelines: counts.pipelines,
111
+ calendars: counts.calendars,
112
+ tags: counts.tags,
113
+ customFields: counts.customFields,
114
+ users: counts.users,
115
+ location: counts.location,
116
+ _meta: modelMeta,
117
+ };
118
+ // Add blocked/networkError flags so agents can branch
119
+ for (const [name, scope] of Object.entries(blocked)) {
120
+ data[`${name}Blocked`] = true;
121
+ data[`${name}Scope`] = scope;
122
+ }
123
+ for (const [name] of Object.entries(networkErrors)) {
124
+ data[`${name}NetworkError`] = true;
125
+ }
126
+
127
+ ctx.out.data(data);
128
+
129
+ ctx.out.card(() => {
130
+ const age = fmtAge(modelMeta.ageMs);
131
+ const staleNote = modelMeta.offline ? ` ⚠ OFFLINE` : (modelMeta.stale ? ` ⚠ STALE — run sizmo sync` : '');
132
+ ctx.out.line(`\n CRM MODEL · loc ${model.locationId} · synced ${age}${staleNote}`);
133
+ ctx.out.line(' ' + '─'.repeat(50));
134
+ for (const spec of ENTITY_SPECS) {
135
+ if (spec.name === 'location') continue; // shown separately
136
+ const ent = model.entities[spec.name];
137
+ if (!ent) { ctx.out.line(` ${spec.name.padEnd(16)} 0`); continue; }
138
+ if (ent.networkError) {
139
+ ctx.out.line(` ${spec.name.padEnd(16)} ⚠ couldn't reach GHL`);
140
+ } else if (ent.blocked) {
141
+ ctx.out.line(` ${spec.name.padEnd(16)} ✖ needs ${ent.scope}`);
142
+ } else {
143
+ const count = Array.isArray(ent.items) ? ent.items.length : 0;
144
+ const entAge = ageMs(ent, nowMs);
145
+ const entStale = isStale(ent, nowMs, specMap[spec.name]?.ttlMs ?? Infinity);
146
+ const ageNote = entAge !== null ? ` · ${fmtAge(entAge)}${entStale ? ' ⚠' : ''}` : '';
147
+ ctx.out.line(` ${spec.name.padEnd(16)} ${count}${ageNote}`);
148
+ }
149
+ }
150
+ // Location line
151
+ const locEnt = model.entities.location;
152
+ if (locEnt && !locEnt.blocked && locEnt.item) {
153
+ const loc = locEnt.item;
154
+ const cur = loc.business?.currency || loc.currency || 'PHP';
155
+ ctx.out.line(` ${'location'.padEnd(16)} ${loc.name || model.locationId} · ${cur} · ${loc.timezone || ''}`);
156
+ }
157
+ ctx.out.line(' ' + '─'.repeat(50));
158
+ ctx.out.line(' sizmo crm <pipelines|calendars|tags|fields|users|location> for details\n');
159
+ });
160
+
161
+ return 0;
162
+ }
163
+
164
+ // ── entity list ───────────────────────────────────────────────────────────────
165
+
166
+ function entityList(entityName, model, modelMeta, nowMs, specMap, showAll, ctx) {
167
+ const ent = model.entities[entityName];
168
+ const spec = specMap[entityName];
169
+
170
+ if (ent?.networkError) {
171
+ ctx.out.warn(`⚠ ${entityName} — couldn't reach GHL during last sync`);
172
+ ctx.out.data({ entity: entityName, networkError: true, _meta: modelMeta });
173
+ return 1;
174
+ }
175
+
176
+ if (!ent || ent.blocked) {
177
+ const scope = ent?.scope ?? 'unknown';
178
+ ctx.out.warn(`✖ ${entityName} blocked — needs ${scope}`);
179
+ ctx.out.data({ entity: entityName, blocked: true, scope, _meta: modelMeta });
180
+ return 1;
181
+ }
182
+
183
+ const items = Array.isArray(ent.items) ? ent.items : [];
184
+ const entAge = ageMs(ent, nowMs);
185
+ const stale = spec ? isStale(ent, nowMs, spec.ttlMs) : false;
186
+ const entMeta = { ...modelMeta, entityFetchedAt: ent.fetchedAt, entityAgeMs: entAge, entityStale: stale };
187
+
188
+ // High-cardinality truncation (tags, customFields)
189
+ const highCard = entityName === 'tags' || entityName === 'customFields';
190
+ const shown = (highCard && !showAll) ? items.slice(0, TRUNCATE_ABOVE) : items;
191
+ const truncated = shown.length < items.length;
192
+
193
+ ctx.out.data({ entity: entityName, items: shown, total: items.length, truncated, _meta: entMeta });
194
+
195
+ ctx.out.card(() => {
196
+ const ageNote = entAge !== null ? `synced ${fmtAge(entAge)} ago` : '';
197
+ const staleNote = stale ? ' ⚠ STALE' : '';
198
+ ctx.out.line(`\n ${entityName.toUpperCase()} · ${items.length} item(s) · ${ageNote}${staleNote}`);
199
+ ctx.out.line(' ' + '─'.repeat(50));
200
+ if (entityName === 'pipelines') {
201
+ for (const pl of shown) {
202
+ ctx.out.line(` ${(pl.name || pl.id).slice(0, 40)}`);
203
+ for (const s of (pl.stages || [])) {
204
+ ctx.out.line(` [${String(s.position ?? '').padStart(2)}] ${(s.name || s.id).slice(0, 36)}`);
205
+ }
206
+ }
207
+ } else if (entityName === 'users') {
208
+ for (const u of shown) {
209
+ const name = [u.firstName, u.lastName].filter(Boolean).join(' ') || u.name || u.id;
210
+ ctx.out.line(` ${name.slice(0, 30).padEnd(30)} ${(u.email || '').slice(0, 36)}`);
211
+ }
212
+ } else {
213
+ for (const item of shown) {
214
+ const label = item.name || item.id;
215
+ const extra = item.fieldKey ? ` key: ${item.fieldKey}` : (item.calendarType ? ` ${item.calendarType}` : '');
216
+ ctx.out.line(` ${label.slice(0, 40)}${extra}`);
217
+ }
218
+ }
219
+ if (truncated) ctx.out.line(` … ${items.length - shown.length} more — --all to show all`);
220
+ ctx.out.line(' ' + '─'.repeat(50) + '\n');
221
+ });
222
+
223
+ return 0;
224
+ }
225
+
226
+ // ── location subcommand ───────────────────────────────────────────────────────
227
+
228
+ function locationCmd(model, modelMeta, ctx) {
229
+ const ent = model.entities.location;
230
+ if (!ent || ent.blocked) {
231
+ const scope = ent?.scope ?? 'locations.readonly';
232
+ ctx.out.warn(`✖ location blocked — needs ${scope}`);
233
+ ctx.out.data({ blocked: true, scope, _meta: modelMeta });
234
+ return 1;
235
+ }
236
+ const item = ent.item ?? {};
237
+ ctx.out.data({ item, location: item, _meta: modelMeta });
238
+ ctx.out.card(() => {
239
+ ctx.out.line(`\n LOCATION · ${item.name || model.locationId}`);
240
+ ctx.out.line(' ' + '─'.repeat(40));
241
+ ctx.out.line(` id ${item.id || model.locationId}`);
242
+ ctx.out.line(` timezone ${item.timezone || '—'}`);
243
+ ctx.out.line(` currency ${item.business?.currency || item.currency || '—'}`);
244
+ ctx.out.line(` country ${item.country || '—'}`);
245
+ ctx.out.line(' ' + '─'.repeat(40) + '\n');
246
+ });
247
+ return 0;
248
+ }
249
+
250
+ // ── helpers ───────────────────────────────────────────────────────────────────
251
+
252
+ function fmtAge(ms) {
253
+ if (ms == null || ms < 0) return '?';
254
+ if (ms < 60_000) return `${Math.round(ms / 1000)}s`;
255
+ if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m`;
256
+ if (ms < 86_400_000) return `${Math.round(ms / 3_600_000)}h`;
257
+ return `${Math.round(ms / 86_400_000)}d`;
258
+ }
@@ -1,7 +1,10 @@
1
1
  // commands/noshow.mjs — No-show recovery: surfaces who no-showed to re-book.
2
2
  // Trust-fix #1: LOC from ctx.cfg.loc (no baked default).
3
+ // v0.5.0: calendar list from CRM model (no per-run /calendars/ re-fetch); events still live.
4
+ // v0.6.0 (C2): modelMeta emitted in JSON envelope; staleness note in TTY.
3
5
  // READ-ONLY. Never messages, never books.
4
6
  import { mapLimit } from '../lib/pool.mjs';
7
+ import { ENTITY_SPECS } from '../lib/model.mjs';
5
8
  export const meta = {
6
9
  name: 'noshow',
7
10
  summary: 'No-show recovery — who to re-book',
@@ -24,12 +27,39 @@ export async function collect(args, ctx) {
24
27
  const NOW = ctx.now;
25
28
  const START = NOW - DAYS * 24 * 60 * 60 * 1000;
26
29
 
27
- const cr = await ctx.http.get('/calendars/', { query: { locationId: LOC }, version: '2021-04-15' });
28
- if (!cr.ok) {
29
- ctx.out.warn(`can't see calendars → HTTP ${cr.code}`, { degraded: true });
30
- return { location: LOC, calendars: 0, noshows: 0, shown: 0, list: [] };
30
+ // Get calendar list from the CRM model if available; fall back to live fetch.
31
+ let cals = null;
32
+ let modelLoaded = null;
33
+ let modelMeta = null;
34
+ if (ctx.ensureModel) {
35
+ try {
36
+ modelLoaded = await ctx.ensureModel();
37
+ if (modelLoaded?.entities?.calendars && !modelLoaded.entities.calendars.blocked && !modelLoaded.entities.calendars.networkError) {
38
+ cals = modelLoaded.entities.calendars.items ?? [];
39
+ }
40
+ } catch { /* fall through to live fetch */ }
41
+ }
42
+ // Build modelMeta for the JSON envelope (C2)
43
+ if (modelLoaded) {
44
+ const specMap = Object.fromEntries(ENTITY_SPECS.map(s => [s.name, s]));
45
+ const calEnt = modelLoaded.entities?.calendars;
46
+ const calSpec = specMap.calendars;
47
+ const calStale = calEnt && calSpec ? (NOW - (calEnt.fetchedAt ?? 0)) > calSpec.ttlMs : false;
48
+ modelMeta = {
49
+ syncedAt: modelLoaded.syncedAt,
50
+ ageMs: NOW - modelLoaded.syncedAt,
51
+ stale: calStale,
52
+ offline: !!(modelLoaded.offline),
53
+ };
54
+ }
55
+ if (cals === null) {
56
+ const cr = await ctx.http.get('/calendars/', { query: { locationId: LOC }, version: '2021-04-15' });
57
+ if (!cr.ok) {
58
+ ctx.out.warn(`can't see calendars → HTTP ${cr.code}`, { degraded: true });
59
+ return { location: LOC, calendars: 0, noshows: 0, shown: 0, list: [], ...(modelMeta ? { modelMeta } : {}) };
60
+ }
61
+ cals = cr.j.calendars || [];
31
62
  }
32
- const cals = cr.j.calendars || [];
33
63
  const noshows = [];
34
64
  let skippedCalendars = 0;
35
65
  // Parallel fan-out, capped at 5 concurrent (GHL rate-limit-safe: 100 req/10s; 5 concurrent is well under).
@@ -82,6 +112,7 @@ export async function collect(args, ctx) {
82
112
  when: new Date(n.when).toISOString(),
83
113
  calendar: n.cal,
84
114
  })),
115
+ ...(modelMeta ? { modelMeta } : {}),
85
116
  };
86
117
  }
87
118
 
@@ -100,6 +131,16 @@ export async function run(args, ctx) {
100
131
 
101
132
  ctx.out.card(() => {
102
133
  ctx.out.line(`\n NO-SHOW RECOVERY — ${data.noshows} no-show(s) · last ${DAYS}d · ${data.calendars} calendars · loc ${data.location}`);
134
+ // C2: staleness note when model is old/offline
135
+ if (data.modelMeta) {
136
+ const mm = data.modelMeta;
137
+ if (mm.offline) {
138
+ ctx.out.line(` · CRM model OFFLINE — calendar list from cache`);
139
+ } else if (mm.stale) {
140
+ const ageD = Math.round(mm.ageMs / 86400000);
141
+ ctx.out.line(` · CRM model ${ageD}d old — run sizmo sync`);
142
+ }
143
+ }
103
144
  ctx.out.line(' ' + '─'.repeat(70));
104
145
  if (!data.list.length) {
105
146
  ctx.out.line(' No no-shows in window. ✅\n');
@@ -1,8 +1,12 @@
1
1
  // commands/pipeline.mjs — Pipeline health: value by stage + stuck sweep.
2
2
  // Trust-fix #1: LOC from ctx.cfg.loc.
3
3
  // Trust-fix #2: opps paginate to completion.
4
+ // v0.5.0: stage/pipeline names sourced from ctx CRM model (no per-run structure re-fetch).
5
+ // v0.6.0 (C2): names resolved via ctx.resolve (never fabricated); modelMeta emitted with staleness signal.
6
+ // I1 fix: stage sort uses model position (not undefined .sid).
4
7
  // READ-ONLY.
5
8
  import { paginate } from '../lib/paginate.mjs';
9
+ import { ENTITY_SPECS } from '../lib/model.mjs';
6
10
 
7
11
  export const meta = {
8
12
  name: 'pipeline',
@@ -31,19 +35,75 @@ export async function collect(args, ctx) {
31
35
  return d >= 1 ? d + 'd' : Math.max(1, Math.floor((NOW - t) / 3600000)) + 'h';
32
36
  };
33
37
 
34
- // pipelines stage id→name map
35
- const p = await ctx.http.get('/opportunities/pipelines', { query: { locationId: LOC } });
36
- if (!p.ok) {
37
- ctx.out.warn(`can't see pipelines HTTP ${p.code}`, { degraded: true });
38
- return { location: LOC, totalValue: 0, openCount: 0, pipelines: [], stuck: [] };
38
+ // Build stage/pipeline name resolution from the CRM model (no per-run structure re-fetch).
39
+ // Falls back to a live fetch only when model is genuinely unavailable.
40
+ // C2: names go through ctx.resolve when available — miss → '<unknown:id — run sizmo sync>'
41
+ // I1: stagePosition map keyed by stage-id carries model position (not a discarded .sid).
42
+
43
+ let modelLoaded = null; // the raw model blob (for modelMeta)
44
+ let resolver = null; // ctx.resolve (makeResolver instance)
45
+ const stagePosition = {}; // sid → model position (for sort)
46
+ const stageName = {}, pipeName = {}; // fallback maps (live-fetch path)
47
+
48
+ // Try model first (via ctx.ensureModel / ctx.resolve)
49
+ const usingModelPath = !!(ctx.ensureModel || ctx.resolve);
50
+ if (usingModelPath) {
51
+ try {
52
+ if (ctx.ensureModel) modelLoaded = await ctx.ensureModel();
53
+ resolver = ctx.resolve ?? null;
54
+ // Build stagePosition from model for sort (I1 fix)
55
+ if (modelLoaded?.entities?.pipelines && !modelLoaded.entities.pipelines.blocked && !modelLoaded.entities.pipelines.networkError) {
56
+ for (const pl of (modelLoaded.entities.pipelines.items ?? [])) {
57
+ for (const s of (pl.stages || [])) {
58
+ stagePosition[s.id] = s.position ?? 0;
59
+ }
60
+ }
61
+ }
62
+ } catch { /* fall through to live fetch */ }
39
63
  }
40
- const pipelines = p.j.pipelines || [];
41
- const stageName = {}, pipeName = {}, stageOrder = {};
42
- for (const pl of pipelines) {
43
- pipeName[pl.id] = pl.name;
44
- (pl.stages || []).forEach((s, i) => { stageName[s.id] = s.name; stageOrder[s.id] = i; });
64
+
65
+ // modelMeta for staleness signal (C2)
66
+ const specMap = Object.fromEntries(ENTITY_SPECS.map(s => [s.name, s]));
67
+ let modelMeta = null;
68
+ if (modelLoaded) {
69
+ const plEnt = modelLoaded.entities?.pipelines;
70
+ const plSpec = specMap.pipelines;
71
+ const plFetchedAt = plEnt?.fetchedAt ?? null;
72
+ const plAgeMs = plFetchedAt != null ? NOW - plFetchedAt : null;
73
+ const plStale = plEnt && plSpec ? (NOW - (plEnt.fetchedAt ?? 0)) > plSpec.ttlMs : false;
74
+ modelMeta = {
75
+ syncedAt: modelLoaded.syncedAt,
76
+ ageMs: NOW - modelLoaded.syncedAt,
77
+ stale: plStale,
78
+ offline: !!(modelLoaded.offline),
79
+ };
80
+ }
81
+
82
+ if (!resolver) {
83
+ // Fallback: live fetch (model genuinely unavailable)
84
+ const p = await ctx.http.get('/opportunities/pipelines', { query: { locationId: LOC } });
85
+ if (!p.ok) {
86
+ ctx.out.warn(`can't see pipelines → HTTP ${p.code}`, { degraded: true });
87
+ return { location: LOC, totalValue: 0, openCount: 0, pipelines: [], stuck: [], modelMeta };
88
+ }
89
+ const pipelines = p.j.pipelines || [];
90
+ for (const pl of pipelines) {
91
+ pipeName[pl.id] = pl.name;
92
+ (pl.stages || []).forEach((s) => { stageName[s.id] = s.name; stagePosition[s.id] = s.position ?? 0; });
93
+ }
45
94
  }
46
95
 
96
+ // Helper: resolve pipeline name (via resolver or fallback map)
97
+ const resolvePipeName = (pid) => {
98
+ if (resolver) return resolver.label('pipeline', pid);
99
+ return pipeName[pid] || pid;
100
+ };
101
+ // Helper: resolve stage name (via resolver or fallback map)
102
+ const resolveStageName = (sid) => {
103
+ if (resolver) return resolver.label('stage', sid);
104
+ return stageName[sid] || sid;
105
+ };
106
+
47
107
  // all open opps paginated to completion (trust-fix #2)
48
108
  const opps = [];
49
109
  let firstOppErr = null;
@@ -98,19 +158,22 @@ export async function collect(args, ctx) {
98
158
  totalValue: total,
99
159
  openCount: opps.length,
100
160
  pipelines: Object.entries(byPipe).map(([pid, stages]) => ({
101
- pipeline: pipeName[pid] || pid,
161
+ pipeline: resolvePipeName(pid),
162
+ // I1 fix: carry sid onto mapped object; sort by model stagePosition (never undefined)
102
163
  stages: Object.entries(stages)
103
- .map(([sid, v]) => ({ stage: stageName[sid] || sid, ...v }))
104
- .sort((a, b) => (stageOrder[a.sid] || 0) - (stageOrder[b.sid] || 0)),
164
+ .map(([sid, v]) => ({ sid, stage: resolveStageName(sid), position: stagePosition[sid] ?? Infinity, ...v }))
165
+ .sort((a, b) => a.position - b.position)
166
+ .map(({ sid: _sid, position: _pos, ...rest }) => rest), // strip internal keys from output
105
167
  })),
106
168
  stuck: stuck.map(x => ({
107
169
  name: x.o.name,
108
170
  value: x.o.monetaryValue,
109
- stage: stageName[x.o.pipelineStageId] || '',
171
+ stage: resolveStageName(x.o.pipelineStageId || x.o.stageId || ''),
110
172
  idle: ago(x.t),
111
173
  oppId: x.o.id,
112
174
  contactId: x.o.contactId,
113
175
  })),
176
+ ...(modelMeta ? { modelMeta } : {}),
114
177
  };
115
178
  }
116
179
 
@@ -124,6 +187,16 @@ export async function run(args, ctx) {
124
187
 
125
188
  ctx.out.card(() => {
126
189
  ctx.out.line(`\n PIPELINE HEALTH · ${money2(data.totalValue)} across ${data.openCount} open deal(s) · loc ${data.location}`);
190
+ // C2: staleness note when model is old/offline
191
+ if (data.modelMeta) {
192
+ const mm = data.modelMeta;
193
+ if (mm.offline) {
194
+ ctx.out.line(` · CRM model OFFLINE — stage names from cache`);
195
+ } else if (mm.stale) {
196
+ const ageD = Math.round(mm.ageMs / 86400000);
197
+ ctx.out.line(` · CRM model ${ageD}d old — run sizmo sync`);
198
+ }
199
+ }
127
200
  for (const pl of data.pipelines) {
128
201
  ctx.out.line(`\n ${pl.pipeline}`);
129
202
  for (const s of pl.stages) {
@@ -2,8 +2,11 @@
2
2
  // Trust-fix #1: LOC from ctx.cfg.loc.
3
3
  // Trust-fix #2: transactions + subscriptions paginate to completion.
4
4
  // Trust-fix #3: collected-by-source per currency (never cross-sums).
5
+ // v0.5.0: default currency from CRM model location (not hardcoded PHP).
6
+ // v0.6.0 (C2): modelMeta emitted in JSON envelope; TTY staleness note.
5
7
  // READ-ONLY. NEVER charges, refunds, or collects.
6
8
  import { paginate } from '../lib/paginate.mjs';
9
+ import { ENTITY_SPECS } from '../lib/model.mjs';
7
10
 
8
11
  export const meta = {
9
12
  name: 'reconcile',
@@ -33,6 +36,34 @@ export async function collect(args, ctx) {
33
36
  return t >= START && t <= NOW;
34
37
  };
35
38
 
39
+ // Location currency from CRM model (fallback PHP if model missing/blocked)
40
+ let locationCurrency = 'PHP';
41
+ let _reconcileModelLoaded = null;
42
+ let modelMeta = null;
43
+ if (ctx.ensureModel) {
44
+ try {
45
+ _reconcileModelLoaded = await ctx.ensureModel();
46
+ const locCur = _reconcileModelLoaded?.entities?.location?.item?.business?.currency
47
+ || _reconcileModelLoaded?.entities?.location?.item?.currency;
48
+ if (locCur) locationCurrency = locCur.toUpperCase();
49
+ } catch { /* use default */ }
50
+ } else if (ctx.cfg.currency) {
51
+ locationCurrency = ctx.cfg.currency;
52
+ }
53
+ // Build modelMeta for the JSON envelope (C2)
54
+ if (_reconcileModelLoaded) {
55
+ const specMap = Object.fromEntries(ENTITY_SPECS.map(s => [s.name, s]));
56
+ const locEnt = _reconcileModelLoaded.entities?.location;
57
+ const locSpec = specMap.location;
58
+ const locStale = locEnt && locSpec ? (NOW - (locEnt.fetchedAt ?? 0)) > locSpec.ttlMs : false;
59
+ modelMeta = {
60
+ syncedAt: _reconcileModelLoaded.syncedAt,
61
+ ageMs: NOW - _reconcileModelLoaded.syncedAt,
62
+ stale: locStale,
63
+ offline: !!(_reconcileModelLoaded.offline),
64
+ };
65
+ }
66
+
36
67
  // transactions paginated to completion (trust-fix #2)
37
68
  const txns = [];
38
69
  let txnErr = null;
@@ -61,7 +92,7 @@ export async function collect(args, ctx) {
61
92
  if (txnErr && txns.length === 0) {
62
93
  ctx.out.warn(`can't see transactions → HTTP ${txnErr}`, { degraded: true });
63
94
  return {
64
- location: LOC, days: DAYS, scanned: 0, inWindow: 0, collected: 0, currency: 'PHP',
95
+ location: LOC, days: DAYS, scanned: 0, inWindow: 0, collected: 0, currency: locationCurrency,
65
96
  bySource: {}, byStatus: {}, flags: { refunds: 0, failed: 0, orphans: 0 }, subscriptions: null,
66
97
  };
67
98
  }
@@ -78,7 +109,7 @@ export async function collect(args, ctx) {
78
109
  const st = (t.status || t.paymentStatus || '').toLowerCase();
79
110
  byStatus[st] = (byStatus[st] || 0) + 1;
80
111
  const amt = Number(t.amount) || 0;
81
- const cur = (t.currency || 'PHP').toUpperCase();
112
+ const cur = (t.currency || locationCurrency).toUpperCase();
82
113
  if (SUCCESS.has(st)) {
83
114
  const s = srcOf(t);
84
115
  byCur[cur] ??= { bySource: {}, total: 0 };
@@ -97,7 +128,7 @@ export async function collect(args, ctx) {
97
128
  // flatten for output — single currency → backward-compat flat shape; multi → byCurrency map
98
129
  const currencies = Object.keys(byCur);
99
130
  const isSingle = currencies.length <= 1;
100
- const currency = isSingle ? (currencies[0] || 'PHP') : (currencies[0] || 'PHP');
131
+ const currency = isSingle ? (currencies[0] || locationCurrency) : (currencies[0] || locationCurrency);
101
132
  const collected = isSingle ? (byCur[currency]?.total ?? 0) : null;
102
133
  const byCurrency = isSingle ? null : Object.fromEntries(currencies.map(c => [c, byCur[c].total]));
103
134
  // bySource: when single currency keep flat {src:{c,v}} for backward compat; multi-currency not surfaced at top level
@@ -136,7 +167,7 @@ export async function collect(args, ctx) {
136
167
  // MRR per-currency — same treatment as transactions (never cross-sum currencies)
137
168
  const mrrByCur = {};
138
169
  for (const x of active) {
139
- const cur = (x.currency || 'PHP').toUpperCase();
170
+ const cur = (x.currency || locationCurrency).toUpperCase();
140
171
  mrrByCur[cur] = (mrrByCur[cur] || 0) + (Number(x.amount) || 0);
141
172
  }
142
173
  const mrrCurrencies = Object.keys(mrrByCur);
@@ -164,6 +195,7 @@ export async function collect(args, ctx) {
164
195
  byStatus,
165
196
  flags: { refunds: refunds.length, failed: failed.length, orphans: orphans.length },
166
197
  subscriptions: subs,
198
+ ...(modelMeta ? { modelMeta } : {}),
167
199
  };
168
200
  }
169
201
 
@@ -179,6 +211,16 @@ export async function run(args, ctx) {
179
211
 
180
212
  ctx.out.card(() => {
181
213
  ctx.out.line(`\n RECONCILE — ${collectedLine} collected · last ${data.days}d · ${data.inWindow} txn in window · loc ${data.location}`);
214
+ // C2: model staleness note
215
+ if (data.modelMeta) {
216
+ const mm = data.modelMeta;
217
+ if (mm.offline) {
218
+ ctx.out.line(` · CRM model OFFLINE — currency from cache`);
219
+ } else if (mm.stale) {
220
+ const ageD = Math.round(mm.ageMs / 86400000);
221
+ ctx.out.line(` · CRM model ${ageD}d old — run sizmo sync`);
222
+ }
223
+ }
182
224
  ctx.out.line(' ' + '─'.repeat(64));
183
225
  ctx.out.line(' BY SOURCE (succeeded)');
184
226
  const srcs = Object.entries(data.bySource).sort((a, b) => b[1].v - a[1].v);