ad2app-lib 1.47.0 → 1.50.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,739 @@
1
+ import {
2
+ ArrayMaxSize,
3
+ IsArray,
4
+ IsBoolean,
5
+ IsDateString,
6
+ IsIn,
7
+ IsInt,
8
+ IsNotEmpty,
9
+ IsOptional,
10
+ IsString,
11
+ Matches,
12
+ Max,
13
+ MaxLength,
14
+ Min,
15
+ } from 'class-validator';
16
+
17
+ import { I_MediaStage } from './I_Media';
18
+ import { I_OfferStatus } from './I_Offer';
19
+ import { AGENCY_GRANT_SCOPES, type I_AgencyGrantScope } from './agency';
20
+ import { PotFields, type I_AgencyPotView, type I_CampaignFile, type I_CreatorPotView, type I_DisclosureCheck } from './agency-pots';
21
+
22
+ /**
23
+ * The campaign loop (spec 175 US7) — an organization runs a campaign with the
24
+ * creators on its roster or in the directory.
25
+ *
26
+ * Two steps, never one (Jan, 2026-09-28):
27
+ *
28
+ * 1. MEMBERSHIP — the agency invites a creator; the creator joins or
29
+ * declines. Joining answers any access request folded into the
30
+ * invitation. No money is named here.
31
+ * 2. OFFER — once joined, the agency sends a rate. It is countered, accepted,
32
+ * confirmed or rejected; a creator who leaves withdraws it.
33
+ *
34
+ * The server owns every transition. Each view carries `availableActions`, and
35
+ * a screen renders exactly those acts — it never derives them from a status,
36
+ * so the two can never disagree.
37
+ *
38
+ * See specs/speckit/175-agency-roster/contracts/agency-api.md → US7.
39
+ */
40
+
41
+ // ── The ONE placements list (FR-025) ────────────────────────────────────────
42
+
43
+ /**
44
+ * What a deliverable may be, per platform. A named list in one place, so a
45
+ * "placement" can never be free text that drifts per campaign.
46
+ */
47
+ export const CAMPAIGN_PLACEMENTS = {
48
+ instagram: ['reel', 'post', 'carousel', 'stories', 'collab_post'],
49
+ tiktok: ['video', 'repost', 'duet', 'stories'],
50
+ youtube: ['video', 'short', 'integration', 'community_post'],
51
+ facebook: ['post', 'reel', 'stories'],
52
+ linkedin: ['post', 'article'],
53
+ } as const;
54
+
55
+ export type CampaignPlatform = keyof typeof CAMPAIGN_PLACEMENTS;
56
+ export type CampaignPlacement = (typeof CAMPAIGN_PLACEMENTS)[CampaignPlatform][number];
57
+
58
+ export const CAMPAIGN_PLATFORMS = Object.keys(CAMPAIGN_PLACEMENTS) as CampaignPlatform[];
59
+
60
+ export const isCampaignPlacement = (platform: string, placement: string): boolean =>
61
+ Object.prototype.hasOwnProperty.call(CAMPAIGN_PLACEMENTS, platform) &&
62
+ (CAMPAIGN_PLACEMENTS[platform as CampaignPlatform] as readonly string[]).includes(placement);
63
+
64
+ // ── The settlement form (FR-027) ─────────────────────────────────────────────
65
+
66
+ /** The creator's own setting. The agency reads it and can never set it. */
67
+ export const SETTLEMENT_FORMS = ['invoice', 'contract', 'barter'] as const;
68
+ export type SettlementForm = (typeof SETTLEMENT_FORMS)[number];
69
+
70
+ // ── States ───────────────────────────────────────────────────────────────────
71
+
72
+ /**
73
+ * An offer campaign negotiates one price per creator (US7); a pot campaign pays
74
+ * from one pot on each creator's own terms (US9). Chosen at creation, fixed once
75
+ * the first creator is invited (FR-030).
76
+ */
77
+ export const AGENCY_CAMPAIGN_KINDS = ['offer', 'pot'] as const;
78
+ export type AgencyCampaignKind = (typeof AGENCY_CAMPAIGN_KINDS)[number];
79
+
80
+ export const AGENCY_CAMPAIGN_MEMBER_STATES = ['invited', 'joined', 'declined', 'left', 'removed'] as const;
81
+ export type AgencyCampaignMemberState = (typeof AGENCY_CAMPAIGN_MEMBER_STATES)[number];
82
+
83
+ /** The offer statuses the agency loop writes. `draft`, `deleted` and `alternativeSuggested` are legacy-only. */
84
+ export const AGENCY_OFFER_STATUSES = [
85
+ I_OfferStatus.SENT,
86
+ I_OfferStatus.INFLUENCER_NEGOTIATED,
87
+ I_OfferStatus.AGENCY_NEGOTIATED,
88
+ I_OfferStatus.ACCEPTED,
89
+ I_OfferStatus.CONFIRMED,
90
+ I_OfferStatus.REJECTED,
91
+ I_OfferStatus.WITHDRAWN,
92
+ ] as const;
93
+ export type AgencyOfferStatus = (typeof AGENCY_OFFER_STATUSES)[number];
94
+
95
+ export type CampaignSide = 'agency' | 'creator';
96
+
97
+ /**
98
+ * Every act on an offer, by either side. Which of them a given caller may take
99
+ * right now is the server's `availableActions`, never a client guess.
100
+ *
101
+ * agency: counter · confirm · reject
102
+ * creator: accept · counter · reject
103
+ */
104
+ export const AGENCY_OFFER_ACTIONS = ['accept', 'counter', 'confirm', 'reject'] as const;
105
+ export type AgencyOfferAction = (typeof AGENCY_OFFER_ACTIONS)[number];
106
+
107
+ /** The agency's acts on the latest material; the creator's only act is to upload. */
108
+ export const AGENCY_MATERIAL_ACTIONS = ['approve', 'request_changes', 'reject'] as const;
109
+ export type AgencyMaterialAction = (typeof AGENCY_MATERIAL_ACTIONS)[number];
110
+
111
+ /**
112
+ * The board's columns, in order (FR-024). A creator stands in exactly one.
113
+ * `declined`, `left`, `removed` and `rejected` are OFF the board: those rows
114
+ * stay in the list with their date (US7 scenario 9), never in a column.
115
+ */
116
+ export const AGENCY_CAMPAIGN_STEPS = [
117
+ 'invited',
118
+ 'joined',
119
+ 'offer_sent',
120
+ 'negotiating',
121
+ 'confirmed',
122
+ 'material_in',
123
+ 'approved',
124
+ 'published',
125
+ ] as const;
126
+ export type AgencyCampaignStep = (typeof AGENCY_CAMPAIGN_STEPS)[number];
127
+
128
+ export type AgencyCampaignOffBoard = 'declined' | 'left' | 'removed' | 'rejected';
129
+
130
+ export const AGENCY_MILESTONE_KINDS = ['draft_due', 'live_by', 'custom'] as const;
131
+ export type AgencyMilestoneKind = (typeof AGENCY_MILESTONE_KINDS)[number];
132
+
133
+ /** A milestone may wait on one rule: every ACTIVE offer at a status, or every active creator's latest material at a stage. */
134
+ export const AGENCY_MILESTONE_OFFER_RULES = [I_OfferStatus.CONFIRMED] as const;
135
+ export const AGENCY_MILESTONE_MEDIA_RULES = [I_MediaStage.UPLOADED, I_MediaStage.ACCEPTED] as const;
136
+
137
+ export const AGENCY_CAMPAIGN_EVENT_KINDS = [
138
+ 'invited',
139
+ 'joined',
140
+ 'declined',
141
+ 'left',
142
+ 'removed',
143
+ 'offer_sent',
144
+ 'offer_countered',
145
+ 'offer_accepted',
146
+ 'offer_confirmed',
147
+ 'offer_rejected',
148
+ 'offer_withdrawn',
149
+ 'material_uploaded',
150
+ 'material_approved',
151
+ 'material_changes_asked',
152
+ 'material_rejected',
153
+ 'published',
154
+ 'milestone_confirmed',
155
+ // US9: the pot. Every money act carries its amount (FR-021, FR-033).
156
+ 'pot_applied',
157
+ 'pot_application_withdrawn',
158
+ 'pot_accepted',
159
+ 'pot_declined',
160
+ 'pot_released',
161
+ 'pot_refilled',
162
+ 'pot_moved',
163
+ 'pot_deadline_moved',
164
+ 'pot_publish_time_set',
165
+ 'pot_publish_time_confirmed',
166
+ /** Zernio refused the post at scheduling; `note` carries its reason. */
167
+ 'pot_publish_refused',
168
+ 'pot_published',
169
+ 'pot_counted',
170
+ 'pot_frozen',
171
+ 'pot_bonus_held',
172
+ 'pot_bonus_approved',
173
+ 'pot_bonus_capped',
174
+ 'pot_bonus_message',
175
+ 'pot_kept',
176
+ 'pot_broken',
177
+ 'pot_removal_decided',
178
+ 'caption_edited',
179
+ 'file_uploaded',
180
+ 'file_removed',
181
+ ] as const;
182
+ export type AgencyCampaignEventKind = (typeof AGENCY_CAMPAIGN_EVENT_KINDS)[number];
183
+
184
+ /** Refusal codes the frontend owes its own copy for. */
185
+ export const AGENCY_CAMPAIGN_CODES = {
186
+ /** The transition is not open at this state, or not for this side. */
187
+ TRANSITION_REFUSED: 'agency-campaign-transition-refused',
188
+ /** A campaign sent to a creator must carry its brief text (FR-025). */
189
+ BRIEF_REQUIRED: 'agency-campaign-brief-required',
190
+ /** A deliverable on a platform the creator has not connected (FR-025). */
191
+ PLATFORM_NOT_CONNECTED: 'agency-campaign-platform-not-connected',
192
+ /** The creator is already on this campaign. */
193
+ ALREADY_MEMBER: 'agency-campaign-already-member',
194
+ /** The upload was confirmed but no stored file exists at its path (FR-029). */
195
+ MATERIAL_NOT_STORED: 'agency-campaign-material-not-stored',
196
+ /** A demo creator cannot be invited or mailed (spec 179). */
197
+ DEMO_CREATOR: 'agency-campaign-demo-creator',
198
+ } as const;
199
+ export type AgencyCampaignCode = (typeof AGENCY_CAMPAIGN_CODES)[keyof typeof AGENCY_CAMPAIGN_CODES];
200
+
201
+ // ── Views ────────────────────────────────────────────────────────────────────
202
+
203
+ export interface I_CampaignDeliverable {
204
+ platform: CampaignPlatform;
205
+ placement: CampaignPlacement;
206
+ qty: number;
207
+ note?: string;
208
+ }
209
+
210
+ export interface I_AgencyCampaignBrief {
211
+ /** The concept, in prose. May be empty only while the campaign is a draft. */
212
+ text: string;
213
+ deliverables: I_CampaignDeliverable[];
214
+ /** ISO dates, or null when not set. */
215
+ draftDueAt: string | null;
216
+ liveByAt: string | null;
217
+ /** Usage rights on the brand's profiles, in months. */
218
+ licenceMonths: number | null;
219
+ /** Paid promotion (whitelisting), in months. */
220
+ paidPromoMonths: number | null;
221
+ revisionRounds: number | null;
222
+ }
223
+
224
+ /** Money is integer minor units plus an ISO-4217 code; the legacy float `price` is never written. */
225
+ export interface I_Money {
226
+ minor: number;
227
+ currency: string;
228
+ }
229
+
230
+ export interface I_AgencyOfferView {
231
+ id: string;
232
+ status: AgencyOfferStatus;
233
+ /** The price on the table now: the latest one either side named. */
234
+ price: I_Money;
235
+ /** Whose move it is; null once the offer is settled. */
236
+ turn: CampaignSide | null;
237
+ /** The acts open to the CALLER'S side, right now. */
238
+ availableActions: AgencyOfferAction[];
239
+ sentAt: string;
240
+ updatedAt: string;
241
+ }
242
+
243
+ export interface I_AgencyMaterial {
244
+ id: string;
245
+ fileName: string;
246
+ contentType: string;
247
+ sizeBytes: number | null;
248
+ stage: I_MediaStage;
249
+ uploadedAt: string;
250
+ /** The note sent with a change request or a rejection of THIS version. */
251
+ note: string | null;
252
+ /** The post's caption, uploaded with the material (US9, FR-032); the creator may edit it until publication. Optional: 1.49 backends omit it. */
253
+ caption?: string | null;
254
+ /** `checkDisclosure(caption)`, returned with every read (FR-040); null without a caption. Optional: 1.49 backends omit it. */
255
+ disclosure?: I_DisclosureCheck | null;
256
+ }
257
+
258
+ export interface I_AgencyMilestone {
259
+ id: string;
260
+ kind: AgencyMilestoneKind;
261
+ /** Only for `custom`; the others are named by i18n from `kind`. */
262
+ label: string | null;
263
+ dueAt: string | null;
264
+ /** Null = campaign-wide. */
265
+ creatorUserId: string | null;
266
+ offerRule: (typeof AGENCY_MILESTONE_OFFER_RULES)[number] | null;
267
+ mediaRule: (typeof AGENCY_MILESTONE_MEDIA_RULES)[number] | null;
268
+ readyToConfirm: boolean;
269
+ confirmedAt: string | null;
270
+ }
271
+
272
+ export interface I_AgencyCampaignEvent {
273
+ kind: AgencyCampaignEventKind;
274
+ side: CampaignSide;
275
+ /** Done by ad2app itself (the daily pot job), not by a person on that side. */
276
+ byAd2app?: true;
277
+ at: string;
278
+ price?: I_Money;
279
+ /** A pot act's amount: a refill, a move, a reserved guarantee, an accrued bonus. */
280
+ amount?: I_Money;
281
+ note?: string;
282
+ }
283
+
284
+ export interface I_CampaignCreatorIdentity {
285
+ userId: string | null;
286
+ handle: string;
287
+ displayName?: string;
288
+ avatarUrl?: string;
289
+ }
290
+
291
+ /** One creator's row on the campaign screen (the list twin of the board). */
292
+ export interface I_AgencyCampaignMemberRow {
293
+ memberId: string;
294
+ creator: I_CampaignCreatorIdentity;
295
+ state: AgencyCampaignMemberState;
296
+ /** The board column, or the reason the row is off the board. */
297
+ step: AgencyCampaignStep | AgencyCampaignOffBoard;
298
+ /** When the row reached its current step. */
299
+ stepAt: string;
300
+ offer: I_AgencyOfferView | null;
301
+ latestMaterial: I_AgencyMaterial | null;
302
+ /** The agency's acts on `latestMaterial`. */
303
+ materialActions: AgencyMaterialAction[];
304
+ /** Read from the creator's own setting (FR-027); null when they have not set it. */
305
+ settlementForm: SettlementForm | null;
306
+ nextDeadline: { at: string; kind: AgencyMilestoneKind; label: string | null } | null;
307
+ /** True when the agency may still remove them (no confirmed offer). */
308
+ removable: boolean;
309
+ /** True when the agency may send an offer now (joined, none sent yet). */
310
+ canSendOffer: boolean;
311
+ canMarkPublished: boolean;
312
+ /** Scopes a folded access request asked for, while the invitation is open. */
313
+ requestedScopes: I_AgencyGrantScope[];
314
+ }
315
+
316
+ export interface I_AgencyCampaignPipeline {
317
+ /** Creators per board column; off-board rows are not counted. */
318
+ steps: Record<AgencyCampaignStep, number>;
319
+ total: number;
320
+ }
321
+
322
+ export interface I_AgencyCampaignCard {
323
+ id: string;
324
+ name: string;
325
+ /** Absent from 1.49 backends: read as 'offer'. */
326
+ kind?: AgencyCampaignKind;
327
+ isDraft: boolean;
328
+ liveByAt: string | null;
329
+ draftDueAt: string | null;
330
+ pipeline: I_AgencyCampaignPipeline;
331
+ /** How many things on this campaign wait on the agency (the rail's definition). */
332
+ attention: number;
333
+ createdAt: string;
334
+ }
335
+
336
+ export interface I_AgencyCampaign {
337
+ id: string;
338
+ name: string;
339
+ /** Absent from 1.49 backends: read as 'offer'. */
340
+ kind?: AgencyCampaignKind;
341
+ /** Set on a pot campaign; the pot board itself is `GET /agency/campaigns/:id/pot/board`. */
342
+ pot?: I_AgencyPotView | null;
343
+ isDraft: boolean;
344
+ brief: I_AgencyCampaignBrief;
345
+ members: I_AgencyCampaignMemberRow[];
346
+ milestones: I_AgencyMilestone[];
347
+ pipeline: I_AgencyCampaignPipeline;
348
+ createdAt: string;
349
+ }
350
+
351
+ /** Opening one creator (FR-024): their course with dates, notes, files and milestones. */
352
+ export interface I_AgencyCampaignCreatorDetail {
353
+ row: I_AgencyCampaignMemberRow;
354
+ creatorBrief: string | null;
355
+ events: I_AgencyCampaignEvent[];
356
+ materials: I_AgencyMaterial[];
357
+ milestones: I_AgencyMilestone[];
358
+ }
359
+
360
+ /** `GET /agency/campaigns/attention` — what waits on the agency (FR-022, the rail). */
361
+ export interface I_AgencyCampaignAttention {
362
+ counterOffers: number;
363
+ materialsToReview: number;
364
+ milestonesReady: number;
365
+ /** US9: applications to answer, held bonuses to decide, and posts taken down after the bonus window. */
366
+ potDecisions?: number;
367
+ total: number;
368
+ }
369
+
370
+ /** `GET /agency/panel` (FR-022). */
371
+ export interface I_AgencyPanel {
372
+ /** Open milestones of running (non-draft) campaigns due today. */
373
+ dueToday: number;
374
+ /** Open milestones of running (non-draft) campaigns past their date. */
375
+ overdue: number;
376
+ /** The roster's own "needs attention" count, or null when it could not be read: shown as a dash, never 0. */
377
+ needsAttention: number | null;
378
+ deadlines: Array<{
379
+ campaignId: string;
380
+ campaignName: string;
381
+ milestoneId: string;
382
+ kind: AgencyMilestoneKind;
383
+ label: string | null;
384
+ dueAt: string;
385
+ creatorHandle: string | null;
386
+ confirmed: boolean;
387
+ }>;
388
+ campaigns: Array<{ id: string; name: string; pipeline: I_AgencyCampaignPipeline; liveByAt: string | null }>;
389
+ }
390
+
391
+ // ── The creator's side ───────────────────────────────────────────────────────
392
+
393
+ /**
394
+ * What a creator reads about ONE campaign they are on. Deliberately narrow
395
+ * (SC-014): their own membership, offer, brief, materials and deadlines — no
396
+ * other creator, no total, no average, no count of others.
397
+ */
398
+ export interface I_CreatorCampaignView {
399
+ memberId: string;
400
+ state: AgencyCampaignMemberState;
401
+ organization: { id: string; name: string };
402
+ /** `kind` is absent from 1.49 backends: read as 'offer'. */
403
+ campaign: { id: string; name: string; brief: I_AgencyCampaignBrief; kind?: AgencyCampaignKind };
404
+ /** Set on a pot campaign: the creator's own terms, earnings and the two-valued availability, nothing pot-level (SC-016). */
405
+ pot?: I_CreatorPotView | null;
406
+ /** The agency's files for the campaign (FR-039): every one is for its creators; opened through a signed read. */
407
+ files?: I_CampaignFile[];
408
+ creatorBrief: string | null;
409
+ /** The access request folded into the invitation, while it is open. */
410
+ requestedScopes: I_AgencyGrantScope[];
411
+ offer: I_AgencyOfferView | null;
412
+ materials: I_AgencyMaterial[];
413
+ /** True when the creator may upload now (confirmed, and no version awaits review). */
414
+ canUpload: boolean;
415
+ /** True when the creator may leave (no confirmed offer). */
416
+ canLeave: boolean;
417
+ milestones: I_AgencyMilestone[];
418
+ invitedAt: string;
419
+ }
420
+
421
+ /** `GET /agency/campaign-invitations/mine` — pending invitations and live campaigns. */
422
+ export interface I_CreatorCampaigns {
423
+ items: I_CreatorCampaignView[];
424
+ /** The creator's turn, the creator's badge: invitations to answer, offers to answer, and materials to upload (a first version, or a new one after changes were asked or a version was rejected). */
425
+ waitingOnYou: number;
426
+ }
427
+
428
+ export interface I_MaterialUploadTicket {
429
+ /** Opaque; sent back on confirm. */
430
+ uploadId: string;
431
+ /** PUT the file's bytes here with `headers`. */
432
+ uploadUrl: string;
433
+ headers: Record<string, string>;
434
+ expiresAt: string;
435
+ }
436
+
437
+ export interface I_SignedRead {
438
+ url: string;
439
+ expiresAt: string;
440
+ }
441
+
442
+ // ── Bodies ───────────────────────────────────────────────────────────────────
443
+ //
444
+ // Flat on purpose: nested bodies need class-transformer's `@Type`, and a
445
+ // linked lib carries its own copy whose metadata the backend's ValidationPipe
446
+ // never reads. `deliverables` is an array of plain objects checked item by
447
+ // item in the service against CAMPAIGN_PLACEMENTS — the pair is the rule.
448
+
449
+ /** The brief's fields, shared by create and update. All optional on the wire. The pot's fields ride along (US9). */
450
+ class AgencyCampaignBriefFields extends PotFields {
451
+ @IsOptional()
452
+ @IsString()
453
+ @MaxLength(10000)
454
+ text?: string;
455
+
456
+ @IsOptional()
457
+ @IsArray()
458
+ @ArrayMaxSize(30)
459
+ deliverables?: I_CampaignDeliverable[];
460
+
461
+ @IsOptional()
462
+ @IsDateString()
463
+ draftDueAt?: string | null;
464
+
465
+ @IsOptional()
466
+ @IsDateString()
467
+ liveByAt?: string | null;
468
+
469
+ @IsOptional()
470
+ @IsInt()
471
+ @Min(0)
472
+ @Max(120)
473
+ licenceMonths?: number | null;
474
+
475
+ @IsOptional()
476
+ @IsInt()
477
+ @Min(0)
478
+ @Max(120)
479
+ paidPromoMonths?: number | null;
480
+
481
+ @IsOptional()
482
+ @IsInt()
483
+ @Min(0)
484
+ @Max(20)
485
+ revisionRounds?: number | null;
486
+
487
+ protected assignBrief(data?: Partial<AgencyCampaignBriefFields>): void {
488
+ this.assignPot(data);
489
+ this.text = data?.text;
490
+ this.deliverables = data?.deliverables;
491
+ this.draftDueAt = data?.draftDueAt;
492
+ this.liveByAt = data?.liveByAt;
493
+ this.licenceMonths = data?.licenceMonths;
494
+ this.paidPromoMonths = data?.paidPromoMonths;
495
+ this.revisionRounds = data?.revisionRounds;
496
+ }
497
+ }
498
+
499
+ export class CreateAgencyCampaignDto extends AgencyCampaignBriefFields {
500
+ @IsString()
501
+ @IsNotEmpty()
502
+ @MaxLength(128)
503
+ name: string;
504
+
505
+ /** Absent = an offer campaign (US7). */
506
+ @IsOptional()
507
+ @IsIn(AGENCY_CAMPAIGN_KINDS as unknown as string[])
508
+ kind?: AgencyCampaignKind;
509
+
510
+ constructor(data?: Partial<CreateAgencyCampaignDto>) {
511
+ super();
512
+ this.name = data?.name ?? '';
513
+ this.kind = data?.kind;
514
+ this.assignBrief(data);
515
+ }
516
+ }
517
+
518
+ export class UpdateAgencyCampaignDto extends AgencyCampaignBriefFields {
519
+ @IsOptional()
520
+ @IsString()
521
+ @IsNotEmpty()
522
+ @MaxLength(128)
523
+ name?: string;
524
+
525
+ /** Setting false publishes the draft; the brief text must then be present (FR-025). */
526
+ @IsOptional()
527
+ @IsBoolean()
528
+ isDraft?: boolean;
529
+
530
+ /** Switch offer ↔ pot; refused once the first creator is invited (FR-030). */
531
+ @IsOptional()
532
+ @IsIn(AGENCY_CAMPAIGN_KINDS as unknown as string[])
533
+ kind?: AgencyCampaignKind;
534
+
535
+ constructor(data?: Partial<UpdateAgencyCampaignDto>) {
536
+ super();
537
+ this.name = data?.name;
538
+ this.isDraft = data?.isDraft;
539
+ this.kind = data?.kind;
540
+ this.assignBrief(data);
541
+ }
542
+ }
543
+
544
+ export class InviteCampaignCreatorDto {
545
+ /** A user id: a UUID or a Firebase uid. */
546
+ @IsString()
547
+ @Matches(/^[A-Za-z0-9-]{1,128}$/)
548
+ creatorUserId: string;
549
+
550
+ @IsOptional()
551
+ @IsString()
552
+ @MaxLength(5000)
553
+ creatorBrief?: string;
554
+
555
+ /** The access request folded into the invitation when no grant covers them (FR-026). */
556
+ @IsOptional()
557
+ @IsArray()
558
+ @IsIn(AGENCY_GRANT_SCOPES as unknown as string[], { each: true })
559
+ scopes?: I_AgencyGrantScope[];
560
+
561
+ constructor(data?: Partial<InviteCampaignCreatorDto>) {
562
+ this.creatorUserId = data?.creatorUserId ?? '';
563
+ this.creatorBrief = data?.creatorBrief;
564
+ this.scopes = data?.scopes;
565
+ }
566
+ }
567
+
568
+ export class SendCampaignOfferDto {
569
+ @IsInt()
570
+ @Min(0)
571
+ @Max(1_000_000_000)
572
+ priceMinor: number;
573
+
574
+ @IsString()
575
+ @Matches(/^[A-Z]{3}$/)
576
+ currency: string;
577
+
578
+ constructor(data?: Partial<SendCampaignOfferDto>) {
579
+ this.priceMinor = data?.priceMinor ?? 0;
580
+ this.currency = data?.currency ?? 'PLN';
581
+ }
582
+ }
583
+
584
+ /** One act on an offer. `counter` needs `priceMinor`; any act may carry a note. */
585
+ export class CampaignOfferActDto {
586
+ @IsIn(AGENCY_OFFER_ACTIONS as unknown as string[])
587
+ action: AgencyOfferAction;
588
+
589
+ /** A counter keeps the offer's currency; only the amount moves. */
590
+ @IsOptional()
591
+ @IsInt()
592
+ @Min(0)
593
+ @Max(1_000_000_000)
594
+ priceMinor?: number;
595
+
596
+ @IsOptional()
597
+ @IsString()
598
+ @MaxLength(2000)
599
+ note?: string;
600
+
601
+ constructor(data?: Partial<CampaignOfferActDto>) {
602
+ this.action = data?.action ?? 'accept';
603
+ this.priceMinor = data?.priceMinor;
604
+ this.note = data?.note;
605
+ }
606
+ }
607
+
608
+ /** One agency act on a material. `request_changes` requires the note. */
609
+ export class CampaignMaterialActDto {
610
+ @IsIn(AGENCY_MATERIAL_ACTIONS as unknown as string[])
611
+ action: AgencyMaterialAction;
612
+
613
+ @IsOptional()
614
+ @IsString()
615
+ @MaxLength(2000)
616
+ note?: string;
617
+
618
+ constructor(data?: Partial<CampaignMaterialActDto>) {
619
+ this.action = data?.action ?? 'approve';
620
+ this.note = data?.note;
621
+ }
622
+ }
623
+
624
+ export class JoinCampaignDto {
625
+ /** A subset of what was asked; absent = everything asked. */
626
+ @IsOptional()
627
+ @IsArray()
628
+ @IsIn(AGENCY_GRANT_SCOPES as unknown as string[], { each: true })
629
+ scopes?: I_AgencyGrantScope[];
630
+
631
+ constructor(data?: Partial<JoinCampaignDto>) {
632
+ this.scopes = data?.scopes;
633
+ }
634
+ }
635
+
636
+ /** Material files: images and video only, at most 2 GB. */
637
+ export const MATERIAL_MAX_BYTES = 2 * 1024 * 1024 * 1024;
638
+
639
+ /**
640
+ * The photo formats a material may be. Raster only: no SVG or HTML, because a
641
+ * material is opened in the browser. Shared so the upload picker, the
642
+ * creator's own check and the API refuse exactly the same files.
643
+ */
644
+ export const MATERIAL_IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/heic', 'image/heif', 'image/avif'] as const;
645
+
646
+ /** A material's content type: one of MATERIAL_IMAGE_TYPES, or any video. */
647
+ export const MATERIAL_CONTENT_TYPE = new RegExp(
648
+ `^(${MATERIAL_IMAGE_TYPES.map((type) => type.replace(/[/+.]/g, (c) => `\\${c}`)).join('|')}|video\\/[A-Za-z0-9.+-]+)$`,
649
+ );
650
+
651
+ export class PrepareMaterialUploadDto {
652
+ @IsString()
653
+ @IsNotEmpty()
654
+ @MaxLength(255)
655
+ fileName: string;
656
+
657
+ @IsString()
658
+ /** Raster images and video (MATERIAL_CONTENT_TYPE). */
659
+ @Matches(MATERIAL_CONTENT_TYPE)
660
+ contentType: string;
661
+
662
+ @IsInt()
663
+ @Min(1)
664
+ @Max(MATERIAL_MAX_BYTES)
665
+ sizeBytes: number;
666
+
667
+ constructor(data?: Partial<PrepareMaterialUploadDto>) {
668
+ this.fileName = data?.fileName ?? '';
669
+ this.contentType = data?.contentType ?? '';
670
+ this.sizeBytes = data?.sizeBytes ?? 0;
671
+ }
672
+ }
673
+
674
+ export class ConfirmMaterialUploadDto {
675
+ @IsString()
676
+ @IsNotEmpty()
677
+ @MaxLength(512)
678
+ uploadId: string;
679
+
680
+ /** The post's caption, sent with the material on a pot campaign (FR-032). */
681
+ @IsOptional()
682
+ @IsString()
683
+ @MaxLength(2200)
684
+ caption?: string;
685
+
686
+ constructor(data?: Partial<ConfirmMaterialUploadDto>) {
687
+ this.uploadId = data?.uploadId ?? '';
688
+ this.caption = data?.caption;
689
+ }
690
+ }
691
+
692
+ export class CreateAgencyMilestoneDto {
693
+ @IsIn(AGENCY_MILESTONE_KINDS as unknown as string[])
694
+ kind: AgencyMilestoneKind;
695
+
696
+ /** Required for `custom`, ignored otherwise. */
697
+ @IsOptional()
698
+ @IsString()
699
+ @MaxLength(256)
700
+ label?: string;
701
+
702
+ @IsOptional()
703
+ @IsDateString()
704
+ dueAt?: string | null;
705
+
706
+ /** Absent = campaign-wide. */
707
+ @IsOptional()
708
+ @IsString()
709
+ @Matches(/^[A-Za-z0-9-]{1,128}$/)
710
+ creatorUserId?: string | null;
711
+
712
+ @IsOptional()
713
+ @IsIn(AGENCY_MILESTONE_OFFER_RULES as unknown as string[])
714
+ offerRule?: (typeof AGENCY_MILESTONE_OFFER_RULES)[number] | null;
715
+
716
+ @IsOptional()
717
+ @IsIn(AGENCY_MILESTONE_MEDIA_RULES as unknown as string[])
718
+ mediaRule?: (typeof AGENCY_MILESTONE_MEDIA_RULES)[number] | null;
719
+
720
+ constructor(data?: Partial<CreateAgencyMilestoneDto>) {
721
+ this.kind = data?.kind ?? 'custom';
722
+ this.label = data?.label;
723
+ this.dueAt = data?.dueAt;
724
+ this.creatorUserId = data?.creatorUserId;
725
+ this.offerRule = data?.offerRule;
726
+ this.mediaRule = data?.mediaRule;
727
+ }
728
+ }
729
+
730
+ /** `PATCH /users/me/settlement-form` — the creator's own setting (FR-027). */
731
+ export class UpdateSettlementFormDto {
732
+ @IsOptional()
733
+ @IsIn(SETTLEMENT_FORMS as unknown as string[])
734
+ settlementForm: SettlementForm | null;
735
+
736
+ constructor(data?: Partial<UpdateSettlementFormDto>) {
737
+ this.settlementForm = data?.settlementForm ?? null;
738
+ }
739
+ }