ad2app-lib 1.44.1 → 1.49.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,79 @@
1
+ import { strict as assert } from 'node:assert';
2
+ import { test } from 'node:test';
3
+ import { validateSync } from 'class-validator';
4
+
5
+ import { AgencyAccessRequestDto, UpdateAgencyDiscoverableDto, type I_AgencyDiscoverResult, type I_AgencyRosterRow, type I_ConnectedPlatforms } from './agency';
6
+
7
+ /**
8
+ * Spec 175 US5 (T026/T028): the discover and request contract.
9
+ */
10
+
11
+ test('an access request accepts a Firebase uid, not only a UUID: most creators have one', () => {
12
+ const dto = new AgencyAccessRequestDto({ userId: 'fb59705bBaba4f2cA569B54ccbed', scopes: ['analytics'] });
13
+ assert.deepEqual(validateSync(dto), []);
14
+ });
15
+
16
+ test('an access request refuses an id that could not be a user id, and an empty scope set', () => {
17
+ assert.notDeepEqual(validateSync(new AgencyAccessRequestDto({ userId: 'nul\u0000byte', scopes: ['analytics'] })), []);
18
+ assert.notDeepEqual(validateSync(new AgencyAccessRequestDto({ userId: 'abc', scopes: [] })), []);
19
+ assert.notDeepEqual(validateSync(new AgencyAccessRequestDto({ userId: 'abc', scopes: ['dms' as never] })), []);
20
+ });
21
+
22
+ test('the discoverable switch is a boolean and nothing else', () => {
23
+ assert.deepEqual(validateSync(new UpdateAgencyDiscoverableDto({ discoverable: true })), []);
24
+ assert.notDeepEqual(validateSync(Object.assign(new UpdateAgencyDiscoverableDto(), { discoverable: 'yes' })), []);
25
+ });
26
+
27
+ test('a discover answer is ONE shape: hits carry handle, platforms and the platform matched only; a miss is an empty list (FR-015, T068)', () => {
28
+ // Two different creators can hold the same username on different platforms
29
+ // (@mark on YouTube, another @mark on Instagram): every match is returned,
30
+ // each naming where it matched, as separate people (Maciej, 2026-09-26).
31
+ const hits: I_AgencyDiscoverResult = {
32
+ results: [
33
+ { userId: 'u1', handle: 'mark', platforms: ['youtube'], matchedPlatform: 'youtube' },
34
+ { userId: 'u2', handle: 'mark', platforms: ['instagram', 'tiktok'], matchedPlatform: 'instagram' },
35
+ ],
36
+ };
37
+ const byEmail: I_AgencyDiscoverResult = { results: [{ userId: 'u3', handle: 'kasia.cooks', platforms: ['instagram'] }] };
38
+ const miss: I_AgencyDiscoverResult = { results: [] };
39
+ assert.deepEqual(Object.keys(hits.results[0]).sort(), ['handle', 'matchedPlatform', 'platforms', 'userId']);
40
+ assert.equal(byEmail.results[0].matchedPlatform, undefined);
41
+ assert.deepEqual(miss.results, []);
42
+ });
43
+
44
+ import { AGENCY_REQUEST_CODES, type I_CreatorAccessGrant } from './agency';
45
+
46
+ test('the discoverable switch refuses a body with no value, rather than defaulting it (review M7)', () => {
47
+ const dto = Object.assign(new UpdateAgencyDiscoverableDto(), {});
48
+ assert.notDeepEqual(validateSync(dto), []);
49
+ });
50
+
51
+ test('request and discovery outcomes travel as codes, so the screen composes EN and PL itself (review M4)', () => {
52
+ assert.deepEqual(AGENCY_REQUEST_CODES, {
53
+ RATE_LIMITED: 'agency-rate-limited',
54
+ REQUEST_EXPIRED: 'agency-request-expired',
55
+ REQUEST_ANSWERED: 'agency-request-answered',
56
+ });
57
+ });
58
+
59
+ test('a grant carries the agency’s open second ask separately from what the creator granted (review H4, M3)', () => {
60
+ const grant: Pick<I_CreatorAccessGrant, 'grantedScopes' | 'reaskedScopes' | 'reaskExpiresAt'> = {
61
+ grantedScopes: ['analytics'],
62
+ reaskedScopes: ['comments'],
63
+ reaskExpiresAt: '2026-10-09T00:00:00.000Z',
64
+ };
65
+ assert.deepEqual(grant.reaskedScopes, ['comments']);
66
+ });
67
+
68
+ test('a roster row can carry the agency\'s last second ask and when another is possible, and nothing about the answer', () => {
69
+ const row: I_AgencyRosterRow = {
70
+ grantId: 'g1', creator: { id: 'c1', handle: 'h' }, platforms: ['instagram'], state: 'active', grantedScopes: ['analytics'],
71
+ reaskedAt: '2026-09-25T19:43:02.959Z', reaskAvailableAt: '2026-10-09T19:43:02.959Z',
72
+ };
73
+ assert.deepEqual(Object.keys(row).filter((k) => k.startsWith('reask')).sort(), ['reaskAvailableAt', 'reaskedAt']);
74
+ });
75
+
76
+ test('connected platforms are a plain list in the lib\'s spelling', () => {
77
+ const answer: I_ConnectedPlatforms = { platforms: ['instagram', 'tiktok'] };
78
+ assert.equal(answer.platforms.length, 2);
79
+ });
@@ -0,0 +1,103 @@
1
+ import { IsNotEmpty, IsString, Matches, MaxLength } from 'class-validator';
2
+
3
+ import type { CampaignSide, I_CampaignCreatorIdentity } from './agency-campaigns';
4
+
5
+ /**
6
+ * Agency ↔ creator threads (spec 175 US8, FR-023): one conversation per
7
+ * organization and creator, inside ad2app.
8
+ *
9
+ * Deliberately NOT the creator's platform inbox: nothing here reaches a social
10
+ * platform, and no grant scope opens a thread (SC-015). A thread is the two
11
+ * parties' own; a grant is a read of the creator's numbers, and the two never
12
+ * meet.
13
+ *
14
+ * "Unanswered" is derived: the other side wrote last. There is no read state
15
+ * and no mark-read step.
16
+ */
17
+
18
+ export const AGENCY_THREAD_CODES = {
19
+ /** No live relationship any more: the history stays readable, nothing can be sent (FR-023). */
20
+ CLOSED: 'agency-thread-closed',
21
+ /** Too many messages from one person in the window (THREAD_POST_LIMIT). */
22
+ RATE_LIMITED: 'agency-thread-rate-limited',
23
+ } as const;
24
+
25
+ /** Messages one person may post across all their threads per window: a conversation pace, never a script's. */
26
+ export const THREAD_POST_LIMIT = { limit: 60, windowMinutes: 10 } as const;
27
+
28
+ /** Long enough for a real message, short enough to stay a message. */
29
+ export const THREAD_MESSAGE_MAX = 4000;
30
+
31
+ /** Messages per read: the newest page with the thread, earlier pages on request. */
32
+ export const THREAD_PAGE_SIZE = 100;
33
+
34
+ export interface I_AgencyThreadMessage {
35
+ id: string;
36
+ side: CampaignSide;
37
+ body: string;
38
+ at: string;
39
+ }
40
+
41
+ export interface I_AgencyThreadSummary {
42
+ id: string;
43
+ /** Agency side: who the thread is with. */
44
+ creator: I_CampaignCreatorIdentity;
45
+ /** Creator side: which organization. */
46
+ organization: { id: string; name: string };
47
+ lastMessage: { side: CampaignSide; body: string; at: string } | null;
48
+ /** The other side wrote last, seen from the caller's side. */
49
+ unanswered: boolean;
50
+ /** No live relationship: read-only for whoever still exists. */
51
+ closed: boolean;
52
+ }
53
+
54
+ export interface I_AgencyThread {
55
+ summary: I_AgencyThreadSummary;
56
+ /** The newest page, oldest first. */
57
+ messages: I_AgencyThreadMessage[];
58
+ /** Older messages exist: read them with `…/messages?before=<oldest id>`. */
59
+ hasEarlier: boolean;
60
+ }
61
+
62
+ /** `GET …/threads/:id/messages?before=<message id>` — the page before a message. */
63
+ export interface I_AgencyThreadPage {
64
+ /** Oldest first. */
65
+ messages: I_AgencyThreadMessage[];
66
+ hasEarlier: boolean;
67
+ }
68
+
69
+ /** `GET /agency/threads` — the agency's inbox. */
70
+ export interface I_AgencyThreads {
71
+ items: I_AgencyThreadSummary[];
72
+ /** Creators on the roster the agency has no open thread with yet. */
73
+ startable: I_CampaignCreatorIdentity[];
74
+ /** The Inbox rail count. */
75
+ unanswered: number;
76
+ }
77
+
78
+ /** `GET /agency/creator-threads/mine` — the creator's threads, in the Agency tab. */
79
+ export interface I_CreatorThreads {
80
+ items: I_AgencyThreadSummary[];
81
+ unanswered: number;
82
+ }
83
+
84
+ export class StartAgencyThreadDto {
85
+ @IsString()
86
+ @Matches(/^[A-Za-z0-9-]{1,128}$/)
87
+ creatorUserId: string;
88
+
89
+ constructor(data?: Partial<StartAgencyThreadDto>) {
90
+ this.creatorUserId = data?.creatorUserId ?? '';
91
+ }
92
+ }
93
+
94
+ export class PostThreadMessageDto {
95
+ @IsString()
96
+ @IsNotEmpty()
97
+ @MaxLength(THREAD_MESSAGE_MAX)
98
+ text: string;
99
+
100
+ constructor(data?: Partial<PostThreadMessageDto>) {
101
+ this.text = data?.text ?? '';
102
+ }
103
+ }
@@ -7,6 +7,7 @@ import {
7
7
  CREATOR_ACCESS_GRANT_STATES,
8
8
  AGENCY_ROSTER_ROW_STATES,
9
9
  AGENCY_ROSTER_NEEDS_ATTENTION_STATES,
10
+ AGENCY_ROSTER_METRICS_STATES,
10
11
  isAgencyGrantScope,
11
12
  CreateAgencyOrganizationDto,
12
13
  AgencyAccessRequestDto,
@@ -50,7 +51,7 @@ test("grant states are the five the data model pins, including declined and expi
50
51
  test("roster row states include `stale`, and `revoked` is NOT one of them", () => {
51
52
  assert.deepEqual(
52
53
  [...AGENCY_ROSTER_ROW_STATES],
53
- ["requested", "lapsed", "disconnected", "stale", "active"],
54
+ ["requested", "lapsed", "disconnected", "syncing", "stale", "active"],
54
55
  );
55
56
  assert.equal(
56
57
  (AGENCY_ROSTER_ROW_STATES as readonly string[]).includes("revoked"),
@@ -66,6 +67,11 @@ test("needs-attention is disconnected + lapsed + stale, and never `requested` (F
66
67
  false,
67
68
  "a requested grant waits on the CREATOR, so it never lights the agency's badge",
68
69
  );
70
+ assert.equal(
71
+ (AGENCY_ROSTER_NEEDS_ATTENTION_STATES as readonly string[]).includes("syncing"),
72
+ false,
73
+ "a creator who joined within the day waits on the first sync, not on the agency (T067)",
74
+ );
69
75
  // Every needs-attention state must be a real roster state, or the tile counts a
70
76
  // state no row can ever hold.
71
77
  for (const state of AGENCY_ROSTER_NEEDS_ATTENTION_STATES) {
@@ -73,6 +79,20 @@ test("needs-attention is disconnected + lapsed + stale, and never `requested` (F
73
79
  }
74
80
  });
75
81
 
82
+ test("only rows whose card opens carry numbers: active and stale (review M1/M2, 2026-09-24)", () => {
83
+ assert.deepEqual([...AGENCY_ROSTER_METRICS_STATES], ["syncing", "stale", "active"]);
84
+ for (const blocked of ["requested", "lapsed", "disconnected"]) {
85
+ assert.equal(
86
+ (AGENCY_ROSTER_METRICS_STATES as readonly string[]).includes(blocked),
87
+ false,
88
+ `a ${blocked} row must carry no metrics and count in no total`,
89
+ );
90
+ }
91
+ for (const state of AGENCY_ROSTER_METRICS_STATES) {
92
+ assert.ok((AGENCY_ROSTER_ROW_STATES as readonly string[]).includes(state));
93
+ }
94
+ });
95
+
76
96
  test("DTOs construct from a plain object (the house `new I_DTO()` shape)", () => {
77
97
  const org = new CreateAgencyOrganizationDto({
78
98
  name: "Studio Kot",
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  ArrayNotEmpty,
3
3
  IsArray,
4
+ IsBoolean,
4
5
  IsEmail,
5
6
  IsIn,
6
7
  IsNotEmpty,
@@ -9,6 +10,7 @@ import {
9
10
  IsUrl,
10
11
  IsUUID,
11
12
  Length,
13
+ Matches,
12
14
  } from 'class-validator';
13
15
 
14
16
  /**
@@ -50,6 +52,8 @@ export const AGENCY_ROUTE_PATHS = {
50
52
  join: '/agency/join',
51
53
  /** A creator answering an access request (FR-016). */
52
54
  requests: '/agency/requests',
55
+ /** A creator's campaign invitations and campaigns: the Agency tab of their Inbox (FR-023, FR-026). */
56
+ creatorCampaigns: '/inbox/agency',
53
57
  } as const;
54
58
 
55
59
  // ── Scopes ───────────────────────────────────────────────────────────────────
@@ -145,6 +149,12 @@ export interface I_AgencyMember {
145
149
  /** Null while an e-mail invitation is outstanding — nobody has accepted yet. */
146
150
  userId?: string;
147
151
  invitedEmail: string;
152
+ /**
153
+ * The account's own e-mail once the invitation is accepted (and always for
154
+ * the owner, who applied rather than being invited). The Team panel names a
155
+ * person by this, never by the organization's contact address (T063).
156
+ */
157
+ accountEmail?: string;
148
158
  role: AgencyMemberRole;
149
159
  invitedAt: string;
150
160
  /** "Who is in" is exactly `acceptedAt != null`. */
@@ -216,8 +226,30 @@ export interface I_CreatorAccessGrant {
216
226
  sponsorship?: { tier: SponsoredTier; since: string; until?: string };
217
227
  /** Which platforms the grant reaches right now — derived at read time, never stored. */
218
228
  platformsReached: PublishPlatform[];
229
+ /**
230
+ * The agency's OPEN second ask on an active grant, for scopes the creator
231
+ * did not grant (FR-008). Kept apart from `requestedScopes`, so the consent
232
+ * screen names exactly this ask and nothing refused earlier (review H4).
233
+ * Absent when there is no open ask.
234
+ */
235
+ reaskedScopes?: I_AgencyGrantScope[];
236
+ /** When that open ask expires unanswered. */
237
+ reaskExpiresAt?: string;
219
238
  }
220
239
 
240
+ /**
241
+ * Outcomes of discovery and requests that are not refusals of access, sent as
242
+ * codes so the screen composes the sentence in the reader's language (FR-019,
243
+ * review M4). A 429 carries `{ code, limit, windowMinutes }`.
244
+ */
245
+ export const AGENCY_REQUEST_CODES = {
246
+ RATE_LIMITED: 'agency-rate-limited',
247
+ REQUEST_EXPIRED: 'agency-request-expired',
248
+ REQUEST_ANSWERED: 'agency-request-answered',
249
+ } as const;
250
+
251
+ export type AgencyRequestCode = (typeof AGENCY_REQUEST_CODES)[keyof typeof AGENCY_REQUEST_CODES];
252
+
221
253
  // ── Roster ───────────────────────────────────────────────────────────────────
222
254
 
223
255
  /**
@@ -234,6 +266,13 @@ export const AGENCY_ROSTER_ROW_STATES = [
234
266
  'lapsed',
235
267
  /** the creator has no live platform connection */
236
268
  'disconnected',
269
+ /**
270
+ * shared within the last 24 hours and not synced yet: the first daily sync
271
+ * has not run. Not stale, and not something the agency can act on (T067,
272
+ * the 2026-09-26 UI run: a creator who agreed a minute ago read as
273
+ * "needs attention").
274
+ */
275
+ 'syncing',
237
276
  /** no successful daily sync in 24 hours (FR-011, Jan 2026-09-21) */
238
277
  'stale',
239
278
  'active',
@@ -257,30 +296,115 @@ export const AGENCY_ROSTER_NEEDS_ATTENTION_STATES = [
257
296
  export type AgencyRosterNeedsAttentionState =
258
297
  (typeof AGENCY_ROSTER_NEEDS_ATTENTION_STATES)[number];
259
298
 
299
+ /**
300
+ * The rows that carry numbers — ONE home for "which creators' numbers may the
301
+ * agency see on the roster" (decided 2026-09-24, review M1/M2).
302
+ *
303
+ * Only a row whose card OPENS carries `metrics` and counts in the followers,
304
+ * followers-delta and comments totals: `active`, and `stale` (the card opens;
305
+ * the numbers are as old as `lastSyncAt` says). A `lapsed` creator's reads are
306
+ * refused on the card, so their numbers must not reach the agency through the
307
+ * roster instead; a `disconnected` creator has nothing live to count; a
308
+ * `requested` creator has shared nothing yet.
309
+ */
310
+ export const AGENCY_ROSTER_METRICS_STATES = [
311
+ 'syncing',
312
+ 'stale',
313
+ 'active',
314
+ ] as const satisfies readonly AgencyRosterRowState[];
315
+
316
+ export type AgencyRosterMetricsState = (typeof AGENCY_ROSTER_METRICS_STATES)[number];
317
+
318
+ /**
319
+ * One creator's numbers on the roster (FR-011a: sortable metric columns), read
320
+ * from the daily-synced tables, never a vendor call. `null` is a REASON, never
321
+ * a zero: the creator did not grant that scope, or nothing has been synced yet.
322
+ */
323
+ export interface I_AgencyRosterRowMetrics {
324
+ /** Latest followers over the creator's CONNECTED platforms; null without `analytics` or before the first sync. */
325
+ followers: number | null;
326
+ /** Against the snapshot seven days earlier, per connected platform; null without `analytics` or without a week-old point. */
327
+ followersDelta7d: number | null;
328
+ /** The current Monday-to-Sunday week, organization timezone; null without `comments`. */
329
+ commentsThisWeek: number | null;
330
+ }
331
+
260
332
  export interface I_AgencyRosterRow {
261
333
  grantId: string;
262
334
  creator: { id: string; handle: string };
263
335
  platforms: PublishPlatform[];
264
336
  state: AgencyRosterRowState;
265
337
  grantedScopes: I_AgencyGrantScope[];
338
+ /**
339
+ * The newest successful daily sync. Absent on a `requested` row (nothing is
340
+ * shared yet, so there is nothing to have synced for the agency) and on a
341
+ * creator never synced. `platforms` stays on a requested row: it is what the
342
+ * consent screen names, a fact about the account rather than shared data.
343
+ */
266
344
  lastSyncAt?: string;
345
+ /**
346
+ * Present ONLY on rows in AGENCY_ROSTER_METRICS_STATES (`active`, `stale`):
347
+ * the rows whose card opens. Absent on `lapsed`, `disconnected` and
348
+ * `requested` rows — no numbers, not null numbers.
349
+ */
350
+ metrics?: I_AgencyRosterRowMetrics;
351
+ /**
352
+ * The agency's own last "Ask again" (FR-008), while its one-per-TTL clock
353
+ * runs (audit F19): when it was sent and when another is possible. It says
354
+ * nothing about the ANSWER, because the clock runs whatever the creator did.
355
+ * The roster uses it to say "asked on …, again after …" instead of offering
356
+ * an ask that would not be sent. Absent once the clock has run out.
357
+ */
358
+ reaskedAt?: string;
359
+ reaskAvailableAt?: string;
360
+ }
361
+
362
+ /**
363
+ * The platforms a creator has connected, as the agency side sees them
364
+ * (discovery, the grant's "reaches"). `GET users/me/connected-platforms`,
365
+ * answered to every creator, subscribed or not.
366
+ */
367
+ export interface I_ConnectedPlatforms {
368
+ platforms: PublishPlatform[];
267
369
  }
268
370
 
269
371
  export interface I_AgencyRoster {
270
372
  totals: {
271
373
  creators: number;
272
374
  active: number;
273
- /** Summed only over creators holding `analytics`; disconnected platforms excluded. */
274
- followers: number;
275
- followersDelta7d: number;
276
- /** Calendar week, Mon-Sun, organization timezone, creators holding `comments`. */
277
- commentsThisWeek: number;
375
+ /**
376
+ * Summed only over rows in AGENCY_ROSTER_METRICS_STATES whose creator holds
377
+ * `analytics`; disconnected platforms excluded. `null` when no counted
378
+ * creator has a synced number yet, never a zero standing in (T067).
379
+ */
380
+ followers: number | null;
381
+ /**
382
+ * Same rows as `followers`, against the snapshot seven days earlier. `null`
383
+ * when no counted creator has a week-old point — unknown, never a zero.
384
+ */
385
+ followersDelta7d: number | null;
386
+ /**
387
+ * Calendar week, Mon-Sun, organization timezone, over rows in
388
+ * AGENCY_ROSTER_METRICS_STATES whose creator holds `comments`. Like
389
+ * `followers`, `null` when no counted creator has a synced number yet:
390
+ * unknown, never a zero (SC-008, T067).
391
+ */
392
+ commentsThisWeek: number | null;
278
393
  /** Count of rows in AGENCY_ROSTER_NEEDS_ATTENTION_STATES. */
279
394
  needsAttention: number;
280
395
  };
281
396
  items: I_AgencyRosterRow[];
282
397
  }
283
398
 
399
+ /**
400
+ * A coverage that ENDED recently, so the creator meeting the paywall again is
401
+ * told why (FR-014: "an honest message, never a silent loss of access").
402
+ */
403
+ export interface I_SponsorshipEnded {
404
+ organizationName: string;
405
+ endedAt: string;
406
+ }
407
+
284
408
  /** What a sponsored creator sees on their OWN subscription screen (FR-014). */
285
409
  export interface I_SponsoredBy {
286
410
  organizationName: string;
@@ -289,6 +413,89 @@ export interface I_SponsoredBy {
289
413
  until?: string;
290
414
  }
291
415
 
416
+ // ── Billing (US4) ────────────────────────────────────────────────────────────
417
+
418
+ /** The card on file, as Stripe describes it. Never more than these four facts. */
419
+ export interface I_AgencyPaymentMethod {
420
+ brand: string;
421
+ last4: string;
422
+ expMonth: number;
423
+ expYear: number;
424
+ }
425
+
426
+ /**
427
+ * One invoice. `amount` is Stripe's minor unit verbatim; the display edge
428
+ * formats it once, like every other price in the app.
429
+ */
430
+ export interface I_AgencyInvoice {
431
+ id: string;
432
+ number: string | null;
433
+ date: string;
434
+ amount: number;
435
+ currency: string;
436
+ status: string;
437
+ pdfUrl?: string;
438
+ hostedUrl?: string;
439
+ }
440
+
441
+ /**
442
+ * One creator on an ACTIVE grant, with whether the agency covers their plan
443
+ * (FR-013b). `tier: null` is "not covered". `until` is set while a coverage is
444
+ * ending: it runs to the close of the period already paid for.
445
+ */
446
+ export interface I_AgencyCoveredCreator {
447
+ grantId: string;
448
+ creator: { id: string; handle: string };
449
+ tier: SponsoredTier | null;
450
+ since?: string;
451
+ until?: string;
452
+ }
453
+
454
+ /**
455
+ * An ended relationship, for the past-creators history (FR-013b). `creator.id`
456
+ * is null once the creator's account was purged; the handle is the snapshot
457
+ * frozen at grant time.
458
+ */
459
+ export interface I_AgencyPastCreator {
460
+ grantId: string;
461
+ creator: { id: string | null; handle: string; displayName?: string };
462
+ endedAt: string;
463
+ endedBy: CreatorAccessGrantRevokedBy;
464
+ coveredUntil?: string;
465
+ }
466
+
467
+ /**
468
+ * Everything the owner's billing section and panel show (FR-013, FR-013b).
469
+ *
470
+ * `seats` is the ONE home of the billed quantity: the count of active grants.
471
+ * Covering a creator never changes it (clarify 2026-09-17), so nothing here
472
+ * derives a price from `covered`.
473
+ *
474
+ * `price` is null when the seat product is not configured in this deployment
475
+ * (T031, the money gate); the screen says so rather than inventing a number.
476
+ * `nextInvoice` is Stripe's own preview, so a coupon is already applied to it.
477
+ */
478
+ export interface I_AgencyBilling {
479
+ subscriptionStatus: AgencySubscriptionStatus;
480
+ /** Creators with an active grant. */
481
+ seats: number;
482
+ /**
483
+ * What Stripe bills: `max(1, seats)`. An agency with no creators yet still
484
+ * pays for one seat, and the screen must say the number Stripe charges.
485
+ */
486
+ billedSeats: number;
487
+ price: { amount: number; currency: string; interval: string } | null;
488
+ currentPeriodEnd?: string;
489
+ cancelAtPeriodEnd?: boolean;
490
+ nextInvoice?: { amount: number; currency: string; date: string };
491
+ discount?: { name?: string; percentOff?: number; amountOff?: number };
492
+ paymentMethod?: I_AgencyPaymentMethod;
493
+ billingDetails?: { name?: string; email?: string; country?: string; taxId?: string };
494
+ invoices: I_AgencyInvoice[];
495
+ covered: I_AgencyCoveredCreator[];
496
+ pastCreators: I_AgencyPastCreator[];
497
+ }
498
+
292
499
  // ── DTOs ─────────────────────────────────────────────────────────────────────
293
500
 
294
501
  /**
@@ -426,7 +633,13 @@ export class AgencyDiscoverQueryDto {
426
633
  }
427
634
 
428
635
  export class AgencyAccessRequestDto {
429
- @IsUUID()
636
+ /**
637
+ * A user id: a UUID or a Firebase uid (letters, digits, hyphens). It was
638
+ * `@IsUUID()` until US5 was built, which refused most real creators — their
639
+ * ids are Firebase uids.
640
+ */
641
+ @IsString()
642
+ @Matches(/^[A-Za-z0-9-]{1,128}$/)
430
643
  userId: string;
431
644
 
432
645
  @IsArray()
@@ -440,6 +653,48 @@ export class AgencyAccessRequestDto {
440
653
  }
441
654
  }
442
655
 
656
+ /** `PATCH /users/me/agency-discoverable` — "Let agencies find me" (FR-015). */
657
+ export class UpdateAgencyDiscoverableDto {
658
+ @IsBoolean()
659
+ discoverable: boolean;
660
+
661
+ /**
662
+ * NO default: a required boolean that defaults to false lets an empty body
663
+ * through validation and reach the database as `undefined` (review M7).
664
+ */
665
+ constructor(data?: Partial<UpdateAgencyDiscoverableDto>) {
666
+ if (data?.discoverable !== undefined) this.discoverable = data.discoverable;
667
+ }
668
+ }
669
+
670
+ /**
671
+ * One discover hit: the public handle and the platforms, NEVER a metric and
672
+ * never a grant flag, so the payload cannot tell a caller whether it already
673
+ * holds the creator (FR-015). `userId` is what the request is sent for.
674
+ */
675
+ export interface I_AgencyDiscoverHit {
676
+ userId: string;
677
+ handle: string;
678
+ platforms: PublishPlatform[];
679
+ /**
680
+ * The platform whose username matched. Absent on an e-mail match. Two hits
681
+ * with the same handle are two different people (@mark on YouTube, another
682
+ * @mark on Instagram), and this is what tells them apart (T068).
683
+ */
684
+ matchedPlatform?: PublishPlatform;
685
+ }
686
+
687
+ /**
688
+ * `POST /agency/roster/discover` — the SAME shape and status for every case:
689
+ * discoverable exact matches, a non-discoverable account, a partial match and
690
+ * no account at all (SC-009). A miss is `{ results: [] }`, never a 404. An
691
+ * exact handle held by several findable creators returns each of them, one
692
+ * row per person (Maciej, 2026-09-26, T068); an e-mail matches one person.
693
+ */
694
+ export interface I_AgencyDiscoverResult {
695
+ results: I_AgencyDiscoverHit[];
696
+ }
697
+
443
698
  export class AdminSetOrganizationStatusDto {
444
699
  @IsIn(['approved', 'rejected'])
445
700
  status: Extract<AgencyOrganizationStatus, 'approved' | 'rejected'>;
@@ -43,6 +43,8 @@ export * from "./agent";
43
43
 
44
44
  // ── Agency domain (organizations, memberships, creator access grants, spec 175)
45
45
  export * from "./agency";
46
+ export * from "./agency-campaigns";
47
+ export * from "./agency-threads";
46
48
 
47
49
  // ── Access control ────────────────────────────────────────────────────────────
48
50
  export * from "./I_AccessDenial";
@@ -5,6 +5,8 @@
5
5
  * and the customer portal redirect.
6
6
  */
7
7
 
8
+ import type { I_SponsoredBy, I_SponsorshipEnded } from '../agency';
9
+
8
10
  // ── SchedulingSubscriptionTier ────────────────────────────────────────────────
9
11
 
10
12
  export type SchedulingSubscriptionTier = 'free' | 'starter' | 'pro';
@@ -23,6 +25,7 @@ export type SchedulingSubscriptionInfoInput = Pick<
23
25
  SchedulingSubscriptionInfoDTO,
24
26
  'tier' | 'status' | 'expiresAt'
25
27
  > &
28
+ Partial<Pick<SchedulingSubscriptionInfoDTO, 'sponsoredBy' | 'sponsorshipEnded'>> &
26
29
  (
27
30
  | { maxAccounts: number; maxPlatforms?: number }
28
31
  | {
@@ -46,6 +49,14 @@ export class SchedulingSubscriptionInfoDTO {
46
49
  * Read `maxAccounts`. Always equal to it; removed in a later release.
47
50
  */
48
51
  maxPlatforms: number;
52
+ /**
53
+ * Present only while an agency covers this creator's plan (spec 175 FR-014).
54
+ * `until` appears once the sponsorship is ending: the date the plan runs to.
55
+ * Absent — never null — for everyone else, so "not sponsored" has one shape.
56
+ */
57
+ sponsoredBy?: I_SponsoredBy;
58
+ /** Present only when a coverage ended recently and nothing replaced it. Absent otherwise. */
59
+ sponsorshipEnded?: I_SponsorshipEnded;
49
60
 
50
61
  constructor(data: SchedulingSubscriptionInfoInput) {
51
62
  const maxAccounts = 'maxAccounts' in data ? data.maxAccounts : data.maxPlatforms;
@@ -54,6 +65,8 @@ export class SchedulingSubscriptionInfoDTO {
54
65
  this.expiresAt = data.expiresAt;
55
66
  this.maxAccounts = maxAccounts;
56
67
  this.maxPlatforms = maxAccounts;
68
+ if (data.sponsoredBy) this.sponsoredBy = data.sponsoredBy;
69
+ if (data.sponsorshipEnded) this.sponsorshipEnded = data.sponsorshipEnded;
57
70
  }
58
71
  }
59
72