hazo_umetrics 1.3.0 → 1.4.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/CHANGE_LOG.md CHANGED
@@ -1,5 +1,40 @@
1
1
  # hazo_umetrics — Change Log
2
2
 
3
+ ## 1.4.0 — Bulk catalog edits, per-action datapoints, user-journey viz, real user identity
4
+
5
+ **New features (minor bump — no breaking changes):**
6
+
7
+ ### New: Bulk multiselect in Action Catalog
8
+ - `updateActionDefBulk(adapter, {app_id, action_keys, patch})` — update many actions at once.
9
+ - `applyLinksBulk(adapter, {app_id, action_keys, add_related, remove_related})` — cartesian add/remove related links.
10
+ - `POST /actions/bulk` API: set status, key-event flag, parent, and related actions across N actions in one request. Permission: `metrics.manage`.
11
+ - UI: checkbox column + `BulkActionBar` (status select, key-event toggles, parent picker, related add/remove) appear above the tree when ≥1 action selected.
12
+
13
+ ### New: Per-action datapoint schema
14
+ - `datapoint_schema JSONB/TEXT` column on `hazo_umetrics_action_def` (migration: `ALTER TABLE hazo_umetrics_action_def ADD COLUMN datapoint_schema JSONB NOT NULL DEFAULT '[]'` for PG; `TEXT` for SQLite).
15
+ - `DatapointField`, `DatapointSchema` types exported from `hazo_umetrics`.
16
+ - JSON textarea in the Action Catalog edit form to declare which `dimensions` keys are meaningful.
17
+ - `datapointStats(adapter, opts)` — aggregates dimension values (numeric histograms + categorical top lists).
18
+ - `GET /analytics/datapoints?app_id=&action_key=` — returns `{action_key, total_events, keys[]}`.
19
+ - `DatapointPanel` component: action picker + per-key cards (histogram bars for numeric, ranked list for categorical).
20
+
21
+ ### New: User-journey aggregation + graph
22
+ - `userJourney(adapter, opts)` — groups events by session, computes per-step timing, emits `JourneyGraph` (nodes + edges with avgMs).
23
+ - `GET /analytics/journey?app_id=[&user_id=][&session_id=]` — returns `{sessions, graph}`.
24
+ - `JourneyPanel` component: dagre LR graph via `hazo_canvas` (optional peer dep) with edge timing labels; falls back to ordered step list when canvas unavailable.
25
+ - `hazo_canvas` ≥1.9.1 required for timing labels on dagre edges (`direction:'LR'`).
26
+
27
+ ### New: Host-injected user identity
28
+ - `resolveUserProfiles?: ResolveUserProfiles` prop on `AnalyticsPanel` and `JourneyPanel`.
29
+ - UserFilter dropdown now shows `name / email / avatar` when resolver is wired; falls back to truncated UUID.
30
+ - JourneyPanel session headers show resolved name/email/avatar.
31
+ - Test-app: `POST /api/hazo_umetrics/user_profiles` route wires `hazo_auth` `get_profiles` as an example host implementation.
32
+
33
+ ### hazo_canvas 1.9.1
34
+ - Dagre edge `label` field now rendered as a midpoint `kind:'text'` child (timing labels for journey viz).
35
+
36
+ ---
37
+
3
38
  ## 1.3.0 — Analytics dashboard + behavioral fixes + createUmetricsConnect
4
39
 
5
40
  **New features + bug fixes (no breaking changes):**
package/README.md CHANGED
@@ -125,6 +125,128 @@ trackAction('timer.complete', { url: '/timer' });
125
125
 
126
126
  Auto-tracked on mount: page views (`ui.pageview.*`), clicks (`ui.click.*`), errors (`ui.error.*`), dwell time (`ui.dwell.*`).
127
127
 
