ad2app-lib 1.44.1 → 1.47.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.
@@ -79,6 +79,25 @@ export declare const ACCESS_DENIAL_CODES: {
79
79
  * per person, FR-004). Names the case and NOTHING else about that account.
80
80
  */
81
81
  readonly AGENCY_ALREADY_IN_ORGANIZATION: "agency-already-in-organization";
82
+ /**
83
+ * The owner invited an address that is ALREADY on this organization's team,
84
+ * themselves included (T063). A different case from the one above: the owner
85
+ * is told this person is already in, not that they belong elsewhere.
86
+ */
87
+ readonly AGENCY_ALREADY_MEMBER: "agency-already-member";
88
+ /**
89
+ * The owner tried to cover a creator who pays for their own plan. Covering
90
+ * is for a creator without a paid plan (US4 scenario 3); covering a payer
91
+ * would leave them paying for what the agency also pays for (T066).
92
+ */
93
+ readonly AGENCY_CREATOR_PAYS_OWN_PLAN: "agency-creator-pays-own-plan";
94
+ /**
95
+ * A creator whose plan an agency covers asked for the billing portal, and
96
+ * has no Stripe customer of their own to open it for (FR-014). The body
97
+ * names the covering agency; the client hides the portal button instead of
98
+ * showing a Stripe error.
99
+ */
100
+ readonly SPONSORED_NO_PORTAL: "sponsored-no-portal";
82
101
  };
83
102
  export type AccessDenialCode = (typeof ACCESS_DENIAL_CODES)[keyof typeof ACCESS_DENIAL_CODES];
84
103
  /**
@@ -83,6 +83,25 @@ exports.ACCESS_DENIAL_CODES = {
83
83
  * per person, FR-004). Names the case and NOTHING else about that account.
84
84
  */
85
85
  AGENCY_ALREADY_IN_ORGANIZATION: "agency-already-in-organization",
86
+ /**
87
+ * The owner invited an address that is ALREADY on this organization's team,
88
+ * themselves included (T063). A different case from the one above: the owner
89
+ * is told this person is already in, not that they belong elsewhere.
90
+ */
91
+ AGENCY_ALREADY_MEMBER: "agency-already-member",
92
+ /**
93
+ * The owner tried to cover a creator who pays for their own plan. Covering
94
+ * is for a creator without a paid plan (US4 scenario 3); covering a payer
95
+ * would leave them paying for what the agency also pays for (T066).
96
+ */
97
+ AGENCY_CREATOR_PAYS_OWN_PLAN: "agency-creator-pays-own-plan",
98
+ /**
99
+ * A creator whose plan an agency covers asked for the billing portal, and
100
+ * has no Stripe customer of their own to open it for (FR-014). The body
101
+ * names the covering agency; the client hides the portal button instead of
102
+ * showing a Stripe error.
103
+ */
104
+ SPONSORED_NO_PORTAL: "sponsored-no-portal",
86
105
  };
87
106
  /**
88
107
  * WHICH precondition the entitlement wall refused on (backend#305, spec 170).
@@ -93,6 +93,12 @@ export interface I_AgencyMember {
93
93
  /** Null while an e-mail invitation is outstanding — nobody has accepted yet. */
94
94
  userId?: string;
95
95
  invitedEmail: string;
96
+ /**
97
+ * The account's own e-mail once the invitation is accepted (and always for
98
+ * the owner, who applied rather than being invited). The Team panel names a
99
+ * person by this, never by the organization's contact address (T063).
100
+ */
101
+ accountEmail?: string;
96
102
  role: AgencyMemberRole;
97
103
  invitedAt: string;
98
104
  /** "Who is in" is exactly `acceptedAt != null`. */
@@ -152,7 +158,27 @@ export interface I_CreatorAccessGrant {
152
158
  };
153
159
  /** Which platforms the grant reaches right now — derived at read time, never stored. */
154
160
  platformsReached: PublishPlatform[];
161
+ /**
162
+ * The agency's OPEN second ask on an active grant, for scopes the creator
163
+ * did not grant (FR-008). Kept apart from `requestedScopes`, so the consent
164
+ * screen names exactly this ask and nothing refused earlier (review H4).
165
+ * Absent when there is no open ask.
166
+ */
167
+ reaskedScopes?: I_AgencyGrantScope[];
168
+ /** When that open ask expires unanswered. */
169
+ reaskExpiresAt?: string;
155
170
  }
171
+ /**
172
+ * Outcomes of discovery and requests that are not refusals of access, sent as
173
+ * codes so the screen composes the sentence in the reader's language (FR-019,
174
+ * review M4). A 429 carries `{ code, limit, windowMinutes }`.
175
+ */
176
+ export declare const AGENCY_REQUEST_CODES: {
177
+ readonly RATE_LIMITED: "agency-rate-limited";
178
+ readonly REQUEST_EXPIRED: "agency-request-expired";
179
+ readonly REQUEST_ANSWERED: "agency-request-answered";
180
+ };
181
+ export type AgencyRequestCode = (typeof AGENCY_REQUEST_CODES)[keyof typeof AGENCY_REQUEST_CODES];
156
182
  /**
157
183
  * Roster row states, DECLARED IN PRECEDENCE ORDER (data-model §Derived).
158
184
  *
@@ -160,7 +186,7 @@ export interface I_CreatorAccessGrant {
160
186
  * first. `revoked` is deliberately absent — a revoked creator leaves the roster
161
187
  * entirely for the settings history (FR-011).
162
188
  */
163
- export declare const AGENCY_ROSTER_ROW_STATES: readonly ["requested", "lapsed", "disconnected", "stale", "active"];
189
+ export declare const AGENCY_ROSTER_ROW_STATES: readonly ["requested", "lapsed", "disconnected", "syncing", "stale", "active"];
164
190
  export type AgencyRosterRowState = (typeof AGENCY_ROSTER_ROW_STATES)[number];
165
191
  /**
166
192
  * What "needs attention" means — ONE home for the KPI tile, the roster segment
@@ -171,6 +197,32 @@ export type AgencyRosterRowState = (typeof AGENCY_ROSTER_ROW_STATES)[number];
171
197
  */
172
198
  export declare const AGENCY_ROSTER_NEEDS_ATTENTION_STATES: readonly ["lapsed", "disconnected", "stale"];
