ad2app-lib 1.44.0 → 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.
@@ -1,20 +1,16 @@
1
1
  "use strict";
2
- /**
3
- * Agency domain — organizations, memberships and creator access grants (spec 175).
4
- *
5
- * The shape behind "an agency sees its creators' numbers, because the creators
6
- * said yes". Two doors, one product: a creator account is unchanged by any of
7
- * this, and gains at most one grant.
8
- *
9
- * The one idea worth carrying into every consumer: a GRANT IS READ-ONLY AND
10
- * CREATOR-OWNED. The agency asks for scopes; only a creator's own act ever
11
- * writes `grantedScopes` (FR-008, SC-002). Nothing here can express a write on
12
- * a creator's account, and that is deliberate — the absence is the feature.
13
- *
14
- * See specs/175-agency-roster/contracts/lib-types.md.
15
- */
2
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
3
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
4
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
5
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
6
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
7
+ };
8
+ var __metadata = (this && this.__metadata) || function (k, v) {
9
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
10
+ };
16
11
  Object.defineProperty(exports, "__esModule", { value: true });
17
- 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
+ const class_validator_1 = require("class-validator");
18
14
  // ── Cross-repo route constants ───────────────────────────────────────────────
19
15
  /**
20
16
  * Paths that BOTH repos have to agree on, with one home here.
@@ -93,6 +89,16 @@ exports.CREATOR_ACCESS_GRANT_STATES = [
93
89
  'expired',
94
90
  'revoked',
95
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
+ };
96
102
  // ── Roster ───────────────────────────────────────────────────────────────────
97
103
  /**
98
104
  * Roster row states, DECLARED IN PRECEDENCE ORDER (data-model §Derived).
@@ -108,6 +114,13 @@ exports.AGENCY_ROSTER_ROW_STATES = [
108
114
  'lapsed',
109
115
  /** the creator has no live platform connection */
110
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',
111
124
  /** no successful daily sync in 24 hours (FR-011, Jan 2026-09-21) */
112
125
  'stale',
113
126
  'active',
@@ -124,77 +137,213 @@ exports.AGENCY_ROSTER_NEEDS_ATTENTION_STATES = [
124
137
  'disconnected',
125
138
  'stale',
126
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
+ ];
127
156
  // ── DTOs ─────────────────────────────────────────────────────────────────────
157
+ /**
158
+ * Every DTO below carries class-validator decorators and a constructor that
159
+ * survives being called with nothing.
160
+ *
161
+ * Both are load-bearing and were missing until 2026-09-22. The backend mounts
162
+ * a global `ValidationPipe`, which validates the decorated metadata of the
163
+ * class a handler's `@Body()` is typed with — an undecorated class, or a body
164
+ * typed as a plain interface, is simply passed through unchecked. And the pipe
165
+ * instantiates the class with NO arguments, so a constructor that reads
166
+ * `data.name` would throw a TypeError on every request rather than validate
167
+ * anything. The rest of this library's DTOs take `data?: Partial<T>` for that
168
+ * reason; these now do too.
169
+ */
128
170
  class CreateAgencyOrganizationDto {
129
171
  constructor(data) {
130
- this.name = data.name;
131
- this.website = data.website;
132
- this.registryId = data.registryId;
133
- this.registryCountry = data.registryCountry;
134
- this.contactEmail = data.contactEmail;
172
+ this.name = data?.name ?? '';
173
+ this.website = data?.website;
174
+ this.registryId = data?.registryId ?? '';
175
+ this.registryCountry = data?.registryCountry ?? '';
176
+ this.contactEmail = data?.contactEmail ?? '';
135
177
  }
136
178
  }
137
179
  exports.CreateAgencyOrganizationDto = CreateAgencyOrganizationDto;
