ad2app-lib 1.44.1 → 1.49.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/analytics/index.d.ts +44 -0
- package/dist/analytics/index.js +17 -0
- package/dist/types/I_AccessDenial.d.ts +19 -0
- package/dist/types/I_AccessDenial.js +19 -0
- package/dist/types/I_Offer.d.ts +3 -1
- package/dist/types/I_Offer.js +2 -0
- package/dist/types/agency-campaigns.d.ts +391 -0
- package/dist/types/agency-campaigns.js +426 -0
- package/dist/types/agency-threads.d.ts +87 -0
- package/dist/types/agency-threads.js +60 -0
- package/dist/types/agency.d.ts +250 -6
- package/dist/types/agency.js +54 -2
- package/dist/types/index.d.ts +2 -0
- package/dist/types/index.js +2 -0
- package/dist/types/scheduling/I_SchedulingProUpgrade.d.ts +10 -1
- package/dist/types/scheduling/I_SchedulingProUpgrade.js +4 -0
- package/package.json +2 -2
- package/src/analytics/index.test.ts +41 -1
- package/src/analytics/index.ts +39 -0
- package/src/types/I_AccessDenial.ts +19 -0
- package/src/types/I_Offer.ts +2 -0
- package/src/types/access-denial.test.ts +1 -1
- package/src/types/agency-billing.test.ts +83 -0
- package/src/types/agency-campaigns.test.ts +85 -0
- package/src/types/agency-campaigns.ts +663 -0
- package/src/types/agency-discover.test.ts +79 -0
- package/src/types/agency-threads.ts +103 -0
- package/src/types/agency.test.ts +21 -1
- package/src/types/agency.ts +261 -6
- package/src/types/index.ts +2 -0
- package/src/types/scheduling/I_SchedulingProUpgrade.ts +13 -0
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { IsNotEmpty, IsString, Matches, MaxLength } from 'class-validator';
|
|
2
|
+
|
|
3
|
+
import type { CampaignSide, I_CampaignCreatorIdentity } from './agency-campaigns';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Agency ↔ creator threads (spec 175 US8, FR-023): one conversation per
|
|
7
|
+
* organization and creator, inside ad2app.
|
|
8
|
+
*
|
|
9
|
+
* Deliberately NOT the creator's platform inbox: nothing here reaches a social
|
|
10
|
+
* platform, and no grant scope opens a thread (SC-015). A thread is the two
|
|
11
|
+
* parties' own; a grant is a read of the creator's numbers, and the two never
|
|
12
|
+
* meet.
|
|
13
|
+
*
|
|
14
|
+
* "Unanswered" is derived: the other side wrote last. There is no read state
|
|
15
|
+
* and no mark-read step.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export const AGENCY_THREAD_CODES = {
|
|
19
|
+
/** No live relationship any more: the history stays readable, nothing can be sent (FR-023). */
|
|
20
|
+
CLOSED: 'agency-thread-closed',
|
|
21
|
+
/** Too many messages from one person in the window (THREAD_POST_LIMIT). */
|
|
22
|
+
RATE_LIMITED: 'agency-thread-rate-limited',
|
|
23
|
+
} as const;
|
|
24
|
+
|
|
25
|
+
/** Messages one person may post across all their threads per window: a conversation pace, never a script's. */
|
|
26
|
+
export const THREAD_POST_LIMIT = { limit: 60, windowMinutes: 10 } as const;
|
|
27
|
+
|
|
28
|
+
/** Long enough for a real message, short enough to stay a message. */
|
|
29
|
+
export const THREAD_MESSAGE_MAX = 4000;
|
|
30
|
+
|
|
31
|
+
/** Messages per read: the newest page with the thread, earlier pages on request. */
|
|
32
|
+
export const THREAD_PAGE_SIZE = 100;
|
|
33
|
+
|
|
34
|
+
export interface I_AgencyThreadMessage {
|
|
35
|
+
id: string;
|
|
36
|
+
side: CampaignSide;
|
|
37
|
+
body: string;
|
|
38
|
+
at: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface I_AgencyThreadSummary {
|
|
42
|
+
id: string;
|
|
43
|
+
/** Agency side: who the thread is with. */
|
|
44
|
+
creator: I_CampaignCreatorIdentity;
|
|
45
|
+
/** Creator side: which organization. */
|
|
46
|
+
organization: { id: string; name: string };
|
|
47
|
+
lastMessage: { side: CampaignSide; body: string; at: string } | null;
|
|
48
|
+
/** The other side wrote last, seen from the caller's side. */
|
|
49
|
+
unanswered: boolean;
|
|
50
|
+
/** No live relationship: read-only for whoever still exists. */
|
|
51
|
+
closed: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface I_AgencyThread {
|
|
55
|
+
summary: I_AgencyThreadSummary;
|
|
56
|
+
/** The newest page, oldest first. */
|
|
57
|
+
messages: I_AgencyThreadMessage[];
|
|
58
|
+
/** Older messages exist: read them with `…/messages?before=<oldest id>`. */
|
|
59
|
+
hasEarlier: boolean;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** `GET …/threads/:id/messages?before=<message id>` — the page before a message. */
|
|
63
|
+
export interface I_AgencyThreadPage {
|
|
64
|
+
/** Oldest first. */
|
|
65
|
+
messages: I_AgencyThreadMessage[];
|
|
66
|
+
hasEarlier: boolean;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** `GET /agency/threads` — the agency's inbox. */
|
|
70
|
+
export interface I_AgencyThreads {
|
|
71
|
+
items: I_AgencyThreadSummary[];
|
|
72
|
+
/** Creators on the roster the agency has no open thread with yet. */
|
|
73
|
+
startable: I_CampaignCreatorIdentity[];
|
|
74
|
+
/** The Inbox rail count. */
|
|
75
|
+
unanswered: number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** `GET /agency/creator-threads/mine` — the creator's threads, in the Agency tab. */
|
|
79
|
+
export interface I_CreatorThreads {
|
|
80
|
+
items: I_AgencyThreadSummary[];
|
|
81
|
+
unanswered: number;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export class StartAgencyThreadDto {
|
|
85
|
+
@IsString()
|
|
86
|
+
@Matches(/^[A-Za-z0-9-]{1,128}$/)
|
|
87
|
+
creatorUserId: string;
|
|
88
|
+
|
|
89
|
+
constructor(data?: Partial<StartAgencyThreadDto>) {
|
|
90
|
+
this.creatorUserId = data?.creatorUserId ?? '';
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export class PostThreadMessageDto {
|
|
95
|
+
@IsString()
|
|
96
|
+
@IsNotEmpty()
|
|
97
|
+
@MaxLength(THREAD_MESSAGE_MAX)
|
|
98
|
+
text: string;
|
|
99
|
+
|
|
100
|
+
constructor(data?: Partial<PostThreadMessageDto>) {
|
|
101
|
+
this.text = data?.text ?? '';
|
|
102
|
+
}
|
|
103
|
+
}
|
package/src/types/agency.test.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
CREATOR_ACCESS_GRANT_STATES,
|
|
8
8
|
AGENCY_ROSTER_ROW_STATES,
|
|
9
9
|
AGENCY_ROSTER_NEEDS_ATTENTION_STATES,
|
|
10
|
+
AGENCY_ROSTER_METRICS_STATES,
|
|
10
11
|
isAgencyGrantScope,
|
|
11
12
|
CreateAgencyOrganizationDto,
|
|
12
13
|
AgencyAccessRequestDto,
|
|
@@ -50,7 +51,7 @@ test("grant states are the five the data model pins, including declined and expi
|
|
|
50
51
|
test("roster row states include `stale`, and `revoked` is NOT one of them", () => {
|
|
51
52
|
assert.deepEqual(
|
|
52
53
|
[...AGENCY_ROSTER_ROW_STATES],
|
|
53
|
-
["requested", "lapsed", "disconnected", "stale", "active"],
|
|
54
|
+
["requested", "lapsed", "disconnected", "syncing", "stale", "active"],
|
|
54
55
|
);
|
|
55
56
|
assert.equal(
|
|
56
57
|
(AGENCY_ROSTER_ROW_STATES as readonly string[]).includes("revoked"),
|
|
@@ -66,6 +67,11 @@ test("needs-attention is disconnected + lapsed + stale, and never `requested` (F
|
|
|
66
67
|
false,
|
|
67
68
|
"a requested grant waits on the CREATOR, so it never lights the agency's badge",
|
|
68
69
|
);
|
|
70
|
+
assert.equal(
|
|
71
|
+
(AGENCY_ROSTER_NEEDS_ATTENTION_STATES as readonly string[]).includes("syncing"),
|
|
72
|
+
false,
|
|
73
|
+
"a creator who joined within the day waits on the first sync, not on the agency (T067)",
|
|
74
|
+
);
|
|
69
75
|
// Every needs-attention state must be a real roster state, or the tile counts a
|
|
70
76
|
// state no row can ever hold.
|
|
71
77
|
for (const state of AGENCY_ROSTER_NEEDS_ATTENTION_STATES) {
|
|
@@ -73,6 +79,20 @@ test("needs-attention is disconnected + lapsed + stale, and never `requested` (F
|
|
|
73
79
|
}
|
|
74
80
|
});
|
|
75
81
|
|
|
82
|
+
test("only rows whose card opens carry numbers: active and stale (review M1/M2, 2026-09-24)", () => {
|
|
83
|
+
assert.deepEqual([...AGENCY_ROSTER_METRICS_STATES], ["syncing", "stale", "active"]);
|
|
84
|
+
for (const blocked of ["requested", "lapsed", "disconnected"]) {
|
|
85
|
+
assert.equal(
|
|
86
|
+
(AGENCY_ROSTER_METRICS_STATES as readonly string[]).includes(blocked),
|
|
87
|
+
false,
|
|
88
|
+
`a ${blocked} row must carry no metrics and count in no total`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
for (const state of AGENCY_ROSTER_METRICS_STATES) {
|
|
92
|
+
assert.ok((AGENCY_ROSTER_ROW_STATES as readonly string[]).includes(state));
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
76
96
|
test("DTOs construct from a plain object (the house `new I_DTO()` shape)", () => {
|
|
77
97
|
const org = new CreateAgencyOrganizationDto({
|
|
78
98
|
name: "Studio Kot",
|
package/src/types/agency.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
ArrayNotEmpty,
|
|
3
3
|
IsArray,
|
|
4
|
+
IsBoolean,
|
|
4
5
|
IsEmail,
|
|
5
6
|
IsIn,
|
|
6
7
|
IsNotEmpty,
|
|
@@ -9,6 +10,7 @@ import {
|
|
|
9
10
|
IsUrl,
|
|
10
11
|
IsUUID,
|
|
11
12
|
Length,
|
|
13
|
+
Matches,
|
|
12
14
|
} from 'class-validator';
|
|
13
15
|
|
|
14
16
|
/**
|
|
@@ -50,6 +52,8 @@ export const AGENCY_ROUTE_PATHS = {
|
|
|
50
52
|
join: '/agency/join',
|
|
51
53
|
/** A creator answering an access request (FR-016). */
|
|
52
54
|
requests: '/agency/requests',
|
|
55
|
+
/** A creator's campaign invitations and campaigns: the Agency tab of their Inbox (FR-023, FR-026). */
|
|
56
|
+
creatorCampaigns: '/inbox/agency',
|
|
53
57
|
} as const;
|
|
54
58
|
|
|
55
59
|
// ── Scopes ───────────────────────────────────────────────────────────────────
|
|
@@ -145,6 +149,12 @@ export interface I_AgencyMember {
|
|
|
145
149
|
/** Null while an e-mail invitation is outstanding — nobody has accepted yet. */
|
|
146
150
|
userId?: string;
|
|
147
151
|
invitedEmail: string;
|
|
152
|
+
/**
|
|
153
|
+
* The account's own e-mail once the invitation is accepted (and always for
|
|
154
|
+
* the owner, who applied rather than being invited). The Team panel names a
|
|
155
|
+
* person by this, never by the organization's contact address (T063).
|
|
156
|
+
*/
|
|
157
|
+
accountEmail?: string;
|
|
148
158
|
role: AgencyMemberRole;
|
|
149
159
|
invitedAt: string;
|
|
150
160
|
/** "Who is in" is exactly `acceptedAt != null`. */
|
|
@@ -216,8 +226,30 @@ export interface I_CreatorAccessGrant {
|
|
|
216
226
|
sponsorship?: { tier: SponsoredTier; since: string; until?: string };
|
|
217
227
|
/** Which platforms the grant reaches right now — derived at read time, never stored. */
|
|
218
228
|
platformsReached: PublishPlatform[];
|
|
229
|
+
/**
|
|
230
|
+
* The agency's OPEN second ask on an active grant, for scopes the creator
|
|
231
|
+
* did not grant (FR-008). Kept apart from `requestedScopes`, so the consent
|
|
232
|
+
* screen names exactly this ask and nothing refused earlier (review H4).
|
|
233
|
+
* Absent when there is no open ask.
|
|
234
|
+
*/
|
|
235
|
+
reaskedScopes?: I_AgencyGrantScope[];
|
|
236
|
+
/** When that open ask expires unanswered. */
|
|
237
|
+
reaskExpiresAt?: string;
|
|
219
238
|
}
|
|
220
239
|
|
|
240
|
+
/**
|
|
241
|
+
* Outcomes of discovery and requests that are not refusals of access, sent as
|
|
242
|
+
* codes so the screen composes the sentence in the reader's language (FR-019,
|
|
243
|
+
* review M4). A 429 carries `{ code, limit, windowMinutes }`.
|
|
244
|
+
*/
|
|
245
|
+
export const AGENCY_REQUEST_CODES = {
|
|
246
|
+
RATE_LIMITED: 'agency-rate-limited',
|
|
247
|
+
REQUEST_EXPIRED: 'agency-request-expired',
|
|
248
|
+
REQUEST_ANSWERED: 'agency-request-answered',
|
|
249
|
+
} as const;
|
|
250
|
+
|
|
251
|
+
export type AgencyRequestCode = (typeof AGENCY_REQUEST_CODES)[keyof typeof AGENCY_REQUEST_CODES];
|
|
252
|
+
|
|
221
253
|
// ── Roster ───────────────────────────────────────────────────────────────────
|
|
222
254
|
|
|
223
255
|
/**
|
|
@@ -234,6 +266,13 @@ export const AGENCY_ROSTER_ROW_STATES = [
|
|
|
234
266
|
'lapsed',
|
|
235
267
|
/** the creator has no live platform connection */
|
|
236
268
|
'disconnected',
|
|
269
|
+
/**
|
|
270
|
+
* shared within the last 24 hours and not synced yet: the first daily sync
|
|
271
|
+
* has not run. Not stale, and not something the agency can act on (T067,
|
|
272
|
+
* the 2026-09-26 UI run: a creator who agreed a minute ago read as
|
|
273
|
+
* "needs attention").
|
|
274
|
+
*/
|
|
275
|
+
'syncing',
|
|
237
276
|
/** no successful daily sync in 24 hours (FR-011, Jan 2026-09-21) */
|
|
238
277
|
'stale',
|
|
239
278
|
'active',
|
|
@@ -257,30 +296,115 @@ export const AGENCY_ROSTER_NEEDS_ATTENTION_STATES = [
|
|
|
257
296
|
export type AgencyRosterNeedsAttentionState =
|
|
258
297
|
(typeof AGENCY_ROSTER_NEEDS_ATTENTION_STATES)[number];
|
|
259
298
|
|
|
299
|
+
/**
|
|
300
|
+
* The rows that carry numbers — ONE home for "which creators' numbers may the
|
|
301
|
+
* agency see on the roster" (decided 2026-09-24, review M1/M2).
|
|
302
|
+
*
|
|
303
|
+
* Only a row whose card OPENS carries `metrics` and counts in the followers,
|
|
304
|
+
* followers-delta and comments totals: `active`, and `stale` (the card opens;
|
|
305
|
+
* the numbers are as old as `lastSyncAt` says). A `lapsed` creator's reads are
|
|
306
|
+
* refused on the card, so their numbers must not reach the agency through the
|
|
307
|
+
* roster instead; a `disconnected` creator has nothing live to count; a
|
|
308
|
+
* `requested` creator has shared nothing yet.
|
|
309
|
+
*/
|
|
310
|
+
export const AGENCY_ROSTER_METRICS_STATES = [
|
|
311
|
+
'syncing',
|
|
312
|
+
'stale',
|
|
313
|
+
'active',
|
|
314
|
+
] as const satisfies readonly AgencyRosterRowState[];
|
|
315
|
+
|
|
316
|
+
export type AgencyRosterMetricsState = (typeof AGENCY_ROSTER_METRICS_STATES)[number];
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* One creator's numbers on the roster (FR-011a: sortable metric columns), read
|
|
320
|
+
* from the daily-synced tables, never a vendor call. `null` is a REASON, never
|
|
321
|
+
* a zero: the creator did not grant that scope, or nothing has been synced yet.
|
|
322
|
+
*/
|
|
323
|
+
export interface I_AgencyRosterRowMetrics {
|
|
324
|
+
/** Latest followers over the creator's CONNECTED platforms; null without `analytics` or before the first sync. */
|
|
325
|
+
followers: number | null;
|
|
326
|
+
/** Against the snapshot seven days earlier, per connected platform; null without `analytics` or without a week-old point. */
|
|
327
|
+
followersDelta7d: number | null;
|
|
328
|
+
/** The current Monday-to-Sunday week, organization timezone; null without `comments`. */
|
|
329
|
+
commentsThisWeek: number | null;
|
|
330
|
+
}
|
|
331
|
+
|
|
260
332
|
export interface I_AgencyRosterRow {
|
|
261
333
|
grantId: string;
|
|
262
334
|
creator: { id: string; handle: string };
|
|
263
335
|
platforms: PublishPlatform[];
|
|
264
336
|
state: AgencyRosterRowState;
|
|
265
337
|
grantedScopes: I_AgencyGrantScope[];
|
|
338
|
+
/**
|
|
339
|
+
* The newest successful daily sync. Absent on a `requested` row (nothing is
|
|
340
|
+
* shared yet, so there is nothing to have synced for the agency) and on a
|
|
341
|
+
* creator never synced. `platforms` stays on a requested row: it is what the
|
|
342
|
+
* consent screen names, a fact about the account rather than shared data.
|
|
343
|
+
*/
|
|
266
344
|
lastSyncAt?: string;
|
|
345
|
+
/**
|
|
346
|
+
* Present ONLY on rows in AGENCY_ROSTER_METRICS_STATES (`active`, `stale`):
|
|
347
|
+
* the rows whose card opens. Absent on `lapsed`, `disconnected` and
|
|
348
|
+
* `requested` rows — no numbers, not null numbers.
|
|
349
|
+
*/
|
|
350
|
+
metrics?: I_AgencyRosterRowMetrics;
|
|
351
|
+
/**
|
|
352
|
+
* The agency's own last "Ask again" (FR-008), while its one-per-TTL clock
|
|
353
|
+
* runs (audit F19): when it was sent and when another is possible. It says
|
|
354
|
+
* nothing about the ANSWER, because the clock runs whatever the creator did.
|
|
355
|
+
* The roster uses it to say "asked on …, again after …" instead of offering
|
|
356
|
+
* an ask that would not be sent. Absent once the clock has run out.
|
|
357
|
+
*/
|
|
358
|
+
reaskedAt?: string;
|
|
359
|
+
reaskAvailableAt?: string;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* The platforms a creator has connected, as the agency side sees them
|
|
364
|
+
* (discovery, the grant's "reaches"). `GET users/me/connected-platforms`,
|
|
365
|
+
* answered to every creator, subscribed or not.
|
|
366
|
+
*/
|
|
367
|
+
export interface I_ConnectedPlatforms {
|
|
368
|
+
platforms: PublishPlatform[];
|
|
267
369
|
}
|
|
268
370
|
|
|
269
371
|
export interface I_AgencyRoster {
|
|
270
372
|
totals: {
|
|
271
373
|
creators: number;
|
|
272
374
|
active: number;
|
|
273
|
-
/**
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
375
|
+
/**
|
|
376
|
+
* Summed only over rows in AGENCY_ROSTER_METRICS_STATES whose creator holds
|
|
377
|
+
* `analytics`; disconnected platforms excluded. `null` when no counted
|
|
378
|
+
* creator has a synced number yet, never a zero standing in (T067).
|
|
379
|
+
*/
|
|
380
|
+
followers: number | null;
|
|
381
|
+
/**
|
|
382
|
+
* Same rows as `followers`, against the snapshot seven days earlier. `null`
|
|
383
|
+
* when no counted creator has a week-old point — unknown, never a zero.
|
|
384
|
+
*/
|
|
385
|
+
followersDelta7d: number | null;
|
|
386
|
+
/**
|
|
387
|
+
* Calendar week, Mon-Sun, organization timezone, over rows in
|
|
388
|
+
* AGENCY_ROSTER_METRICS_STATES whose creator holds `comments`. Like
|
|
389
|
+
* `followers`, `null` when no counted creator has a synced number yet:
|
|
390
|
+
* unknown, never a zero (SC-008, T067).
|
|
391
|
+
*/
|
|
392
|
+
commentsThisWeek: number | null;
|
|
278
393
|
/** Count of rows in AGENCY_ROSTER_NEEDS_ATTENTION_STATES. */
|
|
279
394
|
needsAttention: number;
|
|
280
395
|
};
|
|
281
396
|
items: I_AgencyRosterRow[];
|
|
282
397
|
}
|
|
283
398
|
|
|
399
|
+
/**
|
|
400
|
+
* A coverage that ENDED recently, so the creator meeting the paywall again is
|
|
401
|
+
* told why (FR-014: "an honest message, never a silent loss of access").
|
|
402
|
+
*/
|
|
403
|
+
export interface I_SponsorshipEnded {
|
|
404
|
+
organizationName: string;
|
|
405
|
+
endedAt: string;
|
|
406
|
+
}
|
|
407
|
+
|
|
284
408
|
/** What a sponsored creator sees on their OWN subscription screen (FR-014). */
|
|
285
409
|
export interface I_SponsoredBy {
|
|
286
410
|
organizationName: string;
|
|
@@ -289,6 +413,89 @@ export interface I_SponsoredBy {
|
|
|
289
413
|
until?: string;
|
|
290
414
|
}
|
|
291
415
|
|
|
416
|
+
// ── Billing (US4) ────────────────────────────────────────────────────────────
|
|
417
|
+
|
|
418
|
+
/** The card on file, as Stripe describes it. Never more than these four facts. */
|
|
419
|
+
export interface I_AgencyPaymentMethod {
|
|
420
|
+
brand: string;
|
|
421
|
+
last4: string;
|
|
422
|
+
expMonth: number;
|
|
423
|
+
expYear: number;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* One invoice. `amount` is Stripe's minor unit verbatim; the display edge
|
|
428
|
+
* formats it once, like every other price in the app.
|
|
429
|
+
*/
|
|
430
|
+
export interface I_AgencyInvoice {
|
|
431
|
+
id: string;
|
|
432
|
+
number: string | null;
|
|
433
|
+
date: string;
|
|
434
|
+
amount: number;
|
|
435
|
+
currency: string;
|
|
436
|
+
status: string;
|
|
437
|
+
pdfUrl?: string;
|
|
438
|
+
hostedUrl?: string;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* One creator on an ACTIVE grant, with whether the agency covers their plan
|
|
443
|
+
* (FR-013b). `tier: null` is "not covered". `until` is set while a coverage is
|
|
444
|
+
* ending: it runs to the close of the period already paid for.
|
|
445
|
+
*/
|
|
446
|
+
export interface I_AgencyCoveredCreator {
|
|
447
|
+
grantId: string;
|
|
448
|
+
creator: { id: string; handle: string };
|
|
449
|
+
tier: SponsoredTier | null;
|
|
450
|
+
since?: string;
|
|
451
|
+
until?: string;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* An ended relationship, for the past-creators history (FR-013b). `creator.id`
|
|
456
|
+
* is null once the creator's account was purged; the handle is the snapshot
|
|
457
|
+
* frozen at grant time.
|
|
458
|
+
*/
|
|
459
|
+
export interface I_AgencyPastCreator {
|
|
460
|
+
grantId: string;
|
|
461
|
+
creator: { id: string | null; handle: string; displayName?: string };
|
|
462
|
+
endedAt: string;
|
|
463
|
+
endedBy: CreatorAccessGrantRevokedBy;
|
|
464
|
+
coveredUntil?: string;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Everything the owner's billing section and panel show (FR-013, FR-013b).
|
|
469
|
+
*
|
|
470
|
+
* `seats` is the ONE home of the billed quantity: the count of active grants.
|
|
471
|
+
* Covering a creator never changes it (clarify 2026-09-17), so nothing here
|
|
472
|
+
* derives a price from `covered`.
|
|
473
|
+
*
|
|
474
|
+
* `price` is null when the seat product is not configured in this deployment
|
|
475
|
+
* (T031, the money gate); the screen says so rather than inventing a number.
|
|
476
|
+
* `nextInvoice` is Stripe's own preview, so a coupon is already applied to it.
|
|
477
|
+
*/
|
|
478
|
+
export interface I_AgencyBilling {
|
|
479
|
+
subscriptionStatus: AgencySubscriptionStatus;
|
|
480
|
+
/** Creators with an active grant. */
|
|
481
|
+
seats: number;
|
|
482
|
+
/**
|
|
483
|
+
* What Stripe bills: `max(1, seats)`. An agency with no creators yet still
|
|
484
|
+
* pays for one seat, and the screen must say the number Stripe charges.
|
|
485
|
+
*/
|
|
486
|
+
billedSeats: number;
|
|
487
|
+
price: { amount: number; currency: string; interval: string } | null;
|
|
488
|
+
currentPeriodEnd?: string;
|
|
489
|
+
cancelAtPeriodEnd?: boolean;
|
|
490
|
+
nextInvoice?: { amount: number; currency: string; date: string };
|
|
491
|
+
discount?: { name?: string; percentOff?: number; amountOff?: number };
|
|
492
|
+
paymentMethod?: I_AgencyPaymentMethod;
|
|
493
|
+
billingDetails?: { name?: string; email?: string; country?: string; taxId?: string };
|
|
494
|
+
invoices: I_AgencyInvoice[];
|
|
495
|
+
covered: I_AgencyCoveredCreator[];
|
|
496
|
+
pastCreators: I_AgencyPastCreator[];
|
|
497
|
+
}
|
|
498
|
+
|
|
292
499
|
// ── DTOs ─────────────────────────────────────────────────────────────────────
|
|
293
500
|
|
|
294
501
|
/**
|
|
@@ -426,7 +633,13 @@ export class AgencyDiscoverQueryDto {
|
|
|
426
633
|
}
|
|
427
634
|
|
|
428
635
|
export class AgencyAccessRequestDto {
|
|
429
|
-
|
|
636
|
+
/**
|
|
637
|
+
* A user id: a UUID or a Firebase uid (letters, digits, hyphens). It was
|
|
638
|
+
* `@IsUUID()` until US5 was built, which refused most real creators — their
|
|
639
|
+
* ids are Firebase uids.
|
|
640
|
+
*/
|
|
641
|
+
@IsString()
|
|
642
|
+
@Matches(/^[A-Za-z0-9-]{1,128}$/)
|
|
430
643
|
userId: string;
|
|
431
644
|
|
|
432
645
|
@IsArray()
|
|
@@ -440,6 +653,48 @@ export class AgencyAccessRequestDto {
|
|
|
440
653
|
}
|
|
441
654
|
}
|
|
442
655
|
|
|
656
|
+
/** `PATCH /users/me/agency-discoverable` — "Let agencies find me" (FR-015). */
|
|
657
|
+
export class UpdateAgencyDiscoverableDto {
|
|
658
|
+
@IsBoolean()
|
|
659
|
+
discoverable: boolean;
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* NO default: a required boolean that defaults to false lets an empty body
|
|
663
|
+
* through validation and reach the database as `undefined` (review M7).
|
|
664
|
+
*/
|
|
665
|
+
constructor(data?: Partial<UpdateAgencyDiscoverableDto>) {
|
|
666
|
+
if (data?.discoverable !== undefined) this.discoverable = data.discoverable;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* One discover hit: the public handle and the platforms, NEVER a metric and
|
|
672
|
+
* never a grant flag, so the payload cannot tell a caller whether it already
|
|
673
|
+
* holds the creator (FR-015). `userId` is what the request is sent for.
|
|
674
|
+
*/
|
|
675
|
+
export interface I_AgencyDiscoverHit {
|
|
676
|
+
userId: string;
|
|
677
|
+
handle: string;
|
|
678
|
+
platforms: PublishPlatform[];
|
|
679
|
+
/**
|
|
680
|
+
* The platform whose username matched. Absent on an e-mail match. Two hits
|
|
681
|
+
* with the same handle are two different people (@mark on YouTube, another
|
|
682
|
+
* @mark on Instagram), and this is what tells them apart (T068).
|
|
683
|
+
*/
|
|
684
|
+
matchedPlatform?: PublishPlatform;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* `POST /agency/roster/discover` — the SAME shape and status for every case:
|
|
689
|
+
* discoverable exact matches, a non-discoverable account, a partial match and
|
|
690
|
+
* no account at all (SC-009). A miss is `{ results: [] }`, never a 404. An
|
|
691
|
+
* exact handle held by several findable creators returns each of them, one
|
|
692
|
+
* row per person (Maciej, 2026-09-26, T068); an e-mail matches one person.
|
|
693
|
+
*/
|
|
694
|
+
export interface I_AgencyDiscoverResult {
|
|
695
|
+
results: I_AgencyDiscoverHit[];
|
|
696
|
+
}
|
|
697
|
+
|
|
443
698
|
export class AdminSetOrganizationStatusDto {
|
|
444
699
|
@IsIn(['approved', 'rejected'])
|
|
445
700
|
status: Extract<AgencyOrganizationStatus, 'approved' | 'rejected'>;
|
package/src/types/index.ts
CHANGED
|
@@ -43,6 +43,8 @@ export * from "./agent";
|
|
|
43
43
|
|
|
44
44
|
// ── Agency domain (organizations, memberships, creator access grants, spec 175)
|
|
45
45
|
export * from "./agency";
|
|
46
|
+
export * from "./agency-campaigns";
|
|
47
|
+
export * from "./agency-threads";
|
|
46
48
|
|
|
47
49
|
// ── Access control ────────────────────────────────────────────────────────────
|
|
48
50
|
export * from "./I_AccessDenial";
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
* and the customer portal redirect.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import type { I_SponsoredBy, I_SponsorshipEnded } from '../agency';
|
|
9
|
+
|
|
8
10
|
// ── SchedulingSubscriptionTier ────────────────────────────────────────────────
|
|
9
11
|
|
|
10
12
|
export type SchedulingSubscriptionTier = 'free' | 'starter' | 'pro';
|
|
@@ -23,6 +25,7 @@ export type SchedulingSubscriptionInfoInput = Pick<
|
|
|
23
25
|
SchedulingSubscriptionInfoDTO,
|
|
24
26
|
'tier' | 'status' | 'expiresAt'
|
|
25
27
|
> &
|
|
28
|
+
Partial<Pick<SchedulingSubscriptionInfoDTO, 'sponsoredBy' | 'sponsorshipEnded'>> &
|
|
26
29
|
(
|
|
27
30
|
| { maxAccounts: number; maxPlatforms?: number }
|
|
28
31
|
| {
|
|
@@ -46,6 +49,14 @@ export class SchedulingSubscriptionInfoDTO {
|
|
|
46
49
|
* Read `maxAccounts`. Always equal to it; removed in a later release.
|
|
47
50
|
*/
|
|
48
51
|
maxPlatforms: number;
|
|
52
|
+
/**
|
|
53
|
+
* Present only while an agency covers this creator's plan (spec 175 FR-014).
|
|
54
|
+
* `until` appears once the sponsorship is ending: the date the plan runs to.
|
|
55
|
+
* Absent — never null — for everyone else, so "not sponsored" has one shape.
|
|
56
|
+
*/
|
|
57
|
+
sponsoredBy?: I_SponsoredBy;
|
|
58
|
+
/** Present only when a coverage ended recently and nothing replaced it. Absent otherwise. */
|
|
59
|
+
sponsorshipEnded?: I_SponsorshipEnded;
|
|
49
60
|
|
|
50
61
|
constructor(data: SchedulingSubscriptionInfoInput) {
|
|
51
62
|
const maxAccounts = 'maxAccounts' in data ? data.maxAccounts : data.maxPlatforms;
|
|
@@ -54,6 +65,8 @@ export class SchedulingSubscriptionInfoDTO {
|
|
|
54
65
|
this.expiresAt = data.expiresAt;
|
|
55
66
|
this.maxAccounts = maxAccounts;
|
|
56
67
|
this.maxPlatforms = maxAccounts;
|
|
68
|
+
if (data.sponsoredBy) this.sponsoredBy = data.sponsoredBy;
|
|
69
|
+
if (data.sponsorshipEnded) this.sponsorshipEnded = data.sponsorshipEnded;
|
|
57
70
|
}
|
|
58
71
|
}
|
|
59
72
|
|