173
199
  export type AgencyRosterNeedsAttentionState = (typeof AGENCY_ROSTER_NEEDS_ATTENTION_STATES)[number];
200
+ /**
201
+ * The rows that carry numbers — ONE home for "which creators' numbers may the
202
+ * agency see on the roster" (decided 2026-09-24, review M1/M2).
203
+ *
204
+ * Only a row whose card OPENS carries `metrics` and counts in the followers,
205
+ * followers-delta and comments totals: `active`, and `stale` (the card opens;
206
+ * the numbers are as old as `lastSyncAt` says). A `lapsed` creator's reads are
207
+ * refused on the card, so their numbers must not reach the agency through the
208
+ * roster instead; a `disconnected` creator has nothing live to count; a
209
+ * `requested` creator has shared nothing yet.
210
+ */
211
+ export declare const AGENCY_ROSTER_METRICS_STATES: readonly ["syncing", "stale", "active"];
212
+ export type AgencyRosterMetricsState = (typeof AGENCY_ROSTER_METRICS_STATES)[number];
213
+ /**
214
+ * One creator's numbers on the roster (FR-011a: sortable metric columns), read
215
+ * from the daily-synced tables, never a vendor call. `null` is a REASON, never
216
+ * a zero: the creator did not grant that scope, or nothing has been synced yet.
217
+ */
218
+ export interface I_AgencyRosterRowMetrics {
219
+ /** Latest followers over the creator's CONNECTED platforms; null without `analytics` or before the first sync. */
220
+ followers: number | null;
221
+ /** Against the snapshot seven days earlier, per connected platform; null without `analytics` or without a week-old point. */
222
+ followersDelta7d: number | null;
223
+ /** The current Monday-to-Sunday week, organization timezone; null without `comments`. */
224
+ commentsThisWeek: number | null;
225
+ }
174
226
  export interface I_AgencyRosterRow {
175
227
  grantId: string;
176
228
  creator: {
@@ -180,22 +232,72 @@ export interface I_AgencyRosterRow {
180
232
  platforms: PublishPlatform[];
181
233
  state: AgencyRosterRowState;
182
234
  grantedScopes: I_AgencyGrantScope[];
235
+ /**
236
+ * The newest successful daily sync. Absent on a `requested` row (nothing is
237
+ * shared yet, so there is nothing to have synced for the agency) and on a
238
+ * creator never synced. `platforms` stays on a requested row: it is what the
239
+ * consent screen names, a fact about the account rather than shared data.
240
+ */
183
241
  lastSyncAt?: string;
242
+ /**
243
+ * Present ONLY on rows in AGENCY_ROSTER_METRICS_STATES (`active`, `stale`):
244
+ * the rows whose card opens. Absent on `lapsed`, `disconnected` and
245
+ * `requested` rows — no numbers, not null numbers.
246
+ */
247
+ metrics?: I_AgencyRosterRowMetrics;
248
+ /**
249
+ * The agency's own last "Ask again" (FR-008), while its one-per-TTL clock
250
+ * runs (audit F19): when it was sent and when another is possible. It says
251
+ * nothing about the ANSWER, because the clock runs whatever the creator did.
252
+ * The roster uses it to say "asked on …, again after …" instead of offering
253
+ * an ask that would not be sent. Absent once the clock has run out.
254
+ */
255
+ reaskedAt?: string;
256
+ reaskAvailableAt?: string;
257
+ }
258
+ /**
259
+ * The platforms a creator has connected, as the agency side sees them
260
+ * (discovery, the grant's "reaches"). `GET users/me/connected-platforms`,
261
+ * answered to every creator, subscribed or not.
262
+ */
263
+ export interface I_ConnectedPlatforms {
264
+ platforms: PublishPlatform[];
184
265
  }
185
266
  export interface I_AgencyRoster {
186
267
  totals: {
187
268
  creators: number;
188
269
  active: number;
189
- /** Summed only over creators holding `analytics`; disconnected platforms excluded. */
190
- followers: number;
191
- followersDelta7d: number;
192
- /** Calendar week, Mon-Sun, organization timezone, creators holding `comments`. */
193
- commentsThisWeek: number;
270
+ /**
271
+ * Summed only over rows in AGENCY_ROSTER_METRICS_STATES whose creator holds
272
+ * `analytics`; disconnected platforms excluded. `null` when no counted
273
+ * creator has a synced number yet, never a zero standing in (T067).
274
+ */
275
+ followers: number | null;
276
+ /**
277
+ * Same rows as `followers`, against the snapshot seven days earlier. `null`
278
+ * when no counted creator has a week-old point — unknown, never a zero.
279
+ */
280
+ followersDelta7d: number | null;
281
+ /**
282
+ * Calendar week, Mon-Sun, organization timezone, over rows in
283
+ * AGENCY_ROSTER_METRICS_STATES whose creator holds `comments`. Like
284
+ * `followers`, `null` when no counted creator has a synced number yet:
285
+ * unknown, never a zero (SC-008, T067).
286
+ */
287
+ commentsThisWeek: number | null;
194
288
  /** Count of rows in AGENCY_ROSTER_NEEDS_ATTENTION_STATES. */
195
289
  needsAttention: number;
196
290
  };
197
291
  items: I_AgencyRosterRow[];
198
292
  }
293
+ /**
294
+ * A coverage that ENDED recently, so the creator meeting the paywall again is
295
+ * told why (FR-014: "an honest message, never a silent loss of access").
296
+ */
297
+ export interface I_SponsorshipEnded {
298
+ organizationName: string;
299
+ endedAt: string;
300
+ }
199
301
  /** What a sponsored creator sees on their OWN subscription screen (FR-014). */
200
302
  export interface I_SponsoredBy {
201
303
  organizationName: string;
@@ -203,6 +305,106 @@ export interface I_SponsoredBy {
203
305
  /** Present once the sponsorship is ending: the date their plan runs to. */
204
306
  until?: string;
205
307
  }
308
+ /** The card on file, as Stripe describes it. Never more than these four facts. */
309
+ export interface I_AgencyPaymentMethod {
310
+ brand: string;
311
+ last4: string;
312
+ expMonth: number;
313
+ expYear: number;
314
+ }
315
+ /**
316
+ * One invoice. `amount` is Stripe's minor unit verbatim; the display edge
317
+ * formats it once, like every other price in the app.
318
+ */
319
+ export interface I_AgencyInvoice {
320
+ id: string;
321
+ number: string | null;
322
+ date: string;
323
+ amount: number;
324
+ currency: string;
325
+ status: string;
326
+ pdfUrl?: string;
327
+ hostedUrl?: string;
328
+ }
329
+ /**
330
+ * One creator on an ACTIVE grant, with whether the agency covers their plan
331
+ * (FR-013b). `tier: null` is "not covered". `until` is set while a coverage is
332
+ * ending: it runs to the close of the period already paid for.
333
+ */
334
+ export interface I_AgencyCoveredCreator {
335
+ grantId: string;
336
+ creator: {
337
+ id: string;
338
+ handle: string;
339
+ };
340
+ tier: SponsoredTier | null;
341
+ since?: string;
342
+ until?: string;
343
+ }
344
+ /**
345
+ * An ended relationship, for the past-creators history (FR-013b). `creator.id`
346
+ * is null once the creator's account was purged; the handle is the snapshot
347
+ * frozen at grant time.
348
+ */
349
+ export interface I_AgencyPastCreator {
350
+ grantId: string;
351
+ creator: {
352
+ id: string | null;
353
+ handle: string;
354
+ displayName?: string;
355
+ };
356
+ endedAt: string;
357
+ endedBy: CreatorAccessGrantRevokedBy;
358
+ coveredUntil?: string;
359
+ }
360
+ /**
361
+ * Everything the owner's billing section and panel show (FR-013, FR-013b).
362
+ *
363
+ * `seats` is the ONE home of the billed quantity: the count of active grants.
364
+ * Covering a creator never changes it (clarify 2026-09-17), so nothing here
365
+ * derives a price from `covered`.
366
+ *
367
+ * `price` is null when the seat product is not configured in this deployment
368
+ * (T031, the money gate); the screen says so rather than inventing a number.
369
+ * `nextInvoice` is Stripe's own preview, so a coupon is already applied to it.
370
+ */
371
+ export interface I_AgencyBilling {
372
+ subscriptionStatus: AgencySubscriptionStatus;
373
+ /** Creators with an active grant. */
374
+ seats: number;
375
+ /**
376
+ * What Stripe bills: `max(1, seats)`. An agency with no creators yet still
377
+ * pays for one seat, and the screen must say the number Stripe charges.
378
+ */
379
+ billedSeats: number;
380
+ price: {
381
+ amount: number;
382
+ currency: string;
383
+ interval: string;
384
+ } | null;
385
+ currentPeriodEnd?: string;
386
+ cancelAtPeriodEnd?: boolean;
387
+ nextInvoice?: {
388
+ amount: number;
389
+ currency: string;
390
+ date: string;
391
+ };
392
+ discount?: {
393
+ name?: string;
394
+ percentOff?: number;
395
+ amountOff?: number;
396
+ };
397
+ paymentMethod?: I_AgencyPaymentMethod;
398
+ billingDetails?: {
399
+ name?: string;
400
+ email?: string;
401
+ country?: string;
402
+ taxId?: string;
403
+ };
404
+ invoices: I_AgencyInvoice[];
405
+ covered: I_AgencyCoveredCreator[];
406
+ pastCreators: I_AgencyPastCreator[];
407
+ }
206
408
  /**
207
409
  * Every DTO below carries class-validator decorators and a constructor that
208
410
  * survives being called with nothing.
@@ -264,10 +466,50 @@ export declare class AgencyDiscoverQueryDto {
264
466
  constructor(data?: Partial<AgencyDiscoverQueryDto>);
265
467
  }
266
468
  export declare class AgencyAccessRequestDto {
469
+ /**
470
+ * A user id: a UUID or a Firebase uid (letters, digits, hyphens). It was
471
+ * `@IsUUID()` until US5 was built, which refused most real creators — their
472
+ * ids are Firebase uids.
473
+ */
267
474
  userId: string;
268
475
  scopes: I_AgencyGrantScope[];
269
476
  constructor(data?: Partial<AgencyAccessRequestDto>);
270
477
  }
478
+ /** `PATCH /users/me/agency-discoverable` — "Let agencies find me" (FR-015). */
479
+ export declare class UpdateAgencyDiscoverableDto {
480
+ discoverable: boolean;
481
+ /**
482
+ * NO default: a required boolean that defaults to false lets an empty body
483
+ * through validation and reach the database as `undefined` (review M7).
484
+ */
485
+ constructor(data?: Partial<UpdateAgencyDiscoverableDto>);
486
+ }
487
+ /**
488
+ * One discover hit: the public handle and the platforms, NEVER a metric and
489
+ * never a grant flag, so the payload cannot tell a caller whether it already
490
+ * holds the creator (FR-015). `userId` is what the request is sent for.
491
+ */
492
+ export interface I_AgencyDiscoverHit {
493
+ userId: string;
494
+ handle: string;
495
+ platforms: PublishPlatform[];
496
+ /**
497
+ * The platform whose username matched. Absent on an e-mail match. Two hits
498
+ * with the same handle are two different people (@mark on YouTube, another
499
+ * @mark on Instagram), and this is what tells them apart (T068).
500
+ */
501
+ matchedPlatform?: PublishPlatform;
502
+ }
503
+ /**
504
+ * `POST /agency/roster/discover` — the SAME shape and status for every case:
505
+ * discoverable exact matches, a non-discoverable account, a partial match and
506
+ * no account at all (SC-009). A miss is `{ results: [] }`, never a 404. An
507
+ * exact handle held by several findable creators returns each of them, one
508
+ * row per person (Maciej, 2026-09-26, T068); an e-mail matches one person.
509
+ */
510
+ export interface I_AgencyDiscoverResult {
511
+ results: I_AgencyDiscoverHit[];
512
+ }
271
513
  export declare class AdminSetOrganizationStatusDto {
272
514
  status: Extract<AgencyOrganizationStatus, 'approved' | 'rejected'>;
273
515
  /** Required on reject — a rejected applicant is shown this (FR-002). The
@@ -9,7 +9,7 @@ var __metadata = (this && this.__metadata) || function (k, v) {
9
9
  if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
10
10
  };
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
- exports.AdminSetOrganizationStatusDto = exports.AgencyAccessRequestDto = exports.AgencyDiscoverQueryDto = exports.SponsorCreatorDto = exports.UpdateGrantScopesDto = exports.AcceptGrantDto = exports.AcceptAgencyMemberInviteDto = exports.RedeemAgencyInviteDto = exports.InviteAgencyMemberDto = exports.CreateAgencyOrganizationDto = exports.AGENCY_ROSTER_NEEDS_ATTENTION_STATES = exports.AGENCY_ROSTER_ROW_STATES = exports.CREATOR_ACCESS_GRANT_STATES = exports.AGENCY_ORGANIZATION_STATUSES = exports.isAgencyGrantScope = exports.AGENCY_GRANT_SCOPES = exports.AGENCY_ROUTE_PATHS = void 0;
12
+ exports.AdminSetOrganizationStatusDto = exports.UpdateAgencyDiscoverableDto = exports.AgencyAccessRequestDto = exports.AgencyDiscoverQueryDto = exports.SponsorCreatorDto = exports.UpdateGrantScopesDto = exports.AcceptGrantDto = exports.AcceptAgencyMemberInviteDto = exports.RedeemAgencyInviteDto = exports.InviteAgencyMemberDto = exports.CreateAgencyOrganizationDto = exports.AGENCY_ROSTER_METRICS_STATES = exports.AGENCY_ROSTER_NEEDS_ATTENTION_STATES = exports.AGENCY_ROSTER_ROW_STATES = exports.AGENCY_REQUEST_CODES = exports.CREATOR_ACCESS_GRANT_STATES = exports.AGENCY_ORGANIZATION_STATUSES = exports.isAgencyGrantScope = exports.AGENCY_GRANT_SCOPES = exports.AGENCY_ROUTE_PATHS = void 0;
13
13
  const class_validator_1 = require("class-validator");
14
14
  // ── Cross-repo route constants ───────────────────────────────────────────────
15
15
  /**
@@ -89,6 +89,16 @@ exports.CREATOR_ACCESS_GRANT_STATES = [
89
89
  'expired',
90
90
  'revoked',
91
91
  ];
92
+ /**
93
+ * Outcomes of discovery and requests that are not refusals of access, sent as
94
+ * codes so the screen composes the sentence in the reader's language (FR-019,
95
+ * review M4). A 429 carries `{ code, limit, windowMinutes }`.
96
+ */
97
+ exports.AGENCY_REQUEST_CODES = {
98
+ RATE_LIMITED: 'agency-rate-limited',
99
+ REQUEST_EXPIRED: 'agency-request-expired',
100
+ REQUEST_ANSWERED: 'agency-request-answered',
101
+ };
92
102
  // ── Roster ───────────────────────────────────────────────────────────────────
93
103
  /**
94
104
  * Roster row states, DECLARED IN PRECEDENCE ORDER (data-model §Derived).
@@ -104,6 +114,13 @@ exports.AGENCY_ROSTER_ROW_STATES = [
104
114
  'lapsed',
105
115
  /** the creator has no live platform connection */
106
116
  'disconnected',
117
+ /**
118
+ * shared within the last 24 hours and not synced yet: the first daily sync
119
+ * has not run. Not stale, and not something the agency can act on (T067,
120
+ * the 2026-09-26 UI run: a creator who agreed a minute ago read as
121
+ * "needs attention").
122
+ */
123
+ 'syncing',
107
124
  /** no successful daily sync in 24 hours (FR-011, Jan 2026-09-21) */
108
125
  'stale',
109
126
  'active',
@@ -120,6 +137,22 @@ exports.AGENCY_ROSTER_NEEDS_ATTENTION_STATES = [
120
137
  'disconnected',
121
138
  'stale',
122
139
  ];
140
+ /**
141
+ * The rows that carry numbers — ONE home for "which creators' numbers may the
142
+ * agency see on the roster" (decided 2026-09-24, review M1/M2).
143
+ *
144
+ * Only a row whose card OPENS carries `metrics` and counts in the followers,
145
+ * followers-delta and comments totals: `active`, and `stale` (the card opens;
146
+ * the numbers are as old as `lastSyncAt` says). A `lapsed` creator's reads are
147
+ * refused on the card, so their numbers must not reach the agency through the
148
+ * roster instead; a `disconnected` creator has nothing live to count; a
149
+ * `requested` creator has shared nothing yet.
150
+ */
151
+ exports.AGENCY_ROSTER_METRICS_STATES = [
152
+ 'syncing',
153
+ 'stale',
154
+ 'active',
155
+ ];
123
156
  // ── DTOs ─────────────────────────────────────────────────────────────────────
124
157
  /**
125
158
  * Every DTO below carries class-validator decorators and a constructor that
@@ -272,7 +305,8 @@ class AgencyAccessRequestDto {
272
305
  }
273
306
  exports.AgencyAccessRequestDto = AgencyAccessRequestDto;
274
307
  __decorate([
275
- (0, class_validator_1.IsUUID)(),
308
+ (0, class_validator_1.IsString)(),
309
+ (0, class_validator_1.Matches)(/^[A-Za-z0-9-]{1,128}$/),
276
310
  __metadata("design:type", String)
277
311
  ], AgencyAccessRequestDto.prototype, "userId", void 0);
278
312
  __decorate([
@@ -281,6 +315,22 @@ __decorate([
281
315
  (0, class_validator_1.IsIn)(exports.AGENCY_GRANT_SCOPES, { each: true }),
282
316
  __metadata("design:type", Array)
283
317
  ], AgencyAccessRequestDto.prototype, "scopes", void 0);
318
+ /** `PATCH /users/me/agency-discoverable` — "Let agencies find me" (FR-015). */
319
+ class UpdateAgencyDiscoverableDto {
320
+ /**
321
+ * NO default: a required boolean that defaults to false lets an empty body
322
+ * through validation and reach the database as `undefined` (review M7).
323
+ */
324
+ constructor(data) {
325
+ if (data?.discoverable !== undefined)
326
+ this.discoverable = data.discoverable;
327
+ }
328
+ }
329
+ exports.UpdateAgencyDiscoverableDto = UpdateAgencyDiscoverableDto;
330
+ __decorate([
331
+ (0, class_validator_1.IsBoolean)(),
332
+ __metadata("design:type", Boolean)
333
+ ], UpdateAgencyDiscoverableDto.prototype, "discoverable", void 0);
284
334
  class AdminSetOrganizationStatusDto {
285
335
  constructor(data) {
286
336
  this.status = data?.status ?? 'approved';
@@ -4,13 +4,14 @@
4
4
  * Covers subscription tier state, Stripe checkout flows,
5
5
  * and the customer portal redirect.
6
6
  */
7
+ import type { I_SponsoredBy, I_SponsorshipEnded } from '../agency';
7
8
  export type SchedulingSubscriptionTier = 'free' | 'starter' | 'pro';
8
9
  export type SchedulingSubscriptionStatus = 'active' | 'canceled' | 'expired';
9
10
  /**
10
11
  * Constructor input. Takes `maxAccounts`, or the deprecated `maxPlatforms` so
11
12
  * code written against 1.40.0 keeps compiling; either way both fields are set.
12
13
  */
13
- export type SchedulingSubscriptionInfoInput = Pick<SchedulingSubscriptionInfoDTO, 'tier' | 'status' | 'expiresAt'> & ({
14
+ export type SchedulingSubscriptionInfoInput = Pick<SchedulingSubscriptionInfoDTO, 'tier' | 'status' | 'expiresAt'> & Partial<Pick<SchedulingSubscriptionInfoDTO, 'sponsoredBy' | 'sponsorshipEnded'>> & ({
14
15
  maxAccounts: number;
15
16
  maxPlatforms?: number;
16
17
  } | {
@@ -32,6 +33,14 @@ export declare class SchedulingSubscriptionInfoDTO {
32
33
  * Read `maxAccounts`. Always equal to it; removed in a later release.
33
34
  */
34
35
  maxPlatforms: number;
36
+ /**
37
+ * Present only while an agency covers this creator's plan (spec 175 FR-014).
38
+ * `until` appears once the sponsorship is ending: the date the plan runs to.
39
+ * Absent — never null — for everyone else, so "not sponsored" has one shape.
40
+ */
41
+ sponsoredBy?: I_SponsoredBy;
42
+ /** Present only when a coverage ended recently and nothing replaced it. Absent otherwise. */
43
+ sponsorshipEnded?: I_SponsorshipEnded;
35
44
  constructor(data: SchedulingSubscriptionInfoInput);
36
45
  }
37
46
  /**
@@ -19,6 +19,10 @@ class SchedulingSubscriptionInfoDTO {
19
19
  this.expiresAt = data.expiresAt;
20
20
  this.maxAccounts = maxAccounts;
21
21
  this.maxPlatforms = maxAccounts;
22
+ if (data.sponsoredBy)
23
+ this.sponsoredBy = data.sponsoredBy;
24
+ if (data.sponsorshipEnded)
25
+ this.sponsorshipEnded = data.sponsorshipEnded;
22
26
  }
23
27
  }
24
28
  exports.SchedulingSubscriptionInfoDTO = SchedulingSubscriptionInfoDTO;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ad2app-lib",
3
- "version": "1.44.1",
3
+ "version": "1.47.0",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "commonjs",
@@ -51,7 +51,7 @@
51
51
  "prepare": "npm run build"
52
52
  },
53
53
  "keywords": [],
54
- "author": "Maciej G\u00f3rski@ad2.app",
54
+ "author": "Maciej Górski@ad2.app",
55
55
  "license": "ISC",
56
56
  "description": "Package to share types and utils across the ad2app projects",
57
57
  "dependencies": {
@@ -82,6 +82,25 @@ export const ACCESS_DENIAL_CODES = {
82
82
  * per person, FR-004). Names the case and NOTHING else about that account.
83
83
  */
84
84
  AGENCY_ALREADY_IN_ORGANIZATION: "agency-already-in-organization",
85
+ /**
86
+ * The owner invited an address that is ALREADY on this organization's team,
87
+ * themselves included (T063). A different case from the one above: the owner
88
+ * is told this person is already in, not that they belong elsewhere.
89
+ */
90
+ AGENCY_ALREADY_MEMBER: "agency-already-member",
91
+ /**
92
+ * The owner tried to cover a creator who pays for their own plan. Covering
93
+ * is for a creator without a paid plan (US4 scenario 3); covering a payer
94
+ * would leave them paying for what the agency also pays for (T066).
95
+ */
96
+ AGENCY_CREATOR_PAYS_OWN_PLAN: "agency-creator-pays-own-plan",
97
+ /**
98
+ * A creator whose plan an agency covers asked for the billing portal, and
99
+ * has no Stripe customer of their own to open it for (FR-014). The body
100
+ * names the covering agency; the client hides the portal button instead of
101
+ * showing a Stripe error.
102
+ */
103
+ SPONSORED_NO_PORTAL: "sponsored-no-portal",
85
104
  } as const;
86
105
 
87
106
  export type AccessDenialCode =
@@ -87,7 +87,7 @@ test('every AGENCY code is namespaced, so a new domain cannot shadow an old wall
87
87
  key.startsWith('AGENCY_'),
88
88
  );
89
89
 
90
- assert.equal(agencyCodes.length, 12);
90
+ assert.equal(agencyCodes.length, 14);
91
91
  for (const [, value] of agencyCodes) {
92
92
  assert.match(value, /^agency-/, `un-namespaced agency code: ${value}`);
93
93
  }
@@ -0,0 +1,83 @@
1
+ import { strict as assert } from 'node:assert';
2
+ import { test } from 'node:test';
3
+
4
+ import { ACCESS_DENIAL_CODES } from './I_AccessDenial';
5
+ import type { I_AgencyBilling, I_SponsoredBy } from './agency';
6
+ import { SchedulingSubscriptionInfoDTO } from './scheduling/I_SchedulingProUpgrade';
7
+
8
+ /**
9
+ * Spec 175 US4 (T022/T024). The billing contract between backend and frontend:
10
+ * the settings screen, the creator's own subscription screen, and the one new
11
+ * refusal a sponsored creator can meet.
12
+ */
13
+
14
+ test('a sponsored creator without a Stripe customer is refused the portal with its own code', () => {
15
+ assert.equal(ACCESS_DENIAL_CODES.SPONSORED_NO_PORTAL, 'sponsored-no-portal');
16
+ });
17
+
18
+ test('the subscription DTO carries sponsoredBy when the agency covers the plan', () => {
19
+ const sponsoredBy: I_SponsoredBy = { organizationName: 'Northlight', tier: 'pro', until: '2026-10-04T00:00:00.000Z' };
20
+ const dto = new SchedulingSubscriptionInfoDTO({
21
+ tier: 'pro',
22
+ status: 'active',
23
+ expiresAt: null,
24
+ maxAccounts: 10,
25
+ sponsoredBy,
26
+ });
27
+ assert.deepEqual(dto.sponsoredBy, sponsoredBy);
28
+ });
29
+
30
+ test('the subscription DTO leaves sponsoredBy ABSENT for everyone else, never null', () => {
31
+ const dto = new SchedulingSubscriptionInfoDTO({ tier: 'free', status: 'expired', expiresAt: null, maxAccounts: 0 });
32
+ assert.equal('sponsoredBy' in dto, false);
33
+ });
34
+
35
+ test('the billing overview names seats, price, invoices, who is covered and who left', () => {
36
+ const billing: I_AgencyBilling = {
37
+ subscriptionStatus: 'active',
38
+ seats: 3,
39
+ billedSeats: 3,
40
+ price: { amount: 2500, currency: 'USD', interval: 'month' },
41
+ currentPeriodEnd: '2026-10-04T00:00:00.000Z',
42
+ cancelAtPeriodEnd: false,
43
+ nextInvoice: { amount: 7500, currency: 'USD', date: '2026-10-04T00:00:00.000Z' },
44
+ discount: { name: 'First agency', percentOff: 100 },
45
+ paymentMethod: { brand: 'visa', last4: '4242', expMonth: 9, expYear: 2029 },
46
+ billingDetails: { name: 'Northlight Talent', email: 'kontakt@northlight.pl', country: 'PL' },
47
+ invoices: [
48
+ { id: 'in_1', number: 'AD2-0001', date: '2026-09-04T00:00:00.000Z', amount: 7500, currency: 'USD', status: 'paid' },
49
+ ],
50
+ covered: [
51
+ { grantId: 'g1', creator: { id: 'u1', handle: 'marta.moves' }, tier: 'pro', since: '2026-09-10T00:00:00.000Z' },
52
+ { grantId: 'g2', creator: { id: 'u2', handle: 'kubaplays' }, tier: null },
53
+ ],
54
+ pastCreators: [
55
+ {
56
+ grantId: 'g3',
57
+ creator: { id: null, handle: 'bartek.runs' },
58
+ endedAt: '2026-09-20T00:00:00.000Z',
59
+ endedBy: 'creator',
60
+ coveredUntil: '2026-10-04T00:00:00.000Z',
61
+ },
62
+ ],
63
+ };
64
+ assert.equal(billing.covered.filter((row) => row.tier !== null).length, 1);
65
+ assert.equal(billing.pastCreators[0].creator.id, null);
66
+ });
67
+
68
+ test('billedSeats is what Stripe bills: never below one, while seats is the creators with an active grant (review M1)', () => {
69
+ const billing: I_AgencyBilling = {
70
+ subscriptionStatus: 'none', seats: 0, billedSeats: 1, price: null, invoices: [], covered: [], pastCreators: [],
71
+ };
72
+ assert.equal(billing.billedSeats, 1);
73
+ });
74
+
75
+ test('the subscription DTO carries a RECENTLY ENDED coverage so the creator is told why the paywall is back (review M4, FR-014)', () => {
76
+ const dto = new SchedulingSubscriptionInfoDTO({
77
+ tier: 'free', status: 'expired', expiresAt: null, maxAccounts: 0,
78
+ sponsorshipEnded: { organizationName: 'Northlight', endedAt: '2026-10-04T00:00:00.000Z' },
79
+ });
80
+ assert.deepEqual(dto.sponsorshipEnded, { organizationName: 'Northlight', endedAt: '2026-10-04T00:00:00.000Z' });
81
+ const plain = new SchedulingSubscriptionInfoDTO({ tier: 'free', status: 'expired', expiresAt: null, maxAccounts: 0 });
82
+ assert.equal('sponsorshipEnded' in plain, false);
83
+ });
@@ -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
+ });
@@ -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
  /**
@@ -145,6 +147,12 @@ export interface I_AgencyMember {
145
147
  /** Null while an e-mail invitation is outstanding — nobody has accepted yet. */
146
148
  userId?: string;
147
149
  invitedEmail: string;
150
+ /**
151
+ * The account's own e-mail once the invitation is accepted (and always for
152
+ * the owner, who applied rather than being invited). The Team panel names a
153
+ * person by this, never by the organization's contact address (T063).
154
+ */
155
+ accountEmail?: string;
148
156
  role: AgencyMemberRole;
149
157
  invitedAt: string;
150
158
  /** "Who is in" is exactly `acceptedAt != null`. */
@@ -216,8 +224,30 @@ export interface I_CreatorAccessGrant {
216
224
  sponsorship?: { tier: SponsoredTier; since: string; until?: string };
217
225
  /** Which platforms the grant reaches right now — derived at read time, never stored. */
218
226
  platformsReached: PublishPlatform[];
227
+ /**
228
+ * The agency's OPEN second ask on an active grant, for scopes the creator
229
+ * did not grant (FR-008). Kept apart from `requestedScopes`, so the consent
230
+ * screen names exactly this ask and nothing refused earlier (review H4).
231
+ * Absent when there is no open ask.
232
+ */
233
+ reaskedScopes?: I_AgencyGrantScope[];
234
+ /** When that open ask expires unanswered. */
235
+ reaskExpiresAt?: string;
219
236
  }
220
237
 
238
+ /**
239
+ * Outcomes of discovery and requests that are not refusals of access, sent as
240
+ * codes so the screen composes the sentence in the reader's language (FR-019,
241
+ * review M4). A 429 carries `{ code, limit, windowMinutes }`.
242
+ */
243
+ export const AGENCY_REQUEST_CODES = {
244
+ RATE_LIMITED: 'agency-rate-limited',
245
+ REQUEST_EXPIRED: 'agency-request-expired',
246
+ REQUEST_ANSWERED: 'agency-request-answered',
247
+ } as const;
248
+
249
+ export type AgencyRequestCode = (typeof AGENCY_REQUEST_CODES)[keyof typeof AGENCY_REQUEST_CODES];
250
+
221
251
  // ── Roster ───────────────────────────────────────────────────────────────────
222
252
 
223
253
  /**
@@ -234,6 +264,13 @@ export const AGENCY_ROSTER_ROW_STATES = [
234
264
  'lapsed',
235
265
  /** the creator has no live platform connection */
236
266
  'disconnected',
267
+ /**
268
+ * shared within the last 24 hours and not synced yet: the first daily sync
269
+ * has not run. Not stale, and not something the agency can act on (T067,
270
+ * the 2026-09-26 UI run: a creator who agreed a minute ago read as
271
+ * "needs attention").
272
+ */
273
+ 'syncing',
237
274
  /** no successful daily sync in 24 hours (FR-011, Jan 2026-09-21) */
238
275
  'stale',
239
276
  'active',
@@ -257,30 +294,115 @@ export const AGENCY_ROSTER_NEEDS_ATTENTION_STATES = [
257
294
  export type AgencyRosterNeedsAttentionState =
258
295
  (typeof AGENCY_ROSTER_NEEDS_ATTENTION_STATES)[number];
259
296
 
297
+ /**
298
+ * The rows that carry numbers — ONE home for "which creators' numbers may the
299
+ * agency see on the roster" (decided 2026-09-24, review M1/M2).
300
+ *
301
+ * Only a row whose card OPENS carries `metrics` and counts in the followers,
302
+ * followers-delta and comments totals: `active`, and `stale` (the card opens;
303
+ * the numbers are as old as `lastSyncAt` says). A `lapsed` creator's reads are
304
+ * refused on the card, so their numbers must not reach the agency through the
305
+ * roster instead; a `disconnected` creator has nothing live to count; a
306
+ * `requested` creator has shared nothing yet.
307
+ */
308
+ export const AGENCY_ROSTER_METRICS_STATES = [
309
+ 'syncing',
310
+ 'stale',
311
+ 'active',
312
+ ] as const satisfies readonly AgencyRosterRowState[];
313
+
314
+ export type AgencyRosterMetricsState = (typeof AGENCY_ROSTER_METRICS_STATES)[number];
315
+
316
+ /**
317
+ * One creator's numbers on the roster (FR-011a: sortable metric columns), read
318
+ * from the daily-synced tables, never a vendor call. `null` is a REASON, never
319
+ * a zero: the creator did not grant that scope, or nothing has been synced yet.
320
+ */
321
+ export interface I_AgencyRosterRowMetrics {
322
+ /** Latest followers over the creator's CONNECTED platforms; null without `analytics` or before the first sync. */
323
+ followers: number | null;
324
+ /** Against the snapshot seven days earlier, per connected platform; null without `analytics` or without a week-old point. */
325
+ followersDelta7d: number | null;
326
+ /** The current Monday-to-Sunday week, organization timezone; null without `comments`. */
327
+ commentsThisWeek: number | null;
328
+ }
329
+
260
330
  export interface I_AgencyRosterRow {
261
331
  grantId: string;
262
332
  creator: { id: string; handle: string };
263
333
  platforms: PublishPlatform[];
264
334
  state: AgencyRosterRowState;
265
335
  grantedScopes: I_AgencyGrantScope[];
336
+ /**
337
+ * The newest successful daily sync. Absent on a `requested` row (nothing is
338
+ * shared yet, so there is nothing to have synced for the agency) and on a
339
+ * creator never synced. `platforms` stays on a requested row: it is what the
340
+ * consent screen names, a fact about the account rather than shared data.
341
+ */
266
342
  lastSyncAt?: string;
343
+ /**
344
+ * Present ONLY on rows in AGENCY_ROSTER_METRICS_STATES (`active`, `stale`):
345
+ * the rows whose card opens. Absent on `lapsed`, `disconnected` and
346
+ * `requested` rows — no numbers, not null numbers.
347
+ */
348
+ metrics?: I_AgencyRosterRowMetrics;
349
+ /**
350
+ * The agency's own last "Ask again" (FR-008), while its one-per-TTL clock
351
+ * runs (audit F19): when it was sent and when another is possible. It says
352
+ * nothing about the ANSWER, because the clock runs whatever the creator did.
353
+ * The roster uses it to say "asked on …, again after …" instead of offering
354
+ * an ask that would not be sent. Absent once the clock has run out.
355
+ */
356
+ reaskedAt?: string;
357
+ reaskAvailableAt?: string;
358
+ }
359
+
360
+ /**
361
+ * The platforms a creator has connected, as the agency side sees them
362
+ * (discovery, the grant's "reaches"). `GET users/me/connected-platforms`,
363
+ * answered to every creator, subscribed or not.
364
+ */
365
+ export interface I_ConnectedPlatforms {
366
+ platforms: PublishPlatform[];
267
367
  }
268
368
 
269
369
  export interface I_AgencyRoster {
270
370
  totals: {
271
371
  creators: number;
272
372
  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;
373
+ /**
374
+ * Summed only over rows in AGENCY_ROSTER_METRICS_STATES whose creator holds
375
+ * `analytics`; disconnected platforms excluded. `null` when no counted
376
+ * creator has a synced number yet, never a zero standing in (T067).
377
+ */
378
+ followers: number | null;
379
+ /**
380
+ * Same rows as `followers`, against the snapshot seven days earlier. `null`
381
+ * when no counted creator has a week-old point — unknown, never a zero.
382
+ */
383
+ followersDelta7d: number | null;
384
+ /**
385
+ * Calendar week, Mon-Sun, organization timezone, over rows in
386
+ * AGENCY_ROSTER_METRICS_STATES whose creator holds `comments`. Like
387
+ * `followers`, `null` when no counted creator has a synced number yet:
388
+ * unknown, never a zero (SC-008, T067).
389
+ */
390
+ commentsThisWeek: number | null;
278
391
  /** Count of rows in AGENCY_ROSTER_NEEDS_ATTENTION_STATES. */
279
392
  needsAttention: number;
280
393
  };
281
394
  items: I_AgencyRosterRow[];
282
395
  }
283
396
 
397
+ /**
398
+ * A coverage that ENDED recently, so the creator meeting the paywall again is
399
+ * told why (FR-014: "an honest message, never a silent loss of access").
400
+ */
401
+ export interface I_SponsorshipEnded {
402
+ organizationName: string;
403
+ endedAt: string;
404
+ }
405
+
284
406
  /** What a sponsored creator sees on their OWN subscription screen (FR-014). */
285
407
  export interface I_SponsoredBy {
286
408
  organizationName: string;
@@ -289,6 +411,89 @@ export interface I_SponsoredBy {
289
411
  until?: string;
290
412
  }
291
413
 
414
+ // ── Billing (US4) ────────────────────────────────────────────────────────────
415
+
416
+ /** The card on file, as Stripe describes it. Never more than these four facts. */
417
+ export interface I_AgencyPaymentMethod {
418
+ brand: string;
419
+ last4: string;
420
+ expMonth: number;
421
+ expYear: number;
422
+ }
423
+
424
+ /**
425
+ * One invoice. `amount` is Stripe's minor unit verbatim; the display edge
426
+ * formats it once, like every other price in the app.
427
+ */
428
+ export interface I_AgencyInvoice {
429
+ id: string;
430
+ number: string | null;
431
+ date: string;
432
+ amount: number;
433
+ currency: string;
434
+ status: string;
435
+ pdfUrl?: string;
436
+ hostedUrl?: string;
437
+ }
438
+
439
+ /**
440
+ * One creator on an ACTIVE grant, with whether the agency covers their plan
441
+ * (FR-013b). `tier: null` is "not covered". `until` is set while a coverage is
442
+ * ending: it runs to the close of the period already paid for.
443
+ */
444
+ export interface I_AgencyCoveredCreator {
445
+ grantId: string;
446
+ creator: { id: string; handle: string };
447
+ tier: SponsoredTier | null;
448
+ since?: string;
449
+ until?: string;
450
+ }
451
+
452
+ /**
453
+ * An ended relationship, for the past-creators history (FR-013b). `creator.id`
454
+ * is null once the creator's account was purged; the handle is the snapshot
455
+ * frozen at grant time.
456
+ */
457
+ export interface I_AgencyPastCreator {
458
+ grantId: string;
459
+ creator: { id: string | null; handle: string; displayName?: string };
460
+ endedAt: string;
461
+ endedBy: CreatorAccessGrantRevokedBy;
462
+ coveredUntil?: string;
463
+ }
464
+
465
+ /**
466
+ * Everything the owner's billing section and panel show (FR-013, FR-013b).
467
+ *
468
+ * `seats` is the ONE home of the billed quantity: the count of active grants.
469
+ * Covering a creator never changes it (clarify 2026-09-17), so nothing here
470
+ * derives a price from `covered`.
471
+ *
472
+ * `price` is null when the seat product is not configured in this deployment
473
+ * (T031, the money gate); the screen says so rather than inventing a number.
474
+ * `nextInvoice` is Stripe's own preview, so a coupon is already applied to it.
475
+ */
476
+ export interface I_AgencyBilling {
477
+ subscriptionStatus: AgencySubscriptionStatus;
478
+ /** Creators with an active grant. */
479
+ seats: number;
480
+ /**
481
+ * What Stripe bills: `max(1, seats)`. An agency with no creators yet still
482
+ * pays for one seat, and the screen must say the number Stripe charges.
483
+ */
484
+ billedSeats: number;
485
+ price: { amount: number; currency: string; interval: string } | null;
486
+ currentPeriodEnd?: string;
487
+ cancelAtPeriodEnd?: boolean;
488
+ nextInvoice?: { amount: number; currency: string; date: string };
489
+ discount?: { name?: string; percentOff?: number; amountOff?: number };
490
+ paymentMethod?: I_AgencyPaymentMethod;
491
+ billingDetails?: { name?: string; email?: string; country?: string; taxId?: string };
492
+ invoices: I_AgencyInvoice[];
493
+ covered: I_AgencyCoveredCreator[];
494
+ pastCreators: I_AgencyPastCreator[];
495
+ }
496
+
292
497
  // ── DTOs ─────────────────────────────────────────────────────────────────────
293
498
 
294
499
  /**
@@ -426,7 +631,13 @@ export class AgencyDiscoverQueryDto {
426
631
  }
427
632
 
428
633
  export class AgencyAccessRequestDto {
429
- @IsUUID()
634
+ /**
635
+ * A user id: a UUID or a Firebase uid (letters, digits, hyphens). It was
636
+ * `@IsUUID()` until US5 was built, which refused most real creators — their
637
+ * ids are Firebase uids.
638
+ */
639
+ @IsString()
640
+ @Matches(/^[A-Za-z0-9-]{1,128}$/)
430
641
  userId: string;
431
642
 
432
643
  @IsArray()
@@ -440,6 +651,48 @@ export class AgencyAccessRequestDto {
440
651
  }
441
652
  }
442
653
 
654
+ /** `PATCH /users/me/agency-discoverable` — "Let agencies find me" (FR-015). */
655
+ export class UpdateAgencyDiscoverableDto {
656
+ @IsBoolean()
657
+ discoverable: boolean;
658
+
659
+ /**
660
+ * NO default: a required boolean that defaults to false lets an empty body
661
+ * through validation and reach the database as `undefined` (review M7).
662
+ */
663
+ constructor(data?: Partial<UpdateAgencyDiscoverableDto>) {
664
+ if (data?.discoverable !== undefined) this.discoverable = data.discoverable;
665
+ }
666
+ }
667
+
668
+ /**
669
+ * One discover hit: the public handle and the platforms, NEVER a metric and
670
+ * never a grant flag, so the payload cannot tell a caller whether it already
671
+ * holds the creator (FR-015). `userId` is what the request is sent for.
672
+ */
673
+ export interface I_AgencyDiscoverHit {
674
+ userId: string;
675
+ handle: string;
676
+ platforms: PublishPlatform[];
677
+ /**
678
+ * The platform whose username matched. Absent on an e-mail match. Two hits
679
+ * with the same handle are two different people (@mark on YouTube, another
680
+ * @mark on Instagram), and this is what tells them apart (T068).
681
+ */
682
+ matchedPlatform?: PublishPlatform;
683
+ }
684
+
685
+ /**
686
+ * `POST /agency/roster/discover` — the SAME shape and status for every case:
687
+ * discoverable exact matches, a non-discoverable account, a partial match and
688
+ * no account at all (SC-009). A miss is `{ results: [] }`, never a 404. An
689
+ * exact handle held by several findable creators returns each of them, one
690
+ * row per person (Maciej, 2026-09-26, T068); an e-mail matches one person.
691
+ */
692
+ export interface I_AgencyDiscoverResult {
693
+ results: I_AgencyDiscoverHit[];
694
+ }
695
+
443
696
  export class AdminSetOrganizationStatusDto {
444
697
  @IsIn(['approved', 'rejected'])
445
698
  status: Extract<AgencyOrganizationStatus, 'approved' | 'rejected'>;
@@ -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