ad2app-lib 1.49.0 → 1.59.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,751 @@
1
+ import { IsArray, IsBoolean, IsDateString, IsIn, IsInt, IsNotEmpty, IsOptional, IsString, Matches, Max, MaxLength, Min } from 'class-validator';
2
+
3
+ import { AGENCY_GRANT_SCOPES, type I_AgencyGrantScope } from './agency';
4
+
5
+ import type { I_CampaignCreatorIdentity, SettlementForm } from './agency-campaigns';
6
+
7
+ /**
8
+ * Pot campaigns (spec 175 US9): the second way to pay creators on a campaign,
9
+ * beside the negotiated offer of US7.
10
+ *
11
+ * The agency funds ONE pot and sets a maximum per creator; each creator applies
12
+ * with their own guarantee and rate per 1,000 views; the agency accepts or
13
+ * declines, never counters (SC-018). Earnings are the guarantee plus the rate
14
+ * times the counted views, never above the maximum, and accrue only while the
15
+ * pot has unreserved money (FR-030, FR-032).
16
+ *
17
+ * Money is integer minor units plus an ISO-4217 code, everywhere. A creator
18
+ * never receives a pot-level figure: the only pot-level signal they get is the
19
+ * two-valued availability (SC-016), fenced by `creatorPotForbiddenKeys`.
20
+ *
21
+ * See specs/speckit/175-agency-roster/plan.md → US9 addendum.
22
+ */
23
+
24
+ // ── The disclosure check (FR-040) ───────────────────────────────────────────
25
+
26
+ /** A caption shows this many characters before "more"; the disclosure must sit inside them. */
27
+ export const DISCLOSURE_VISIBLE_CHARS = 125;
28
+
29
+ /**
30
+ * UOKiK's recommended wordings, with or without "#", with or without Polish
31
+ * diacritics. The list is a counsel decision (spec 175 T030) and lives here
32
+ * only: the upload form, the approval, the publish gate and the report all
33
+ * call `checkDisclosure`.
34
+ */
35
+ const STRONG_WORDINGS = [
36
+ 'reklama',
37
+ 'materia[łl]\\s*reklamowy',
38
+ 'wsp[óo][łl]praca\\s*reklamowa',
39
+ 'p[łl]atna\\s*wsp[óo][łl]praca',
40
+ 'post\\s*sponsorowany',
41
+ ];
42
+
43
+ /** Tags that say something but may not be enough: flagged, never accepted silently. */
44
+ const WEAK_TAGS = ['wsp[óo][łl]praca', 'ad', 'spons', 'promo'];
45
+
46
+ /**
47
+ * A wording is a whole word: nothing glued after it, and nothing glued before it
48
+ * unless it opens with `#` (a tag glued to the word before it still starts at its `#`).
49
+ */
50
+ const word = (body: string): RegExp =>
51
+ new RegExp(`(?:#(?:${body})|(?<![\\p{L}\\p{N}_#])(?:${body}))(?![\\p{L}\\p{N}_])`, 'giu');
52
+ const tag = (body: string): RegExp => new RegExp(`#(?:${body})(?![\\p{L}\\p{N}_])`, 'giu');
53
+
54
+ const STRONG = word(STRONG_WORDINGS.join('|'));
55
+ const WEAK = tag(WEAK_TAGS.join('|'));
56
+
57
+ export const DISCLOSURE_RESULTS = ['pass', 'weak', 'missing'] as const;
58
+ export type DisclosureResult = (typeof DISCLOSURE_RESULTS)[number];
59
+
60
+ export interface I_DisclosureCheck {
61
+ /**
62
+ * `pass`: a recommended wording within the first 125 characters.
63
+ * `weak`: only a bare tag, or a recommended wording placed past the fold.
64
+ * `missing`: nothing.
65
+ */
66
+ result: DisclosureResult;
67
+ /** The text that was found, as written. */
68
+ found: string | null;
69
+ /** Its character index in the caption. */
70
+ at: number | null;
71
+ }
72
+
73
+ export const checkDisclosure = (raw: string): I_DisclosureCheck => {
74
+ // Composed form, so a decomposed "ł"/"ó" (pasted from some keyboards) reads as one letter.
75
+ const caption = raw.normalize('NFC');
76
+ const strong = [...caption.matchAll(STRONG)][0];
77
+ if (strong && strong.index !== undefined) {
78
+ return {
79
+ result: strong.index < DISCLOSURE_VISIBLE_CHARS ? 'pass' : 'weak',
80
+ found: strong[0],
81
+ at: strong.index,
82
+ };
83
+ }
84
+ const weak = [...caption.matchAll(WEAK)][0];
85
+ if (weak && weak.index !== undefined) return { result: 'weak', found: weak[0], at: weak.index };
86
+ return { result: 'missing', found: null, at: null };
87
+ };
88
+
89
+ // ── Earnings (FR-030, FR-032, FR-035) ───────────────────────────────────────
90
+
91
+ /** The per-view part for `views`: rate × views / 1000, rounded DOWN to the minor unit. Exact for any size. */
92
+ export const potBonusMinor = (ratePer1000Minor: number, views: number): number =>
93
+ Number((BigInt(wholeOrZero(ratePer1000Minor)) * BigInt(wholeOrZero(views))) / 1000n);
94
+
95
+ /** A non-negative whole number; anything else (NaN, Infinity, negative) is 0. */
96
+ function wholeOrZero(n: number): number {
97
+ return Number.isFinite(n) && n > 0 ? Math.trunc(n) : 0;
98
+ }
99
+
100
+ export interface I_PotEarningsInput {
101
+ guaranteeMinor: number;
102
+ ratePer1000Minor: number;
103
+ views: number;
104
+ maxMinor: number;
105
+ }
106
+
107
+ /** What a creator's terms earn at `views` with an unlimited pot: never above the maximum. */
108
+ export const potEarnings = ({ guaranteeMinor, ratePer1000Minor, views, maxMinor }: I_PotEarningsInput): number =>
109
+ Math.min(maxMinor, guaranteeMinor + potBonusMinor(ratePer1000Minor, views));
110
+
111
+ /** The exact view count at which the terms reach the maximum (rounded up), or null when they never do. */
112
+ export const potViewsAtMax = ({ guaranteeMinor, ratePer1000Minor, maxMinor }: Omit<I_PotEarningsInput, 'views'>): number | null => {
113
+ const left = maxMinor - guaranteeMinor;
114
+ if (left <= 0) return 0;
115
+ const rate = BigInt(wholeOrZero(ratePer1000Minor));
116
+ if (rate === 0n) return null;
117
+ const numerator = BigInt(Math.trunc(left)) * 1000n;
118
+ return Number((numerator + rate - 1n) / rate);
119
+ };
120
+
121
+ /** Cost per 1,000 views in minor units, rounded half up; null without views. */
122
+ export const potCostPer1000Minor = (costMinor: number, views: number): number | null => {
123
+ const v = wholeOrZero(views);
124
+ return v > 0 ? Number((BigInt(wholeOrZero(costMinor)) * 2000n + BigInt(v)) / (2n * BigInt(v))) : null;
125
+ };
126
+
127
+ /**
128
+ * The currencies a pot may be kept in: every amount is in minor units of two
129
+ * decimals (grosz, cents), so a zero- or three-decimal currency (JPY, KWD) is
130
+ * refused rather than shown a hundred times off (review 2026-10-06 L14).
131
+ */
132
+ export const POT_CURRENCIES = ['PLN', 'EUR', 'USD', 'GBP', 'CHF', 'CZK', 'SEK', 'NOK', 'DKK'] as const;
133
+ export type PotCurrency = (typeof POT_CURRENCIES)[number];
134
+
135
+ // ── The creator's fence (SC-016) ────────────────────────────────────────────
136
+
137
+ /** Any key carrying a pot-level figure. A creator-facing response must have none, at any depth. */
138
+ export const CREATOR_POT_FORBIDDEN_KEY = /budget|remainder|reserved|funded|goal|covered|commitment|acceptedCreators|earned|blended|resultViews|heldViews|countedViews/i;
139
+
140
+ /** The paths of every forbidden key in `value`; empty when the response is clean. */
141
+ export const creatorPotForbiddenKeys = (value: unknown, path = ''): string[] => {
142
+ if (value === null || typeof value !== 'object') return [];
143
+ return Object.entries(value as Record<string, unknown>).flatMap(([key, child]) => {
144
+ const at = path ? `${path}.${key}` : key;
145
+ return [...(CREATOR_POT_FORBIDDEN_KEY.test(key) ? [at] : []), ...creatorPotForbiddenKeys(child, at)];
146
+ });
147
+ };
148
+
149
+ // ── States ───────────────────────────────────────────────────────────────────
150
+
151
+ /**
152
+ * An entry is one creator's application on one pot.
153
+ * applied → accepted | declined | withdrawn; accepted → released (left or
154
+ * removed before publication).
155
+ */
156
+ export const POT_ENTRY_STATES = ['applied', 'accepted', 'declined', 'withdrawn', 'released'] as const;
157
+ export type PotEntryState = (typeof POT_ENTRY_STATES)[number];
158
+
159
+ /**
160
+ * The pot board's columns, in order (design A2): invitation sent, application,
161
+ * in the campaign, material in, scheduled, bonus counting, bonus final, done
162
+ * (the keep-up period ended with the post still up).
163
+ */
164
+ export const POT_STEPS = ['invited', 'applied', 'accepted', 'material_in', 'scheduled', 'counting', 'final', 'done'] as const;
165
+ export type PotStep = (typeof POT_STEPS)[number];
166
+
167
+ /** Rows off the board, kept in the table with their date. */
168
+ export type PotOffBoard = 'declined' | 'withdrawn' | 'released' | 'left' | 'removed' | 'broken';
169
+
170
+ export const POT_AVAILABILITY = ['full_max_available', 'filling_up'] as const;
171
+ export type PotAvailability = (typeof POT_AVAILABILITY)[number];
172
+
173
+ /** Why a bonus is held (FR-037): the jump against the creator's own history, or paid delivery ad2app did not start. */
174
+ export const POT_HOLD_REASONS = ['jump', 'paid_delivery'] as const;
175
+ export type PotHoldReason = (typeof POT_HOLD_REASONS)[number];
176
+
177
+ /** The agency's three acts on a held bonus. `message` records the act; the thread opens on the client. */
178
+ export const POT_HOLD_ACTIONS = ['approve', 'cap', 'message'] as const;
179
+ export type PotHoldAction = (typeof POT_HOLD_ACTIONS)[number];
180
+
181
+ /** The keep-up check's outcome (FR-032): still up, or taken down before the end. */
182
+ export type PotKeepUpOutcome = 'kept' | 'broken';
183
+
184
+ /**
185
+ * A post taken down after the bonus window but before the keep-up end: the
186
+ * agency is informed and decides whether the guarantee stays earned (FR-032).
187
+ * Before the window ends the guarantee is not earned, with no decision.
188
+ */
189
+ export const POT_REMOVAL_DECISIONS = ['keep_base', 'withhold_base'] as const;
190
+ export type PotRemovalDecision = (typeof POT_REMOVAL_DECISIONS)[number];
191
+
192
+ /** Refusal codes the frontend owes its own copy for. */
193
+ export const AGENCY_POT_CODES = {
194
+ /** Accepting would reserve more than the pot has unreserved. */
195
+ POT_CANNOT_COVER: 'agency-pot-cannot-cover',
196
+ /** The guarantee must be below the maximum per creator. */
197
+ GUARANTEE_TOO_HIGH: 'agency-pot-guarantee-too-high',
198
+ /** Applications closed at the application deadline, or the pot was moved. */
199
+ APPLICATIONS_CLOSED: 'agency-pot-applications-closed',
200
+ /** The kind is fixed once a creator is invited; the terms once an entry is accepted. */
201
+ POT_LOCKED: 'agency-pot-locked',
202
+ /** A refill above the maximum budget. */
203
+ ABOVE_MAX_BUDGET: 'agency-pot-above-max-budget',
204
+ /** The caption fails the disclosure check (FR-040). */
205
+ DISCLOSURE_REQUIRED: 'agency-pot-disclosure-required',
206
+ /** The creator has not connected the platform the post goes to. */
207
+ NO_ACCOUNT: 'agency-pot-no-account',
208
+ /** The confirmed time is not the one on the row now (the agency moved it meanwhile). */
209
+ PUBLISH_TIME_CHANGED: 'agency-pot-publish-time-changed',
210
+ } as const;
211
+ export type AgencyPotCode = (typeof AGENCY_POT_CODES)[keyof typeof AGENCY_POT_CODES];
212
+
213
+ // ── Shared shapes ───────────────────────────────────────────────────────────
214
+
215
+ export interface I_PotTerms {
216
+ guaranteeMinor: number;
217
+ ratePer1000Minor: number;
218
+ }
219
+
220
+ /**
221
+ * Set once by the agency, pre-filled for the creator (FR-040). A field the
222
+ * platform does not support is dropped at publish (research/us9/publishing-fields.md).
223
+ */
224
+ export interface I_PotPublishingSettings {
225
+ /** Instagram "Paid partnership", TikTok "Branded content", X "Paid partnership". */
226
+ paidPartnershipLabel: boolean;
227
+ /** The brand tagged as sponsor on Instagram. */
228
+ sponsorHandle: string | null;
229
+ /** A Collab invite to the brand's Instagram account. */
230
+ collabHandle: string | null;
231
+ /** A link or a discount code, posted as the first comment. */
232
+ firstComment: string | null;
233
+ commentsEnabled: boolean;
234
+ }
235
+
236
+ /** The dates on the timeline (FR-036). Bonus and keep-up ends derive from publication due; never set by hand. */
237
+ export interface I_PotSchedule {
238
+ materialsDueAt: string | null;
239
+ publicationDueAt: string | null;
240
+ bonusEndsAt: string | null;
241
+ keepUpEndsAt: string | null;
242
+ }
243
+
244
+ // ── The agency's side ───────────────────────────────────────────────────────
245
+
246
+ /** The pot's parts. approvedBonus + heldBonus + reservedGuarantees + unreserved = funded, always. */
247
+ export interface I_AgencyPotParts {
248
+ fundedMinor: number;
249
+ /** Accepted creators' guarantees, published or not. */
250
+ reservedGuaranteesMinor: number;
251
+ /** Per-view bonus accrued and payable. */
252
+ approvedBonusMinor: number;
253
+ /** Per-view bonus accrued and held under FR-037: committed, not payable, out of the cost figures. */
254
+ heldBonusMinor: number;
255
+ unreservedMinor: number;
256
+ }
257
+
258
+ export interface I_AgencyPotView {
259
+ currency: string;
260
+ maxBudgetMinor: number;
261
+ maxPerCreatorMinor: number;
262
+ bonusWindowDays: number;
263
+ keepUpMonths: number;
264
+ goalViews: number | null;
265
+ coverUrl: string | null;
266
+ applicationDeadlineAt: string | null;
267
+ /** Set when the remainder was moved into another campaign: closed to new applications. */
268
+ movedTo: { campaignId: string; name: string } | null;
269
+ publishing: I_PotPublishingSettings;
270
+ schedule: I_PotSchedule;
271
+ parts: I_AgencyPotParts;
272
+ /** floor(funded / max per creator). */
273
+ creatorsCoveredAtFullMax: number;
274
+ /** Accepted creators × the maximum: what the pot could owe if every one reached it. */
275
+ maxCommitmentMinor: number;
276
+ /** Published creators' guarantees plus approved bonus. */
277
+ earnedMinor: number;
278
+ /** Raw readings summed over every row: the totals row. */
279
+ views: number;
280
+ /** The goal's progress: raw readings of posts whose bonus is not under review, as the client report counts them (FR-037). */
281
+ resultViews: number;
282
+ /** Raw readings of posts whose bonus is under review, named beside the goal and left out of it. */
283
+ heldViews: number;
284
+ /** Views the bonus counts toward (capped under FR-037): the cost basis. */
285
+ countedViews: number;
286
+ /** Blended cost per 1,000 views over published, unheld rows; null with no views. */
287
+ blendedCostPer1000Minor: number | null;
288
+ /** The latest count across the pot; every export is stamped with it. */
289
+ lastCountAt: string | null;
290
+ }
291
+
292
+ /** The FR-037 evidence, in numbers. */
293
+ export interface I_PotHoldEvidence {
294
+ reason: PotHoldReason;
295
+ medianViews: number | null;
296
+ /** views / median, two decimals. */
297
+ viewsVsMedian: number | null;
298
+ /** Engagement per view now and usually, as fractions. */
299
+ engagementNow: number | null;
300
+ engagementUsual: number | null;
301
+ jumpViews: number | null;
302
+ jumpDays: number | null;
303
+ }
304
+
305
+ export interface I_PotHold {
306
+ heldAt: string;
307
+ evidence: I_PotHoldEvidence;
308
+ /** null while it waits on the agency. */
309
+ decision: 'approved' | 'capped' | null;
310
+ decidedAt: string | null;
311
+ /** With `capped`: the views the bonus counts to (2 × median). */
312
+ capViews: number | null;
313
+ /** What `cap` would leave as bonus, for the button. */
314
+ cappedBonusMinor: number | null;
315
+ }
316
+
317
+ /** Zernio refused the post when ad2app scheduled it: the confirmation is cleared and the creator confirms again. */
318
+ export interface I_PotPublishRefusal {
319
+ at: string;
320
+ /** The provider's words, per platform (`tiktok: …; instagram: …`). */
321
+ reason: string;
322
+ }
323
+
324
+ /** One creator's row in the pot table (FR-035). Every total equals the sum of its rows. */
325
+ export interface I_AgencyPotRow {
326
+ memberId: string;
327
+ entryId: string | null;
328
+ creator: I_CampaignCreatorIdentity;
329
+ step: PotStep | PotOffBoard;
330
+ stepAt: string;
331
+ terms: I_PotTerms | null;
332
+ /** The latest reading; null before publication. */
333
+ views: number | null;
334
+ /** Views the bonus counts toward: `views`, stopped at the FR-037 cap when the agency set one. */
335
+ countedViews: number | null;
336
+ guaranteeReservedMinor: number;
337
+ /** Earned so far: the guarantee once published plus approved bonus. */
338
+ earnedMinor: number;
339
+ heldBonusMinor: number;
340
+ /** earned / max, as a fraction; null before publication. */
341
+ shareOfMax: number | null;
342
+ costPer1000Minor: number | null;
343
+ settlementForm: SettlementForm | null;
344
+ publishAt: string | null;
345
+ publishConfirmedAt: string | null;
346
+ publishedAt: string | null;
347
+ postUrl: string | null;
348
+ bonusEndsAt: string | null;
349
+ keepUpEndsAt: string | null;
350
+ frozen: boolean;
351
+ /** `needsDecision`: taken down after the bonus window, waiting on the agency's keep-or-withhold (FR-032). */
352
+ outcome: { kind: PotKeepUpOutcome; lastFoundAt: string | null; decision: PotRemovalDecision | null; needsDecision: boolean } | null;
353
+ hold: I_PotHold | null;
354
+ lastCountAt: string | null;
355
+ /** The provider refused the post at scheduling, since the time was last set or confirmed; null otherwise. */
356
+ publishRefusal?: I_PotPublishRefusal | null;
357
+ }
358
+
359
+ export interface I_AgencyPotTotals {
360
+ acceptedCreators: number;
361
+ reservedGuaranteesMinor: number;
362
+ views: number;
363
+ countedViews: number;
364
+ earnedMinor: number;
365
+ blendedCostPer1000Minor: number | null;
366
+ }
367
+
368
+ /** `GET /agency/campaigns/:id/pot/board` (FR-035). */
369
+ export interface I_AgencyPotBoard {
370
+ pot: I_AgencyPotView;
371
+ rows: I_AgencyPotRow[];
372
+ totals: I_AgencyPotTotals;
373
+ /** Creators per board column. */
374
+ steps: Record<PotStep, number>;
375
+ }
376
+
377
+ /** The creator's statistics an application shows, from what they shared (FR-035). */
378
+ export interface I_PotCreatorProfile {
379
+ platforms: Array<{ platform: string; followers: number | null }>;
380
+ medianViews: number | null;
381
+ meanViews: number | null;
382
+ p25Views: number | null;
383
+ p75Views: number | null;
384
+ /** Interactions per view over 90 days, as a fraction. */
385
+ engagement: number | null;
386
+ posts90: number;
387
+ recent: Array<{ postedAt: string; views: number | null; url: string | null; thumbnailUrl: string | null }>;
388
+ }
389
+
390
+ export type PotScenario = 'none' | 'p25' | 'median' | 'p75' | 'max';
391
+
392
+ /** `GET /agency/campaigns/:id/pot/entries/:entryId` while it is an application. */
393
+ export interface I_PotApplicationView {
394
+ entryId: string;
395
+ memberId: string;
396
+ creator: I_CampaignCreatorIdentity;
397
+ terms: I_PotTerms;
398
+ appliedAt: string;
399
+ /**
400
+ * `shared`: the profile is filled. `not_shared`: no active grant with the
401
+ * analytics scope, said in words. `unavailable`: the read failed just now;
402
+ * never shown as zeros.
403
+ */
404
+ profileState: 'shared' | 'not_shared' | 'unavailable';
405
+ /** Set only when `profileState` is `shared`. */
406
+ profile: I_PotCreatorProfile | null;
407
+ viewsAtMax: number | null;
408
+ scenarios: Array<{ scenario: PotScenario; views: number | null; costMinor: number | null; costPer1000Minor: number | null }>;
409
+ blendedCostPer1000Minor: number | null;
410
+ unreservedBeforeMinor: number;
411
+ unreservedAfterMinor: number;
412
+ /** False when the pot cannot cover the guarantee: accept is refused with POT_CANNOT_COVER. */
413
+ canAccept: boolean;
414
+ }
415
+
416
+ // ── The creator's side (SC-016: nothing pot-level) ──────────────────────────
417
+
418
+ export interface I_CreatorPotEarnings {
419
+ guaranteeMinor: number;
420
+ /** Payable per-view bonus. */
421
+ bonusMinor: number;
422
+ /** Bonus under review (FR-037): shown with the amount, never as an accusation. */
423
+ heldBonusMinor: number;
424
+ totalMinor: number;
425
+ views: number;
426
+ lastCountAt: string | null;
427
+ frozen: boolean;
428
+ }
429
+
430
+ export interface I_CreatorPotEntry {
431
+ state: PotEntryState;
432
+ terms: I_PotTerms;
433
+ appliedAt: string;
434
+ decidedAt: string | null;
435
+ publishAt: string | null;
436
+ publishConfirmedAt: string | null;
437
+ publishedAt: string | null;
438
+ postUrl: string | null;
439
+ bonusEndsAt: string | null;
440
+ keepUpEndsAt: string | null;
441
+ outcome: PotKeepUpOutcome | null;
442
+ /** True once the keep-up period ended with the post still up. */
443
+ mayTakeDown: boolean;
444
+ /**
445
+ * A post taken down before the keep-up end (FR-032): inside the bonus window
446
+ * the guarantee is lost; after it the agency decides, then keeps or returns it.
447
+ */
448
+ removal: 'guarantee_lost' | 'agency_deciding' | 'guarantee_kept' | 'guarantee_returned' | null;
449
+ /** null before publication. */
450
+ earnings: I_CreatorPotEarnings | null;
451
+ /** The provider refused the post at scheduling, since the time was last set or confirmed; null otherwise. */
452
+ publishRefusal?: I_PotPublishRefusal | null;
453
+ }
454
+
455
+ /** Attached to `I_CreatorCampaignView.pot` on a pot campaign. */
456
+ export interface I_CreatorPotView {
457
+ campaignId: string;
458
+ currency: string;
459
+ maxPerCreatorMinor: number;
460
+ bonusWindowDays: number;
461
+ keepUpMonths: number;
462
+ coverUrl: string | null;
463
+ applicationDeadlineAt: string | null;
464
+ availability: PotAvailability;
465
+ /** The maximum in the creator's preferred currency, approximate; null with no rate for the day. */
466
+ approx: { currency: string; maxPerCreatorMinor: number; rateDate: string } | null;
467
+ schedule: I_PotSchedule;
468
+ publishing: I_PotPublishingSettings;
469
+ entry: I_CreatorPotEntry | null;
470
+ canApply: boolean;
471
+ canWithdraw: boolean;
472
+ canConfirmPublishAt: boolean;
473
+ /** The creator's own 90-day median views from their shared statistics, for the application estimate; null when unknown. */
474
+ medianViews: number | null;
475
+ }
476
+
477
+ // ── The client report (FR-038) ──────────────────────────────────────────────
478
+
479
+ /** One published post in the brand's report. Costs ride along; the page shows them only when the agency ticks them. */
480
+ export interface I_PotReportPost {
481
+ memberId: string;
482
+ creator: I_CampaignCreatorIdentity;
483
+ platform: string | null;
484
+ publishedAt: string;
485
+ postUrl: string | null;
486
+ thumbnailUrl: string | null;
487
+ caption: string | null;
488
+ disclosure: I_DisclosureCheck | null;
489
+ /** Whether the platform's paid-partnership label went out (null: not asked for). */
490
+ labelApplied: boolean | null;
491
+ /** A durable ad2app link that opens a fresh signed read of the approved material on every open; null when none. */
492
+ materialLink: string | null;
493
+ views: number;
494
+ likes: number | null;
495
+ comments: number | null;
496
+ shares: number | null;
497
+ saves: number | null;
498
+ followers: number | null;
499
+ /** The creator's own 90-day median, when known: each post is judged against its creator. */
500
+ medianViews: number | null;
501
+ /** Views by day since publication, as counted (the latest reading of each day). */
502
+ daily: Array<{ day: string; views: number }>;
503
+ /** A bonus under review (FR-037): left out of the sums and named as such. */
504
+ held: boolean;
505
+ publishedOnTime: boolean | null;
506
+ keepUpEndsAt: string | null;
507
+ versions: number;
508
+ revisionRounds: number;
509
+ /** The most-liked comments, text and likes only; null when the creator does not share comments. */
510
+ topComments: Array<{ text: string; likes: number }> | null;
511
+ earnedMinor: number;
512
+ }
513
+
514
+ /** `GET /agency/campaigns/:id/pot/report`. */
515
+ export interface I_PotClientReport {
516
+ campaign: { id: string; name: string; organizationName: string; brief: string };
517
+ currency: string;
518
+ goalViews: number | null;
519
+ publicationDueAt: string | null;
520
+ /** When the bonus window of the campaign closes (publication due + the window): the pacing projection runs to it. */
521
+ campaignEndsAt: string | null;
522
+ keepUpMonths: number;
523
+ generatedAt: string;
524
+ lastCountAt: string | null;
525
+ posts: I_PotReportPost[];
526
+ /** What the published, unheld posts cost: shown only when the agency includes costs. */
527
+ totalCostMinor: number;
528
+ }
529
+
530
+ // ── Files (FR-039) ──────────────────────────────────────────────────────────
531
+
532
+ export interface I_CampaignFile {
533
+ id: string;
534
+ fileName: string;
535
+ contentType: string;
536
+ sizeBytes: number;
537
+ uploadedAt: string;
538
+ uploadedBy: string;
539
+ }
540
+
541
+ /** PDF, images, video, office documents and archives; no HTML or SVG (opened in the browser). */
542
+ export const CAMPAIGN_FILE_CONTENT_TYPE =
543
+ /^(application\/pdf|image\/(png|jpeg|gif|webp|heic|heif|avif)|video\/[A-Za-z0-9.+-]+|application\/(msword|vnd\.openxmlformats-officedocument\.[a-z.]+|vnd\.ms-(excel|powerpoint)|vnd\.oasis\.opendocument\.[a-z.]+|rtf|zip|x-zip-compressed|x-7z-compressed|x-rar-compressed|vnd\.rar|gzip|x-tar)|text\/(plain|csv))$/;
544
+ export const CAMPAIGN_FILE_MAX_BYTES = 2 * 1024 * 1024 * 1024;
545
+
546
+ // ── Bodies ───────────────────────────────────────────────────────────────────
547
+
548
+ const MONEY_MAX = 100_000_000_000;
549
+
550
+ /**
551
+ * `POST /agency/campaign-invitations/:memberId/application` — the creator's own
552
+ * terms. Applying from an invitation also joins the campaign, answering any
553
+ * access request folded into it with `scopes` (absent = everything asked).
554
+ */
555
+ export class ApplyToPotDto {
556
+ @IsInt()
557
+ @Min(0)
558
+ @Max(MONEY_MAX)
559
+ guaranteeMinor: number;
560
+
561
+ @IsInt()
562
+ @Min(0)
563
+ @Max(MONEY_MAX)
564
+ ratePer1000Minor: number;
565
+
566
+ @IsOptional()
567
+ @IsArray()
568
+ @IsIn(AGENCY_GRANT_SCOPES as unknown as string[], { each: true })
569
+ scopes?: I_AgencyGrantScope[];
570
+
571
+ constructor(data?: Partial<ApplyToPotDto>) {
572
+ this.guaranteeMinor = data?.guaranteeMinor ?? 0;
573
+ this.ratePer1000Minor = data?.ratePer1000Minor ?? 0;
574
+ this.scopes = data?.scopes;
575
+ }
576
+ }
577
+
578
+ /** `POST /agency/campaigns/:id/pot/refill` — up to the maximum budget. */
579
+ export class RefillPotDto {
580
+ @IsInt()
581
+ @Min(1)
582
+ @Max(MONEY_MAX)
583
+ amountMinor: number;
584
+
585
+ constructor(data?: Partial<RefillPotDto>) {
586
+ this.amountMinor = data?.amountMinor ?? 0;
587
+ }
588
+ }
589
+
590
+ /** `PATCH /agency/campaigns/:id/pot/application-deadline`. */
591
+ export class MovePotDeadlineDto {
592
+ @IsDateString()
593
+ applicationDeadlineAt: string;
594
+
595
+ constructor(data?: Partial<MovePotDeadlineDto>) {
596
+ this.applicationDeadlineAt = data?.applicationDeadlineAt ?? '';
597
+ }
598
+ }
599
+
600
+ /** The agency sets the time agreed with the brand; the creator confirms that same value. */
601
+ export class PotPublishAtDto {
602
+ @IsDateString()
603
+ publishAt: string;
604
+
605
+ constructor(data?: Partial<PotPublishAtDto>) {
606
+ this.publishAt = data?.publishAt ?? '';
607
+ }
608
+ }
609
+
610
+ export class PotHoldActDto {
611
+ @IsIn(POT_HOLD_ACTIONS as unknown as string[])
612
+ action: PotHoldAction;
613
+
614
+ constructor(data?: Partial<PotHoldActDto>) {
615
+ this.action = data?.action ?? 'approve';
616
+ }
617
+ }
618
+
619
+ /** `POST /agency/campaigns/:id/pot/entries/:entryId/removal` — the agency's decision on a late takedown (FR-032). */
620
+ export class PotRemovalDecisionDto {
621
+ @IsIn(POT_REMOVAL_DECISIONS as unknown as string[])
622
+ decision: PotRemovalDecision;
623
+
624
+ constructor(data?: Partial<PotRemovalDecisionDto>) {
625
+ this.decision = data?.decision ?? 'keep_base';
626
+ }
627
+ }
628
+
629
+ /** The caption is the creator's until publication (FR-032). */
630
+ export class UpdateMaterialCaptionDto {
631
+ @IsString()
632
+ @MaxLength(2200)
633
+ caption: string;
634
+
635
+ constructor(data?: Partial<UpdateMaterialCaptionDto>) {
636
+ this.caption = data?.caption ?? '';
637
+ }
638
+ }
639
+
640
+ export class PrepareCampaignFileUploadDto {
641
+ @IsString()
642
+ @IsNotEmpty()
643
+ @MaxLength(255)
644
+ fileName: string;
645
+
646
+ @IsString()
647
+ @Matches(CAMPAIGN_FILE_CONTENT_TYPE)
648
+ contentType: string;
649
+
650
+ @IsInt()
651
+ @Min(1)
652
+ @Max(CAMPAIGN_FILE_MAX_BYTES)
653
+ sizeBytes: number;
654
+
655
+ constructor(data?: Partial<PrepareCampaignFileUploadDto>) {
656
+ this.fileName = data?.fileName ?? '';
657
+ this.contentType = data?.contentType ?? '';
658
+ this.sizeBytes = data?.sizeBytes ?? 0;
659
+ }
660
+ }
661
+
662
+ /** The pot's fields on campaign create (and on a move); flat for the reason given in agency-campaigns.ts. */
663
+ export class PotFields {
664
+ @IsOptional()
665
+ @IsInt()
666
+ @Min(1)
667
+ @Max(MONEY_MAX)
668
+ potBudgetMinor?: number;
669
+
670
+ @IsOptional()
671
+ @IsInt()
672
+ @Min(1)
673
+ @Max(MONEY_MAX)
674
+ potMaxBudgetMinor?: number;
675
+
676
+ /**
677
+ * The maximum one creator can earn, guarantee included. Named "cap" on the
678
+ * wire: a body field matching /creator|user/ reads as a person id to the
679
+ * agency boundary scan (backend agency-grant-never-opens-inbox.spec.ts).
680
+ */
681
+ @IsOptional()
682
+ @IsInt()
683
+ @Min(1)
684
+ @Max(MONEY_MAX)
685
+ potCapMinor?: number;
686
+
687
+ @IsOptional()
688
+ @IsInt()
689
+ @Min(1)
690
+ @Max(365)
691
+ potBonusWindowDays?: number;
692
+
693
+ @IsOptional()
694
+ @IsInt()
695
+ @Min(1)
696
+ @Max(60)
697
+ potKeepUpMonths?: number;
698
+
699
+ @IsOptional()
700
+ @IsInt()
701
+ @Min(1)
702
+ @Max(100_000_000_000)
703
+ potGoalViews?: number | null;
704
+
705
+ @IsOptional()
706
+ @IsIn(POT_CURRENCIES as unknown as string[])
707
+ potCurrency?: string;
708
+
709
+ @IsOptional()
710
+ @IsDateString()
711
+ potApplicationDeadlineAt?: string | null;
712
+
713
+ @IsOptional()
714
+ @IsBoolean()
715
+ potPaidPartnershipLabel?: boolean;
716
+
717
+ @IsOptional()
718
+ @IsString()
719
+ @Matches(/^@?[A-Za-z0-9._]{1,64}$/)
720
+ potSponsorHandle?: string | null;
721
+
722
+ @IsOptional()
723
+ @IsString()
724
+ @Matches(/^@?[A-Za-z0-9._]{1,64}$/)
725
+ potCollabHandle?: string | null;
726
+
727
+ @IsOptional()
728
+ @IsString()
729
+ @MaxLength(2200)
730
+ potFirstComment?: string | null;
731
+
732
+ @IsOptional()
733
+ @IsBoolean()
734
+ potCommentsEnabled?: boolean;
735
+
736
+ protected assignPot(data?: Partial<PotFields>): void {
737
+ this.potBudgetMinor = data?.potBudgetMinor;
738
+ this.potMaxBudgetMinor = data?.potMaxBudgetMinor;
739
+ this.potCapMinor = data?.potCapMinor;
740
+ this.potBonusWindowDays = data?.potBonusWindowDays;
741
+ this.potKeepUpMonths = data?.potKeepUpMonths;
742
+ this.potGoalViews = data?.potGoalViews;
743
+ this.potCurrency = data?.potCurrency;
744
+ this.potApplicationDeadlineAt = data?.potApplicationDeadlineAt;
745
+ this.potPaidPartnershipLabel = data?.potPaidPartnershipLabel;
746
+ this.potSponsorHandle = data?.potSponsorHandle;
747
+ this.potCollabHandle = data?.potCollabHandle;
748
+ this.potFirstComment = data?.potFirstComment;
749
+ this.potCommentsEnabled = data?.potCommentsEnabled;
750
+ }
751
+ }