180
+ __decorate([
181
+ (0, class_validator_1.IsString)(),
182
+ (0, class_validator_1.IsNotEmpty)(),
183
+ __metadata("design:type", String)
184
+ ], CreateAgencyOrganizationDto.prototype, "name", void 0);
185
+ __decorate([
186
+ (0, class_validator_1.IsOptional)(),
187
+ (0, class_validator_1.IsUrl)({ protocols: ['http', 'https'], require_protocol: true }),
188
+ __metadata("design:type", String)
189
+ ], CreateAgencyOrganizationDto.prototype, "website", void 0);
190
+ __decorate([
191
+ (0, class_validator_1.IsString)(),
192
+ (0, class_validator_1.IsNotEmpty)(),
193
+ __metadata("design:type", String)
194
+ ], CreateAgencyOrganizationDto.prototype, "registryId", void 0);
195
+ __decorate([
196
+ (0, class_validator_1.IsString)(),
197
+ (0, class_validator_1.Length)(2, 2),
198
+ __metadata("design:type", String)
199
+ ], CreateAgencyOrganizationDto.prototype, "registryCountry", void 0);
200
+ __decorate([
201
+ (0, class_validator_1.IsEmail)(),
202
+ __metadata("design:type", String)
203
+ ], CreateAgencyOrganizationDto.prototype, "contactEmail", void 0);
138
204
  class InviteAgencyMemberDto {
139
205
  constructor(data) {
140
- this.email = data.email;
206
+ this.email = data?.email ?? '';
141
207
  }
142
208
  }
143
209
  exports.InviteAgencyMemberDto = InviteAgencyMemberDto;
210
+ __decorate([
211
+ (0, class_validator_1.IsEmail)(),
212
+ __metadata("design:type", String)
213
+ ], InviteAgencyMemberDto.prototype, "email", void 0);
144
214
  class RedeemAgencyInviteDto {
145
215
  constructor(data) {
146
- this.token = data.token;
216
+ this.token = data?.token ?? '';
147
217
  }
148
218
  }
149
219
  exports.RedeemAgencyInviteDto = RedeemAgencyInviteDto;
220
+ __decorate([
221
+ (0, class_validator_1.IsString)(),
222
+ (0, class_validator_1.IsNotEmpty)(),
223
+ __metadata("design:type", String)
224
+ ], RedeemAgencyInviteDto.prototype, "token", void 0);
150
225
  class AcceptAgencyMemberInviteDto {
151
226
  constructor(data) {
152
- this.token = data.token;
227
+ this.token = data?.token ?? '';
153
228
  }
154
229
  }
155
230
  exports.AcceptAgencyMemberInviteDto = AcceptAgencyMemberInviteDto;
231
+ __decorate([
232
+ (0, class_validator_1.IsString)(),
233
+ (0, class_validator_1.IsNotEmpty)(),
234
+ __metadata("design:type", String)
235
+ ], AcceptAgencyMemberInviteDto.prototype, "token", void 0);
156
236
  /**
157
237
  * Accepting consent. Either arm identifies the grant being answered; `scopes`
158
238
  * is the creator's answer and defaults to the requested set when absent.
159
239
  */
160
240
  class AcceptGrantDto {
161
241
  constructor(data) {
162
- this.token = data.token;
163
- this.grantId = data.grantId;
164
- this.scopes = data.scopes;
242
+ this.token = data?.token;
243
+ this.grantId = data?.grantId;
244
+ this.scopes = data?.scopes;
165
245
  }
166
246
  }
167
247
  exports.AcceptGrantDto = AcceptGrantDto;
248
+ __decorate([
249
+ (0, class_validator_1.IsOptional)(),
250
+ (0, class_validator_1.IsString)(),
251
+ (0, class_validator_1.IsNotEmpty)(),
252
+ __metadata("design:type", String)
253
+ ], AcceptGrantDto.prototype, "token", void 0);
254
+ __decorate([
255
+ (0, class_validator_1.IsOptional)(),
256
+ (0, class_validator_1.IsUUID)(),
257
+ __metadata("design:type", String)
258
+ ], AcceptGrantDto.prototype, "grantId", void 0);
259
+ __decorate([
260
+ (0, class_validator_1.IsOptional)(),
261
+ (0, class_validator_1.IsArray)(),
262
+ (0, class_validator_1.ArrayNotEmpty)(),
263
+ (0, class_validator_1.IsIn)(exports.AGENCY_GRANT_SCOPES, { each: true }),
264
+ __metadata("design:type", Array)
265
+ ], AcceptGrantDto.prototype, "scopes", void 0);
168
266
  /** The creator narrowing or widening a live grant — the only other write path. */
