@pitcher/cli-scripts 2.13.0 → 2.14.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
@@ -352,7 +352,7 @@ p org get --api-key myapikey
352
352
 
353
353
  ## To run the `analytics` command:
354
354
 
355
- Read rollup analytics totals for an organization or instance. Requires an org-admin API key — a non-admin key returns an auth error.
355
+ Read rollup analytics totals (`org`/`instance`), KPI metrics, and content/user engagement analytics. `org`/`instance` are Django rollups; `kpi` and the 7 verbs below hit the **clickhouse-api** service (`clickhouse.pitcher.com`) via the same env resolution as everything else — `--org`/`--url` pick the environment (routed via `x-original-domain`). Access **follows the key user's real role**: `popularity`, `users`, `files`, and `canvases` need an **org-admin** key; `engagement`, `viewers`, and `sessions` work with a member key. `kpi` and `sessions` return the raw payload; the other row-heavy verbs return a shaped envelope (`summary`, `rows`, `total_count`, `returned`, `has_more`, and sometimes `truncated`/`warnings`) capped by `--limit` (default 20, max 100). Date-range verbs default to a **30-day window** when `--start-date`/`--end-date` are omitted. `viewers` and `sessions` require `--canvas-id`; `sessions` also requires `--user`.
356
356
 
357
357
  ```bash
358
358
  # Organization-wide rollups (org-admin key required)
@@ -368,6 +368,30 @@ p analytics instance --instance-id 01... --api-key myapikey
368
368
  p analytics kpi --instance-id 01... --type total-members --api-key myapikey
369
369
  p analytics kpi --instance-id 01... --type total-active-users \
370
370
  --start-date 2026-01-01 --end-date 2026-01-31 --api-key myapikey
371
+
372
+ # DSR/content engagement rows (member key)
373
+ p analytics engagement --instance-id 01... --engagement-level high --limit 50 --api-key myapikey
374
+
375
+ # Most-viewed canvases / view-analysis (org-admin key)
376
+ p analytics popularity --instance-id 01... --sort-by total_views --sort-order DESC --api-key myapikey
377
+
378
+ # User roster (org-admin key)
379
+ p analytics users --instance-id 01... --api-key myapikey
380
+
381
+ # Active-user statistics instead of the roster (org-admin key)
382
+ p analytics users --instance-id 01... --stats --start-date 2026-01-01 --end-date 2026-01-31 --api-key myapikey
383
+
384
+ # File usage analytics (org-admin key)
385
+ p analytics files --instance-id 01... --file-type pdf --sort-by views --sort-order desc --api-key myapikey
386
+
387
+ # Per-section canvas engagement list (org-admin key)
388
+ p analytics canvases --instance-id 01... --group-ids 01GROUPID... --api-key myapikey
389
+
390
+ # DSR canvas viewers — who viewed a given canvas (member key)
391
+ p analytics viewers --instance-id 01... --canvas-id 01CANVASID... --viewer-type external --api-key myapikey
392
+
393
+ # Per-viewer session event timeline for a DSR canvas (member key)
394
+ p analytics sessions --instance-id 01... --canvas-id 01CANVASID... --user rep@example.com --api-key myapikey
371
395
  ```
372
396
 
373
397
  ## To run the `users` command:
@@ -25,4 +25,152 @@ export interface AnalyticsKpiOpts extends CommonOpts {
25
25
  * `total-members` need none) — that validation stays server-side.
26
26
  */
27
27
  export declare function runAnalyticsKpi(opts: AnalyticsKpiOpts, deps?: AnalyticsDeps): Promise<Envelope<unknown>>;
