ad2app-lib 1.49.0 → 1.59.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,6 +1,7 @@
1
1
  import { IsNotEmpty, IsString, Matches, MaxLength } from 'class-validator';
2
2
 
3
3
  import type { CampaignSide, I_CampaignCreatorIdentity } from './agency-campaigns';
4
+ import type { AgencyOrganizationType } from './agency';
4
5
 
5
6
  /**
6
7
  * Agency ↔ creator threads (spec 175 US8, FR-023): one conversation per
@@ -36,15 +37,25 @@ export interface I_AgencyThreadMessage {
36
37
  side: CampaignSide;
37
38
  body: string;
38
39
  at: string;
40
+ /** A delegate wrote this for the creator through a `deals` grant (spec 148): which grant, and the member's name. */
41
+ actingFor?: { grantId: string; memberName: string };
39
42
  }
40
43
 
41
44
  export interface I_AgencyThreadSummary {
42
45
  id: string;
43
46
  /** Agency side: who the thread is with. */
44
47
  creator: I_CampaignCreatorIdentity;
45
- /** Creator side: which organization. */
46
- organization: { id: string; name: string };
47
- lastMessage: { side: CampaignSide; body: string; at: string } | null;
48
+ /** Creator side: which organization, and whether it is an agency or a brand (spec 148). */
49
+ organization: { id: string; name: string; type?: AgencyOrganizationType };
50
+ /** `actingFor`: a delegate wrote the last message for the creator (spec 148 US8), under this member's name. */
51
+ lastMessage: { side: CampaignSide; body: string; at: string; actingFor?: { memberName: string } } | null;
52
+ /**
53
+ * Spec 148 US8 (audit #2): the other organizations whose members read this
54
+ * conversation acting for the creator: a manager holding the creator's
55
+ * `deals` grant while this is a deal's thread, or one whose member already
56
+ * wrote in it. Empty when only the two parties see it.
57
+ */
58
+ delegates?: Array<{ id: string; name: string }>;
48
59
  /** The other side wrote last, seen from the caller's side. */
49
60
  unanswered: boolean;
50
61
  /** No live relationship: read-only for whoever still exists. */
@@ -11,6 +11,7 @@ import {
11
11
  IsUUID,
12
12
  Length,
13
13
  Matches,
14
+ ValidateIf,
14
15
  } from 'class-validator';
15
16
 
16
17
  /**
@@ -87,10 +88,37 @@ export type I_AgencyGrantScope = (typeof AGENCY_GRANT_SCOPES)[number];
87
88
  export const isAgencyGrantScope = (value: string): value is I_AgencyGrantScope =>
88
89
  (AGENCY_GRANT_SCOPES as readonly string[]).includes(value);
89
90
 
91
+ /**
92
+ * Delegation (spec 148 FR-017a): the one scope that reaches WRITES, and only
93
+ * the creator-side deal acts (apply, offer acts, materials, the deal thread).
94
+ *
95
+ * Kept OUT of AGENCY_GRANT_SCOPES on purpose: that list is what an
96
+ * organization may ASK for, and no organization ever asks for this. Only the
97
+ * creator adds it to `grantedScopes`, which makes it the one exception to
98
+ * "granted is a subset of requested". Publishing, contract signature,
99
+ * payouts, the settlement form and platforms stay unreachable through it.
100
+ */
101
+ export const DELEGATION_SCOPES = ['deals'] as const;
102
+
103
+ export type I_DelegationScope = (typeof DELEGATION_SCOPES)[number];
104
+
105
+ /** Everything a grant may carry once the creator has answered. */
106
+ export type I_GrantScope = I_AgencyGrantScope | I_DelegationScope;
107
+
108
+ /** Narrowing guard over both sets; an unrecognised scope fails closed. */
109
+ export const isGrantScope = (value: string): value is I_GrantScope =>
110
+ isAgencyGrantScope(value) || (DELEGATION_SCOPES as readonly string[]).includes(value);
111
+
90
112
  // ── Organization ─────────────────────────────────────────────────────────────
91
113
 
92
- /** `'brand'` arrives with spec 148; one column, no enum table. */
93
- export type AgencyOrganizationType = 'agency';
114
+ /**
115
+ * `'brand'` arrived with spec 148 (US2): one column, no enum table. A brand
116
+ * authors campaigns like an agency but has no roster; it is created only
117
+ * while the backend's brand door is open.
118
+ */
119
+ export const AGENCY_ORGANIZATION_TYPES = ['agency', 'brand'] as const;
120
+
121
+ export type AgencyOrganizationType = (typeof AGENCY_ORGANIZATION_TYPES)[number];
94
122
 
95
123
  /**
96
124
  * `closed` was added by the 2026-09-21 requirements review (FR-004b).
@@ -134,6 +162,11 @@ export interface I_AgencyOrganization {
134
162
  /** ISO-3166 alpha-2 of the register the identifier belongs to. */
135
163
  registryCountry: string;
136
164
  contactEmail: string;
165
+ /**
166
+ * The organization's logo, a storage path read through a signed URL (spec
167
+ * 148). Required before a campaign is listed open. Absent on older backends.
168
+ */
169
+ logoUrl?: string;
137
170
  status: AgencyOrganizationStatus;
138
171
  /** The reason an admin gave; shown to a rejected applicant (FR-002). */
139
172
  statusReason?: string;
@@ -182,7 +215,14 @@ export const CREATOR_ACCESS_GRANT_STATES = [
182
215
 
183
216
  export type CreatorAccessGrantState = (typeof CREATOR_ACCESS_GRANT_STATES)[number];
184
217
 
185
- export type CreatorAccessGrantOrigin = 'invite_link' | 'agency_request';
218
+ /**
219
+ * `dealroom_application` (spec 148): created or widened when the creator
220
+ * applies to an open listing. The system revokes a grant of this origin, and
221
+ * only of this origin, when that deal ends.
222
+ */
223
+ export const CREATOR_ACCESS_GRANT_ORIGINS = ['invite_link', 'agency_request', 'dealroom_application'] as const;
224
+
225
+ export type CreatorAccessGrantOrigin = (typeof CREATOR_ACCESS_GRANT_ORIGINS)[number];
186
226
 
187
227
  /** Who ended it. Shown in the agency's past-creators history (FR-013b). */
188
228
  export type CreatorAccessGrantRevokedBy =
@@ -203,10 +243,19 @@ export interface I_CreatorAccessGrant {
203
243
  creatorUserId?: string;
204
244
  organizationId: string;
205
245
  organizationName: string;
246
+ /** Spec 148: `brand` cannot hold `deals` (it is the creator's counterparty), so the switch is not offered. Absent on older backends. */
247
+ organizationType?: AgencyOrganizationType;
206
248
  /** What the agency asked for. */
207
249
  requestedScopes: I_AgencyGrantScope[];
208
- /** What the creator actually granted — a subset, possibly smaller, never larger. */
250
+ /** What the creator actually granted — a subset, possibly smaller, never larger. Reads only. */
209
251
  grantedScopes: I_AgencyGrantScope[];
252
+ /**
253
+ * Spec 148 FR-017a: the creator lets this organization run their Dealroom
254
+ * deals (the `deals` delegation scope, which no organization can ask for and
255
+ * only the creator adds). Kept out of `grantedScopes` so that list stays the
256
+ * reads 175 code is typed on. Absent on older backends: read absent as no.
257
+ */
258
+ deals?: boolean;
210
259
  /** Frozen at grant time; what the history shows after a purge. */
211
260
  creatorIdentity: { handle: string; displayName?: string };
212
261
  state: CreatorAccessGrantState;
@@ -357,6 +406,12 @@ export interface I_AgencyRosterRow {
357
406
  */
358
407
  reaskedAt?: string;
359
408
  reaskAvailableAt?: string;
409
+ /**
410
+ * Spec 148 US8: the organization may act for this creator in the Dealroom
411
+ * (the active grant holds `deals`, the org is not a brand). Only
412
+ * sent while the Dealroom is on; absent while it is off (read absent as no).
413
+ */
414
+ canActForCreator?: boolean;
360
415
  }
361
416
 
362
417
  /**
@@ -534,7 +589,24 @@ export class CreateAgencyOrganizationDto {
534
589
  @IsEmail()
535
590
  contactEmail: string;
536
591
 
592
+ /** Spec 148 US2: absent = `agency`. `brand` is refused while the backend's brand door is closed. */
593
+ @IsOptional()
594
+ @IsIn(AGENCY_ORGANIZATION_TYPES as unknown as string[])
595
+ type?: AgencyOrganizationType;
596
+
597
+ /**
598
+ * Spec 148 US2, money release only (DORMANT): the card a brand pays its
599
+ * first subscription charge with, taken BEFORE the organization exists.
600
+ * Ignored while the backend's money switch is off (brands apply free).
601
+ */
602
+ @IsOptional()
603
+ @IsString()
604
+ @Matches(/^pm_[A-Za-z0-9]{1,200}$/)
605
+ paymentMethodId?: string;
606
+
537
607
  constructor(data?: Partial<CreateAgencyOrganizationDto>) {
608
+ this.type = data?.type;
609
+ this.paymentMethodId = data?.paymentMethodId;
538
610
  this.name = data?.name ?? '';
539
611
  this.website = data?.website;
540
612
  this.registryId = data?.registryId ?? '';
@@ -600,18 +672,44 @@ export class AcceptGrantDto {
600
672
  }
601
673
  }
602
674
 
603
- /** The creator narrowing or widening a live grant — the only other write path. */
675
+ /**
676
+ * The creator narrowing or widening a live grant — the only other write path.
677
+ *
678
+ * `scopes` are the READS only; `deals` is never one of them (it stays out of
679
+ * AGENCY_GRANT_SCOPES). Delegation (spec 148 FR-017a) is its own field, so the
680
+ * data-access screen, which saves reads, can never drop it by omission:
681
+ *
682
+ * - `deals` absent ⇒ delegation unchanged (a reads-only save keeps `deals`);
683
+ * - `deals: true` ⇒ added, `deals: false` ⇒ removed;
684
+ * - `scopes` may be left out when `deals` is sent ⇒ the reads stay as they are.
685
+ */
604
686
  export class UpdateGrantScopesDto {
687
+ @ValidateIf((dto: UpdateGrantScopesDto) => dto.deals === undefined || dto.scopes !== undefined)
605
688
  @IsArray()
606
689
  @ArrayNotEmpty()
607
690
  @IsIn(AGENCY_GRANT_SCOPES as unknown as string[], { each: true })
608
- scopes: I_AgencyGrantScope[];
691
+ scopes?: I_AgencyGrantScope[];
692
+
693
+ /** Let this organization run my deals (spec 148 US8). Creator-only; absent = unchanged. */
694
+ @IsOptional()
695
+ @IsBoolean()
696
+ deals?: boolean;
609
697
 
610
698
  constructor(data?: Partial<UpdateGrantScopesDto>) {
611
- this.scopes = data?.scopes ?? [];
699
+ // Undefined when not given (never []): class-transformer builds the body
700
+ // with an empty construct, and a default would make `{ deals: true }` fail.
701
+ if (data?.scopes !== undefined) this.scopes = data.scopes;
702
+ if (data?.deals !== undefined) this.deals = data.deals;
612
703
  }
613
704
  }
614
705
 
706
+ /**
707
+ * Spec 148 US8: the header a delegate names the creator with on the
708
+ * creator-side deal routes (`X-Acting-For: <creatorUserId>`). Every other
709
+ * route refuses a request that carries it (`dealroom-delegate-forbidden`).
710
+ */
711
+ export const ACTING_FOR_HEADER = 'x-acting-for';
712
+
615
713
  export class SponsorCreatorDto {
616
714
  @IsIn(['starter', 'pro'])
617
715
  tier: SponsoredTier;
@@ -695,6 +793,21 @@ export interface I_AgencyDiscoverResult {
695
793
  results: I_AgencyDiscoverHit[];
696
794
  }
697
795
 
796
+ /** `GET /admin/agency-organizations[?type=]`: one pending organization on the admin queue. */
797
+ export interface I_AdminOrganizationRow {
798
+ id: string;
799
+ /** Spec 148: `agency` or `brand`; filter with `?type=`. */
800
+ type: AgencyOrganizationType;
801
+ name: string;
802
+ website: string | null;
803
+ registryId: string;
804
+ registryCountry: string;
805
+ contactEmail: string;
806
+ /** Derived, a SIGNAL only (FR-003). */
807
+ contactEmailDomain: string;
808
+ appliedAt: string;
809
+ }
810
+
698
811
  export class AdminSetOrganizationStatusDto {
699
812
  @IsIn(['approved', 'rejected'])
700
813
  status: Extract<AgencyOrganizationStatus, 'approved' | 'rejected'>;
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Review #5 (H4): this minor must not break code written against 1.50.0. The
3
+ * fixture below is how the backend's and the frontend's master use the grant
4
+ * types (grant-view.ts, AgencyJoinPage, AgencyRequestPage, DataAccessSection);
5
+ * it type-checks against the current sources with the real compiler, so a
6
+ * widening that breaks a consumer fails here, not after the publish.
7
+ */
8
+ import { test } from "node:test";
9
+ import assert from "node:assert/strict";
10
+ import { mkdtempSync, writeFileSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join, resolve } from "node:path";
13
+ import ts from "typescript";
14
+
15
+ const TYPES = resolve(__dirname, "index").replace(/\\/g, "/");
16
+
17
+ const FIXTURE = `
18
+ import {
19
+ AGENCY_GRANT_SCOPES,
20
+ type I_AgencyGrantScope,
21
+ type I_CreatorAccessGrant,
22
+ type I_AgencyRosterRow,
23
+ type CreatorAccessGrantOrigin,
24
+ type AgencyOrganizationType,
25
+ UpdateGrantScopesDto,
26
+ } from "${TYPES}";
27
+
28
+ declare const grant: I_CreatorAccessGrant;
29
+ declare const row: I_AgencyRosterRow;
30
+ declare const reask: { scopes: I_AgencyGrantScope[] } | null;
31
+ const inCanonicalOrder = (scopes: readonly I_AgencyGrantScope[]): I_AgencyGrantScope[] =>
32
+ AGENCY_GRANT_SCOPES.filter((s) => scopes.includes(s));
33
+ const word = (scope: I_AgencyGrantScope): string => scope;
34
+
35
+ // frontend master: AgencyJoinPage:177, AgencyRequestPage:91/190, DataAccessSection:244
36
+ (grant.grantedScopes ?? []).map((scope) => word(scope));
37
+ inCanonicalOrder(grant.grantedScopes ?? []);
38
+ const initial: I_AgencyGrantScope[] = grant.grantedScopes ?? [];
39
+ const missing = AGENCY_GRANT_SCOPES.filter((s) => !row.grantedScopes.includes(s));
40
+
41
+ // backend master: grant-view.ts builds the row from 175's columns
42
+ declare const columns: { granted: I_AgencyGrantScope[]; requested: I_AgencyGrantScope[]; origin: 'invite_link' | 'agency_request' };
43
+ const built: I_CreatorAccessGrant = {
44
+ id: "g", organizationId: "o", organizationName: "O",
45
+ requestedScopes: columns.requested,
46
+ grantedScopes: columns.granted,
47
+ creatorIdentity: { handle: "h" },
48
+ state: "active",
49
+ origin: columns.origin,
50
+ platformsReached: [],
51
+ ...(reask ? { reaskedScopes: reask.scopes, reaskExpiresAt: "x" } : {}),
52
+ };
53
+
54
+ // backend master: the scope update reads dto.scopes as the new reads
55
+ declare const dto: UpdateGrantScopesDto;
56
+ const next: I_AgencyGrantScope[] = dto?.scopes;
57
+
58
+ const origin: CreatorAccessGrantOrigin = "agency_request";
59
+ const type: AgencyOrganizationType = "agency";
60
+ export { initial, missing, built, next, origin, type };
61
+ `;
62
+
63
+ test("1.50.0 consumer code type-checks against this version (no breaking widening)", () => {
64
+ const dir = mkdtempSync(join(tmpdir(), "lib-compat-"));
65
+ const file = join(dir, "consumer.ts");
66
+ writeFileSync(file, FIXTURE);
67
+ const program = ts.createProgram([file], {
68
+ // Both consumers compile with strict off (backend: unset, frontend: false).
69
+ strict: false,
70
+ noEmit: true,
71
+ target: ts.ScriptTarget.ES2020,
72
+ module: ts.ModuleKind.CommonJS,
73
+ moduleResolution: ts.ModuleResolutionKind.Node10,
74
+ experimentalDecorators: true,
75
+ emitDecoratorMetadata: true,
76
+ esModuleInterop: true,
77
+ skipLibCheck: true,
78
+ });
79
+ const errors = ts
80
+ .getPreEmitDiagnostics(program)
81
+ .filter((d) => d.file?.fileName === file)
82
+ .map((d) => ts.flattenDiagnosticMessageText(d.messageText, "\n").split("\n")[0]);
83
+ assert.deepEqual(errors, []);
84
+ });
85
+
86
+ test("delegation rides in its own field, never in grantedScopes", () => {
87
+ const grant: Pick<import("./agency").I_CreatorAccessGrant, "grantedScopes" | "deals"> = { grantedScopes: ["analytics"], deals: true };
88
+ assert.equal(grant.deals, true);
89
+ assert.deepEqual(grant.grantedScopes, ["analytics"]);
90
+ });
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Spec 148 audit #1: the additive wire fields the fixes need. Pinned
3
+ * so a rename is a decision: the terms-version refusal code the frontend
4
+ * words, and the optional fields an older backend simply leaves out.
5
+ */
6
+ import { test } from "node:test";
7
+ import assert from "node:assert/strict";
8
+
9
+ import { ACCESS_DENIAL_CODES } from "./I_AccessDenial";
10
+ import type { I_AgencyCampaignEvent, I_CreatorCampaignView, I_CreatorDelegateAct } from "./agency-campaigns";
11
+ import type { I_DealContract, I_DealroomListing } from "./dealroom";
12
+ import type { I_CreatorAccessGrant } from "./agency";
13
+
14
+ test("an unset terms version is a coded refusal, never a 500", () => {
15
+ assert.equal(ACCESS_DENIAL_CODES.AGENCY_TERMS_VERSION_MISSING, "agency-terms-version-missing");
16
+ });
17
+
18
+ test("the listing names its campaign; an older backend may leave it out", () => {
19
+ const named: Pick<I_DealroomListing, "campaignName"> = { campaignName: "Poranna rutyna" };
20
+ const older: Pick<I_DealroomListing, "campaignName"> = {};
21
+ assert.equal(named.campaignName, "Poranna rutyna");
22
+ assert.equal(older.campaignName, undefined);
23
+ });
24
+
25
+ test("a contract carries the caller's own signing link, when there is one", () => {
26
+ const mine: Pick<I_DealContract, "signingUrl"> = { signingUrl: "https://sign.example.test/s/1" };
27
+ const none: Pick<I_DealContract, "signingUrl"> = { signingUrl: null };
28
+ assert.ok(mine.signingUrl);
29
+ assert.equal(none.signingUrl, null);
30
+ });
31
+
32
+ test("an application's events are marked as such, not as a note", () => {
33
+ const event: I_AgencyCampaignEvent = { kind: "joined", side: "creator", at: "2026-10-07T10:00:00.000Z", fromApplication: true };
34
+ assert.equal(event.fromApplication, true);
35
+ assert.equal(event.note, undefined);
36
+ });
37
+
38
+ test("the creator's own view lists what a manager did for them", () => {
39
+ const act: I_CreatorDelegateAct = { kind: "material_uploaded", at: "2026-10-07T10:00:00.000Z", memberName: "Ola", organizationName: "Demo Talent Studio" };
40
+ const view: Pick<I_CreatorCampaignView, "delegateActs"> = { delegateActs: [act] };
41
+ assert.equal(view.delegateActs?.[0].memberName, "Ola");
42
+ });
43
+
44
+ test("a grant names its organization's type, so a brand is never offered `deals`", () => {
45
+ const grant: Pick<I_CreatorAccessGrant, "organizationType"> = { organizationType: "brand" };
46
+ assert.equal(grant.organizationType, "brand");
47
+ });
48
+
49
+ test("review #14/#28: money-off, an unset value, a waiting template and a missing logo are four codes", async () => {
50
+ const { DEALROOM_CODES } = await import("./dealroom");
51
+ assert.equal(DEALROOM_CODES.MONEY_DISABLED, "dealroom-money-disabled");
52
+ assert.equal(DEALROOM_CODES.CONFIG_MISSING, "dealroom-config-missing");
53
+ assert.equal(DEALROOM_CODES.TEMPLATE_PENDING, "dealroom-template-pending");
54
+ assert.equal(DEALROOM_CODES.LOGO_REQUIRED, "dealroom-logo-required");
55
+ assert.equal(new Set(Object.values(DEALROOM_CODES)).size, Object.values(DEALROOM_CODES).length);
56
+ });
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Spec 148 wave 2 (T023-T027, T063): the wire shapes the listing, the brand
3
+ * door and the apply route read. Validated through class-validator, the way
4
+ * the backend's global ValidationPipe reads them.
5
+ */
6
+ import { test } from "node:test";
7
+ import assert from "node:assert/strict";
8
+
9
+ import { AdminSetOrganizationStatusDto, CreateAgencyOrganizationDto } from "./agency";
10
+ import { CreateAgencyCampaignDto, UpdateAgencyCampaignDto } from "./agency-campaigns";
11
+ import { ApplyToDealroomListingDto, DEALROOM_LISTING_STATES } from "./dealroom";
12
+
13
+ const errorsOf = async (dto: object): Promise<string[]> => {
14
+ const { validate } = await import("class-validator");
15
+ return (await validate(dto)).map((e) => e.property);
16
+ };
17
+
18
+ const org = { name: "Marka", registryId: "1", registryCountry: "PL", contactEmail: "a@b.pl" };
19
+
20
+ test("an organization's type is optional (agency) and only agency or brand", async () => {
21
+ assert.deepEqual(await errorsOf(new CreateAgencyOrganizationDto(org)), []);
22
+ assert.deepEqual(await errorsOf(new CreateAgencyOrganizationDto({ ...org, type: "brand" })), []);
23
+ assert.deepEqual(await errorsOf(new CreateAgencyOrganizationDto({ ...org, type: "school" as never })), ["type"]);
24
+ assert.ok(new AdminSetOrganizationStatusDto());
25
+ });
26
+
27
+ test("the listing fields ride on 175's campaign bodies, each optional and checked", async () => {
28
+ assert.deepEqual(await errorsOf(new CreateAgencyCampaignDto({ name: "X" })), []);
29
+ assert.deepEqual(
30
+ await errorsOf(
31
+ new UpdateAgencyCampaignDto({
32
+ visibility: "open",
33
+ targetProfile: { platforms: ["tiktok"], followerBand: {}, niches: ["beauty"] },
34
+ slots: 3,
35
+ applicationDeadline: "2026-12-01T00:00:00.000Z",
36
+ closedAt: "2026-11-01T00:00:00.000Z",
37
+ }),
38
+ ),
39
+ [],
40
+ );
41
+ assert.deepEqual(
42
+ (await errorsOf(new UpdateAgencyCampaignDto({ visibility: "public" as never, slots: 0, applicationDeadline: "soon" }))).sort(),
43
+ ["applicationDeadline", "slots", "visibility"],
44
+ );
45
+ });
46
+
47
+ test("an application names at least one scope, from the reads only", async () => {
48
+ assert.deepEqual(await errorsOf(new ApplyToDealroomListingDto({ scopes: ["analytics"], priceMinor: 100, currency: "PLN" })), []);
49
+ assert.deepEqual(await errorsOf(new ApplyToDealroomListingDto({ scopes: [] })), ["scopes"]);
50
+ assert.deepEqual(await errorsOf(new ApplyToDealroomListingDto({ scopes: ["deals" as never] })), ["scopes"]);
51
+ assert.deepEqual(await errorsOf(new ApplyToDealroomListingDto({ scopes: ["posts"], currency: "zł" })), ["currency"]);
52
+ });
53
+
54
+ test("the derived listing states, in order", () => {
55
+ assert.deepEqual([...DEALROOM_LISTING_STATES], ["draft", "live", "frozen", "closed"]);
56
+ });
57
+
58
+ test("a brand's first-charge card is optional and a Stripe payment method id only (dormant)", async () => {
59
+ assert.deepEqual(await errorsOf(new CreateAgencyOrganizationDto({ ...org, type: "brand", paymentMethodId: "pm_123abc" })), []);
60
+ assert.deepEqual(await errorsOf(new CreateAgencyOrganizationDto({ ...org, type: "brand", paymentMethodId: "4242424242424242" })), ["paymentMethodId"]);
61
+ });
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Spec 148 wave 3 (T068/T069 US8, T067 US6, T041 US9): the wire shapes the
3
+ * delegation, the offer-deal publication and the calendar read. Validated
4
+ * through class-validator, the way the backend's global ValidationPipe reads
5
+ * them (an empty construct, then the body's properties assigned).
6
+ */
7
+ import { test } from "node:test";
8
+ import assert from "node:assert/strict";
9
+
10
+ import { ACTING_FOR_HEADER, UpdateGrantScopesDto } from "./agency";
11
+ import { DEALROOM_CALENDAR_KINDS, DealroomCalendarQueryDto } from "./dealroom";
12
+
13
+ const errorsOf = async (dto: object): Promise<string[]> => {
14
+ const { validate } = await import("class-validator");
15
+ return (await validate(dto)).map((e) => e.property).sort();
16
+ };
17
+
18
+ /** What the ValidationPipe does: `new Dto()`, then the body's own keys. */
19
+ const piped = (body: Record<string, unknown>) => Object.assign(new UpdateGrantScopesDto(), body);
20
+
21
+ test("the data-access screen's reads-only save is unchanged and never names deals", async () => {
22
+ assert.deepEqual(await errorsOf(piped({ scopes: ["analytics"] })), []);
23
+ assert.equal(piped({ scopes: ["analytics"] }).deals, undefined);
24
+ assert.deepEqual(await errorsOf(piped({ scopes: [] })), ["scopes"]);
25
+ assert.deepEqual(await errorsOf(piped({})), ["scopes"]);
26
+ });
27
+
28
+ test("`deals` is never a read scope: it cannot ride in `scopes`", async () => {
29
+ assert.deepEqual(await errorsOf(piped({ scopes: ["analytics", "deals"] })), ["scopes"]);
30
+ });
31
+
32
+ test("delegation is its own boolean, with or without the reads", async () => {
33
+ assert.deepEqual(await errorsOf(piped({ deals: true })), []);
34
+ assert.deepEqual(await errorsOf(piped({ deals: false })), []);
35
+ assert.deepEqual(await errorsOf(piped({ scopes: ["posts"], deals: true })), []);
36
+ assert.deepEqual(await errorsOf(piped({ deals: "yes" })), ["deals"]);
37
+ assert.deepEqual(await errorsOf(piped({ scopes: [], deals: true })), ["scopes"]);
38
+ });
39
+
40
+ test("the delegate names the creator in one header, lower-case as Node reads it", () => {
41
+ assert.equal(ACTING_FOR_HEADER, "x-acting-for");
42
+ });
43
+
44
+ test("the calendar window needs both ends as dates", async () => {
45
+ assert.deepEqual(await errorsOf(new DealroomCalendarQueryDto({ from: "2026-10-01", to: "2026-11-01" })), []);
46
+ assert.deepEqual(await errorsOf(new DealroomCalendarQueryDto({ from: "soon" })), ["from", "to"]);
47
+ });
48
+
49
+ test("the calendar's marker kinds, in order", () => {
50
+ assert.deepEqual([...DEALROOM_CALENDAR_KINDS], [
51
+ "milestone",
52
+ "application_deadline",
53
+ "materials_due",
54
+ "publication_due",
55
+ "publish_at",
56
+ "bonus_ends",
57
+ "keep_up_ends",
58
+ ]);
59
+ });
60
+
61
+ test("wave 3b: the delegate-consent code, the roster's act-for flag and the followers gap's platform", async () => {
62
+ const { DEALROOM_CODES } = await import("./dealroom");
63
+ assert.equal(DEALROOM_CODES.DELEGATE_CONSENT_FORBIDDEN, "dealroom-delegate-consent-forbidden");
64
+ const gap: import("./dealroom").I_NearMatchGap = { kind: "followers", platform: "tiktok", followers: 8_800, band: { min: 10_000 }, percent: 12, direction: "under" };
65
+ assert.equal(gap.kind === "followers" && gap.platform, "tiktok");
66
+ const row: import("./agency").I_AgencyRosterRow = { grantId: "g", creator: { id: "c", handle: "@a" }, platforms: [], state: "active", grantedScopes: ["analytics"], canActForCreator: true };
67
+ assert.equal(row.canActForCreator, true);
68
+ });
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Spec 148 wave 4 (US5 T029/T030, US6 T032/T033, US7 T036/T037), DORMANT:
3
+ * the wire shapes of the contract step, the deal money and the payouts.
4
+ * Validated through class-validator, the way the backend's global
5
+ * ValidationPipe reads them (an empty construct, then the body's keys).
6
+ */
7
+ import { test } from "node:test";
8
+ import assert from "node:assert/strict";
9
+
10
+ import { AGENCY_CAMPAIGN_EVENT_KINDS } from "./agency-campaigns";
11
+ import {
12
+ CreateDealContractDto,
13
+ DEAL_CONTRACT_SOURCES,
14
+ DEAL_CONTRACT_STATES,
15
+ DEAL_MONEY_ACTIONS,
16
+ DEAL_PAYMENT_METHODS,
17
+ DEAL_SIGNATURE_LEVELS,
18
+ DEALROOM_CODES,
19
+ DEALROOM_MONEY_EVENT_KINDS,
20
+ PAYOUT_PROFILE_FIELDS,
21
+ StartDealPaymentDto,
22
+ UpsertPayoutProfileDto,
23
+ } from "./dealroom";
24
+
25
+ const errorsOf = async (dto: object): Promise<string[]> => {
26
+ const { validate } = await import("class-validator");
27
+ return (await validate(dto)).map((e) => e.property).sort();
28
+ };
29
+
30
+ test("the contract step's vocabulary (data-model §dealroom_contracts)", () => {
31
+ assert.deepEqual([...DEAL_CONTRACT_SOURCES], ["template", "uploaded"]);
32
+ assert.deepEqual([...DEAL_CONTRACT_STATES], ["draft", "sent", "signed"]);
33
+ assert.deepEqual([...DEAL_SIGNATURE_LEVELS], ["SES", "QES"]);
34
+ });
35
+
36
+ test("a template contract takes the form fields; an upload takes the file", async () => {
37
+ const template = Object.assign(new CreateDealContractDto(), {
38
+ source: "template",
39
+ deliverables: ["1 reel on Instagram"],
40
+ liveByAt: "2026-11-01T10:00:00.000Z",
41
+ licenceMonths: 6,
42
+ });
43
+ assert.deepEqual(await errorsOf(template), []);
44
+
45
+ const upload = Object.assign(new CreateDealContractDto(), { source: "uploaded", fileName: "umowa.pdf", fileBase64: "JVBERi0=" });
46
+ assert.deepEqual(await errorsOf(upload), []);
47
+
48
+ assert.deepEqual(await errorsOf(Object.assign(new CreateDealContractDto(), { source: "fax" })), ["source"]);
49
+ assert.deepEqual(await errorsOf(Object.assign(new CreateDealContractDto(), { source: "template", signatureLevel: "AES" })), ["signatureLevel"]);
50
+ assert.deepEqual(await errorsOf(Object.assign(new CreateDealContractDto(), { source: "template", licenceMonths: -1 })), ["licenceMonths"]);
51
+ });
52
+
53
+ test("a payment names its method or none (card by default; invoice for big sums)", async () => {
54
+ assert.deepEqual([...DEAL_PAYMENT_METHODS], ["card", "invoice"]);
55
+ assert.deepEqual(await errorsOf(new StartDealPaymentDto()), []);
56
+ assert.deepEqual(await errorsOf(Object.assign(new StartDealPaymentDto(), { method: "invoice" })), []);
57
+ assert.deepEqual(await errorsOf(Object.assign(new StartDealPaymentDto(), { method: "cash" })), ["method"]);
58
+ });
59
+
60
+ test("the payout profile: IBAN and payout currency, nothing about a provider", async () => {
61
+ const ok = Object.assign(new UpsertPayoutProfileDto(), { iban: "PL61109010140000071219812874", payoutCurrency: "PLN", nip: "5260250274" });
62
+ assert.deepEqual(await errorsOf(ok), []);
63
+ assert.deepEqual(await errorsOf(Object.assign(new UpsertPayoutProfileDto(), { iban: "not an iban", payoutCurrency: "PLN" })), ["iban"]);
64
+ assert.deepEqual(await errorsOf(Object.assign(new UpsertPayoutProfileDto(), { iban: "PL61109010140000071219812874", payoutCurrency: "zł" })), ["payoutCurrency"]);
65
+ assert.equal("stripeAccountId" in ok, false);
66
+ assert.deepEqual([...PAYOUT_PROFILE_FIELDS], ["settlement_form", "nip", "tax_statement", "intl_billing_data", "iban", "payout_currency"]);
67
+ });
68
+
69
+ test("the money acts a deal view offers its reader", () => {
70
+ assert.deepEqual([...DEAL_MONEY_ACTIONS], ["send_contract", "pay_deposit", "decline_deposit", "fund", "refund", "claim_guarantee"]);
71
+ });
72
+
73
+ test("the four wave-4 codes are added, never renamed", () => {
74
+ assert.equal(DEALROOM_CODES.DEAL_BELOW_MINIMUM, "dealroom-deal-below-minimum");
75
+ assert.equal(DEALROOM_CODES.MONEY_WRONG_STEP, "dealroom-money-wrong-step");
76
+ assert.equal(DEALROOM_CODES.CONTRACT_EXISTS, "dealroom-contract-exists");
77
+ assert.equal(DEALROOM_CODES.GUARANTEE_UNAVAILABLE, "dealroom-guarantee-unavailable");
78
+ });
79
+
80
+ test("contract, money and payout steps are kinds in 175's one event log (FR-008a)", () => {
81
+ for (const kind of DEALROOM_MONEY_EVENT_KINDS) {
82
+ assert.ok((AGENCY_CAMPAIGN_EVENT_KINDS as readonly string[]).includes(kind), kind);
83
+ assert.ok(kind.length <= 32, kind);
84
+ }
85
+ assert.deepEqual([...DEALROOM_MONEY_EVENT_KINDS], [
86
+ "contract_sent",
87
+ "contract_signed",
88
+ "contract_declined",
89
+ "deposit_paid",
90
+ "deal_funded",
91
+ "deal_closed_unviewed",
92
+ "deal_refund_requested",
93
+ "deal_refunded",
94
+ "guarantee_claimed",
95
+ "payout_initiated",
96
+ "payout_paid",
97
+ ]);
98
+ });
99
+
100
+ test("a payout profile with no payable settlement form names no billing variant", () => {
101
+ const profile: import("./dealroom").I_PayoutProfile = { billingVariant: null, track: null, payoutCurrency: "PLN", kycState: "none" };
102
+ assert.equal(profile.billingVariant, null);
103
+ });