128
+ ## Bulk Action Catalog edits
129
+
130
+ ```ts
131
+ // POST /api/hazo_umetrics/actions/bulk
132
+ // Body: { app_id, action_keys, patch?, add_related?, remove_related? }
133
+ // Permission: metrics.manage
134
+ // Response: { ok: true, data: { updated: string[], missing: string[], links: { added, removed } } }
135
+
136
+ // Set status on many actions at once
137
+ await fetch('/api/hazo_umetrics/actions/bulk', {
138
+ method: 'POST',
139
+ body: JSON.stringify({
140
+ app_id: 'my-app',
141
+ action_keys: ['timer.start', 'timer.complete'],
142
+ patch: { status: 'active', is_key_event: true },
143
+ }),
144
+ });
145
+
146
+ // Add related links in bulk (cartesian: every key in action_keys ↔ every key in add_related)
147
+ await fetch('/api/hazo_umetrics/actions/bulk', {
148
+ method: 'POST',
149
+ body: JSON.stringify({
150
+ app_id: 'my-app',
151
+ action_keys: ['timer.start'],
152
+ add_related: ['timer.complete', 'timer.pause'],
153
+ }),
154
+ });
155
+ ```
156
+
157
+ `action_keys` must be a non-empty array — an empty array returns 400. Both `patch` and `add_related`/`remove_related` can be combined in one request.
158
+
159
+ ## Per-action datapoint schema
160
+
161
+ Declare which `dimensions` keys are analytically meaningful for a given action:
162
+
163
+ ```ts
164
+ // PATCH the action to set a datapoint_schema
165
+ await fetch('/api/hazo_umetrics/actions?app_id=my-app', {
166
+ method: 'PATCH',
167
+ body: JSON.stringify({
168
+ app_id: 'my-app',
169
+ action_key: 'timer.complete',
170
+ patch: {
171
+ datapoint_schema: [
172
+ { key: 'duration_ms', label: 'Duration (ms)', type: 'number' },
173
+ { key: 'mode', label: 'Timer mode', type: 'string' },
174
+ ],
175
+ },
176
+ }),
177
+ });
178
+ ```
179
+
180
+ Types exported from `hazo_umetrics`: `DatapointField`, `DatapointSchema`.
181
+
182
+ Once a schema is set, the `DatapointPanel` UI component shows per-key cards: histogram bars for `type:'number'` keys, ranked frequency lists for `type:'string'` keys.
183
+
184
+ ## Journey analytics
185
+
186
+ ```ts
187
+ // GET /api/hazo_umetrics/analytics/journey?app_id=<id>[&user_id=<id>][&session_id=<id>]
188
+ // Permission: metrics.view
189
+ // Response: { ok: true, data: { sessions: Session[], graph: JourneyGraph } }
190
+ ```
191
+
192
+ `sessions` is an ordered list of per-session step arrays with timing. `graph.nodes` and `graph.edges` provide the aggregated action-transition graph for all sessions (or filtered by user/session). Each edge carries `avgMs` (mean inter-step latency).
193
+
194
+ ```tsx
195
+ import { JourneyPanel } from 'hazo_umetrics/ui';
196
+
197
+ // Embedded panel — fetches journey data from /api/hazo_umetrics/analytics/journey
198
+ <JourneyPanel
199
+ appId="my-app"
200
+ resolveUserProfiles={resolveUserProfiles} // optional — enriches session headers
201
+ />
202
+ ```
203
+
204
+ `JourneyPanel` renders a dagre LR graph via `hazo_canvas` when installed (`npm install hazo_canvas@^1.9.1`). Without `hazo_canvas`, it falls back to an ordered step list. Dagre edge timing labels require `hazo_canvas ≥ 1.9.1`.
205
+
206
+ ## Datapoint analytics
207
+
208
+ ```ts
209
+ // GET /api/hazo_umetrics/analytics/datapoints?app_id=<id>&action_key=<key>
210
+ // Permission: metrics.view
211
+ // Response: { ok: true, data: { action_key, total_events, keys: DatapointKeyStats[] } }
212
+ ```
213
+
214
+ Each entry in `keys` describes one dimension: `{ key, type, total, histogram | topValues }`. Use the `DatapointPanel` component to render these automatically.
215
+
216
+ ## resolveUserProfiles — wiring user identity
217
+
218
+ `AnalyticsPanel` and `JourneyPanel` both accept an optional `resolveUserProfiles` prop that enriches user IDs with real names, emails, and avatars:
219
+
220
+ ```ts
221
+ import type { ResolveUserProfiles } from 'hazo_umetrics/ui';
222
+
223
+ const resolveUserProfiles: ResolveUserProfiles = async (ids) => {
224
+ const r = await fetch('/api/my-app/user_profiles', {
225
+ method: 'POST',
226
+ body: JSON.stringify({ ids }),
227
+ headers: { 'Content-Type': 'application/json' },
228
+ });
229
+ const j = await r.json();
230
+ return j.profiles ?? [];
231
+ };
232
+
233
+ // Each profile: { user_id, email?, name?, profile_picture_url? }
234
+ ```
235
+
236
+ **Wiring with `hazo_auth` (gotimer / kinstripe pattern):**
237
+
238
+ ```ts
239
+ // app/api/hazo_umetrics/user_profiles/route.ts
240
+ import { authConnect } from '@/lib/db';
241
+ import { hazo_get_user_profiles } from 'hazo_auth/server-lib';
242
+
243
+ export async function POST(req: Request) {
244
+ const { ids } = await req.json();
245
+ const result = await hazo_get_user_profiles(authConnect, ids);
246
+ return Response.json({ profiles: result.profiles ?? [] });
247
+ }
248
+ ```
249
+
128
250
  ## API route factories
