ad2app-lib 1.31.0 → 1.34.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.
@@ -162,6 +162,39 @@ export type ComposeStep = 'platforms' | 'content' | 'details' | 'review';
162
162
  * PublishFailureReason. Do NOT rename after shipping — wire values.
163
163
  */
164
164
  export type ComposeBlockReason = 'empty' | 'missing_fields' | 'over_limit' | 'media' | 'channel_blocked' | 'poll';
165
+ /**
166
+ * Why a connect attempt ended without a connected account (spec 155 US3).
167
+ *
168
+ * Wire values, never renamed: historical rows do not migrate. A new cause is a
169
+ * new value appended here, never a re-meaning of an old one.
170
+ *
171
+ * - `paywall` — a free-tier user hit the connect wall (the pre-existing emit).
172
+ * - `canceled` — the person denied or dismissed the platform's OAuth.
173
+ * - `failed` — the vendor returned a failure on the callback.
174
+ * - `returned_without_account` — they came back and no account arrived: the
175
+ * silent abandon, e.g. Facebook's Page picker offering an empty list to
176
+ * someone who only has a personal profile.
177
+ * - `personal_profile` — they told us before OAuth that they post from a
178
+ * personal Facebook profile, which Meta's API cannot publish to.
179
+ */
180
+ /**
181
+ * Which finish line an onboarding completion crossed (094 US6/US8).
182
+ *
183
+ * Until 2026-09-01 the guided tour ended at a scheduled post, and almost
184
+ * nobody crossed it: four completions by two people in ninety days, against
185
+ * fifteen skips that all landed on the compose step. It now ends at a
186
+ * connected account. Both lines appear in the same series, so a completion
187
+ * that does not say which one it crossed turns a redefinition into an
188
+ * unexplained jump.
189
+ *
190
+ * Wire values, never renamed.
191
+ */
192
+ export type OnboardingCompletedAt = 'connect' | 'install' | 'schedule';
193
+ /** The finish lines, in the order the flow can reach them. */
194
+ export declare const ONBOARDING_COMPLETED_AT: readonly ["connect", "install", "schedule"];
195
+ export type ConnectBlockReason = 'paywall' | 'canceled' | 'failed' | 'returned_without_account' | 'personal_profile';
196
+ /** The reason set, in the order the spec lists it. Iterable for contract tests and dashboards. */
197
+ export declare const CONNECT_BLOCK_REASONS: readonly ["paywall", "canceled", "failed", "returned_without_account", "personal_profile"];
165
198
  export type PublishFailureReason = 'auth' | 'rate_limit' | 'media' | 'content' | 'network' | 'platform' | 'unknown';
166
199
  /** Property shape per event. Keeps emitters honest across repos. */
