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.
- package/dist/types/I_AccessDenial.d.ts +19 -0
- package/dist/types/I_AccessDenial.js +19 -0
- package/dist/types/agency.d.ts +277 -17
- package/dist/types/agency.js +182 -33
- package/dist/types/scheduling/I_SchedulingProUpgrade.d.ts +10 -1
- package/dist/types/scheduling/I_SchedulingProUpgrade.js +4 -0
- package/package.json +1 -1
- package/src/types/I_AccessDenial.ts +19 -0
- package/src/types/access-denial.test.ts +1 -1
- package/src/types/agency-billing.test.ts +83 -0
- package/src/types/agency-discover.test.ts +79 -0
- package/src/types/agency.test.ts +79 -1
- package/src/types/agency.ts +363 -34
- package/src/types/scheduling/I_SchedulingProUpgrade.ts +13 -0
package/dist/types/agency.js
CHANGED
|
@@ -1,20 +1,16 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
|
131
|
-
this.website = data
|
|
132
|
-
this.registryId = data
|
|
133
|
-
this.registryCountry = data
|
|
134
|
-
this.contactEmail = data
|
|
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
|
|
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
|
|
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
|
|
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
|
|
163
|
-
this.grantId = data
|
|
164
|
-
this.scopes = data
|
|
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
|
|
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
|
|
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
|
|
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
|
|
190
|
-
this.scopes = data
|
|
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
|
|
197
|
-
this.reason = data
|
|
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
|
@@ -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,
|
|
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
|
+
});
|
package/src/types/agency.test.ts
CHANGED
|
@@ -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
|
+
});
|