ad2app-lib 1.43.0 → 1.44.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,138 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+
4
+ import {
5
+ AGENCY_GRANT_SCOPES,
6
+ AGENCY_ORGANIZATION_STATUSES,
7
+ CREATOR_ACCESS_GRANT_STATES,
8
+ AGENCY_ROSTER_ROW_STATES,
9
+ AGENCY_ROSTER_NEEDS_ATTENTION_STATES,
10
+ isAgencyGrantScope,
11
+ CreateAgencyOrganizationDto,
12
+ AgencyAccessRequestDto,
13
+ InviteAgencyMemberDto,
14
+ RedeemAgencyInviteDto,
15
+ AcceptAgencyMemberInviteDto,
16
+ AcceptGrantDto,
17
+ UpdateGrantScopesDto,
18
+ SponsorCreatorDto,
19
+ AgencyDiscoverQueryDto,
20
+ AdminSetOrganizationStatusDto,
21
+ } from "./agency";
22
+
23
+ test("the grant scope set is exactly the three reads the spec allows", () => {
24
+ assert.deepEqual([...AGENCY_GRANT_SCOPES], ["analytics", "comments", "posts"]);
25
+ });
26
+
27
+ test("isAgencyGrantScope fails closed on anything outside the set", () => {
28
+ for (const scope of AGENCY_GRANT_SCOPES) assert.equal(isAgencyGrantScope(scope), true);
29
+ // The refusals that matter: every one of these is a WRITE the grant must never reach
30
+ // (FR-010), and a scope string arriving from a request body is untrusted input.
31
+ for (const notAScope of ["messages", "dms", "publish", "reply", "moderate", "accounts", ""]) {
32
+ assert.equal(isAgencyGrantScope(notAScope), false, `must refuse: ${notAScope}`);
33
+ }
34
+ });
35
+
36
+ test("organization statuses carry `closed` — a closed organization's row is retained, never deleted", () => {
37
+ assert.deepEqual(
38
+ [...AGENCY_ORGANIZATION_STATUSES],
39
+ ["pending", "approved", "rejected", "closed"],
40
+ );
41
+ });
42
+
43
+ test("grant states are the five the data model pins, including declined and expired", () => {
44
+ assert.deepEqual(
45
+ [...CREATOR_ACCESS_GRANT_STATES],
46
+ ["requested", "active", "declined", "expired", "revoked"],
47
+ );
48
+ });
49
+
50
+ test("roster row states include `stale`, and `revoked` is NOT one of them", () => {
51
+ assert.deepEqual(
52
+ [...AGENCY_ROSTER_ROW_STATES],
53
+ ["requested", "lapsed", "disconnected", "stale", "active"],
54
+ );
55
+ assert.equal(
56
+ (AGENCY_ROSTER_ROW_STATES as readonly string[]).includes("revoked"),
57
+ false,
58
+ "a revoked creator leaves the roster for the settings history (FR-011)",
59
+ );
60
+ });
61
+
62
+ test("needs-attention is disconnected + lapsed + stale, and never `requested` (FR-011)", () => {
63
+ assert.deepEqual([...AGENCY_ROSTER_NEEDS_ATTENTION_STATES], ["lapsed", "disconnected", "stale"]);
64
+ assert.equal(
65
+ (AGENCY_ROSTER_NEEDS_ATTENTION_STATES as readonly string[]).includes("requested"),
66
+ false,
67
+ "a requested grant waits on the CREATOR, so it never lights the agency's badge",
68
+ );
69
+ // Every needs-attention state must be a real roster state, or the tile counts a
70
+ // state no row can ever hold.
71
+ for (const state of AGENCY_ROSTER_NEEDS_ATTENTION_STATES) {
72
+ assert.ok((AGENCY_ROSTER_ROW_STATES as readonly string[]).includes(state));
73
+ }
74
+ });
75
+
76
+ test("DTOs construct from a plain object (the house `new I_DTO()` shape)", () => {
77
+ const org = new CreateAgencyOrganizationDto({
78
+ name: "Studio Kot",
79
+ registryId: "5252445767",
80
+ registryCountry: "PL",
81
+ contactEmail: "biuro@studiokot.pl",
82
+ });
83
+ assert.equal(org.name, "Studio Kot");
84
+ assert.equal(org.website, undefined);
85
+
86
+ const request = new AgencyAccessRequestDto({ userId: "u-1", scopes: ["analytics"] });
87
+ assert.deepEqual(request.scopes, ["analytics"]);
88
+ });
89
+
90
+ test("every agency DTO survives being constructed with NOTHING", () => {
91
+ // What a validation pipe actually does. `plainToInstance` calls the
92
+ // constructor with no arguments before copying the body onto it, so a
93
+ // constructor that read `data.name` threw a TypeError on every request
94
+ // instead of validating anything (found 2026-09-22).
95
+ const DTOS = [
96
+ CreateAgencyOrganizationDto,
97
+ InviteAgencyMemberDto,
98
+ RedeemAgencyInviteDto,
99
+ AcceptAgencyMemberInviteDto,
100
+ AcceptGrantDto,
101
+ UpdateGrantScopesDto,
102
+ SponsorCreatorDto,
103
+ AgencyDiscoverQueryDto,
104
+ AgencyAccessRequestDto,
105
+ AdminSetOrganizationStatusDto,
106
+ ];
107
+
108
+ for (const Dto of DTOS) {
109
+ assert.doesNotThrow(() => new (Dto as new () => unknown)(), `${Dto.name} threw on an empty construct`);
110
+ }
111
+ });
112
+
113
+ test("every agency DTO carries validation metadata, not just a shape", () => {
114
+ // An undecorated class typed onto a handler's @Body() is passed through
115
+ // unchecked: the pipe validates DECORATORS, and there were none until
116
+ // 2026-09-22. Read from class-validator's own registry rather than by
117
+ // booting a pipe.
118
+ const { getMetadataStorage } = require("class-validator");
119
+ const storage = getMetadataStorage();
120
+
121
+ const DECORATED = [
122
+ CreateAgencyOrganizationDto,
123
+ InviteAgencyMemberDto,
124
+ RedeemAgencyInviteDto,
125
+ AcceptAgencyMemberInviteDto,
126
+ AcceptGrantDto,
127
+ UpdateGrantScopesDto,
128
+ SponsorCreatorDto,
129
+ AgencyDiscoverQueryDto,
130
+ AgencyAccessRequestDto,
131
+ AdminSetOrganizationStatusDto,
132
+ ];
133
+
134
+ for (const Dto of DECORATED) {
135
+ const rules = storage.getTargetValidationMetadatas(Dto, Dto.name, false, false);
136
+ assert.ok(rules.length > 0, `${Dto.name} has no validation rules`);
137
+ }
138
+ });
@@ -0,0 +1,458 @@
1
+ import {
2
+ ArrayNotEmpty,
3
+ IsArray,
4
+ IsEmail,
5
+ IsIn,
6
+ IsNotEmpty,
7
+ IsOptional,
8
+ IsString,
9
+ IsUrl,
10
+ IsUUID,
11
+ Length,
12
+ } from 'class-validator';
13
+
14
+ /**
15
+ * Agency domain — organizations, memberships and creator access grants (spec 175).
16
+ *
17
+ * The shape behind "an agency sees its creators' numbers, because the creators
18
+ * said yes". Two doors, one product: a creator account is unchanged by any of
19
+ * this, and gains at most one grant.
20
+ *
21
+ * The one idea worth carrying into every consumer: a GRANT IS READ-ONLY AND
22
+ * CREATOR-OWNED. The agency asks for scopes; only a creator's own act ever
23
+ * writes `grantedScopes` (FR-008, SC-002). Nothing here can express a write on
24
+ * a creator's account, and that is deliberate — the absence is the feature.
25
+ *
26
+ * See specs/175-agency-roster/contracts/lib-types.md.
27
+ */
28
+
29
+ import type { PublishPlatform } from '../publish-limits/types';
30
+
31
+ // ── Cross-repo route constants ───────────────────────────────────────────────
32
+
33
+ /**
34
+ * Paths that BOTH repos have to agree on, with one home here.
35
+ *
36
+ * The backend mails this path; the frontend registers it. They deploy
37
+ * separately, so nothing else notices them drifting — and the first version of
38
+ * that link pointed at a route no repo served, which is how an entire user
39
+ * story shipped with its headline capability unreachable.
40
+ *
41
+ * A test in each repo asserts its own side against these constants. The first
42
+ * attempt at that guard read across the filesystem into a sibling checkout,
43
+ * which worked on one laptop and threw ENOENT in CI; a shared constant is the
44
+ * version that actually runs.
45
+ */
46
+ export const AGENCY_ROUTE_PATHS = {
47
+ /** A colleague accepting a team invitation (FR-005). */
48
+ joinTeam: '/agency/join-team',
49
+ /** A creator consenting to an agency's access (FR-007). */
50
+ join: '/agency/join',
51
+ /** A creator answering an access request (FR-016). */
52
+ requests: '/agency/requests',
53
+ } as const;
54
+
55
+ // ── Scopes ───────────────────────────────────────────────────────────────────
56
+
57
+ /**
58
+ * The complete set of reads a grant may carry. A CEILING, not a starting point.
59
+ *
60
+ * All three are reads of what the creator already sees on their own screens.
61
+ * Private messages, comment replies and moderation, publishing, connection
62
+ * changes and settings are absent by construction (FR-010) — there is no scope
63
+ * string that could name them, so a widened grant is a spec change rather than
64
+ * a config change.
65
+ */
66
+ export const AGENCY_GRANT_SCOPES = [
67
+ /** the creator's own five analytics reads, full history, no date filtering */
68
+ 'analytics',
69
+ /** comments under the creator's posts, READ only — the one inbox read a grant reaches */
70
+ 'comments',
71
+ /** the creator's published posts with their own numbers — never drafts or the queue */
72
+ 'posts',
73
+ ] as const;
74
+
75
+ export type I_AgencyGrantScope = (typeof AGENCY_GRANT_SCOPES)[number];
76
+
77
+ /**
78
+ * Narrowing guard — an unrecognised scope must fail closed.
79
+ *
80
+ * Scope strings arrive from request bodies (the agency's ask, the creator's
81
+ * answer), so this is a trust boundary, not a convenience.
82
+ */
83
+ export const isAgencyGrantScope = (value: string): value is I_AgencyGrantScope =>
84
+ (AGENCY_GRANT_SCOPES as readonly string[]).includes(value);
85
+
86
+ // ── Organization ─────────────────────────────────────────────────────────────
87
+
88
+ /** `'brand'` arrives with spec 148; one column, no enum table. */
89
+ export type AgencyOrganizationType = 'agency';
90
+
91
+ /**
92
+ * `closed` was added by the 2026-09-21 requirements review (FR-004b).
93
+ *
94
+ * It exists so closure has an end state that is NOT a deleted row: grants are
95
+ * `on delete cascade` from the organization, so hard-deleting one would destroy
96
+ * the consent records FR-009 says are never deleted — and which are also the
97
+ * invoice basis.
98
+ */
99
+ export const AGENCY_ORGANIZATION_STATUSES = [
100
+ 'pending',
101
+ 'approved',
102
+ 'rejected',
103
+ 'closed',
104
+ ] as const;
105
+
106
+ export type AgencyOrganizationStatus = (typeof AGENCY_ORGANIZATION_STATUSES)[number];
107
+
108
+ /** Mirrored from Stripe. `comped` is a 100% coupon and is treated as active. */
109
+ export type AgencySubscriptionStatus =
110
+ | 'none'
111
+ | 'active'
112
+ | 'trialing'
113
+ | 'past_due'
114
+ | 'unpaid'
115
+ | 'canceled'
116
+ | 'comped';
117
+
118
+ /** Two roles, no more (FR-004). Only the owner reaches billing, members and the link. */
119
+ export type AgencyMemberRole = 'owner' | 'member';
120
+
121
+ export type SponsoredTier = 'starter' | 'pro';
122
+
123
+ export interface I_AgencyOrganization {
124
+ id: string;
125
+ type: AgencyOrganizationType;
126
+ name: string;
127
+ website?: string;
128
+ /** NIP/KRS or a foreign equivalent — free text, verified by a human, never by code. */
129
+ registryId: string;
130
+ /** ISO-3166 alpha-2 of the register the identifier belongs to. */
131
+ registryCountry: string;
132
+ contactEmail: string;
133
+ status: AgencyOrganizationStatus;
134
+ /** The reason an admin gave; shown to a rejected applicant (FR-002). */
135
+ statusReason?: string;
136
+ subscriptionStatus: AgencySubscriptionStatus;
137
+ /** The date sponsorships run to when a grant is revoked or the plan lapses. */
138
+ currentPeriodEnd?: string;
139
+ createdAt: string;
140
+ }
141
+
142
+ export interface I_AgencyMember {
143
+ id: string;
144
+ organizationId: string;
145
+ /** Null while an e-mail invitation is outstanding — nobody has accepted yet. */
146
+ userId?: string;
147
+ invitedEmail: string;
148
+ role: AgencyMemberRole;
149
+ invitedAt: string;
150
+ /** "Who is in" is exactly `acceptedAt != null`. */
151
+ acceptedAt?: string;
152
+ revokedAt?: string;
153
+ }
154
+
155
+ // ── Grant ────────────────────────────────────────────────────────────────────
156
+
157
+ /**
158
+ * The five states the data model pins.
159
+ *
160
+ * `declined` and `expired` belong to the US5 request path (FR-016) and were
161
+ * missing from the spec's own Key Entities until the 2026-09-21 review. There
162
+ * is no transition OUT of `revoked`, `declined` or `expired`: a new consent is
163
+ * a new row, so the record of what was agreed is never overwritten.
164
+ */
165
+ export const CREATOR_ACCESS_GRANT_STATES = [
166
+ 'requested',
167
+ 'active',
168
+ 'declined',
169
+ 'expired',
170
+ 'revoked',
171
+ ] as const;
172
+
173
+ export type CreatorAccessGrantState = (typeof CREATOR_ACCESS_GRANT_STATES)[number];
174
+
175
+ export type CreatorAccessGrantOrigin = 'invite_link' | 'agency_request';
176
+
177
+ /** Who ended it. Shown in the agency's past-creators history (FR-013b). */
178
+ export type CreatorAccessGrantRevokedBy =
179
+ | 'creator'
180
+ | 'agency'
181
+ | 'organization'
182
+ | 'account-deleted'
183
+ | 'purge'
184
+ | 'system';
185
+
186
+ export interface I_CreatorAccessGrant {
187
+ id: string;
188
+ /**
189
+ * Null once the creator's user row is hard-purged (audit D-D): the grant
190
+ * survives as the consent record, with `creatorIdentity` standing in for the
191
+ * person who is no longer there.
192
+ */
193
+ creatorUserId?: string;
194
+ organizationId: string;
195
+ organizationName: string;
196
+ /** What the agency asked for. */
197
+ requestedScopes: I_AgencyGrantScope[];
198
+ /** What the creator actually granted — a subset, possibly smaller, never larger. */
199
+ grantedScopes: I_AgencyGrantScope[];
200
+ /** Frozen at grant time; what the history shows after a purge. */
201
+ creatorIdentity: { handle: string; displayName?: string };
202
+ state: CreatorAccessGrantState;
203
+ origin: CreatorAccessGrantOrigin;
204
+ requestedAt?: string;
205
+ expiresAt?: string;
206
+ grantedAt?: string;
207
+ /** Which terms the creator accepted. A `draft-` prefix means pre-addendum (FR-020). */
208
+ termsVersion?: string;
209
+ revokedAt?: string;
210
+ revokedBy?: CreatorAccessGrantRevokedBy;
211
+ /**
212
+ * The agency covering this creator's plan. `until` is set when the
213
+ * sponsorship is ending — it runs to the close of the period already paid
214
+ * for, so a revoke never silently drops the creator's plan (FR-014).
215
+ */
216
+ sponsorship?: { tier: SponsoredTier; since: string; until?: string };
217
+ /** Which platforms the grant reaches right now — derived at read time, never stored. */
218
+ platformsReached: PublishPlatform[];
219
+ }
220
+
221
+ // ── Roster ───────────────────────────────────────────────────────────────────
222
+
223
+ /**
224
+ * Roster row states, DECLARED IN PRECEDENCE ORDER (data-model §Derived).
225
+ *
226
+ * A row holds exactly one state: the first of these that applies, most-blocking
227
+ * first. `revoked` is deliberately absent — a revoked creator leaves the roster
228
+ * entirely for the settings history (FR-011).
229
+ */
230
+ export const AGENCY_ROSTER_ROW_STATES = [
231
+ /** the agency asked, the creator has not answered */
232
+ 'requested',
233
+ /** the creator's own plan has lapsed */
234
+ 'lapsed',
235
+ /** the creator has no live platform connection */
236
+ 'disconnected',
237
+ /** no successful daily sync in 24 hours (FR-011, Jan 2026-09-21) */
238
+ 'stale',
239
+ 'active',
240
+ ] as const;
241
+
242
+ export type AgencyRosterRowState = (typeof AGENCY_ROSTER_ROW_STATES)[number];
243
+
244
+ /**
245
+ * What "needs attention" means — ONE home for the KPI tile, the roster segment
246
+ * and the rail badge, so the three can never drift apart (FR-011).
247
+ *
248
+ * `requested` is excluded on purpose: it waits on the CREATOR, and FR-004a says
249
+ * the badge means something waits on the agency.
250
+ */
251
+ export const AGENCY_ROSTER_NEEDS_ATTENTION_STATES = [
252
+ 'lapsed',
253
+ 'disconnected',
254
+ 'stale',
255
+ ] as const satisfies readonly AgencyRosterRowState[];
256
+
257
+ export type AgencyRosterNeedsAttentionState =
258
+ (typeof AGENCY_ROSTER_NEEDS_ATTENTION_STATES)[number];
259
+
260
+ export interface I_AgencyRosterRow {
261
+ grantId: string;
262
+ creator: { id: string; handle: string };
263
+ platforms: PublishPlatform[];
264
+ state: AgencyRosterRowState;
265
+ grantedScopes: I_AgencyGrantScope[];
266
+ lastSyncAt?: string;
267
+ }
268
+
269
+ export interface I_AgencyRoster {
270
+ totals: {
271
+ creators: number;
272
+ 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;
278
+ /** Count of rows in AGENCY_ROSTER_NEEDS_ATTENTION_STATES. */
279
+ needsAttention: number;
280
+ };
281
+ items: I_AgencyRosterRow[];
282
+ }
283
+
284
+ /** What a sponsored creator sees on their OWN subscription screen (FR-014). */
285
+ export interface I_SponsoredBy {
286
+ organizationName: string;
287
+ tier: SponsoredTier;
288
+ /** Present once the sponsorship is ending: the date their plan runs to. */
289
+ until?: string;
290
+ }
291
+
292
+ // ── DTOs ─────────────────────────────────────────────────────────────────────
293
+
294
+ /**
295
+ * Every DTO below carries class-validator decorators and a constructor that
296
+ * survives being called with nothing.
297
+ *
298
+ * Both are load-bearing and were missing until 2026-09-22. The backend mounts
299
+ * a global `ValidationPipe`, which validates the decorated metadata of the
300
+ * class a handler's `@Body()` is typed with — an undecorated class, or a body
301
+ * typed as a plain interface, is simply passed through unchecked. And the pipe
302
+ * instantiates the class with NO arguments, so a constructor that reads
303
+ * `data.name` would throw a TypeError on every request rather than validate
304
+ * anything. The rest of this library's DTOs take `data?: Partial<T>` for that
305
+ * reason; these now do too.
306
+ */
307
+
308
+ export class CreateAgencyOrganizationDto {
309
+ @IsString()
310
+ @IsNotEmpty()
311
+ name: string;
312
+
313
+ /** Rendered as a link on the admin screen, so the protocol is checked here. */
314
+ @IsOptional()
315
+ @IsUrl({ protocols: ['http', 'https'], require_protocol: true })
316
+ website?: string;
317
+
318
+ @IsString()
319
+ @IsNotEmpty()
320
+ registryId: string;
321
+
322
+ /** ISO-3166 alpha-2: which register a reviewer should look in. */
323
+ @IsString()
324
+ @Length(2, 2)
325
+ registryCountry: string;
326
+
327
+ @IsEmail()
328
+ contactEmail: string;
329
+
330
+ constructor(data?: Partial<CreateAgencyOrganizationDto>) {
331
+ this.name = data?.name ?? '';
332
+ this.website = data?.website;
333
+ this.registryId = data?.registryId ?? '';
334
+ this.registryCountry = data?.registryCountry ?? '';
335
+ this.contactEmail = data?.contactEmail ?? '';
336
+ }
337
+ }
338
+
339
+ export class InviteAgencyMemberDto {
340
+ @IsEmail()
341
+ email: string;
342
+
343
+ constructor(data?: Partial<InviteAgencyMemberDto>) {
344
+ this.email = data?.email ?? '';
345
+ }
346
+ }
347
+
348
+ export class RedeemAgencyInviteDto {
349
+ @IsString()
350
+ @IsNotEmpty()
351
+ token: string;
352
+
353
+ constructor(data?: Partial<RedeemAgencyInviteDto>) {
354
+ this.token = data?.token ?? '';
355
+ }
356
+ }
357
+
358
+ export class AcceptAgencyMemberInviteDto {
359
+ @IsString()
360
+ @IsNotEmpty()
361
+ token: string;
362
+
363
+ constructor(data?: Partial<AcceptAgencyMemberInviteDto>) {
364
+ this.token = data?.token ?? '';
365
+ }
366
+ }
367
+
368
+ /**
369
+ * Accepting consent. Either arm identifies the grant being answered; `scopes`
370
+ * is the creator's answer and defaults to the requested set when absent.
371
+ */
372
+ export class AcceptGrantDto {
373
+ @IsOptional()
374
+ @IsString()
375
+ @IsNotEmpty()
376
+ token?: string;
377
+
378
+ @IsOptional()
379
+ @IsUUID()
380
+ grantId?: string;
381
+
382
+ /** A subset of the three reads. Never empty: sharing nothing is declining. */
383
+ @IsOptional()
384
+ @IsArray()
385
+ @ArrayNotEmpty()
386
+ @IsIn(AGENCY_GRANT_SCOPES as unknown as string[], { each: true })
387
+ scopes?: I_AgencyGrantScope[];
388
+
389
+ constructor(data?: Partial<AcceptGrantDto>) {
390
+ this.token = data?.token;
391
+ this.grantId = data?.grantId;
392
+ this.scopes = data?.scopes;
393
+ }
394
+ }
395
+
396
+ /** The creator narrowing or widening a live grant — the only other write path. */
397
+ export class UpdateGrantScopesDto {
398
+ @IsArray()
399
+ @ArrayNotEmpty()
400
+ @IsIn(AGENCY_GRANT_SCOPES as unknown as string[], { each: true })
401
+ scopes: I_AgencyGrantScope[];
402
+
403
+ constructor(data?: Partial<UpdateGrantScopesDto>) {
404
+ this.scopes = data?.scopes ?? [];
405
+ }
406
+ }
407
+
408
+ export class SponsorCreatorDto {
409
+ @IsIn(['starter', 'pro'])
410
+ tier: SponsoredTier;
411
+
412
+ constructor(data?: Partial<SponsorCreatorDto>) {
413
+ this.tier = data?.tier ?? 'starter';
414
+ }
415
+ }
416
+
417
+ export class AgencyDiscoverQueryDto {
418
+ /** An EXACT platform username or e-mail. Never recorded in analytics (FR-015). */
419
+ @IsString()
420
+ @IsNotEmpty()
421
+ q: string;
422
+
423
+ constructor(data?: Partial<AgencyDiscoverQueryDto>) {
424
+ this.q = data?.q ?? '';
425
+ }
426
+ }
427
+
428
+ export class AgencyAccessRequestDto {
429
+ @IsUUID()
430
+ userId: string;
431
+
432
+ @IsArray()
433
+ @ArrayNotEmpty()
434
+ @IsIn(AGENCY_GRANT_SCOPES as unknown as string[], { each: true })
435
+ scopes: I_AgencyGrantScope[];
436
+
437
+ constructor(data?: Partial<AgencyAccessRequestDto>) {
438
+ this.userId = data?.userId ?? '';
439
+ this.scopes = data?.scopes ?? [];
440
+ }
441
+ }
442
+
443
+ export class AdminSetOrganizationStatusDto {
444
+ @IsIn(['approved', 'rejected'])
445
+ status: Extract<AgencyOrganizationStatus, 'approved' | 'rejected'>;
446
+
447
+ /** Required on reject — a rejected applicant is shown this (FR-002). The
448
+ * "required when rejecting" half is a rule about the PAIR, so it stays in
449
+ * the service where both fields are in hand. */
450
+ @IsOptional()
451
+ @IsString()
452
+ reason?: string;
453
+
454
+ constructor(data?: Partial<AdminSetOrganizationStatusDto>) {
455
+ this.status = data?.status ?? 'approved';
456
+ this.reason = data?.reason;
457
+ }
458
+ }
@@ -41,5 +41,8 @@ export * from "./scheduling";
41
41
  // ── Agent domain (delegated authority + direct media upload, spec 163) ───────
42
42
  export * from "./agent";
43
43
 
44
+ // ── Agency domain (organizations, memberships, creator access grants, spec 175)
45
+ export * from "./agency";
46
+
44
47
  // ── Access control ────────────────────────────────────────────────────────────
45
48
  export * from "./I_AccessDenial";