28
+ export interface AnalyticsEngagementOpts extends CommonOpts {
29
+ instanceId?: string;
30
+ startDate?: string;
31
+ endDate?: string;
32
+ limit?: number;
33
+ page?: number;
34
+ engagementLevel?: string;
35
+ search?: string;
36
+ view?: string;
37
+ groupIds?: string;
38
+ sortBy?: string;
39
+ sortOrder?: string;
40
+ hideMemberFiles?: boolean;
41
+ }
42
+ /**
43
+ * DSR/content engagement rows from the clickhouse-api analytics service.
44
+ * Row-heavy (member gate) — shaped through `shapeAnalytics` to cap the
45
+ * payload at `--limit` and surface a truncation notice, since the native
46
+ * response can carry hundreds of rows per page.
47
+ */
48
+ export declare function runAnalyticsEngagement(opts: AnalyticsEngagementOpts, deps?: AnalyticsDeps): Promise<Envelope<unknown>>;
49
+ export interface AnalyticsPopularityOpts extends CommonOpts {
50
+ instanceId?: string;
51
+ startDate?: string;
52
+ endDate?: string;
53
+ limit?: number;
54
+ page?: number;
55
+ sortBy?: string;
56
+ sortOrder?: string;
57
+ groupIds?: string;
58
+ }
59
+ /**
60
+ * Most-viewed canvases from the clickhouse-api analytics service. There is no
61
+ * dedicated `popularity` route — this maps to `GET /analytics/canvases/stats`
62
+ * with `type=view-analysis` forced (always sent, never taken from opts).
63
+ * Row-heavy, so shaped through `shapeAnalytics` like `engagement`.
64
+ */
65
+ export declare function runAnalyticsPopularity(opts: AnalyticsPopularityOpts, deps?: AnalyticsDeps): Promise<Envelope<unknown>>;
66
+ export interface AnalyticsUsersOpts extends CommonOpts {
67
+ instanceId?: string;
68
+ stats?: boolean;
69
+ limit?: number;
70
+ page?: number;
71
+ startDate?: string;
72
+ endDate?: string;
73
+ groupIds?: string;
74
+ }
75
+ /**
76
+ * User roster (default) or active-user statistics (`--stats`) from the
77
+ * clickhouse-api analytics service. The two branches hit different routes
78
+ * with different params — verified against `packages/clickhouse-api/src/routes/users.ts`:
79
+ *
80
+ * - Default → `GET /analytics/users/list`: `instance_id` only. Full roster,
81
+ * no dates, no page size. (`show_internal_users` is resolved server-side
82
+ * from an org LD flag — the route never reads it as a query param, so the
83
+ * CLI does not expose it.)
84
+ * - `--stats` → `GET /analytics/users/stats`: requires `start_date`/`end_date`
85
+ * (defaulted via `withDefaultWindow`), and `type` is ALWAYS forced to
86
+ * `'active-users'` — the wire default `type=all` returns a shape with no
87
+ * `.items`, which would otherwise silently shape to an empty row set. The
88
+ * page-size param on this route is the camelCase `pageSize` (verified
89
+ * `users.ts:230`) — the one route in the CLI that reads camelCase on the wire.
90
+ */
91
+ export declare function runAnalyticsUsers(opts: AnalyticsUsersOpts, deps?: AnalyticsDeps): Promise<Envelope<unknown>>;
92
+ export interface AnalyticsFilesOpts extends CommonOpts {
93
+ instanceId?: string;
94
+ startDate?: string;
95
+ endDate?: string;
96
+ limit?: number;
97
+ page?: number;
98
+ fileType?: string;
99
+ filter?: string;
100
+ sortBy?: string;
101
+ sortOrder?: string;
102
+ metadataFilters?: string;
103
+ groupIds?: string;
104
+ userEmails?: string;
105
+ hideMemberFiles?: boolean;
106
+ }
107
+ /**
108
+ * File usage analytics from the clickhouse-api analytics service. There is
109
+ * no dedicated "list" type on the wire — `type` is ALWAYS forced to `'all'`
110
+ * (verified `files.ts`/`files-analytics.service.ts`): the `default` branch of
111
+ * `getFileUsageAnalysisData` throws `Unsupported type`, and only `type=all`
112
+ * yields the paginated row list. `page_size` is snake_case on this
113
+ * route (unlike `users/stats`). `sort_order` must be uppercased on the wire —
114
+ * the server zod is `z.enum(['ASC','DESC'])`.
115
+ */
116
+ export declare function runAnalyticsFiles(opts: AnalyticsFilesOpts, deps?: AnalyticsDeps): Promise<Envelope<unknown>>;
117
+ export interface AnalyticsCanvasesOpts extends CommonOpts {
118
+ instanceId?: string;
119
+ startDate?: string;
120
+ endDate?: string;
121
+ limit?: number;
122
+ page?: number;
123
+ groupIds?: string;
124
+ }
125
+ /**
126
+ * Per-section canvas engagement list from the clickhouse-api analytics
127
+ * service. There is no dedicated "list" type on the wire — `type` is ALWAYS
128
+ * forced to `'section-list'` (verified `canvases.ts`/`canvases-analytics.service.ts`):
129
+ * `getCanvasAnalysisData` has exactly one supported case (`section-list`); its
130
+ * `default` branch throws `Unsupported type`, so any other value would hard-fail
131
+ * server-side. `--status`/`--filter` are deliberately NOT exposed — the
132
+ * `section-list` code path (`fetchSectionsList`) ignores `filter`/`search`/`status`
133
+ * entirely, so they'd be dead flags. There is also no `sort_order` on this route.
134
+ */
135
+ export declare function runAnalyticsCanvases(opts: AnalyticsCanvasesOpts, deps?: AnalyticsDeps): Promise<Envelope<unknown>>;
136
+ export interface AnalyticsViewersOpts extends CommonOpts {
137
+ instanceId?: string;
138
+ canvasId?: string;
139
+ limit?: number;
140
+ page?: number;
141
+ viewerType?: string;
142
+ sortBy?: string;
143
+ sortOrder?: string;
144
+ search?: string;
145
+ startDate?: string;
146
+ endDate?: string;
147
+ }
148
+ /**
149
+ * DSR canvas viewers (who-viewed) from the clickhouse-api analytics service.
150
+ * Maps to `GET /analytics/canvas-details/viewers` (member-gated, not admin) —
151
+ * verified `canvas-details.ts` ~L578-1147. Unlike the other row-heavy verbs,
152
+ * this route has its OWN server-side default window, so dates are NOT run
153
+ * through `withDefaultWindow` here — `start_date`/`end_date` are sent only
154
+ * when the caller supplies them. `sort_order` must be lowercased on the
155
+ * wire — the server zod is `z.enum(['asc','desc'])` (opposite of `files`,
156
+ * which uppercases). `sort_by` is validated client-side against the server
157
+ * enum so bad values fail fast with VALIDATION instead of a server 400.
158
+ */
159
+ export declare function runAnalyticsViewers(opts: AnalyticsViewersOpts, deps?: AnalyticsDeps): Promise<Envelope<unknown>>;
160
+ export interface AnalyticsSessionsOpts extends CommonOpts {
161
+ instanceId?: string;
162
+ canvasId?: string;
163
+ userEmail?: string;
164
+ startDate?: string;
165
+ endDate?: string;
166
+ }
167
+ /**
168
+ * Per-viewer session event timeline (DSR drill-down) from the clickhouse-api
169
+ * analytics service. Maps to `GET /analytics/canvas-details/sessions`
170
+ * (member-gated) — verified `canvas-details.ts` ~L1152-1329. Returns RAW
171
+ * (like `kpi`), NOT run through `shapeAnalytics` — an event timeline must not
172
+ * be truncated. Like `viewers`, this route has its own server-side default
173
+ * window, so dates are sent only when the caller supplies them.
174
+ */
175
+ export declare function runAnalyticsSessions(opts: AnalyticsSessionsOpts, deps?: AnalyticsDeps): Promise<Envelope<unknown>>;
28
176
  export declare function createAnalyticsCommand(): Command;