169
267
  class UpdateGrantScopesDto {
170
268
  constructor(data) {
171
- this.scopes = data.scopes;
269
+ this.scopes = data?.scopes ?? [];
172
270
  }
173
271
  }
174
272
  exports.UpdateGrantScopesDto = UpdateGrantScopesDto;
273
+ __decorate([
274
+ (0, class_validator_1.IsArray)(),
275
+ (0, class_validator_1.ArrayNotEmpty)(),
276
+ (0, class_validator_1.IsIn)(exports.AGENCY_GRANT_SCOPES, { each: true }),
277
+ __metadata("design:type", Array)
278
+ ], UpdateGrantScopesDto.prototype, "scopes", void 0);
175
279
  class SponsorCreatorDto {
176
280
  constructor(data) {
177
- this.tier = data.tier;
281
+ this.tier = data?.tier ?? 'starter';
178
282
  }
179
283
  }
180
284
  exports.SponsorCreatorDto = SponsorCreatorDto;
285
+ __decorate([
286
+ (0, class_validator_1.IsIn)(['starter', 'pro']),
287
+ __metadata("design:type", String)
288
+ ], SponsorCreatorDto.prototype, "tier", void 0);
181
289
  class AgencyDiscoverQueryDto {
182
290
  constructor(data) {
183
- this.q = data.q;
291
+ this.q = data?.q ?? '';
184
292
  }
185
293
  }
186
294
  exports.AgencyDiscoverQueryDto = AgencyDiscoverQueryDto;
295
+ __decorate([
296
+ (0, class_validator_1.IsString)(),
297
+ (0, class_validator_1.IsNotEmpty)(),
298
+ __metadata("design:type", String)
299
+ ], AgencyDiscoverQueryDto.prototype, "q", void 0);
187
300
  class AgencyAccessRequestDto {
188
301
  constructor(data) {
189
- this.userId = data.userId;
190
- this.scopes = data.scopes;
302
+ this.userId = data?.userId ?? '';
303
+ this.scopes = data?.scopes ?? [];
191
304
  }
192
305
  }
193
306
  exports.AgencyAccessRequestDto = AgencyAccessRequestDto;
307
+ __decorate([
308
+ (0, class_validator_1.IsString)(),
309
+ (0, class_validator_1.Matches)(/^[A-Za-z0-9-]{1,128}$/),
310
+ __metadata("design:type", String)
311
+ ], AgencyAccessRequestDto.prototype, "userId", void 0);
312
+ __decorate([
313
+ (0, class_validator_1.IsArray)(),
314
+ (0, class_validator_1.ArrayNotEmpty)(),
315
+ (0, class_validator_1.IsIn)(exports.AGENCY_GRANT_SCOPES, { each: true }),
316
+ __metadata("design:type", Array)
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);
194
334
  class AdminSetOrganizationStatusDto {
195
335
  constructor(data) {
196
- this.status = data.status;
197
- this.reason = data.reason;
336
+ this.status = data?.status ?? 'approved';
337
+ this.reason = data?.reason;
198
338
  }
199
339
  }
200
340
  exports.AdminSetOrganizationStatusDto = AdminSetOrganizationStatusDto;
341
+ __decorate([
342
+ (0, class_validator_1.IsIn)(['approved', 'rejected']),
343
+ __metadata("design:type", Object)
344
+ ], AdminSetOrganizationStatusDto.prototype, "status", void 0);
345
+ __decorate([
346
+ (0, class_validator_1.IsOptional)(),
347
+ (0, class_validator_1.IsString)(),
348
+ __metadata("design:type", String)
349
+ ], AdminSetOrganizationStatusDto.prototype, "reason", void 0);
@@ -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.0",
3
+ "version": "1.47.0",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "commonjs",
@@ -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,9 +7,18 @@ 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,
14
+ InviteAgencyMemberDto,
15
+ RedeemAgencyInviteDto,
16
+ AcceptAgencyMemberInviteDto,
17
+ AcceptGrantDto,
18
+ UpdateGrantScopesDto,
19
+ SponsorCreatorDto,
20
+ AgencyDiscoverQueryDto,
21
+ AdminSetOrganizationStatusDto,
13
22
  } from "./agency";
