@hubfluencer/mcp 0.4.0 → 0.6.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.
@@ -0,0 +1,470 @@
1
+ /**
2
+ * Pure, side-effect-light helpers for the performance-loop, review-link, and
3
+ * asset-catalog tools (index.ts).
4
+ *
5
+ * Like core.ts / campaign.ts / series-calendar.ts, this lives outside index.ts
6
+ * on purpose: index.ts registers every tool and connects an stdio transport at
7
+ * module load, so a unit test can't import it. The request-shape builders, the
8
+ * metric/attribute bounds validators, the review-link create presentation (the
9
+ * plaintext token is shown ONCE), the response summarizers, and the
10
+ * subscription-gate (402) message augmentation are extracted here so they are
11
+ * testable against a fake client / plain inputs.
12
+ *
13
+ * GROUND TRUTH (verified against the API controllers + contexts, 2026-07-02):
14
+ * - creative_performance_controller.ex + performance_controller.ex (+ the
15
+ * Performance context / CreativePerformance schema),
16
+ * review_link_controller.ex (+ the Reviews context / ReviewLink schema),
17
+ * catalog_asset_controller.ex (+ the Assets context / CatalogAsset schema).
18
+ * - Performance + review controllers scope by the caller: a foreign/unknown
19
+ * campaign-pack item or review link is a generic 404 (no enumeration). Reads
20
+ * require video:read; writes require video:generate (agent tokens never carry
21
+ * account:admin). record_metrics / set_attributes / create-review-link spend
22
+ * NO video credits. make_more consumes the FREE daily AiAssistQuota (429
23
+ * ai_assist_quota_exceeded), like the campaign-pack planner, and spends no
24
+ * video credits.
25
+ * - NONE of these controllers mount `Plugs.Idempotency`, so these tools send NO
26
+ * Idempotency-Key. record_metrics is append-only (many snapshots per item),
27
+ * create-review-link mints a fresh token every call — so a transport retry of
28
+ * either FORKS a row; the tool `idempotentHint`s reflect that honestly. The
29
+ * one exception is set_attributes: it upserts the SINGLE attributes row (a
30
+ * second identical call converges to the same row), so it IS idempotent.
31
+ * - The CATALOG asset routes additionally mount `RequireSubscription`, which
32
+ * returns 402 `subscription_required` (NOT 403) when the user has no active
33
+ * subscription — surfaced verbatim so the agent knows it's a billing gate, not
34
+ * a tool bug it can retry.
35
+ * - CAUTION SEMANTICS: get_recommendations returns the server's aggregate
36
+ * UNCHANGED (confidence none/low/moderate, an honest note, never a causal/ROAS
37
+ * claim). This module NEVER re-derives, softens, or overstates it.
38
+ */
39
+
40
+ import { asRecord } from "./core.js";
41
+
42
+ /** The narrow client surface these tools' helpers need — a subset of HubfluencerClient. */
43
+ export interface PerfReviewAssetsClient {
44
+ get<T = unknown>(
45
+ path: string,
46
+ query?: Record<string, string | number | undefined>,
47
+ ): Promise<T>;
48
+ post<T = unknown>(
49
+ path: string,
50
+ body?: unknown,
51
+ idempotencyKey?: string,
52
+ ): Promise<T>;
53
+ }
54
+
55
+ // ── Performance: metric snapshot ──────────────────────────────────────────────
56
+
57
+ // The platforms + sources a performance snapshot accepts
58
+ // (CreativePerformance.platforms/0 + sources/0). Mirrored so the flat tool schema
59
+ // can enum them without a server round-trip.
60
+ export const PERFORMANCE_PLATFORMS = ["tiktok", "instagram", "other"] as const;
61
+ export const PERFORMANCE_SOURCES = [
62
+ "manual",
63
+ "tiktok_api",
64
+ "instagram_api",
65
+ ] as const;
66
+
67
+ // The non-negative integer engagement/conversion counts (CreativePerformance
68
+ // @count_fields + watch_time). Validated client-side so a bad value fails fast
69
+ // with a precise message instead of round-tripping to a 422.
70
+ const PERFORMANCE_COUNT_FIELDS = [
71
+ "views",
72
+ "likes",
73
+ "comments",
74
+ "shares",
75
+ "clicks",
76
+ "conversions",
77
+ "watch_time_seconds",
78
+ ] as const;
79
+
80
+ // revenue is numeric(14,2): a value at/above 10^12 overflows the column (the
81
+ // changeset caps it at 999999999999.99). Mirror the ceiling so the tool rejects
82
+ // an over-magnitude value cleanly rather than eliciting the server 422.
83
+ const MAX_PERFORMANCE_REVENUE = 999_999_999_999.99;
84
+
85
+ export interface RecordPerformanceArgs {
86
+ views?: number;
87
+ likes?: number;
88
+ comments?: number;
89
+ shares?: number;
90
+ clicks?: number;
91
+ conversions?: number;
92
+ revenue?: number;
93
+ watch_time_seconds?: number;
94
+ platform: string;
95
+ source?: string;
96
+ captured_at?: string;
97
+ metadata?: Record<string, unknown>;
98
+ }
99
+
100
+ /**
101
+ * Validates a performance snapshot against the CreativePerformance changeset
102
+ * bounds (counts + watch_time ≥0, revenue ≥0 and ≤~1 trillion). Platform/source
103
+ * membership is enforced by the tool's Zod enum, so it isn't re-checked here.
104
+ * Returns an error string naming the exact bound, or null when valid.
105
+ */
106
+ export function validatePerformanceMetrics(
107
+ args: RecordPerformanceArgs,
108
+ ): string | null {
109
+ for (const field of PERFORMANCE_COUNT_FIELDS) {
110
+ const v = args[field];
111
+ if (v === undefined) continue;
112
+ if (!Number.isFinite(v) || !Number.isInteger(v) || v < 0) {
113
+ return `${field} must be a non-negative integer.`;
114
+ }
115
+ }
116
+ if (args.revenue !== undefined) {
117
+ if (!Number.isFinite(args.revenue) || args.revenue < 0) {
118
+ return "revenue must be a non-negative number.";
119
+ }
120
+ if (args.revenue > MAX_PERFORMANCE_REVENUE) {
121
+ return `revenue must be at most ${MAX_PERFORMANCE_REVENUE}.`;
122
+ }
123
+ }
124
+ return null;
125
+ }
126
+
127
+ /**
128
+ * Builds the record-performance request body from the tool args, dropping
129
+ * undefined so the server sees a clean shape. `captured_at` is REQUIRED by the
130
+ * changeset (it orders snapshots); when the caller omits it we default to the
131
+ * current instant so a bare `record_performance` still succeeds. The owner +
132
+ * campaign_pack_item_id are set server-side from the verified item, never here.
133
+ */
134
+ export function buildPerformanceBody(
135
+ args: RecordPerformanceArgs,
136
+ now: () => Date = () => new Date(),
137
+ ): Record<string, unknown> {
138
+ const body: Record<string, unknown> = { platform: args.platform };
139
+ for (const field of PERFORMANCE_COUNT_FIELDS) {
140
+ if (args[field] !== undefined) body[field] = args[field];
141
+ }
142
+ if (args.revenue !== undefined) body.revenue = args.revenue;
143
+ if (typeof args.source === "string" && args.source.trim() !== "")
144
+ body.source = args.source;
145
+ body.captured_at =
146
+ typeof args.captured_at === "string" && args.captured_at.trim() !== ""
147
+ ? args.captured_at
148
+ : now().toISOString();
149
+ if (args.metadata !== undefined) body.metadata = args.metadata;
150
+ return body;
151
+ }
152
+
153
+ /** One performance snapshot, trimmed to the fields an agent reads back. */
154
+ export interface PerformanceSnapshotSummary {
155
+ id: unknown;
156
+ campaign_pack_item_id: unknown;
157
+ platform: unknown;
158
+ views: unknown;
159
+ likes: unknown;
160
+ comments: unknown;
161
+ shares: unknown;
162
+ clicks: unknown;
163
+ conversions: unknown;
164
+ revenue: unknown;
165
+ watch_time_seconds: unknown;
166
+ source: unknown;
167
+ captured_at: unknown;
168
+ }
169
+
170
+ /** Summarizes one raw performance snapshot (CreativePerformanceController serializer). */
171
+ export function summarizePerformance(
172
+ snapshot: unknown,
173
+ ): PerformanceSnapshotSummary {
174
+ const s = asRecord(snapshot);
175
+ return {
176
+ id: s.id,
177
+ campaign_pack_item_id: s.campaign_pack_item_id,
178
+ platform: s.platform,
179
+ views: s.views ?? null,
180
+ likes: s.likes ?? null,
181
+ comments: s.comments ?? null,
182
+ shares: s.shares ?? null,
183
+ clicks: s.clicks ?? null,
184
+ conversions: s.conversions ?? null,
185
+ revenue: s.revenue ?? null,
186
+ watch_time_seconds: s.watch_time_seconds ?? null,
187
+ source: s.source ?? null,
188
+ captured_at: s.captured_at ?? null,
189
+ };
190
+ }
191
+
192
+ // ── Performance: creative attributes (single upserted row) ────────────────────
193
+
194
+ // The creative-attributes upsert accepts exactly these textual classifiers
195
+ // (creative_performance_controller.ex attribute_attrs). All free-form; the server
196
+ // stores one row per item and a repeat call updates it in place.
197
+ export const CREATIVE_ATTRIBUTE_FIELDS = [
198
+ "hook_type",
199
+ "creative_format",
200
+ "visual_language",
201
+ "product_category",
202
+ "cta",
203
+ "length",
204
+ "caption_style",
205
+ "metadata",
206
+ ] as const;
207
+
208
+ /** Picks only the accepted creative-attribute fields from a raw args object. */
209
+ export function pickCreativeAttributeFields(
210
+ args: Record<string, unknown>,
211
+ ): Record<string, unknown> {
212
+ const out: Record<string, unknown> = {};
213
+ for (const k of CREATIVE_ATTRIBUTE_FIELDS) {
214
+ if (args[k] !== undefined) out[k] = args[k];
215
+ }
216
+ return out;
217
+ }
218
+
219
+ // ── Review links ──────────────────────────────────────────────────────────────
220
+
221
+ // The review-link expiry window (ReviewLink create; ReviewLinkCreateRequest
222
+ // schema). Mirrored so the tool describes + fails fast on the bound; the server
223
+ // clamps anyway (default 168h = 7 days).
224
+ export const REVIEW_LINK_MIN_EXPIRES_IN_HOURS = 1;
225
+ export const REVIEW_LINK_MAX_EXPIRES_IN_HOURS = 720;
226
+
227
+ /**
228
+ * Validates the create-review-link output reference: EXACTLY ONE of
229
+ * video_result_id / slider_id (the changeset requires exactly one on create).
230
+ * Returns an error string, or null when the pair is valid.
231
+ */
232
+ export function validateReviewOutput(
233
+ videoResultId: number | undefined,
234
+ sliderId: number | undefined,
235
+ ): string | null {
236
+ const hasVideo = typeof videoResultId === "number";
237
+ const hasSlider = typeof sliderId === "number";
238
+ if (hasVideo && hasSlider) {
239
+ return "Provide exactly one of video_result_id or slider_id, not both.";
240
+ }
241
+ if (!hasVideo && !hasSlider) {
242
+ return "Provide exactly one of video_result_id (a completed render) or slider_id (a completed carousel).";
243
+ }
244
+ return null;
245
+ }
246
+
247
+ /**
248
+ * Builds the create-review-link request body from the tool args, dropping
249
+ * undefined so the server sees a clean shape. `expires_in_hours` is passed
250
+ * through as-is (the server clamps 1..720); a blank version_label is omitted.
251
+ */
252
+ export function buildReviewLinkBody(args: {
253
+ video_result_id?: number;
254
+ slider_id?: number;
255
+ campaign_pack_id?: number;
256
+ expires_in_hours?: number;
257
+ download_enabled?: boolean;
258
+ version_label?: string;
259
+ }): Record<string, unknown> {
260
+ const body: Record<string, unknown> = {};
261
+ if (args.video_result_id !== undefined)
262
+ body.video_result_id = args.video_result_id;
263
+ if (args.slider_id !== undefined) body.slider_id = args.slider_id;
264
+ if (args.campaign_pack_id !== undefined)
265
+ body.campaign_pack_id = args.campaign_pack_id;
266
+ if (args.expires_in_hours !== undefined)
267
+ body.expires_in_hours = args.expires_in_hours;
268
+ if (args.download_enabled !== undefined)
269
+ body.download_enabled = args.download_enabled;
270
+ if (
271
+ typeof args.version_label === "string" &&
272
+ args.version_label.trim() !== ""
273
+ )
274
+ body.version_label = args.version_label;
275
+ return body;
276
+ }
277
+
278
+ /** One review link, trimmed to what an agent tracking a review needs. */
279
+ export interface ReviewLinkSummary {
280
+ id: unknown;
281
+ status: unknown;
282
+ output_kind: unknown;
283
+ video_result_id: unknown;
284
+ slider_id: unknown;
285
+ campaign_pack_id: unknown;
286
+ expires_at: unknown;
287
+ download_enabled: unknown;
288
+ version_label: unknown;
289
+ view_count: unknown;
290
+ viewed_at: unknown;
291
+ comment_count: unknown;
292
+ }
293
+
294
+ /**
295
+ * Summarizes one raw review link (ReviewLinkController serializer). NEVER carries
296
+ * a token (the serializer omits token_hash and the plaintext only ever appears in
297
+ * the create payload). `comment_count` is the visible-comment count the context
298
+ * annotates on list/show (null on the create response — no activity yet).
299
+ */
300
+ export function summarizeReviewLink(link: unknown): ReviewLinkSummary {
301
+ const l = asRecord(link);
302
+ return {
303
+ id: l.id,
304
+ status: l.status,
305
+ output_kind: l.output_kind ?? null,
306
+ video_result_id: l.video_result_id ?? null,
307
+ slider_id: l.slider_id ?? null,
308
+ campaign_pack_id: l.campaign_pack_id ?? null,
309
+ expires_at: l.expires_at ?? null,
310
+ download_enabled: l.download_enabled ?? null,
311
+ version_label: l.version_label ?? null,
312
+ view_count: l.view_count ?? null,
313
+ viewed_at: l.viewed_at ?? null,
314
+ comment_count: l.comment_count ?? null,
315
+ };
316
+ }
317
+
318
+ export interface CreateReviewLinkResult {
319
+ review_link: ReviewLinkSummary;
320
+ /** The plaintext review token — shown ONCE; NOT retrievable again. */
321
+ token: unknown;
322
+ /** Relative public review path, e.g. /review/<token>. */
323
+ review_path: unknown;
324
+ /** The absolute public review URL on the web-app origin (else the path). */
325
+ review_url: string | null;
326
+ important: string;
327
+ next: string;
328
+ }
329
+
330
+ // The no-login reviewer page (`/review/<token>`) is a FRONT web-app route (Expo
331
+ // Router / Cloudflare Pages), NOT an API endpoint — the Phoenix API host serves a
332
+ // 404 there. So the absolute review URL must be built on the web-app origin, which
333
+ // is a DIFFERENT host from the API base (default API https://hubfluencer.com, web
334
+ // app https://app.hubfluencer.com). This mirrors the front's own
335
+ // REVIEW_WEB_ORIGIN_FALLBACK (types/review-link.ts) and the API's :web_app_origin
336
+ // default (callback_page_safety.ex). `HUBFLUENCER_WEB_URL` overrides it (e.g. a
337
+ // staging web origin) without touching HUBFLUENCER_BASE_URL, which stays the API.
338
+ const DEFAULT_WEB_APP_ORIGIN = "https://app.hubfluencer.com";
339
+
340
+ /**
341
+ * The web-app origin that serves the public no-login pages (the reviewer page at
342
+ * `/review/<token>`). Read from `HUBFLUENCER_WEB_URL`, falling back to the
343
+ * canonical https://app.hubfluencer.com — NOT the API base (a different host).
344
+ */
345
+ export function reviewWebOrigin(env: NodeJS.ProcessEnv = process.env): string {
346
+ const configured = env.HUBFLUENCER_WEB_URL;
347
+ if (typeof configured === "string" && configured.trim() !== "") {
348
+ return configured.trim().replace(/\/+$/, "");
349
+ }
350
+ return DEFAULT_WEB_APP_ORIGIN;
351
+ }
352
+
353
+ /**
354
+ * Shapes the create-review-link result so the ONCE-only token + the public review
355
+ * URL are front-and-centre and the caller is told it can't be fetched again. The
356
+ * server returns { review_link, token, review_path }; `webOrigin` (the web-app
357
+ * host that actually serves `/review/<token>`, NOT the API base) is used to
358
+ * present an absolute review_url the owner can hand to a reviewer, falling back to
359
+ * the relative path.
360
+ */
361
+ export function shapeCreateReviewLink(
362
+ data: unknown,
363
+ webOrigin: string | undefined,
364
+ ): CreateReviewLinkResult {
365
+ const d = asRecord(data);
366
+ const token = d.token ?? null;
367
+ const reviewPath = typeof d.review_path === "string" ? d.review_path : null;
368
+ const reviewUrl = absoluteReviewUrl(reviewPath, webOrigin);
369
+ return {
370
+ review_link: summarizeReviewLink(d.review_link),
371
+ token,
372
+ review_path: reviewPath,
373
+ review_url: reviewUrl,
374
+ important:
375
+ "SAVE THE TOKEN NOW — it is shown only ONCE and is NOT retrievable again. " +
376
+ "Only its hash is stored server-side; list_review_links and revoke_review_link never return it. " +
377
+ `Share the review URL${reviewUrl ? ` (${reviewUrl})` : ""} with your reviewer — they open it with no account.`,
378
+ next: "list_review_links to track approval/comment activity; revoke_review_link({ id }) to disable the URL.",
379
+ };
380
+ }
381
+
382
+ /**
383
+ * Joins a relative review path to the web-app origin, when known, so the owner
384
+ * gets a copy-pasteable absolute URL. The public review page is served by the
385
+ * FRONT web app (`/review/<token>`), not the API host. Returns the relative path
386
+ * unchanged if the origin is absent or unparseable — never throws.
387
+ */
388
+ export function absoluteReviewUrl(
389
+ reviewPath: string | null,
390
+ webOrigin: string | undefined,
391
+ ): string | null {
392
+ if (!reviewPath) return null;
393
+ if (typeof webOrigin !== "string" || webOrigin.trim() === "")
394
+ return reviewPath;
395
+ try {
396
+ return new URL(reviewPath, webOrigin).toString();
397
+ } catch {
398
+ return reviewPath;
399
+ }
400
+ }
401
+
402
+ // ── Asset catalog ─────────────────────────────────────────────────────────────
403
+
404
+ /** One catalog asset, trimmed to the fields an agent reuses across projects. */
405
+ export interface CatalogAssetSummary {
406
+ id: unknown;
407
+ filename: unknown;
408
+ media_type: unknown;
409
+ mime_type: unknown;
410
+ size_bytes: unknown;
411
+ description: unknown;
412
+ status: unknown;
413
+ asset_url: unknown;
414
+ inserted_at: unknown;
415
+ }
416
+
417
+ /**
418
+ * Summarizes one raw catalog asset (CatalogAssetController render_asset) into the
419
+ * compact shape the asset tools return. `asset_url` is the presigned URL (image
420
+ * or video) the agent can reference; the image_meta/verified_* provenance fields
421
+ * are dropped as noise for the agent surface.
422
+ */
423
+ export function summarizeCatalogAsset(asset: unknown): CatalogAssetSummary {
424
+ const a = asRecord(asset);
425
+ return {
426
+ id: a.id,
427
+ filename: a.filename ?? null,
428
+ media_type: a.media_type ?? null,
429
+ mime_type: a.mime_type ?? null,
430
+ size_bytes: a.size_bytes ?? null,
431
+ description: a.description ?? null,
432
+ status: a.status ?? null,
433
+ asset_url: a.asset_url ?? null,
434
+ inserted_at: a.inserted_at ?? null,
435
+ };
436
+ }
437
+
438
+ /** The storage quota block the catalog list/quota endpoints return. */
439
+ export function summarizeAssetQuota(quota: unknown): Record<string, unknown> {
440
+ const q = asRecord(quota);
441
+ return {
442
+ limit_bytes: q.limit_bytes ?? null,
443
+ used_bytes: q.used_bytes ?? null,
444
+ pending_bytes: q.pending_bytes ?? null,
445
+ reserved_bytes: q.reserved_bytes ?? null,
446
+ remaining_bytes: q.remaining_bytes ?? null,
447
+ };
448
+ }
449
+
450
+ // ── Subscription-gate (402) message augmentation ──────────────────────────────
451
+
452
+ /**
453
+ * Augments a `subscription_required` (402) message from the catalog asset routes
454
+ * (RequireSubscription plug). These routes gate the whole asset catalog behind an
455
+ * active subscription — a scope/credit retry can't fix it — so spell that out so
456
+ * the agent surfaces a clear "needs an active subscription" instead of a bare code
457
+ * and doesn't loop. Returns the original message unchanged for any other error.
458
+ */
459
+ export function assetSubscriptionErrorText(
460
+ status: number | undefined,
461
+ code: string | undefined,
462
+ message: string,
463
+ ): string {
464
+ if (status !== 402 || code !== "subscription_required") return message;
465
+ return (
466
+ `${message} The reusable asset catalog requires an ACTIVE SUBSCRIPTION; ` +
467
+ "this is a billing gate, not a scope or credit issue, so retrying won't help — " +
468
+ "subscribe in the app first. (Generating videos/carousels does not require this.)"
469
+ );
470
+ }