@@ -4,6 +4,7 @@ import { ok, fail } from '../core/envelope.js';
4
4
  import { createCliError } from '../core/errors.js';
5
5
  import { emitEnvelope } from '../core/output.js';
6
6
  import { withAuthFlags } from './withAuth.js';
7
+ import { shapeAnalytics, pageSizeFor, withDefaultWindow, todayIso } from '../core/analytics-shape.js';
7
8
  export async function runAnalyticsOrg(opts, deps = {}) {
8
9
  try {
9
10
  const client = clientFor(opts, deps);
@@ -57,6 +58,306 @@ export async function runAnalyticsKpi(opts, deps = {}) {
57
58
  return fail(toCliError(e));
58
59
  }
59
60
  }
61
+ /**
62
+ * DSR/content engagement rows from the clickhouse-api analytics service.
63
+ * Row-heavy (member gate) — shaped through `shapeAnalytics` to cap the
64
+ * payload at `--limit` and surface a truncation notice, since the native
65
+ * response can carry hundreds of rows per page.
66
+ */
67
+ export async function runAnalyticsEngagement(opts, deps = {}) {
68
+ try {
69
+ if (!opts.instanceId)
70
+ throw createCliError('VALIDATION', '--instance-id is required for analytics engagement');
71
+ const client = clickhouseClientFor(opts, deps);
72
+ const windowed = withDefaultWindow(opts, todayIso());
73
+ const params = {
74
+ instance_id: opts.instanceId,
75
+ start_date: windowed.startDate,
76
+ end_date: windowed.endDate,
77
+ page_size: pageSizeFor(opts.limit),
78
+ };
79
+ if (opts.page !== undefined)
80
+ params.page = opts.page;
81
+ if (opts.engagementLevel)
82
+ params.engagement_level = opts.engagementLevel;
83
+ if (opts.search)
84
+ params.search = opts.search;
85
+ if (opts.view)
86
+ params.view = opts.view;
87
+ if (opts.groupIds)
88
+ params.group_ids = opts.groupIds;
89
+ if (opts.sortBy)
90
+ params.sort_by = opts.sortBy;
91
+ if (opts.sortOrder)
92
+ params.sort_order = opts.sortOrder;
93
+ if (opts.hideMemberFiles)
94
+ params.hide_member_files = 'true';
95
+ const res = await client.get('/analytics/engagement', { params });
96
+ return ok(shapeAnalytics(res.data, (n) => ({ summary: n.metrics, rows: n.data?.data ?? [], totalCount: n.data?.total }), {
97
+ limit: opts.limit,
98
+ }));
99
+ }
100
+ catch (e) {
101
+ return fail(toCliError(e));
102
+ }
103
+ }
104
+ /**
105
+ * Most-viewed canvases from the clickhouse-api analytics service. There is no
106
+ * dedicated `popularity` route — this maps to `GET /analytics/canvases/stats`
107
+ * with `type=view-analysis` forced (always sent, never taken from opts).
108
+ * Row-heavy, so shaped through `shapeAnalytics` like `engagement`.
109
+ */
110
+ export async function runAnalyticsPopularity(opts, deps = {}) {
111
+ try {
112
+ if (!opts.instanceId)
113
+ throw createCliError('VALIDATION', '--instance-id is required for analytics popularity');
114
+ const client = clickhouseClientFor(opts, deps);
115
+ const windowed = withDefaultWindow(opts, todayIso());
116
+ const params = {
117
+ instance_id: opts.instanceId,
118
+ start_date: windowed.startDate,
119
+ end_date: windowed.endDate,
120
+ type: 'view-analysis',
121
+ sort_by: opts.sortBy || 'total_views',
122
+ sort_order: opts.sortOrder || 'DESC',
123
+ page_size: pageSizeFor(opts.limit),
124
+ };
125
+ if (opts.page !== undefined)
126
+ params.page = opts.page;
127
+ if (opts.groupIds)
128
+ params.group_ids = opts.groupIds;
129
+ const res = await client.get('/analytics/canvases/stats', { params });
130
+ return ok(shapeAnalytics(res.data, (n) => ({ summary: n.metrics, rows: n.data ?? [], totalCount: n.pagination?.total }), {
131
+ limit: opts.limit,
132
+ }));
133
+ }
134
+ catch (e) {
135
+ return fail(toCliError(e));
136
+ }
137
+ }
138
+ /**
139
+ * User roster (default) or active-user statistics (`--stats`) from the
140
+ * clickhouse-api analytics service. The two branches hit different routes
141
+ * with different params — verified against `packages/clickhouse-api/src/routes/users.ts`:
142
+ *
143
+ * - Default → `GET /analytics/users/list`: `instance_id` only. Full roster,
144
+ * no dates, no page size. (`show_internal_users` is resolved server-side
145
+ * from an org LD flag — the route never reads it as a query param, so the
146
+ * CLI does not expose it.)
147
+ * - `--stats` → `GET /analytics/users/stats`: requires `start_date`/`end_date`
148
+ * (defaulted via `withDefaultWindow`), and `type` is ALWAYS forced to
149
+ * `'active-users'` — the wire default `type=all` returns a shape with no
150
+ * `.items`, which would otherwise silently shape to an empty row set. The
151
+ * page-size param on this route is the camelCase `pageSize` (verified
152
+ * `users.ts:230`) — the one route in the CLI that reads camelCase on the wire.
153
+ */
154
+ export async function runAnalyticsUsers(opts, deps = {}) {
155
+ try {
156
+ if (!opts.instanceId)
157
+ throw createCliError('VALIDATION', '--instance-id is required for analytics users');
158
+ const client = clickhouseClientFor(opts, deps);
159
+ if (!opts.stats) {
160
+ const params = { instance_id: opts.instanceId };
161
+ const res = await client.get('/analytics/users/list', { params });
162
+ return ok(shapeAnalytics(res.data, (n) => ({ summary: undefined, rows: n.data ?? [] }), { limit: opts.limit }));
163
+ }
164
+ const windowed = withDefaultWindow(opts, todayIso());
165
+ const params = {
166
+ instance_id: opts.instanceId,
167
+ start_date: windowed.startDate,
168
+ end_date: windowed.endDate,
169
+ type: 'active-users',
170
+ pageSize: pageSizeFor(opts.limit),
171
+ };
172
+ if (opts.page !== undefined)
173
+ params.page = opts.page;
174
+ if (opts.groupIds)
175
+ params.group_ids = opts.groupIds;
176
+ const res = await client.get('/analytics/users/stats', { params });
177
+ return ok(shapeAnalytics(res.data, (n) => ({ summary: n.metrics, rows: n.data?.items ?? [], totalCount: n.data?.total }), {
178
+ limit: opts.limit,
179
+ }));
180
+ }
181
+ catch (e) {
182
+ return fail(toCliError(e));
183
+ }
184
+ }
185
+ /**
186
+ * File usage analytics from the clickhouse-api analytics service. There is
187
+ * no dedicated "list" type on the wire — `type` is ALWAYS forced to `'all'`
188
+ * (verified `files.ts`/`files-analytics.service.ts`): the `default` branch of
189
+ * `getFileUsageAnalysisData` throws `Unsupported type`, and only `type=all`
190
+ * yields the paginated row list. `page_size` is snake_case on this
191
+ * route (unlike `users/stats`). `sort_order` must be uppercased on the wire —
192
+ * the server zod is `z.enum(['ASC','DESC'])`.
193
+ */
194
+ export async function runAnalyticsFiles(opts, deps = {}) {
195
+ try {
196
+ if (!opts.instanceId)
197
+ throw createCliError('VALIDATION', '--instance-id is required for analytics files');
198
+ const client = clickhouseClientFor(opts, deps);
199
+ const windowed = withDefaultWindow(opts, todayIso());
200
+ const params = {
201
+ instance_id: opts.instanceId,
202
+ start_date: windowed.startDate,
203
+ end_date: windowed.endDate,
204
+ type: 'all',
205
+ page_size: pageSizeFor(opts.limit),
206
+ };
207
+ if (opts.page !== undefined)
208
+ params.page = opts.page;
209
+ if (opts.fileType)
210
+ params.file_type = opts.fileType;
211
+ if (opts.filter)
212
+ params.filter = opts.filter;
213
+ if (opts.sortBy)
214
+ params.sort_by = opts.sortBy;
215
+ if (opts.sortOrder)
216
+ params.sort_order = opts.sortOrder.toUpperCase();
217
+ if (opts.metadataFilters)
218
+ params.metadata_filters = opts.metadataFilters;
219
+ if (opts.groupIds)
220
+ params.group_ids = opts.groupIds;
221
+ if (opts.userEmails)
222
+ params.user_emails = opts.userEmails;
223
+ if (opts.hideMemberFiles)
224
+ params.hide_member_files = 'true';
225
+ const res = await client.get('/analytics/files', { params });
226
+ return ok(shapeAnalytics(res.data, (n) => ({ summary: n.metrics, rows: n.data?.data ?? [], totalCount: n.data?.pagination?.total }), { limit: opts.limit }));
227
+ }
228
+ catch (e) {
229
+ return fail(toCliError(e));
230
+ }
231
+ }
232
+ /**
233
+ * Per-section canvas engagement list from the clickhouse-api analytics
234
+ * service. There is no dedicated "list" type on the wire — `type` is ALWAYS
235
+ * forced to `'section-list'` (verified `canvases.ts`/`canvases-analytics.service.ts`):
236
+ * `getCanvasAnalysisData` has exactly one supported case (`section-list`); its
237
+ * `default` branch throws `Unsupported type`, so any other value would hard-fail
238
+ * server-side. `--status`/`--filter` are deliberately NOT exposed — the
239
+ * `section-list` code path (`fetchSectionsList`) ignores `filter`/`search`/`status`
240
+ * entirely, so they'd be dead flags. There is also no `sort_order` on this route.
241
+ */
242
+ export async function runAnalyticsCanvases(opts, deps = {}) {
243
+ try {
244
+ if (!opts.instanceId)
245
+ throw createCliError('VALIDATION', '--instance-id is required for analytics canvases');
246
+ const client = clickhouseClientFor(opts, deps);
247
+ const windowed = withDefaultWindow(opts, todayIso());
248
+ const params = {
249
+ instance_id: opts.instanceId,
250
+ start_date: windowed.startDate,
251
+ end_date: windowed.endDate,
252
+ type: 'section-list',
253
+ page_size: pageSizeFor(opts.limit),
254
+ };
255
+ if (opts.page !== undefined)
256
+ params.page = opts.page;
257
+ if (opts.groupIds)
258
+ params.group_ids = opts.groupIds;
259
+ const res = await client.get('/analytics/canvases', { params });
260
+ return ok(shapeAnalytics(res.data, (n) => ({ summary: n.metrics, rows: n.data?.items ?? [], totalCount: n.data?.total }), {
261
+ limit: opts.limit,
262
+ }));
263
+ }
264
+ catch (e) {
265
+ return fail(toCliError(e));
266
+ }
267
+ }
268
+ const VIEWERS_SORT_BY = new Set([
269
+ 'email',
270
+ 'session_count',
271
+ 'total_engagement_time',
272
+ 'last_active',
273
+ 'sections_viewed',
274
+ 'files_viewed',
275
+ ]);
276
+ /**
277
+ * DSR canvas viewers (who-viewed) from the clickhouse-api analytics service.
278
+ * Maps to `GET /analytics/canvas-details/viewers` (member-gated, not admin) —
279
+ * verified `canvas-details.ts` ~L578-1147. Unlike the other row-heavy verbs,
280
+ * this route has its OWN server-side default window, so dates are NOT run
281
+ * through `withDefaultWindow` here — `start_date`/`end_date` are sent only
282
+ * when the caller supplies them. `sort_order` must be lowercased on the
283
+ * wire — the server zod is `z.enum(['asc','desc'])` (opposite of `files`,
284
+ * which uppercases). `sort_by` is validated client-side against the server
285
+ * enum so bad values fail fast with VALIDATION instead of a server 400.
286
+ */
287
+ export async function runAnalyticsViewers(opts, deps = {}) {
288
+ try {
289
+ if (!opts.instanceId)
290
+ throw createCliError('VALIDATION', '--instance-id is required for analytics viewers');
291
+ if (!opts.canvasId)
292
+ throw createCliError('VALIDATION', '--canvas-id is required for analytics viewers');
293
+ if (opts.sortBy && !VIEWERS_SORT_BY.has(opts.sortBy)) {
294
+ throw createCliError('VALIDATION', `--sort-by must be one of: ${Array.from(VIEWERS_SORT_BY).join(', ')}`);
295
+ }
296
+ const client = clickhouseClientFor(opts, deps);
297
+ const params = {
298
+ instance_id: opts.instanceId,
299
+ canvas_id: opts.canvasId,
300
+ page_size: pageSizeFor(opts.limit),
301
+ };
302
+ if (opts.viewerType)
303
+ params.viewer_type = opts.viewerType;
304
+ if (opts.sortBy)
305
+ params.sort_by = opts.sortBy;
306
+ if (opts.sortOrder)
307
+ params.sort_order = opts.sortOrder.toLowerCase();
308
+ if (opts.search)
309
+ params.search = opts.search;
310
+ if (opts.page !== undefined)
311
+ params.page = opts.page;
312
+ if (opts.startDate)
313
+ params.start_date = opts.startDate;
314
+ if (opts.endDate)
315
+ params.end_date = opts.endDate;
316
+ const res = await client.get('/analytics/canvas-details/viewers', { params });
317
+ return ok(shapeAnalytics(res.data, (n) => ({
318
+ summary: { canvas_name: n.canvas_name, ...n.overview },
319
+ rows: n.viewers?.items ?? [],
320
+ totalCount: n.viewers?.total,
321
+ warnings: n._meta?.partial ? [`partial: ${(n._meta.unavailable || []).join(', ')}`] : undefined,
322
+ }), { limit: opts.limit }));
323
+ }
324
+ catch (e) {
325
+ return fail(toCliError(e));
326
+ }
327
+ }
328
+ /**
329
+ * Per-viewer session event timeline (DSR drill-down) from the clickhouse-api
330
+ * analytics service. Maps to `GET /analytics/canvas-details/sessions`
331
+ * (member-gated) — verified `canvas-details.ts` ~L1152-1329. Returns RAW
332
+ * (like `kpi`), NOT run through `shapeAnalytics` — an event timeline must not
333
+ * be truncated. Like `viewers`, this route has its own server-side default
334
+ * window, so dates are sent only when the caller supplies them.
335
+ */
336
+ export async function runAnalyticsSessions(opts, deps = {}) {
337
+ try {
338
+ if (!opts.instanceId)
339
+ throw createCliError('VALIDATION', '--instance-id is required for analytics sessions');
340
+ if (!opts.canvasId)
341
+ throw createCliError('VALIDATION', '--canvas-id is required for analytics sessions');
342
+ if (!opts.userEmail)
343
+ throw createCliError('VALIDATION', '--user is required for analytics sessions');
344
+ const client = clickhouseClientFor(opts, deps);
345
+ const params = {
346
+ instance_id: opts.instanceId,
347
+ canvas_id: opts.canvasId,
348
+ user_email: opts.userEmail,
349
+ };
350
+ if (opts.startDate)
351
+ params.start_date = opts.startDate;
352
+ if (opts.endDate)
353
+ params.end_date = opts.endDate;
354
+ const res = await client.get('/analytics/canvas-details/sessions', { params });
355
+ return ok(res.data);
356
+ }
357
+ catch (e) {
358
+ return fail(toCliError(e));
359
+ }
360
+ }
60
361
  export function createAnalyticsCommand() {
61
362
  const cmd = new Command('analytics').description('read rollup analytics totals (requires an org-admin API key)');
62
363
  withAuthFlags(cmd.command('org'))
@@ -84,5 +385,108 @@ export function createAnalyticsCommand() {
84
385
  const globals = command.optsWithGlobals();
85
386
  emitEnvelope(await runAnalyticsKpi({ ...options, quiet: globals.quiet }), globals);
86
387
  });
388
+ withAuthFlags(cmd.command('engagement'))
389
+ .option('--instance-id <id>', 'Instance ID (required)')
390
+ .option('--start-date <iso>', 'Window start (ISO date; defaults to 30 days ago)')
391
+ .option('--end-date <iso>', 'Window end (ISO date; defaults to today)')
392
+ .option('--engagement-level <level>', "Engagement level filter (default 'all')")
393
+ .option('--view <view>', "View context (default 'dsr')")
394
+ .option('--search <term>', 'Search term')
395
+ .option('--group-ids <csv>', 'Comma-separated distribution-group IDs')
396
+ .option('--sort-by <field>', 'Sort field')
397
+ .option('--sort-order <dir>', 'Sort direction')
398
+ .option('--hide-member-files', 'Exclude rep-uploaded files')
399
+ .option('--page <n>', 'Page number', (v) => parseInt(v, 10))
400
+ .option('--limit <n>', 'Max rows to return (default 20, max 100)', (v) => parseInt(v, 10))
401
+ .description('DSR/content engagement rows from the clickhouse analytics service')
402
+ .action(async (options, command) => {
403
+ const globals = command.optsWithGlobals();
404
+ emitEnvelope(await runAnalyticsEngagement({ ...options, quiet: globals.quiet }), globals);
405
+ });
406
+ withAuthFlags(cmd.command('popularity'))
407
+ .option('--instance-id <id>', 'Instance ID (required)')
408
+ .option('--start-date <iso>', 'Window start (ISO date; defaults to 30 days ago)')
409
+ .option('--end-date <iso>', 'Window end (ISO date; defaults to today)')
410
+ .option('--sort-by <field>', "Sort field (default 'total_views')")
411
+ .option('--sort-order <dir>', "Sort direction (default 'DESC')")
412
+ .option('--group-ids <csv>', 'Comma-separated distribution-group IDs')
413
+ .option('--page <n>', 'Page number', (v) => parseInt(v, 10))
414
+ .option('--limit <n>', 'Max rows to return (default 20, max 100)', (v) => parseInt(v, 10))
415
+ .description('most-viewed canvases (view-analysis) from the clickhouse analytics service')
416
+ .action(async (options, command) => {
417
+ const globals = command.optsWithGlobals();
418
+ emitEnvelope(await runAnalyticsPopularity({ ...options, quiet: globals.quiet }), globals);
419
+ });
420
+ withAuthFlags(cmd.command('users'))
421
+ .option('--instance-id <id>', 'Instance ID (required)')
422
+ .option('--stats', 'Return active-user statistics instead of the user roster')
423
+ .option('--start-date <iso>', 'Window start (ISO date; --stats only, defaults to 30 days ago)')
424
+ .option('--end-date <iso>', 'Window end (ISO date; --stats only, defaults to today)')
425
+ .option('--group-ids <csv>', 'Comma-separated distribution-group IDs (--stats only)')
426
+ .option('--page <n>', 'Page number (--stats only)', (v) => parseInt(v, 10))
427
+ .option('--limit <n>', 'Max rows to return (default 20, max 100)', (v) => parseInt(v, 10))
428
+ .description('user roster (default) or active-user statistics (--stats) from the clickhouse analytics service (admin key)')
429
+ .action(async (options, command) => {
430
+ const globals = command.optsWithGlobals();
431
+ emitEnvelope(await runAnalyticsUsers({ ...options, quiet: globals.quiet }), globals);
432
+ });
433
+ withAuthFlags(cmd.command('files'))
434
+ .option('--instance-id <id>', 'Instance ID (required)')
435
+ .option('--start-date <iso>', 'Window start (ISO date; defaults to 30 days ago)')
436
+ .option('--end-date <iso>', 'Window end (ISO date; defaults to today)')
437
+ .option('--file-type <type>', 'Filter by file type')
438
+ .option('--filter <term>', 'Search term')
439
+ .option('--sort-by <field>', 'Sort field')
440
+ .option('--sort-order <dir>', 'Sort direction (asc/desc, normalized to ASC/DESC)')
441
+ .option('--metadata-filters <json>', 'Metadata filters')
442
+ .option('--group-ids <csv>', 'Comma-separated distribution-group IDs')
443
+ .option('--user-emails <csv>', 'Comma-separated user emails to scope by')
444
+ .option('--hide-member-files', 'Exclude rep-uploaded files')
445
+ .option('--page <n>', 'Page number', (v) => parseInt(v, 10))
446
+ .option('--limit <n>', 'Max rows to return (default 20, max 100)', (v) => parseInt(v, 10))
447
+ .description('file usage analytics (admin key) from the clickhouse analytics service')
448
+ .action(async (options, command) => {
449
+ const globals = command.optsWithGlobals();
450
+ emitEnvelope(await runAnalyticsFiles({ ...options, quiet: globals.quiet }), globals);
451
+ });
452
+ withAuthFlags(cmd.command('canvases'))
453
+ .option('--instance-id <id>', 'Instance ID (required)')
454
+ .option('--start-date <iso>', 'Window start (ISO date; defaults to 30 days ago)')
455
+ .option('--end-date <iso>', 'Window end (ISO date; defaults to today)')
456
+ .option('--group-ids <csv>', 'Comma-separated distribution-group IDs')
457
+ .option('--page <n>', 'Page number', (v) => parseInt(v, 10))
458
+ .option('--limit <n>', 'Max rows to return (default 20, max 100)', (v) => parseInt(v, 10))
459
+ .description('per-section canvas engagement list (admin key) from the clickhouse analytics service')
460
+ .action(async (options, command) => {
461
+ const globals = command.optsWithGlobals();
462
+ emitEnvelope(await runAnalyticsCanvases({ ...options, quiet: globals.quiet }), globals);
463
+ });
464
+ withAuthFlags(cmd.command('viewers'))
465
+ .option('--instance-id <id>', 'Instance ID (required)')
466
+ .option('--canvas-id <id>', 'Canvas ID (required)')
467
+ .option('--viewer-type <type>', "Viewer type filter: all, internal, external (default 'all')")
468
+ .option('--sort-by <field>', 'Sort field: email, session_count, total_engagement_time, last_active, sections_viewed, files_viewed')
469
+ .option('--sort-order <dir>', "Sort direction (asc/desc, normalized to lowercase, default 'desc')")
470
+ .option('--search <term>', 'Search term')
471
+ .option('--start-date <iso>', 'Window start (ISO date; server default applies if omitted)')
472
+ .option('--end-date <iso>', 'Window end (ISO date; server default applies if omitted)')
473
+ .option('--page <n>', 'Page number', (v) => parseInt(v, 10))
474
+ .option('--limit <n>', 'Max rows to return (default 20, max 100)', (v) => parseInt(v, 10))
475
+ .description('DSR canvas viewers (who viewed) from the clickhouse analytics service')
476
+ .action(async (options, command) => {
477
+ const globals = command.optsWithGlobals();
478
+ emitEnvelope(await runAnalyticsViewers({ ...options, quiet: globals.quiet }), globals);
479
+ });
480
+ withAuthFlags(cmd.command('sessions'))
481
+ .option('--instance-id <id>', 'Instance ID (required)')
482
+ .option('--canvas-id <id>', 'Canvas ID (required)')
483
+ .option('--user <email>', 'Viewer user email (required)')
484
+ .option('--start-date <iso>', 'Window start (ISO date; server default applies if omitted)')
485
+ .option('--end-date <iso>', 'Window end (ISO date; server default applies if omitted)')
486
+ .description('per-viewer session event timeline for a DSR canvas (clickhouse analytics service)')
487
+ .action(async (options, command) => {
488
+ const globals = command.optsWithGlobals();
489
+ emitEnvelope(await runAnalyticsSessions({ ...options, userEmail: options.user, quiet: globals.quiet }), globals);
490
+ });
87
491
  return cmd;
88
492
  }