129
251
 
130
252
  ```ts
@@ -17,6 +17,30 @@ npm install hazo_core hazo_connect hazo_auth hazo_secure hazo_audit hazo_api haz
17
17
  > `hazo_dataviz` is required when you render `MetricsPanel`/`StatTrend` — its `LineChart`
18
18
  > draws the trend chart (moved out of `hazo_ui` in the 4.0.0 chart migration).
19
19
 
20
+ ## ALTER TABLE for 1.4.0 (existing deployments only)
21
+
22
+ If you are upgrading an **existing** deployment from a version before 1.4.0, run these statements against your umetrics database before upgrading the package:
23
+
24
+ ```sql
25
+ -- PostgreSQL:
26
+ ALTER TABLE hazo_umetrics_action_def ADD COLUMN IF NOT EXISTS datapoint_schema JSONB NOT NULL DEFAULT '[]';
27
+
28
+ -- SQLite (does not support IF NOT EXISTS on ALTER TABLE — skip if column already exists):
29
+ ALTER TABLE hazo_umetrics_action_def ADD COLUMN datapoint_schema TEXT NOT NULL DEFAULT '[]';
30
+ ```
31
+
32
+ Fresh installations (using `db_setup_sqlite.sql` / `db_setup_postgres.sql` from 1.4.0+) already include this column — no ALTER TABLE needed.
33
+
34
+ **Optional — journey graph (hazo_canvas peer dep):**
35
+
36
+ `JourneyPanel` renders an interactive dagre LR graph when `hazo_canvas` is installed. Without it, the panel falls back to a plain ordered step list. Install only if you use `JourneyPanel`:
37
+
38
+ ```bash
39
+ npm install hazo_canvas@^1.9.1
40
+ ```
41
+
42
+ ---
43
+
20
44
  ## 2. Run DB migrations
21
45
 
22
46
  **SQLite:**
@@ -106,6 +106,13 @@ export declare function createActionHandlers(options: ApiFactoryOptions): {
106
106
  * Emits an audit entry 'action.catalog.update'.
107
107
  */
108
108
  updateAction(req: Request): Promise<Response>;
109
+ /**
110
+ * POST /...
111
+ * Body: { app_id, action_keys, patch?, add_related?, remove_related? }
112
+ * Bulk-updates multiple action defs and/or their links. Requires metrics.manage.
113
+ * Emits a single audit entry 'action.catalog.bulk_update' per call.
114
+ */
115
+ bulkUpdateActions(req: Request): Promise<Response>;
109
116
  };
110
117
  export declare function createActivityHandlers(options: ApiFactoryOptions): {
111
118
  /**
@@ -145,5 +152,15 @@ export declare function createBehaviorHandlers(options: ApiFactoryOptions): {
145
152
  * Returns events bucketed by day. Requires metrics.view.
146
153
  */
147
154
  getEventsOverTime(req: Request): Promise<Response>;
155
+ /**
156
+ * GET /...?app_id=<id>&action_key=<key>[&since=<iso>][&until=<iso>][&buckets=<n>][&top_n=<n>]
157
+ * Returns datapoint dimension stats for an action. Requires metrics.view.
158
+ */
159
+ getDatapoints(req: Request): Promise<Response>;
160
+ /**
161
+ * GET /...?app_id=<id>[&user_id=<id>][&session_id=<id>][&since=<iso>]
162
+ * Returns user journey sessions and aggregated graph. Requires metrics.view.
163
+ */
164
+ getJourney(req: Request): Promise<Response>;
148
165
  };
149
166
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/api/index.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/api/index.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;AAuC9D,MAAM,MAAM,SAAS,GAAG,CACtB,GAAG,EAAE,OAAO,EACZ,IAAI,EAAE;IAAE,oBAAoB,CAAC,EAAE,MAAM,EAAE,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,OAAO,CAAA;CAAE,KAC3E,OAAO,CAAC;IACX,aAAa,EAAE,OAAO,CAAC;IACvB,IAAI,EAAE;QAAE,EAAE,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC;IAC5B,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,aAAa,EAAE,OAAO,CAAC;IACvB,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB,CAAC,CAAC;AAEH,MAAM,WAAW,iBAAiB;IAChC,UAAU,EAAE,MAAM,OAAO,CAAC,kBAAkB,CAAC,GAAG,kBAAkB,CAAC;IACnE,OAAO,CAAC,EAAE,SAAS,CAAC;IACpB,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE;QAChB,MAAM,EAAE,MAAM,CAAC;QACf,QAAQ,EAAE,MAAM,CAAC;QACjB,MAAM,EAAE,MAAM,CAAC;QACf,OAAO,CAAC,EAAE,OAAO,CAAC;KACnB,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACrB;AAiGD,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,iBAAiB;IAIzD;;;OAGG;iBACgB,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC;IAqB9C;;;OAGG;uBACsB,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC;IAwBpD;;;;OAIG;oBACmB,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC;EA4CpD;AAqBD,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,iBAAiB;IAW/D;;;OAGG;yBACwB,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC;IAqBtD;;;;OAIG;qBACoB,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC;IAiDlD;;;;OAIG;yBACwB,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC;IA8CtD;;;;OAIG;wBACuB,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC;EA8CxD;AAMD,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,iBAAiB;IAI/D,2DAA2D;iBACxC,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC;IAe9C,sDAAsD;mBACjC,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC;EAenD;AAMD,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,iBAAiB;IAI1D,oDAAoD;eACnC,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC;EAe/C;AAMD,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,IAAI,CAAC,iBAAiB,EAAE,YAAY,CAAC;IAE9E;;;;;;;;;OASG;qBACoB,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC;EAwCrD;AAMD,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,iBAAiB;IAI3D;;;OAGG;qBACoB,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC;IAoBlD;;;OAGG;uBACsB,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC;IAoBpD;;;;;OAKG;sBACqB,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC;IAkDnD;;;;;OAKG;2BAC0B,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC;EAsE3D;AAMD,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,iBAAiB;IAI7D;;;OAGG;qBACoB,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC;EAyBrD;AAMD,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,iBAAiB;IAIzD;;;OAGG;kBACiB,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC;IAkB/C;;;;OAIG;iBACgB,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC;IAoC9C;;;;OAIG;oBACmB,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC;EAiCpD;AAMD,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,iBAAiB;IAI7D;;;;OAIG;qBACoB,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC;IA0DlD;;;OAGG;2BAC0B,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC;IAyBxD;;;OAGG;uBACsB,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC;IAqCpD;;;OAGG;oBACmB,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC;EAuBpD"}
package/dist/api/index.js CHANGED
@@ -8,8 +8,8 @@ import { createCrudService } from 'hazo_connect/server';
8
8
  import { recordStat as _recordStat, getLatestStat as _getLatestStat, getStatSeries as _getStatSeries, } from '../server/stats.js';
9
9
  import { resolveVariant } from '../server/flags.js';
10
10
  import { getActivitySummary as _getActivitySummary, getActivityTrend as _getActivityTrend, } from '../server/activity.js';
11
- import { ensureActionDef as _ensureActionDef, listActions as _listActions, getActionTree as _getActionTree, updateActionDef as _updateActionDef, getLinks as _getLinks, addLink as _addLink, removeLink as _removeLink, } from '../server/actions.js';
12
- import { topActions as _topActions, firstSessionActions as _firstSessionActions, postReloginActions as _postReloginActions, actionsBeforeGoal as _actionsBeforeGoal, topErrors as _topErrors, tabDwell as _tabDwell, frequentActionsReturning as _frequentActionsReturning, eventsOverTime as _eventsOverTime, } from '../server/behavior.js';
11
+ import { ensureActionDef as _ensureActionDef, listActions as _listActions, getActionTree as _getActionTree, updateActionDef as _updateActionDef, updateActionDefBulk as _updateActionDefBulk, applyLinksBulk as _applyLinksBulk, getLinks as _getLinks, addLink as _addLink, removeLink as _removeLink, } from '../server/actions.js';
12
+ import { topActions as _topActions, firstSessionActions as _firstSessionActions, postReloginActions as _postReloginActions, actionsBeforeGoal as _actionsBeforeGoal, topErrors as _topErrors, tabDwell as _tabDwell, frequentActionsReturning as _frequentActionsReturning, eventsOverTime as _eventsOverTime, datapointStats as _datapointStats, userJourney as _userJourney, } from '../server/behavior.js';
13
13
  // ---------------------------------------------------------------------------
14
14
  // Default auth — dynamically imports hazo_auth/server-lib to avoid the
15
15
  // server-only guard firing at module-evaluation time (e.g. in test runners).
@@ -516,6 +516,67 @@ export function createActionHandlers(options) {
516
516
  });
517
517
  return jsonOk({ action: updated });
518
518
  },
519
+ /**
520
+ * POST /...
521
+ * Body: { app_id, action_keys, patch?, add_related?, remove_related? }
522
+ * Bulk-updates multiple action defs and/or their links. Requires metrics.manage.
523
+ * Emits a single audit entry 'action.catalog.bulk_update' per call.
524
+ */
525
+ async bulkUpdateActions(req) {
526
+ let body;
527
+ try {
528
+ body = (await req.json());
529
+ }
530
+ catch {
531
+ return jsonError('BAD_REQUEST', 'Invalid JSON body', 400);
532
+ }
533
+ const { app_id, action_keys, patch, add_related, remove_related } = body;
534
+ if (!app_id) {
535
+ return jsonError('BAD_REQUEST', 'app_id is required', 400);
536
+ }
537
+ if (!Array.isArray(action_keys) || action_keys.length === 0) {
538
+ return jsonError('BAD_REQUEST', 'action_keys must be a non-empty array', 400);
539
+ }
540
+ const gate = await gateAuth(req, {
541
+ required_permissions: ['metrics.manage'],
542
+ app_id,
543
+ getAuth,
544
+ });
545
+ if (!gate.ok)
546
+ return gate.response;
547
+ const adapter = await options.getAdapter();
548
+ const keys = action_keys;
549
+ let updated = [];
550
+ let missing = [];
551
+ let linksAdded = 0;
552
+ let linksRemoved = 0;
553
+ if (patch && Object.keys(patch).length > 0) {
554
+ const result = await _updateActionDefBulk(adapter, {
555
+ app_id,
556
+ action_keys: keys,
557
+ patch: patch,
558
+ });
559
+ updated = result.updated;
560
+ missing = result.missing;
561
+ }
562
+ if ((add_related && add_related.length > 0) || (remove_related && remove_related.length > 0)) {
563
+ const result = await _applyLinksBulk(adapter, {
564
+ app_id,
565
+ action_keys: keys,
566
+ add_related,
567
+ remove_related,
568
+ });
569
+ linksAdded = result.added;
570
+ linksRemoved = result.removed;
571
+ }
572
+ await options.onAudit?.({
573
+ action: 'action.catalog.bulk_update',
574
+ actor_id: gate.userId,
575
+ app_id,
576
+ details: { action_keys: keys, patch, add_related, remove_related },
577
+ });
578
+ return jsonOk({ updated, missing, links: { added: linksAdded, removed: linksRemoved } });
579
+ },
519
580
  };
520
581
  }
521
582
  // ---------------------------------------------------------------------------
@@ -744,5 +805,68 @@ export function createBehaviorHandlers(options) {
744
805
  const data = await _eventsOverTime(adapter, { app_id, since, userIds, limit: 10000, catalogMap, includeArchived, keyEventsOnly });
745
806
  return jsonOk({ data });
746
807
  },
808
+ /**
809
+ * GET /...?app_id=<id>&action_key=<key>[&since=<iso>][&until=<iso>][&buckets=<n>][&top_n=<n>]
810
+ * Returns datapoint dimension stats for an action. Requires metrics.view.
811
+ */
812
+ async getDatapoints(req) {
813
+ const url = new URL(req.url);
814
+ const app_id = url.searchParams.get('app_id') ?? '';
815
+ const action_key = url.searchParams.get('action_key') ?? '';
816
+ const since = url.searchParams.get('since') ?? undefined;
817
+ const until = url.searchParams.get('until') ?? undefined;
818
+ const bucketsParam = url.searchParams.get('buckets');
819
+ const buckets = bucketsParam != null ? parseInt(bucketsParam, 10) : undefined;
820
+ const topNParam = url.searchParams.get('top_n');
821
+ const topN = topNParam != null ? parseInt(topNParam, 10) : undefined;
822
+ const gate = await gateAuth(req, {
823
+ required_permissions: ['metrics.view'],
824
+ app_id: app_id || undefined,
825
+ getAuth,
826
+ });
827
+ if (!gate.ok)
828
+ return gate.response;
829
+ if (!app_id || !action_key) {
830
+ return jsonError('BAD_REQUEST', 'app_id and action_key are required', 400);
831
+ }
832
+ const adapter = await options.getAdapter();
833
+ const catalog = await _listActions(adapter, { app_id });
834
+ const matchingAction = catalog.find((a) => a.action_key === action_key);
835
+ const result = await _datapointStats(adapter, {
836
+ app_id,
837
+ action_key,
838
+ since,
839
+ until,
840
+ buckets,
841
+ topN,
842
+ schema: matchingAction?.datapoint_schema,
843
+ });
844
+ return jsonOk(result);
845
+ },
846
+ /**
847
+ * GET /...?app_id=<id>[&user_id=<id>][&session_id=<id>][&since=<iso>]
848
+ * Returns user journey sessions and aggregated graph. Requires metrics.view.
849
+ */
850
+ async getJourney(req) {
851
+ const url = new URL(req.url);
852
+ const app_id = url.searchParams.get('app_id') ?? '';
853
+ const user_id = url.searchParams.get('user_id') ?? undefined;
854
+ const session_id = url.searchParams.get('session_id') ?? undefined;
855
+ const since = url.searchParams.get('since') ?? undefined;
856
+ const gate = await gateAuth(req, {
857
+ required_permissions: ['metrics.view'],
858
+ app_id: app_id || undefined,
859
+ getAuth,
860
+ });
861
+ if (!gate.ok)
862
+ return gate.response;
863
+ if (!app_id)
864
+ return jsonError('BAD_REQUEST', 'app_id is required', 400);
865
+ const adapter = await options.getAdapter();
866
+ const catalog = await _listActions(adapter, { app_id });
867
+ const catalogMap = new Map(catalog.map((a) => [a.action_key, { label: a.label, datapoint_schema: a.datapoint_schema }]));
868
+ const result = await _userJourney(adapter, { app_id, user_id, session_id, since, catalogMap });
869
+ return jsonOk(result);
870
+ },
747
871
  };
748
872
  }
@@ -1,5 +1,21 @@
1
1
  import type { HazoConnectAdapter } from 'hazo_connect/server';
2
2
  import type { ActionDef, ActionDefPatch, ActionNode, ActionLink } from './types.js';
3
+ type ActionDefRow = {
4
+ action_key: string;
5
+ app_id: string;
6
+ scope_id: string;
7
+ label: string;
8
+ description: string;
9
+ parent_key: string | null;
10
+ url: string | null;
11
+ is_key_event: number | boolean;
12
+ status: string;
13
+ datapoint_schema: string;
14
+ created_at: string;
15
+ updated_at: string;
16
+ [key: string]: unknown;
17
+ };
18
+ export declare function applyPatch(_existing: ActionDef, patch: ActionDefPatch): Partial<ActionDefRow>;
3
19
  /**
4
20
  * Insert-if-absent a draft def row. No-op after first sight (seen-set cached).
5
21
  * Called by the ingestion layer on every `action.*` event.
@@ -63,4 +79,32 @@ export declare function removeLink(adapter: HazoConnectAdapter, params: {
63
79
  action_key: string;
64
80
  related_key: string;
65
81
  }): Promise<void>;
82
+ /**
83
+ * Updates mutable fields across multiple action defs in one call.
84
+ * Returns lists of updated keys and missing (not found) keys.
85
+ */
86
+ export declare function updateActionDefBulk(adapter: HazoConnectAdapter, params: {
87
+ app_id: string;
88
+ action_keys: string[];
89
+ patch: ActionDefPatch;
90
+ }): Promise<{
91
+ updated: string[];
92
+ missing: string[];
93
+ }>;
94
+ /**
95
+ * Applies link additions and removals across multiple action keys (Cartesian product).
96
+ * For each action_key × each add_related key: calls addLink (idempotent, self-link guarded).
97
+ * For each action_key × each remove_related key: calls removeLink.
98
+ * Returns counts of added (non-null addLink results) and removed (all removeLink calls).
99
+ */
100
+ export declare function applyLinksBulk(adapter: HazoConnectAdapter, params: {
101
+ app_id: string;
102
+ action_keys: string[];
103
+ add_related?: string[];
104
+ remove_related?: string[];
105
+ }): Promise<{
106
+ added: number;
107
+ removed: number;
108
+ }>;
109
+ export {};
66
110
  //# sourceMappingURL=actions.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"actions.d.ts","sourceRoot":"","sources":["../../src/server/actions.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;AAC9D,OAAO,KAAK,EAAE,SAAS,EAAE,cAAc,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAoEpF;;;GAGG;AACH,wBAAsB,eAAe,CACnC,OAAO,EAAE,kBAAkB,EAC3B,MAAM,EAAE;IACN,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,GAAG,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACrB,GACA,OAAO,CAAC,IAAI,CAAC,CA+Bf;AAED;;GAEG;AACH,wBAAsB,WAAW,CAC/B,OAAO,EAAE,kBAAkB,EAC3B,MAAM,EAAE;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAE,GAC5C,OAAO,CAAC,SAAS,EAAE,CAAC,CAUtB;AAED;;;GAGG;AACH,wBAAsB,aAAa,CACjC,OAAO,EAAE,kBAAkB,EAC3B,MAAM,EAAE;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAE,GAC5C,OAAO,CAAC,UAAU,EAAE,CAAC,CAwBvB;AAED;;GAEG;AACH,wBAAsB,eAAe,CACnC,OAAO,EAAE,kBAAkB,EAC3B,MAAM,EAAE;IACN,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,cAAc,CAAC;CACvB,GACA,OAAO,CAAC,SAAS,GAAG,IAAI,CAAC,CAwB3B;AAED;;GAEG;AACH,wBAAgB,aAAa,IAAI,IAAI,CAEpC;AAsBD;;GAEG;AACH,wBAAsB,QAAQ,CAC5B,OAAO,EAAE,kBAAkB,EAC3B,MAAM,EAAE;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,UAAU,CAAC,EAAE,MAAM,CAAA;CAAE,GAC9C,OAAO,CAAC,UAAU,EAAE,CAAC,CAoBvB;AAED;;;;GAIG;AACH,wBAAsB,OAAO,CAC3B,OAAO,EAAE,kBAAkB,EAC3B,MAAM,EAAE;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,MAAM,CAAA;CAAE,GACrF,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC,CAsC5B;AAED;;GAEG;AACH,wBAAsB,UAAU,CAC9B,OAAO,EAAE,kBAAkB,EAC3B,MAAM,EAAE;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,MAAM,CAAA;CAAE,GAClE,OAAO,CAAC,IAAI,CAAC,CAiBf"}
1
+ {"version":3,"file":"actions.d.ts","sourceRoot":"","sources":["../../src/server/actions.ts"],"names":[],"mappings":"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"}
@@ -5,6 +5,21 @@ import { createCrudService } from 'hazo_connect/server';
5
5
  // ---------------------------------------------------------------------------
6
6
  // Helpers
7
7
  // ---------------------------------------------------------------------------
8
+ function parseSchema(raw) {
9
+ try {
10
+ const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
11
+ if (!Array.isArray(parsed))
12
+ return [];
13
+ const valid = parsed.every((el) => el != null &&
14
+ typeof el.key === 'string' &&
15
+ typeof el.label === 'string' &&
16
+ typeof el.type === 'string');
17
+ return valid ? parsed : [];
18
+ }
19
+ catch {
20
+ return [];
21
+ }
22
+ }
8
23
  function rowToDef(row) {
9
24
  return {
10
25
  action_key: row.action_key,
@@ -16,10 +31,29 @@ function rowToDef(row) {
16
31
  url: row.url ?? null,
17
32
  is_key_event: Boolean(row.is_key_event),
18
33
  status: row.status,
34
+ datapoint_schema: parseSchema(row.datapoint_schema),
19
35
  created_at: row.created_at,
20
36
  updated_at: row.updated_at,
21
37
  };
22
38
  }
39
+ export function applyPatch(_existing, patch) {
40
+ const changes = {};
41
+ if (patch.label != null)
42
+ changes.label = patch.label;
43
+ if (patch.description != null)
44
+ changes.description = patch.description;
45
+ if ('parent_key' in patch)
46
+ changes.parent_key = patch.parent_key ?? null;
47
+ if ('url' in patch)
48
+ changes.url = patch.url ?? null;
49
+ if (patch.is_key_event != null)
50
+ changes.is_key_event = patch.is_key_event ? 1 : 0;
51
+ if (patch.status != null)
52
+ changes.status = patch.status;
53
+ if ('datapoint_schema' in patch)
54
+ changes.datapoint_schema = JSON.stringify(patch.datapoint_schema ?? []);
55
+ return changes;
56
+ }
23
57
  function getCrud(adapter) {
24
58
  return createCrudService(adapter, 'hazo_umetrics_action_def', {
25
59
  primaryKeys: ['action_key', 'app_id'],
@@ -66,6 +100,7 @@ export async function ensureActionDef(adapter, params) {
66
100
  url: params.url ?? null,
67
101
  is_key_event: 0,
68
102
  status: 'unclassified',
103
+ datapoint_schema: '[]',
69
104
  created_at: new Date().toISOString(),
70
105
  updated_at: new Date().toISOString(),
71
106
  });
@@ -125,23 +160,12 @@ export async function updateActionDef(adapter, params) {
125
160
  });
126
161
  if (!existing)
127
162
  return null;
163
+ const changes = applyPatch(rowToDef(existing), params.patch);
128
164
  const merged = {
129
165
  ...existing,
166
+ ...changes,
130
167
  updated_at: new Date().toISOString(),
131
168
  };
132
- if (params.patch.label != null)
133
- merged.label = params.patch.label;
134
- if (params.patch.description != null)
135
- merged.description = params.patch.description;
136
- if ('parent_key' in params.patch)
137
- merged.parent_key = params.patch.parent_key ?? null;
138
- if ('url' in params.patch)
139
- merged.url = params.patch.url ?? null;
140
- if (params.patch.is_key_event != null) {
141
- merged.is_key_event = params.patch.is_key_event ? 1 : 0;
142
- }
143
- if (params.patch.status != null)
144
- merged.status = params.patch.status;
145
169
  await crud.upsert(merged, ['action_key', 'app_id']);
146
170
  return rowToDef(merged);
147
171
  }
@@ -232,3 +256,61 @@ export async function removeLink(adapter, params) {
232
256
  await crud.deleteWhere({ action_key: a, related_key: b, app_id: params.app_id });
233
257
  }
234
258
  }
259
+ // ---------------------------------------------------------------------------
260
+ // Bulk operations
261
+ // ---------------------------------------------------------------------------
262
+ /**
263
+ * Updates mutable fields across multiple action defs in one call.
264
+ * Returns lists of updated keys and missing (not found) keys.
265
+ */
266
+ export async function updateActionDefBulk(adapter, params) {
267
+ if (params.action_keys.length === 0) {
268
+ return { updated: [], missing: [] };
269
+ }
270
+ const crud = getCrud(adapter);
271
+ const updated = [];
272
+ const missing = [];
273
+ for (const action_key of params.action_keys) {
274
+ const existing = await crud.findOneBy({
275
+ action_key,
276
+ app_id: params.app_id,
277
+ });
278
+ if (!existing) {
279
+ missing.push(action_key);
280
+ continue;
281
+ }
282
+ const changes = applyPatch(rowToDef(existing), params.patch);
283
+ const merged = {
284
+ ...existing,
285
+ ...changes,
286
+ updated_at: new Date().toISOString(),
287
+ };
288
+ await crud.upsert(merged, ['action_key', 'app_id']);
289
+ updated.push(action_key);
290
+ }
291
+ return { updated, missing };
292
+ }
293
+ /**
294
+ * Applies link additions and removals across multiple action keys (Cartesian product).
295
+ * For each action_key × each add_related key: calls addLink (idempotent, self-link guarded).
296
+ * For each action_key × each remove_related key: calls removeLink.
297
+ * Returns counts of added (non-null addLink results) and removed (all removeLink calls).
298
+ */
299
+ export async function applyLinksBulk(adapter, params) {
300
+ let added = 0;
301
+ let removed = 0;
302
+ const addRelated = params.add_related ?? [];
303
+ const removeRelated = params.remove_related ?? [];
304
+ for (const action_key of params.action_keys) {
305
+ for (const related_key of addRelated) {
306
+ const result = await addLink(adapter, { app_id: params.app_id, action_key, related_key });
307
+ if (result !== null)
308
+ added++;
309
+ }
310
+ for (const related_key of removeRelated) {
311
+ await removeLink(adapter, { app_id: params.app_id, action_key, related_key });
312
+ removed++;
313
+ }
314
+ }
315
+ return { added, removed };
316
+ }