167
200
  export interface EventProperties {
@@ -176,6 +209,7 @@ export interface EventProperties {
176
209
  };
177
210
  [EVENTS.ACCOUNT_CONNECT_BLOCKED]: {
178
211
  platform?: string;
212
+ reason: ConnectBlockReason;
179
213
  };
180
214
  [EVENTS.MCP_POST_BLOCKED_FREE_TIER]: {
181
215
  target_count?: number;
@@ -374,6 +408,7 @@ export interface EventProperties {
374
408
  [EVENTS.ONBOARDING_COMPLETED]: {
375
409
  flow?: string;
376
410
  steps_completed?: number;
411
+ completed_at: OnboardingCompletedAt;
377
412
  };
378
413
  [EVENTS.ONBOARDING_SKIPPED]: {
379
414
  flow?: string;
@@ -10,7 +10,7 @@
10
10
  * Do NOT rename events after they ship — historical data does not migrate.
11
11
  */
12
12
  Object.defineProperty(exports, "__esModule", { value: true });
13
- exports.PERSON_PROPS = exports.EMAIL_PROPS = exports.EVENTS = void 0;
13
+ exports.PERSON_PROPS = exports.CONNECT_BLOCK_REASONS = exports.ONBOARDING_COMPLETED_AT = exports.EMAIL_PROPS = exports.EVENTS = void 0;
14
14
  /** Canonical PostHog event names. */
15
15
  exports.EVENTS = {
16
16
  // Acquisition (landing)
@@ -126,6 +126,20 @@ exports.EMAIL_PROPS = {
126
126
  EDITION_VERSION: 'edition_version',
127
127
  FORMAT: 'format',
128
128
  };
129
+ /** The finish lines, in the order the flow can reach them. */
130
+ exports.ONBOARDING_COMPLETED_AT = [
131
+ 'connect',
132
+ 'install',
133
+ 'schedule',
134
+ ];
135
+ /** The reason set, in the order the spec lists it. Iterable for contract tests and dashboards. */
136
+ exports.CONNECT_BLOCK_REASONS = [
137
+ 'paywall',
138
+ 'canceled',
139
+ 'failed',
140
+ 'returned_without_account',
141
+ 'personal_profile',
142
+ ];
129
143
  /** Canonical person property keys (set via identify / $set). */
130
144
  exports.PERSON_PROPS = {
131
145
  EMAIL: 'email',
@@ -0,0 +1,24 @@
1
+ /**
2
+ * The canonical table of what each platform needs before it can be connected.
3
+ *
4
+ * ONE home for these facts: the app renders them on the connect step and on
5
+ * /accounts, and nothing may restate them in a component. A line is rendered
6
+ * only where `requirement !== 'account'` — six platforms deliberately carry
7
+ * empty strings, and the UI renders no caption node for them at all.
8
+ *
9
+ * The cap on a rendered line is 70 characters, enforced by the contract test:
10
+ * it is what fits on one line under the platform name at 390px, measured in
11
+ * the spec 155 design preview.
12
+ *
13
+ * Copy: English authored here, Polish composed as Polish (spec 155
14
+ * `copy-pl.md`, Wariant F). Never edit a line inline — it is a copywriter pass.
15
+ */
16
+ import type { ConnectRequirement, ConnectRequirementPlatform } from './types';
17
+ export type { ConnectRequirement, ConnectRequirementKind, ConnectRequirementPlatform, } from './types';
18
+ export declare const CONNECT_REQUIREMENT_PLATFORMS: readonly ConnectRequirementPlatform[];
19
+ export declare const CONNECT_REQUIREMENTS: Record<ConnectRequirementPlatform, ConnectRequirement>;
20
+ /**
21
+ * The line to render under a platform's name, or '' when the platform imposes
22
+ * no account condition. Callers render nothing at all for an empty string.
23
+ */
24
+ export declare function connectRequirementLine(platform: ConnectRequirementPlatform, locale: 'en' | 'pl'): string;
@@ -0,0 +1,85 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CONNECT_REQUIREMENTS = exports.CONNECT_REQUIREMENT_PLATFORMS = void 0;
4
+ exports.connectRequirementLine = connectRequirementLine;
5
+ /** Verified against the sources below on this date; re-check when a platform changes its rules. */
6
+ const VERIFIED_ON = '2026-09-01';
7
+ const ZERNIO_DOCS = 'https://docs.zernio.com/platforms';
8
+ exports.CONNECT_REQUIREMENT_PLATFORMS = [
9
+ 'instagram',
10
+ 'tiktok',
11
+ 'facebook',
12
+ 'youtube',
13
+ 'twitter',
14
+ 'linkedin',
15
+ 'pinterest',
16
+ 'threads',
17
+ 'bluesky',
18
+ 'reddit',
19
+ ];
20
+ /** A platform with nothing to warn about: the account is the only requirement. */
21
+ function plain(platform) {
22
+ return {
23
+ platform,
24
+ requirement: 'account',
25
+ en: '',
26
+ pl: '',
27
+ sourceUrl: `${ZERNIO_DOCS}/${platform}`,
28
+ verifiedOn: VERIFIED_ON,
29
+ };
30
+ }
31
+ exports.CONNECT_REQUIREMENTS = {
32
+ // "Facebook API only posts to Pages, not personal profiles." Professional
33
+ // mode on a profile does not open the API either; that fuller explanation
34
+ // lives in the Facebook sheet, not on the list.
35
+ facebook: {
36
+ platform: 'facebook',
37
+ requirement: 'page',
38
+ en: 'You need a Page where you are an admin or editor.',
39
+ pl: 'Potrzebna strona, na której jesteś administratorem lub redaktorem.',
40
+ sourceUrl: `${ZERNIO_DOCS}/facebook`,
41
+ verifiedOn: VERIFIED_ON,
42
+ },
43
+ // "Instagram requires a Business or Creator account. Personal accounts
44
+ // cannot post via API." Instagram Login is the default, so no Page is needed.
45
+ instagram: {
46
+ platform: 'instagram',
47
+ requirement: 'professional_account',
48
+ en: 'You need a professional account: Creator or Business.',
49
+ pl: 'Potrzebne konto profesjonalne: Twórca albo Firma.',
50
+ sourceUrl: `${ZERNIO_DOCS}/instagram`,
51
+ verifiedOn: VERIFIED_ON,
52
+ },
53
+ // Threads rides the Instagram account: "You must have an Instagram Business
54
+ // or Creator account with Threads enabled."
55
+ threads: {
56
+ platform: 'threads',
57
+ requirement: 'professional_account',
58
+ en: 'Your Instagram account: professional, with Threads turned on.',
59
+ pl: 'Twoje konto na Instagramie: profesjonalne i z włączonym Threads.',
60
+ sourceUrl: `${ZERNIO_DOCS}/threads`,
61
+ verifiedOn: VERIFIED_ON,
62
+ },
63
+ // Bluesky signs in with an app password, never the account password.
64
+ bluesky: {
65
+ platform: 'bluesky',
66
+ requirement: 'app_password',
67
+ en: 'Sign in with an app password, not your main account password.',
68
+ pl: 'Zaloguj się hasłem aplikacji, nie głównym hasłem do konta.',
69
+ sourceUrl: `${ZERNIO_DOCS}/bluesky`,
70
+ verifiedOn: VERIFIED_ON,
71
+ },
72
+ tiktok: plain('tiktok'),
73
+ youtube: plain('youtube'),
74
+ twitter: plain('twitter'),
75
+ linkedin: plain('linkedin'),
76
+ pinterest: plain('pinterest'),
77
+ reddit: plain('reddit'),
78
+ };
79
+ /**
80
+ * The line to render under a platform's name, or '' when the platform imposes
81
+ * no account condition. Callers render nothing at all for an empty string.
82
+ */
83
+ function connectRequirementLine(platform, locale) {
84
+ return exports.CONNECT_REQUIREMENTS[platform][locale];
85
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Per-platform connect requirements (spec 155).
3
+ *
4
+ * What a creator has to have BEFORE a platform's OAuth can succeed. Only four
5
+ * of the ten platforms impose anything; the rest only ever meet the platform's
6
+ * own consent screens, which is not worth a line on the connect list (design
7
+ * verdict F, Jan 2026-09-01, after the two-column preview showed ten lines
8
+ * crowding the step).
9
+ */
10
+ /** The 10 platforms ad2app can connect. Matches the frontend's CONNECTABLE_PLATFORMS. */
11
+ export type ConnectRequirementPlatform = 'instagram' | 'tiktok' | 'facebook' | 'youtube' | 'twitter' | 'linkedin' | 'pinterest' | 'threads' | 'bluesky' | 'reddit';
12
+ /**
13
+ * What the platform demands of the account itself. Wire values: they drive
14
+ * whether a line renders, so renaming one silently blanks a warning.
15
+ *
16
+ * - `page` — publishing goes to a Page, never a personal profile (Facebook).
17
+ * - `professional_account` — the account must be a Creator/Business account
18
+ * (Instagram, and Threads by way of the Instagram account behind it).
19
+ * - `app_password` — sign-in uses a per-app password, not the account password
20
+ * (Bluesky).
21
+ * - `account` — no condition beyond having the account. Renders NO line.
22
+ */
23
+ export type ConnectRequirementKind = 'page' | 'professional_account' | 'app_password' | 'account';
24
+ export interface ConnectRequirement {
25
+ platform: ConnectRequirementPlatform;
26
+ requirement: ConnectRequirementKind;
27
+ /** The line shown under the platform name. Empty when `requirement` is `account`. */
28
+ en: string;
29
+ /** Polish is its own composition, not a translation of `en`. Empty when `requirement` is `account`. */
30
+ pl: string;
31
+ /** The vendor or platform document the claim rests on. */
32
+ sourceUrl: string;
33
+ /** ISO date the claim was last checked against `sourceUrl`. */
34
+ verifiedOn: string;
35
+ }
@@ -0,0 +1,11 @@
1
+ "use strict";
2
+ /**
3
+ * Per-platform connect requirements (spec 155).
4
+ *
5
+ * What a creator has to have BEFORE a platform's OAuth can succeed. Only four
6
+ * of the ten platforms impose anything; the rest only ever meet the platform's
7
+ * own consent screens, which is not worth a line on the connect list (design
8
+ * verdict F, Jan 2026-09-01, after the two-column preview showed ten lines
9
+ * crowding the step).
10
+ */
11
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -74,6 +74,18 @@ export declare class SchedulingUpdatePostDTO {
74
74
  platforms?: SchedulingPlatformTargetDTO[];
75
75
  /** Full media array for the edit patch (C2, AD2-1026), same shape as create. */
76
76
  mediaItems?: SchedulingMediaItemDTO[];
77
+ /**
78
+ * Per-platform settings on the edit patch, same shape as create (AD2-1328).
79
+ * When PRESENT the patch is the full per-platform state: a platform it does
80
+ * not mention is cleared at the vendor. When ABSENT the stored per-target
81
+ * settings are carried over unchanged. The compose editor always sends it
82
+ * (`{}` when nothing is set) so a cleared field actually clears.
83
+ */
84
+ platformSpecificData?: Record<string, Record<string, unknown>>;
85
+ /** TikTok settings on the edit patch, same shape as create (AD2-1328). */
86
+ tiktokSettings?: Record<string, unknown>;
87
+ /** Compose disclosure on the edit patch, same shape as create (AD2-1328). */
88
+ disclosure?: SchedulingPostDisclosureDTO;
77
89
  constructor(data?: Partial<SchedulingUpdatePostDTO>);
78
90
  }
79
91
  /**
@@ -87,6 +87,9 @@ class SchedulingUpdatePostDTO {
87
87
  this.scheduledAt = data.scheduledAt;
88
88
  this.platforms = data.platforms;
89
89
  this.mediaItems = data.mediaItems;
90
+ this.platformSpecificData = data.platformSpecificData;
91
+ this.tiktokSettings = data.tiktokSettings;
92
+ this.disclosure = data.disclosure;
90
93
  }
91
94
  }
92
95
  exports.SchedulingUpdatePostDTO = SchedulingUpdatePostDTO;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ad2app-lib",
3
- "version": "1.31.0",
3
+ "version": "1.34.0",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "commonjs",
@@ -11,6 +11,7 @@
11
11
  "./legal": "./dist/legal/index.js",
12
12
  "./analytics": "./dist/analytics/index.js",
13
13
  "./publish-limits": "./dist/publish-limits/index.js",
14
+ "./connect-requirements": "./dist/connect-requirements/index.js",
14
15
  "./brand": "./brand/brand.mjs"
15
16
  },
16
17
  "typesVersions": {
@@ -36,6 +37,9 @@
36
37
  "brand": [
37
38
  "brand/brand.d.ts"
38
39
  ],
40
+ "connect-requirements": [
41
+ "dist/connect-requirements/index.d.ts"
42
+ ],
39
43
  "*": [
40
44
  "dist/index.d.ts"
41
45
  ]
@@ -47,7 +51,7 @@
47
51
  "prepare": "npm run build"
48
52
  },
49
53
  "keywords": [],
50
- "author": "Maciej G\u00f3rski@ad2.app",
54
+ "author": "Maciej Górski@ad2.app",
51
55
  "license": "ISC",
52
56
  "description": "Package to share types and utils across the ad2app projects",
53
57
  "dependencies": {
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The connect-abandon contract (spec 155 US3).
3
+ *
4
+ * Every connect attempt that does not end with a connected account leaves one
5
+ * row with one reason. Before this, the event existed but only the paywall
6
+ * emitted it, so a person who walked into Meta's OAuth with a personal profile
7
+ * and never came back left no trace at all: their last event was the click
8
+ * (measured on a real tester, 2026-09-01 10:56:14).
9
+ */
10
+ import assert from 'node:assert/strict';
11
+ import { test } from 'node:test';
12
+
13
+ import { CONNECT_BLOCK_REASONS, EVENTS } from './index';
14
+ import type { ConnectBlockReason, EventProperties } from './index';
15
+
16
+ test('the reason set is the five causes, as wire values', () => {
17
+ assert.deepEqual([...CONNECT_BLOCK_REASONS], [
18
+ 'paywall',
19
+ 'canceled',
20
+ 'failed',
21
+ 'returned_without_account',
22
+ 'personal_profile',
23
+ ]);
24
+ });
25
+
26
+ test('an emit without a reason does not type-check', () => {
27
+ // @ts-expect-error reason is required: a reasonless row is exactly the
28
+ // silent abandon this event exists to end.
29
+ const bad: EventProperties[typeof EVENTS.ACCOUNT_CONNECT_BLOCKED] = { platform: 'facebook' };
30
+ assert.ok(bad);
31
+ });
32
+
33
+ test('a full emit type-checks and carries the platform', () => {
34
+ const reason: ConnectBlockReason = 'personal_profile';
35
+ const good: EventProperties[typeof EVENTS.ACCOUNT_CONNECT_BLOCKED] = {
36
+ platform: 'facebook',
37
+ reason,
38
+ };
39
+ assert.equal(good.reason, 'personal_profile');
40
+ });
@@ -231,6 +231,58 @@ export type ComposeBlockReason =
231
231
  | 'channel_blocked' // the channel itself refused (e.g. a failed prerequisite read)
232
232
  | 'poll'; // an incomplete or unpostable poll
233
233
 
234
+ /**
235
+ * Why a connect attempt ended without a connected account (spec 155 US3).
236
+ *
237
+ * Wire values, never renamed: historical rows do not migrate. A new cause is a
238
+ * new value appended here, never a re-meaning of an old one.
239
+ *
240
+ * - `paywall` — a free-tier user hit the connect wall (the pre-existing emit).
241
+ * - `canceled` — the person denied or dismissed the platform's OAuth.
242
+ * - `failed` — the vendor returned a failure on the callback.
243
+ * - `returned_without_account` — they came back and no account arrived: the
244
+ * silent abandon, e.g. Facebook's Page picker offering an empty list to
245
+ * someone who only has a personal profile.
246
+ * - `personal_profile` — they told us before OAuth that they post from a
247
+ * personal Facebook profile, which Meta's API cannot publish to.
248
+ */
249
+ /**
250
+ * Which finish line an onboarding completion crossed (094 US6/US8).
251
+ *
252
+ * Until 2026-09-01 the guided tour ended at a scheduled post, and almost
253
+ * nobody crossed it: four completions by two people in ninety days, against
254
+ * fifteen skips that all landed on the compose step. It now ends at a
255
+ * connected account. Both lines appear in the same series, so a completion
256
+ * that does not say which one it crossed turns a redefinition into an
257
+ * unexplained jump.
258
+ *
259
+ * Wire values, never renamed.
260
+ */
261
+ export type OnboardingCompletedAt = 'connect' | 'install' | 'schedule';
262
+
263
+ /** The finish lines, in the order the flow can reach them. */
264
+ export const ONBOARDING_COMPLETED_AT = [
265
+ 'connect',
266
+ 'install',
267
+ 'schedule',
268
+ ] as const satisfies readonly OnboardingCompletedAt[];
269
+
270
+ export type ConnectBlockReason =
271
+ | 'paywall'
272
+ | 'canceled'
273
+ | 'failed'
274
+ | 'returned_without_account'
275
+ | 'personal_profile';
276
+
277
+ /** The reason set, in the order the spec lists it. Iterable for contract tests and dashboards. */
278
+ export const CONNECT_BLOCK_REASONS = [
279
+ 'paywall',
280
+ 'canceled',
281
+ 'failed',
282
+ 'returned_without_account',
283
+ 'personal_profile',
284
+ ] as const satisfies readonly ConnectBlockReason[];
285
+
234
286
  export type PublishFailureReason =
235
287
  | 'auth' // token expired / revoked / missing scope / permission denied
236
288
  | 'rate_limit' // provider throttled us (429 / quota)
@@ -255,7 +307,7 @@ export interface EventProperties {
255
307
  target: 'app' | 'ask' | 'terminal' | 'editor';
256
308
  };
257
309
  [EVENTS.FREE_SKILLS_REQUESTED]: { source: string };
258
- [EVENTS.ACCOUNT_CONNECT_BLOCKED]: { platform?: string };
310
+ [EVENTS.ACCOUNT_CONNECT_BLOCKED]: { platform?: string; reason: ConnectBlockReason };
259
311
  [EVENTS.MCP_POST_BLOCKED_FREE_TIER]: { target_count?: number };
260
312
  [EVENTS.EMAIL_SENT]: EmailEventProperties;
261
313
  [EVENTS.EMAIL_DELIVERED]: EmailEventProperties;
@@ -408,7 +460,11 @@ export interface EventProperties {
408
460
 
409
461
  // Onboarding flow (promoted 2026-07-19). flow defaults to the main app onboarding.
410
462
  [EVENTS.ONBOARDING_STARTED]: { flow?: string };
411
- [EVENTS.ONBOARDING_COMPLETED]: { flow?: string; steps_completed?: number };
463
+ [EVENTS.ONBOARDING_COMPLETED]: {
464
+ flow?: string;
465
+ steps_completed?: number;
466
+ completed_at: OnboardingCompletedAt;
467
+ };
412
468
  [EVENTS.ONBOARDING_SKIPPED]: { flow?: string; step?: string };
413
469
 
414
470
  // Inbox engagement — platform is the social platform the comment/DM is on.
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Which finish line a tour completion crossed (094 US8).
3
+ *
4
+ * The tour used to end at a scheduled post and now ends at a connected
5
+ * account. Both lines will appear in the same series, so the event has to say
6
+ * which one it was; without that the change reads as a sudden jump in
7
+ * completions rather than a redefinition.
8
+ */
9
+ import assert from 'node:assert/strict';
10
+ import { test } from 'node:test';
11
+
12
+ import { EVENTS, ONBOARDING_COMPLETED_AT } from './index';
13
+ import type { EventProperties, OnboardingCompletedAt } from './index';
14
+
15
+ test('the finish lines are a closed set of wire values', () => {
16
+ assert.deepEqual([...ONBOARDING_COMPLETED_AT], ['connect', 'install', 'schedule']);
17
+ });
18
+
19
+ test('a completion without its finish line does not type-check', () => {
20
+ // @ts-expect-error completed_at is required: an unlabelled completion is the
21
+ // ambiguity this property exists to remove.
22
+ const bad: EventProperties[typeof EVENTS.ONBOARDING_COMPLETED] = { steps_completed: 4 };
23
+ assert.ok(bad);
24
+ });
25
+
26
+ test('a labelled completion type-checks', () => {
27
+ const via: OnboardingCompletedAt = 'connect';
28
+ const good: EventProperties[typeof EVENTS.ONBOARDING_COMPLETED] = { completed_at: via };
29
+ assert.equal(good.completed_at, 'connect');
30
+ });
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Contract tests for the per-platform connect requirements (spec 155, design
3
+ * verdict F, Jan 2026-09-01).
4
+ *
5
+ * The rule these lock: a line is rendered ONLY where the platform's API
6
+ * imposes a real account condition. Six of the ten platforms impose none, so
7
+ * they carry empty strings and the UI renders no caption node at all. A line
8
+ * that IS rendered has to fit one line under the platform name on a 390px
9
+ * phone, which is where the 70-character cap comes from (measured in the
10
+ * design preview: at 2 columns the same text truncated after three words).
11
+ */
12
+ import assert from 'node:assert/strict';
13
+ import { test } from 'node:test';
14
+
15
+ import {
16
+ CONNECT_REQUIREMENTS,
17
+ CONNECT_REQUIREMENT_PLATFORMS,
18
+ connectRequirementLine,
19
+ } from './index';
20
+ import type { ConnectRequirement, ConnectRequirementPlatform } from './types';
21
+
22
+ const MAX_LINE = 70;
23
+ const CONSTRAINED: Record<string, string> = {
24
+ facebook: 'page',
25
+ instagram: 'professional_account',
26
+ threads: 'professional_account',
27
+ bluesky: 'app_password',
28
+ };
29
+
30
+ test('covers the ten connectable platforms, once each', () => {
31
+ assert.equal(CONNECT_REQUIREMENT_PLATFORMS.length, 10);
32
+ assert.equal(new Set(CONNECT_REQUIREMENT_PLATFORMS).size, 10);
33
+ for (const platform of CONNECT_REQUIREMENT_PLATFORMS) {
34
+ assert.ok(CONNECT_REQUIREMENTS[platform], `missing record: ${platform}`);
35
+ assert.equal(CONNECT_REQUIREMENTS[platform].platform, platform);
36
+ }
37
+ });
38
+
39
+ test('assigns the requirement kind each platform actually has', () => {
40
+ for (const platform of CONNECT_REQUIREMENT_PLATFORMS) {
41
+ const expected = CONNECT_REQUIREMENTS[platform].requirement;
42
+ assert.equal(expected, CONSTRAINED[platform] ?? 'account', `wrong kind: ${platform}`);
43
+ }
44
+ });
45
+
46
+ test('a constrained platform carries one short line in both locales', () => {
47
+ for (const platform of Object.keys(CONSTRAINED) as ConnectRequirementPlatform[]) {
48
+ const record = CONNECT_REQUIREMENTS[platform];
49
+ for (const locale of ['en', 'pl'] as const) {
50
+ const line = record[locale];
51
+ assert.ok(line.length > 0, `${platform}.${locale} is empty`);
52
+ assert.ok(
53
+ line.length <= MAX_LINE,
54
+ `${platform}.${locale} is ${line.length} chars, cap is ${MAX_LINE}`,
55
+ );
56
+ }
57
+ }
58
+ });
59
+
60
+ test('an unconstrained platform carries no line at all', () => {
61
+ const unconstrained = CONNECT_REQUIREMENT_PLATFORMS.filter((p) => !(p in CONSTRAINED));
62
+ assert.equal(unconstrained.length, 6);
63
+ for (const platform of unconstrained) {
64
+ assert.equal(CONNECT_REQUIREMENTS[platform].en, '');
65
+ assert.equal(CONNECT_REQUIREMENTS[platform].pl, '');
66
+ assert.equal(connectRequirementLine(platform, 'pl'), '');
67
+ assert.equal(connectRequirementLine(platform, 'en'), '');
68
+ }
69
+ });
70
+
71
+ test('no line carries a dash character or a screen count', () => {
72
+ for (const platform of CONNECT_REQUIREMENT_PLATFORMS) {
73
+ for (const locale of ['en', 'pl'] as const) {
74
+ const line = CONNECT_REQUIREMENTS[platform][locale];
75
+ assert.ok(!/[—–]/.test(line), `${platform}.${locale} contains a dash`);
76
+ assert.ok(
77
+ !/\d+\s*(screens?|ekran)/i.test(line),
78
+ `${platform}.${locale} promises a screen count`,
79
+ );
80
+ }
81
+ }
82
+ });
83
+
84
+ test('every record cites a source and the date it was verified', () => {
85
+ for (const platform of CONNECT_REQUIREMENT_PLATFORMS) {
86
+ const record: ConnectRequirement = CONNECT_REQUIREMENTS[platform];
87
+ assert.ok(record.sourceUrl.startsWith('https://'), `${platform} sourceUrl is not https`);
88
+ assert.match(record.verifiedOn, /^\d{4}-\d{2}-\d{2}$/, `${platform} verifiedOn is not a date`);
89
+ }
90
+ });
91
+
92
+ test('connectRequirementLine returns the locale asked for', () => {
93
+ assert.equal(connectRequirementLine('facebook', 'pl'), CONNECT_REQUIREMENTS.facebook.pl);
94
+ assert.equal(connectRequirementLine('facebook', 'en'), CONNECT_REQUIREMENTS.facebook.en);
95
+ assert.notEqual(CONNECT_REQUIREMENTS.facebook.pl, CONNECT_REQUIREMENTS.facebook.en);
96
+ });
@@ -0,0 +1,115 @@
1
+ /**
2
+ * The canonical table of what each platform needs before it can be connected.
3
+ *
4
+ * ONE home for these facts: the app renders them on the connect step and on
5
+ * /accounts, and nothing may restate them in a component. A line is rendered
6
+ * only where `requirement !== 'account'` — six platforms deliberately carry
7
+ * empty strings, and the UI renders no caption node for them at all.
8
+ *
9
+ * The cap on a rendered line is 70 characters, enforced by the contract test:
10
+ * it is what fits on one line under the platform name at 390px, measured in
11
+ * the spec 155 design preview.
12
+ *
13
+ * Copy: English authored here, Polish composed as Polish (spec 155
14
+ * `copy-pl.md`, Wariant F). Never edit a line inline — it is a copywriter pass.
15
+ */
16
+ import type {
17
+ ConnectRequirement,
18
+ ConnectRequirementPlatform,
19
+ } from './types';
20
+
21
+ export type {
22
+ ConnectRequirement,
23
+ ConnectRequirementKind,
24
+ ConnectRequirementPlatform,
25
+ } from './types';
26
+
27
+ /** Verified against the sources below on this date; re-check when a platform changes its rules. */
28
+ const VERIFIED_ON = '2026-09-01';
29
+
30
+ const ZERNIO_DOCS = 'https://docs.zernio.com/platforms';
31
+
32
+ export const CONNECT_REQUIREMENT_PLATFORMS: readonly ConnectRequirementPlatform[] = [
33
+ 'instagram',
34
+ 'tiktok',
35
+ 'facebook',
36
+ 'youtube',
37
+ 'twitter',
38
+ 'linkedin',
39
+ 'pinterest',
40
+ 'threads',
41
+ 'bluesky',
42
+ 'reddit',
43
+ ];
44
+
45
+ /** A platform with nothing to warn about: the account is the only requirement. */
46
+ function plain(platform: ConnectRequirementPlatform): ConnectRequirement {
47
+ return {
48
+ platform,
49
+ requirement: 'account',
50
+ en: '',
51
+ pl: '',
52
+ sourceUrl: `${ZERNIO_DOCS}/${platform}`,
53
+ verifiedOn: VERIFIED_ON,
54
+ };
55
+ }
56
+
57
+ export const CONNECT_REQUIREMENTS: Record<ConnectRequirementPlatform, ConnectRequirement> = {
58
+ // "Facebook API only posts to Pages, not personal profiles." Professional
59
+ // mode on a profile does not open the API either; that fuller explanation
60
+ // lives in the Facebook sheet, not on the list.
61
+ facebook: {
62
+ platform: 'facebook',
63
+ requirement: 'page',
64
+ en: 'You need a Page where you are an admin or editor.',
65
+ pl: 'Potrzebna strona, na której jesteś administratorem lub redaktorem.',
66
+ sourceUrl: `${ZERNIO_DOCS}/facebook`,
67
+ verifiedOn: VERIFIED_ON,
68
+ },
69
+ // "Instagram requires a Business or Creator account. Personal accounts
70
+ // cannot post via API." Instagram Login is the default, so no Page is needed.
71
+ instagram: {
72
+ platform: 'instagram',
73
+ requirement: 'professional_account',
74
+ en: 'You need a professional account: Creator or Business.',
75
+ pl: 'Potrzebne konto profesjonalne: Twórca albo Firma.',
76
+ sourceUrl: `${ZERNIO_DOCS}/instagram`,
77
+ verifiedOn: VERIFIED_ON,
78
+ },
79
+ // Threads rides the Instagram account: "You must have an Instagram Business
80
+ // or Creator account with Threads enabled."
81
+ threads: {
82
+ platform: 'threads',
83
+ requirement: 'professional_account',
84
+ en: 'Your Instagram account: professional, with Threads turned on.',
85
+ pl: 'Twoje konto na Instagramie: profesjonalne i z włączonym Threads.',
86
+ sourceUrl: `${ZERNIO_DOCS}/threads`,
87
+ verifiedOn: VERIFIED_ON,
88
+ },
89
+ // Bluesky signs in with an app password, never the account password.
90
+ bluesky: {
91
+ platform: 'bluesky',
92
+ requirement: 'app_password',
93
+ en: 'Sign in with an app password, not your main account password.',
94
+ pl: 'Zaloguj się hasłem aplikacji, nie głównym hasłem do konta.',
95
+ sourceUrl: `${ZERNIO_DOCS}/bluesky`,
96
+ verifiedOn: VERIFIED_ON,
97
+ },
98
+ tiktok: plain('tiktok'),
99
+ youtube: plain('youtube'),
100
+ twitter: plain('twitter'),
101
+ linkedin: plain('linkedin'),
102
+ pinterest: plain('pinterest'),
103
+ reddit: plain('reddit'),
104
+ };
105
+
106
+ /**
107
+ * The line to render under a platform's name, or '' when the platform imposes
108
+ * no account condition. Callers render nothing at all for an empty string.
109
+ */
110
+ export function connectRequirementLine(
111
+ platform: ConnectRequirementPlatform,
112
+ locale: 'en' | 'pl',
113
+ ): string {
114
+ return CONNECT_REQUIREMENTS[platform][locale];
115
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Per-platform connect requirements (spec 155).
3
+ *
4
+ * What a creator has to have BEFORE a platform's OAuth can succeed. Only four
5
+ * of the ten platforms impose anything; the rest only ever meet the platform's
6
+ * own consent screens, which is not worth a line on the connect list (design
7
+ * verdict F, Jan 2026-09-01, after the two-column preview showed ten lines
8
+ * crowding the step).
9
+ */
10
+
11
+ /** The 10 platforms ad2app can connect. Matches the frontend's CONNECTABLE_PLATFORMS. */
12
+ export type ConnectRequirementPlatform =
13
+ | 'instagram'
14
+ | 'tiktok'
15
+ | 'facebook'
16
+ | 'youtube'
17
+ | 'twitter'
18
+ | 'linkedin'
19
+ | 'pinterest'
20
+ | 'threads'
21
+ | 'bluesky'
22
+ | 'reddit';
23
+
24
+ /**
25
+ * What the platform demands of the account itself. Wire values: they drive
26
+ * whether a line renders, so renaming one silently blanks a warning.
27
+ *
28
+ * - `page` — publishing goes to a Page, never a personal profile (Facebook).
29
+ * - `professional_account` — the account must be a Creator/Business account
30
+ * (Instagram, and Threads by way of the Instagram account behind it).
31
+ * - `app_password` — sign-in uses a per-app password, not the account password
32
+ * (Bluesky).
33
+ * - `account` — no condition beyond having the account. Renders NO line.
34
+ */
35
+ export type ConnectRequirementKind =
36
+ | 'page'
37
+ | 'professional_account'
38
+ | 'app_password'
39
+ | 'account';
40
+
41
+ export interface ConnectRequirement {
42
+ platform: ConnectRequirementPlatform;
43
+ requirement: ConnectRequirementKind;
44
+ /** The line shown under the platform name. Empty when `requirement` is `account`. */
45
+ en: string;
46
+ /** Polish is its own composition, not a translation of `en`. Empty when `requirement` is `account`. */
47
+ pl: string;
48
+ /** The vendor or platform document the claim rests on. */
49
+ sourceUrl: string;
50
+ /** ISO date the claim was last checked against `sourceUrl`. */
51
+ verifiedOn: string;
52
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Every published subpath resolves BOTH at runtime and for TypeScript.
3
+ *
4
+ * Spec 155 caught this the expensive way: `exports` gained
5
+ * `./connect-requirements` but `typesVersions` did not, so `npm run build`
6
+ * passed in the lib and the consumer failed with "Cannot find module
7
+ * 'ad2app-lib/connect-requirements' or its corresponding type declarations".
8
+ * The two maps are one fact and drift silently, so pin them together.
9
+ */
10
+ import assert from 'node:assert/strict';
11
+ import { readFileSync } from 'node:fs';
12
+ import { test } from 'node:test';
13
+
14
+ const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as {
15
+ exports: Record<string, string>;
16
+ typesVersions: Record<string, Record<string, string[]>>;
17
+ };
18
+
19
+ test('every export subpath has a matching typesVersions entry', () => {
20
+ const subpaths = Object.keys(pkg.exports)
21
+ .filter((key) => key !== '.')
22
+ .map((key) => key.replace(/^\.\//, ''));
23
+ const typed = pkg.typesVersions['*'];
24
+ for (const subpath of subpaths) {
25
+ assert.ok(typed[subpath], `exports has "${subpath}" but typesVersions does not`);
26
+ assert.match(
27
+ typed[subpath][0],
28
+ /\.d\.(ts|mts)$/,
29
+ `typesVersions["${subpath}"] must point at a declaration file`,
30
+ );
31
+ }
32
+ });
33
+
34
+ test('every typesVersions entry except the catch-all has an export', () => {
35
+ const exported = new Set(
36
+ Object.keys(pkg.exports)
37
+ .filter((key) => key !== '.')
38
+ .map((key) => key.replace(/^\.\//, '')),
39
+ );
40
+ for (const subpath of Object.keys(pkg.typesVersions['*'])) {
41
+ if (subpath === '*') continue;
42
+ assert.ok(exported.has(subpath), `typesVersions has "${subpath}" but exports does not`);
43
+ }
44
+ });
@@ -91,6 +91,28 @@ test('SchedulingUpdatePostDTO platforms/mediaItems are absent by default (backwa
91
91
  assert.equal(patch.mediaItems, undefined);
92
92
  });
93
93
 
94
+ // AD2-1328 — the compose editor has sent the per-platform trio on save since
95
+ // AD2-1026; the backend typed it locally as an intersection because the lib
96
+ // had no home for it. Pinned here so the local clone can retire at ship time.
97
+ test('SchedulingUpdatePostDTO carries platformSpecificData/tiktokSettings/disclosure for the edit patch (AD2-1328)', () => {
98
+ const patch = new SchedulingUpdatePostDTO({
99
+ platforms: [new SchedulingPlatformTargetDTO({ platform: 'youtube', accountId: 'acc-yt' })],
100
+ platformSpecificData: { youtube: { title: 'Launch day' } },
101
+ tiktokSettings: { privacy_level: 'SELF_ONLY' },
102
+ disclosure: { aiGenerated: true },
103
+ });
104
+ assert.deepEqual(patch.platformSpecificData, { youtube: { title: 'Launch day' } });
105
+ assert.deepEqual(patch.tiktokSettings, { privacy_level: 'SELF_ONLY' });
106
+ assert.equal(patch.disclosure?.aiGenerated, true);
107
+ });
108
+
109
+ test('SchedulingUpdatePostDTO per-platform trio is absent by default, so absence still means "keep what is stored"', () => {
110
+ const patch = new SchedulingUpdatePostDTO({ content: 'x' });
111
+ assert.equal(patch.platformSpecificData, undefined);
112
+ assert.equal(patch.tiktokSettings, undefined);
113
+ assert.equal(patch.disclosure, undefined);
114
+ });
115
+
94
116
  // AD2-1153 — the backend's local SocialPostStatus enum already has a
95
117
  // PUBLISHING member (Zernio accepted the post but its outcome couldn't be
96
118
  // confirmed inline); this pins the lib side so the backend's
@@ -134,6 +134,18 @@ export class SchedulingUpdatePostDTO {
134
134
  platforms?: SchedulingPlatformTargetDTO[];
135
135
  /** Full media array for the edit patch (C2, AD2-1026), same shape as create. */
136
136
  mediaItems?: SchedulingMediaItemDTO[];
137
+ /**
138
+ * Per-platform settings on the edit patch, same shape as create (AD2-1328).
139
+ * When PRESENT the patch is the full per-platform state: a platform it does
140
+ * not mention is cleared at the vendor. When ABSENT the stored per-target
141
+ * settings are carried over unchanged. The compose editor always sends it
142
+ * (`{}` when nothing is set) so a cleared field actually clears.
143
+ */
144
+ platformSpecificData?: Record<string, Record<string, unknown>>;
145
+ /** TikTok settings on the edit patch, same shape as create (AD2-1328). */
146
+ tiktokSettings?: Record<string, unknown>;
147
+ /** Compose disclosure on the edit patch, same shape as create (AD2-1328). */
148
+ disclosure?: SchedulingPostDisclosureDTO;
137
149
 
138
150
  constructor(data?: Partial<SchedulingUpdatePostDTO>) {
139
151
  if (!data) return;
@@ -141,6 +153,9 @@ export class SchedulingUpdatePostDTO {
141
153
  this.scheduledAt = data.scheduledAt;
142
154
  this.platforms = data.platforms;
143
155
  this.mediaItems = data.mediaItems;
156
+ this.platformSpecificData = data.platformSpecificData;
157
+ this.tiktokSettings = data.tiktokSettings;
158
+ this.disclosure = data.disclosure;
144
159
  }
145
160
  }
146
161