@@ -0,0 +1,24 @@
1
+ export interface ShapedResult<T> {
2
+ summary: unknown;
3
+ rows: T[];
4
+ total_count: number;
5
+ returned: number;
6
+ has_more: boolean;
7
+ truncated?: string;
8
+ warnings?: string[];
9
+ }
10
+ export declare function shapeAnalytics<T>(native: unknown, adapter: (n: any) => {
11
+ summary: unknown;
12
+ rows: T[];
13
+ totalCount?: number;
14
+ warnings?: string[];
15
+ }, opts?: {
16
+ limit?: number;
17
+ }): ShapedResult<T>;
18
+ export declare function pageSizeFor(limit?: number): number;
19
+ /** Today as an ISO day string (UTC) — what callers pass as `nowIso` to `withDefaultWindow`. */
20
+ export declare function todayIso(): string;
21
+ export declare function withDefaultWindow<T extends {
22
+ startDate?: string;
23
+ endDate?: string;
24
+ }>(opts: T, nowIso: string): T;
@@ -0,0 +1,58 @@
1
+ import { createCliError } from './errors.js';
2
+ const DEFAULT_LIMIT = 20;
3
+ const MAX_LIMIT = 100;
4
+ export function shapeAnalytics(native, adapter, opts = {}) {
5
+ const { summary, rows, totalCount, warnings } = adapter(native);
6
+ const limit = pageSizeFor(opts.limit);
7
+ const all = Array.isArray(rows) ? rows : [];
8
+ const capped = all.slice(0, limit);
9
+ const total = totalCount ?? all.length;
10
+ const has_more = total > capped.length;
11
+ return {
12
+ summary,
13
+ rows: capped,
14
+ total_count: total,
15
+ returned: capped.length,
16
+ has_more,
17
+ ...(has_more
18
+ ? { truncated: `Showing ${capped.length} of ${total.toLocaleString('en-US')} — narrow with --limit or filters` }
19
+ : {}),
20
+ ...(warnings && warnings.length ? { warnings } : {}),
21
+ };
22
+ }
23
+ // Deliberately separate from src/core/pagination.ts (caps at 500, no default) —
24
+ // that helper serves a different set of routes. Analytics defaults to 20 and
25
+ // caps at 100, matching the viewers route's server-side cap. Also the single
26
+ // place the row limit is normalized (shapeAnalytics delegates here): a
27
+ // non-finite limit (NaN from `parseInt('abc')`, ±Infinity) falls back to the
28
+ // default, and 0/negative floors to 1 — so an invalid `--limit` never sends a
29
+ // bad page_size to the wire nor slices client rows to an empty set.
30
+ export function pageSizeFor(limit) {
31
+ const n = Number.isFinite(limit) ? limit : DEFAULT_LIMIT;
32
+ return Math.min(Math.max(n, 1), MAX_LIMIT);
33
+ }
34
+ function subUtcDays(iso, days) {
35
+ const d = new Date(`${iso}T00:00:00Z`);
36
+ if (Number.isNaN(d.getTime())) {
37
+ throw createCliError('VALIDATION', `Invalid date '${iso}' — expected ISO YYYY-MM-DD`);
38
+ }
39
+ d.setUTCDate(d.getUTCDate() - days);
40
+ return d.toISOString().slice(0, 10);
41
+ }
42
+ /** Today as an ISO day string (UTC) — what callers pass as `nowIso` to `withDefaultWindow`. */
43
+ export function todayIso() {
44
+ return new Date().toISOString().slice(0, 10);
45
+ }
46
+ // Completes whichever bound is missing independently — both present is left
47
+ // untouched; both absent get a 30-day window ending at nowIso; only end_date
48
+ // given derives start_date as end-30; only start_date given fills end_date
49
+ // with nowIso. The point is that a lone bound never leaves the other
50
+ // undefined, which axios would silently drop from the wire (both are required
51
+ // server-side).
52
+ export function withDefaultWindow(opts, nowIso) {
53
+ if (opts.startDate && opts.endDate)
54
+ return opts;
55
+ const endDate = opts.endDate ?? nowIso;
56
+ const startDate = opts.startDate ?? subUtcDays(endDate, 30);
57
+ return { ...opts, startDate, endDate };
58
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pitcher/cli-scripts",
3
- "version": "2.13.0",
3
+ "version": "2.14.0",
4
4
  "description": "The Pitcher CLI for various tasks in its ecosystem.",
5
5
  "author": "Pitcher AG",
6
6
  "license": "MIT",