14
23
 
15
24
  test("the grant scope set is exactly the three reads the spec allows", () => {
@@ -42,7 +51,7 @@ test("grant states are the five the data model pins, including declined and expi
42
51
  test("roster row states include `stale`, and `revoked` is NOT one of them", () => {
43
52
  assert.deepEqual(
44
53
  [...AGENCY_ROSTER_ROW_STATES],
45
- ["requested", "lapsed", "disconnected", "stale", "active"],
54
+ ["requested", "lapsed", "disconnected", "syncing", "stale", "active"],
46
55
  );
47
56
  assert.equal(
48
57
  (AGENCY_ROSTER_ROW_STATES as readonly string[]).includes("revoked"),
@@ -58,6 +67,11 @@ test("needs-attention is disconnected + lapsed + stale, and never `requested` (F
58
67
  false,
59
68
  "a requested grant waits on the CREATOR, so it never lights the agency's badge",
60
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
+ );
61
75
  // Every needs-attention state must be a real roster state, or the tile counts a
62
76
  // state no row can ever hold.
63
77
  for (const state of AGENCY_ROSTER_NEEDS_ATTENTION_STATES) {
@@ -65,6 +79,20 @@ test("needs-attention is disconnected + lapsed + stale, and never `requested` (F
65
79
  }
66
80
  });
67
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
+
68
96
  test("DTOs construct from a plain object (the house `new I_DTO()` shape)", () => {
69
97
  const org = new CreateAgencyOrganizationDto({
70
98
  name: "Studio Kot",
@@ -78,3 +106,53 @@ test("DTOs construct from a plain object (the house `new I_DTO()` shape)", () =>
78
106
  const request = new AgencyAccessRequestDto({ userId: "u-1", scopes: ["analytics"] });
79
107
  assert.deepEqual(request.scopes, ["analytics"]);
80
108
  });
109
+
110
+ test("every agency DTO survives being constructed with NOTHING", () => {
111
+ // What a validation pipe actually does. `plainToInstance` calls the
112
+ // constructor with no arguments before copying the body onto it, so a
113
+ // constructor that read `data.name` threw a TypeError on every request
114
+ // instead of validating anything (found 2026-09-22).
115
+ const DTOS = [
116
+ CreateAgencyOrganizationDto,
117
+ InviteAgencyMemberDto,
118
+ RedeemAgencyInviteDto,
119
+ AcceptAgencyMemberInviteDto,
120
+ AcceptGrantDto,
121
+ UpdateGrantScopesDto,
122
+ SponsorCreatorDto,
123
+ AgencyDiscoverQueryDto,
124
+ AgencyAccessRequestDto,
125
+ AdminSetOrganizationStatusDto,
126
+ ];
127
+
128
+ for (const Dto of DTOS) {
129
+ assert.doesNotThrow(() => new (Dto as new () => unknown)(), `${Dto.name} threw on an empty construct`);
130
+ }
131
+ });
132
+
133
+ test("every agency DTO carries validation metadata, not just a shape", () => {
134
+ // An undecorated class typed onto a handler's @Body() is passed through
135
+ // unchecked: the pipe validates DECORATORS, and there were none until
136
+ // 2026-09-22. Read from class-validator's own registry rather than by
137
+ // booting a pipe.
138
+ const { getMetadataStorage } = require("class-validator");
139
+ const storage = getMetadataStorage();
140
+
141
+ const DECORATED = [
142
+ CreateAgencyOrganizationDto,
143
+ InviteAgencyMemberDto,
144
+ RedeemAgencyInviteDto,
145
+ AcceptAgencyMemberInviteDto,
146
+ AcceptGrantDto,
147
+ UpdateGrantScopesDto,
148
+ SponsorCreatorDto,
149
+ AgencyDiscoverQueryDto,
150
+ AgencyAccessRequestDto,
151
+ AdminSetOrganizationStatusDto,
152
+ ];
153
+
154
+ for (const Dto of DECORATED) {
155
+ const rules = storage.getTargetValidationMetadatas(Dto, Dto.name, false, false);
156
+ assert.ok(rules.length > 0, `${Dto.name} has no validation rules`);
157
+ }
158
+ });