ad2app-lib 1.30.0 → 1.31.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.
@@ -33,6 +33,8 @@ export declare const EVENTS: {
33
33
  readonly LOGGED_IN: "logged_in";
34
34
  readonly SOCIAL_ACCOUNT_CONNECTED: "social_account_connected";
35
35
  readonly COMPOSE_STARTED: "compose_started";
36
+ readonly COMPOSE_STEP_VIEWED: "compose_step_viewed";
37
+ readonly COMPOSE_BLOCKED: "compose_blocked";
36
38
  readonly POST_SCHEDULED: "post_scheduled";
37
39
  readonly POST_PUBLISHED: "post_published";
38
40
  readonly PUBLISH_FAILED: "publish_failed";
@@ -147,6 +149,19 @@ export type AppLocale = 'en' | 'pl';
147
149
  * absent/unclassifiable error is `'unknown'`. Do NOT rename these values after
148
150
  * they ship — they are wire values, historical data does not migrate.
149
151
  */
152
+ /**
153
+ * Which wizard step the composer is showing. Wire values, not indexes: a step
154
+ * reordered or inserted would silently re-label every historical row if this
155
+ * carried 0-3 (AD2-1371).
156
+ */
157
+ export type ComposeStep = 'platforms' | 'content' | 'details' | 'review';
158
+ /**
159
+ * Why the composer refused to advance. The readiness summary renders LOCALISED
160
+ * PROSE that names platforms and field labels, and the caption itself is user
161
+ * content, so neither may ride the event: only the class does. Same contract as
162
+ * PublishFailureReason. Do NOT rename after shipping — wire values.
163
+ */
164
+ export type ComposeBlockReason = 'empty' | 'missing_fields' | 'over_limit' | 'media' | 'channel_blocked' | 'poll';
150
165
  export type PublishFailureReason = 'auth' | 'rate_limit' | 'media' | 'content' | 'network' | 'platform' | 'unknown';
151
166
  /** Property shape per event. Keeps emitters honest across repos. */
152
167
  export interface EventProperties {
@@ -211,6 +226,16 @@ export interface EventProperties {
211
226
  [EVENTS.COMPOSE_STARTED]: {
212
227
  source?: 'wizard' | 'quick';
213
228
  };
229
+ [EVENTS.COMPOSE_STEP_VIEWED]: {
230
+ step: ComposeStep;
231
+ };
232
+ [EVENTS.COMPOSE_BLOCKED]: {
233
+ step: ComposeStep;
234
+ /** Distinct reason classes present, deduped. Never the rendered prose. */
235
+ reasons: ComposeBlockReason[];
236
+ /** How many readiness lines the user faced, which `reasons` dedupes away. */
237
+ reason_count: number;
238
+ };
214
239
  [EVENTS.POST_SCHEDULED]: {
215
240
  platforms: string[];
216
241
  platform_count: number;
@@ -43,6 +43,14 @@ exports.EVENTS = {
43
43
  LOGGED_IN: 'logged_in',
44
44
  SOCIAL_ACCOUNT_CONNECTED: 'social_account_connected',
45
45
  COMPOSE_STARTED: 'compose_started',
46
+ // AD2-1371: the composer was a black box between COMPOSE_STARTED and
47
+ // POST_SCHEDULED. Measured 2026-08-28: of seven users who connected an
48
+ // account and never scheduled, the one who reached the composer opened it
49
+ // twice across two days and left both times, and the event stream carried
50
+ // nothing between arriving and leaving. These two say where they stopped
51
+ // and what stopped them.
52
+ COMPOSE_STEP_VIEWED: 'compose_step_viewed',
53
+ COMPOSE_BLOCKED: 'compose_blocked',
46
54
  POST_SCHEDULED: 'post_scheduled',
47
55
  POST_PUBLISHED: 'post_published', // server-confirmed (backend)
48
56
  PUBLISH_FAILED: 'publish_failed', // per-platform publish failure, server-owned (backend, AD2-999)
@@ -10,6 +10,36 @@
10
10
  * ad2app-frontend/src/components/social/compose/platformConstants.ts
11
11
  * (PLATFORM_CONFIGS[*].charLimit / maxImages / maxVideos / acceptsVideo /
12
12
  * acceptsImages / metadata.mediaConstraints.maxVideoDuration).
13
+ *
14
+ * CAVEAT ON THAT WORD "CORRECTED" (AD2-1369, measured 2026-08-28). It holds for
15
+ * the character and count fields. It does NOT hold for maxVideoDurationSec, which
16
+ * was inherited from platformConstants.ts wholesale and never checked against any
17
+ * platform. Five platforms share the value 3600, which is a default rather than a
18
+ * researched figure, and comparing the whole column against the export-specs
19
+ * registry (.claude/skills/ad2app-graphics/data/export-specs.jsonl) shows it
20
+ * disagrees with platform truth in both directions:
21
+ *
22
+ * platform ours platform's own figure
23
+ * twitter 3600 140s free; Premium up to 4h -> ours is ABOVE the free tier
24
+ * instagram 3600 900s (15 min) -> ours is ABOVE
25
+ * linkedin 3600 600s (10 min) -> ours is ABOVE
26
+ * pinterest 900 300s organic (900s is the ADS product) -> ours uses the ads figure
27
+ * bluesky 60 180s -> ours is BELOW
28
+ * facebook 3600 241 min -> ours is below, conservative
29
+ * tiktok 600 600s via API -> matches
30
+ * threads 300 300s -> matches
31
+ * youtube 43200 43200s for verified accounts -> matches (verified only)
32
+ * reddit 3600 not recorded in the registry -> unverified
33
+ *
34
+ * An ABOVE-truth value is the worse half: we accept a video the platform will
35
+ * reject, so the failure surfaces late, at publish time, instead of in compose.
36
+ * A BELOW-truth value silently blocks something the user is allowed to post.
37
+ *
38
+ * Nothing is changed here on purpose. Each row is a live publishing behaviour, and
39
+ * the vendor we publish through (Zernio) may impose its own caps that none of these
40
+ * numbers reflect; I could find no Zernio-side duration limits this session. Fixing
41
+ * a row means verifying BOTH the platform figure and Zernio's, per platform, which
42
+ * is its own ticket. Until then this table tells the truth about itself.
13
43
  * - MAX_UPLOAD_BYTES matches ad2app-backend
14
44
  * src/modules/late/late-media-upload.service.ts MAX_FILE_SIZE_BYTES + FR-105.
15
45
  *
@@ -11,6 +11,36 @@
11
11
  * ad2app-frontend/src/components/social/compose/platformConstants.ts
12
12
  * (PLATFORM_CONFIGS[*].charLimit / maxImages / maxVideos / acceptsVideo /
13
13
  * acceptsImages / metadata.mediaConstraints.maxVideoDuration).
14
+ *
15
+ * CAVEAT ON THAT WORD "CORRECTED" (AD2-1369, measured 2026-08-28). It holds for
16
+ * the character and count fields. It does NOT hold for maxVideoDurationSec, which
17
+ * was inherited from platformConstants.ts wholesale and never checked against any
18
+ * platform. Five platforms share the value 3600, which is a default rather than a
19
+ * researched figure, and comparing the whole column against the export-specs
20
+ * registry (.claude/skills/ad2app-graphics/data/export-specs.jsonl) shows it
21
+ * disagrees with platform truth in both directions:
22
+ *
23
+ * platform ours platform's own figure
24
+ * twitter 3600 140s free; Premium up to 4h -> ours is ABOVE the free tier
25
+ * instagram 3600 900s (15 min) -> ours is ABOVE
26
+ * linkedin 3600 600s (10 min) -> ours is ABOVE
27
+ * pinterest 900 300s organic (900s is the ADS product) -> ours uses the ads figure
28
+ * bluesky 60 180s -> ours is BELOW
29
+ * facebook 3600 241 min -> ours is below, conservative
30
+ * tiktok 600 600s via API -> matches
31
+ * threads 300 300s -> matches
32
+ * youtube 43200 43200s for verified accounts -> matches (verified only)
33
+ * reddit 3600 not recorded in the registry -> unverified
34
+ *
35
+ * An ABOVE-truth value is the worse half: we accept a video the platform will
36
+ * reject, so the failure surfaces late, at publish time, instead of in compose.
37
+ * A BELOW-truth value silently blocks something the user is allowed to post.
38
+ *
39
+ * Nothing is changed here on purpose. Each row is a live publishing behaviour, and
40
+ * the vendor we publish through (Zernio) may impose its own caps that none of these
41
+ * numbers reflect; I could find no Zernio-side duration limits this session. Fixing
42
+ * a row means verifying BOTH the platform figure and Zernio's, per platform, which
43
+ * is its own ticket. Until then this table tells the truth about itself.
14
44
  * - MAX_UPLOAD_BYTES matches ad2app-backend
15
45
  * src/modules/late/late-media-upload.service.ts MAX_FILE_SIZE_BYTES + FR-105.
16
46
  *
@@ -107,6 +137,8 @@ exports.PUBLISH_LIMITS = {
107
137
  charLimit: 300,
108
138
  maxImages: 4,
109
139
  maxVideos: 1,
140
+ // 60 is OURS, not Bluesky's. Bluesky allows 180s (its social-app constants.ts sets
141
+ // VIDEO_MAX_DURATION_MS = 3 * 60 * 1000). Left as-is deliberately, see the header.
110
142
  maxVideoDurationSec: 60,
111
143
  acceptsVideo: true,
112
144
  acceptsImages: true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ad2app-lib",
3
- "version": "1.30.0",
3
+ "version": "1.31.0",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "commonjs",
@@ -89,6 +89,8 @@ const EVENT_PROPERTY_WITNESS: { [E in keyof EventProperties]: EventProperties[E]
89
89
  [EVENTS.LOGGED_IN]: {},
90
90
  [EVENTS.SOCIAL_ACCOUNT_CONNECTED]: { platform: "instagram" },
91
91
  [EVENTS.COMPOSE_STARTED]: {},
92
+ [EVENTS.COMPOSE_STEP_VIEWED]: { step: "content" },
93
+ [EVENTS.COMPOSE_BLOCKED]: { step: "content", reasons: ["missing_fields"], reason_count: 1 },
92
94
  [EVENTS.POST_SCHEDULED]: { platforms: ["instagram"], platform_count: 1 },
93
95
  [EVENTS.POST_PUBLISHED]: { platforms: ["instagram"], platform_count: 1, status: "success", platform: "instagram" },
94
96
  [EVENTS.PUBLISH_FAILED]: { platform: "instagram", reason_class: "auth", post_id: "post-1", platform_count: 1 },
@@ -47,6 +47,14 @@ export const EVENTS = {
47
47
  LOGGED_IN: 'logged_in',
48
48
  SOCIAL_ACCOUNT_CONNECTED: 'social_account_connected',
49
49
  COMPOSE_STARTED: 'compose_started',
50
+ // AD2-1371: the composer was a black box between COMPOSE_STARTED and
51
+ // POST_SCHEDULED. Measured 2026-08-28: of seven users who connected an
52
+ // account and never scheduled, the one who reached the composer opened it
53
+ // twice across two days and left both times, and the event stream carried
54
+ // nothing between arriving and leaving. These two say where they stopped
55
+ // and what stopped them.
56
+ COMPOSE_STEP_VIEWED: 'compose_step_viewed',
57
+ COMPOSE_BLOCKED: 'compose_blocked',
50
58
  POST_SCHEDULED: 'post_scheduled',
51
59
  POST_PUBLISHED: 'post_published', // server-confirmed (backend)
52
60
  PUBLISH_FAILED: 'publish_failed', // per-platform publish failure, server-owned (backend, AD2-999)
@@ -198,6 +206,31 @@ export type AppLocale = 'en' | 'pl';
198
206
  * absent/unclassifiable error is `'unknown'`. Do NOT rename these values after
199
207
  * they ship — they are wire values, historical data does not migrate.
200
208
  */
209
+ /**
210
+ * Which wizard step the composer is showing. Wire values, not indexes: a step
211
+ * reordered or inserted would silently re-label every historical row if this
212
+ * carried 0-3 (AD2-1371).
213
+ */
214
+ export type ComposeStep =
215
+ | 'platforms' // step 1 — pick the accounts to post to
216
+ | 'content' // step 2 — caption and media
217
+ | 'details' // step 3 — per-platform required fields
218
+ | 'review'; // step 4 — schedule or publish
219
+
220
+ /**
221
+ * Why the composer refused to advance. The readiness summary renders LOCALISED
222
+ * PROSE that names platforms and field labels, and the caption itself is user
223
+ * content, so neither may ride the event: only the class does. Same contract as
224
+ * PublishFailureReason. Do NOT rename after shipping — wire values.
225
+ */
226
+ export type ComposeBlockReason =
227
+ | 'empty' // no caption and no media
228
+ | 'missing_fields' // a platform's required fields are unset
229
+ | 'over_limit' // caption longer than a selected platform allows
230
+ | 'media' // media rejected (format, size, duration, count)
231
+ | 'channel_blocked' // the channel itself refused (e.g. a failed prerequisite read)
232
+ | 'poll'; // an incomplete or unpostable poll
233
+
201
234
  export type PublishFailureReason =
202
235
  | 'auth' // token expired / revoked / missing scope / permission denied
203
236
  | 'rate_limit' // provider throttled us (429 / quota)
@@ -251,6 +284,14 @@ export interface EventProperties {
251
284
  connection_ordinal?: number;
252
285
  };
253
286
  [EVENTS.COMPOSE_STARTED]: { source?: 'wizard' | 'quick' };
287
+ [EVENTS.COMPOSE_STEP_VIEWED]: { step: ComposeStep };
288
+ [EVENTS.COMPOSE_BLOCKED]: {
289
+ step: ComposeStep;
290
+ /** Distinct reason classes present, deduped. Never the rendered prose. */
291
+ reasons: ComposeBlockReason[];
292
+ /** How many readiness lines the user faced, which `reasons` dedupes away. */
293
+ reason_count: number;
294
+ };
254
295
  [EVENTS.POST_SCHEDULED]: {
255
296
  platforms: string[];
256
297
  platform_count: number;
@@ -10,6 +10,36 @@
10
10
  * ad2app-frontend/src/components/social/compose/platformConstants.ts
11
11
  * (PLATFORM_CONFIGS[*].charLimit / maxImages / maxVideos / acceptsVideo /
12
12
  * acceptsImages / metadata.mediaConstraints.maxVideoDuration).
13
+ *
14
+ * CAVEAT ON THAT WORD "CORRECTED" (AD2-1369, measured 2026-08-28). It holds for
15
+ * the character and count fields. It does NOT hold for maxVideoDurationSec, which
16
+ * was inherited from platformConstants.ts wholesale and never checked against any
17
+ * platform. Five platforms share the value 3600, which is a default rather than a
18
+ * researched figure, and comparing the whole column against the export-specs
19
+ * registry (.claude/skills/ad2app-graphics/data/export-specs.jsonl) shows it
20
+ * disagrees with platform truth in both directions:
21
+ *
22
+ * platform ours platform's own figure
23
+ * twitter 3600 140s free; Premium up to 4h -> ours is ABOVE the free tier
24
+ * instagram 3600 900s (15 min) -> ours is ABOVE
25
+ * linkedin 3600 600s (10 min) -> ours is ABOVE
26
+ * pinterest 900 300s organic (900s is the ADS product) -> ours uses the ads figure
27
+ * bluesky 60 180s -> ours is BELOW
28
+ * facebook 3600 241 min -> ours is below, conservative
29
+ * tiktok 600 600s via API -> matches
30
+ * threads 300 300s -> matches
31
+ * youtube 43200 43200s for verified accounts -> matches (verified only)
32
+ * reddit 3600 not recorded in the registry -> unverified
33
+ *
34
+ * An ABOVE-truth value is the worse half: we accept a video the platform will
35
+ * reject, so the failure surfaces late, at publish time, instead of in compose.
36
+ * A BELOW-truth value silently blocks something the user is allowed to post.
37
+ *
38
+ * Nothing is changed here on purpose. Each row is a live publishing behaviour, and
39
+ * the vendor we publish through (Zernio) may impose its own caps that none of these
40
+ * numbers reflect; I could find no Zernio-side duration limits this session. Fixing
41
+ * a row means verifying BOTH the platform figure and Zernio's, per platform, which
42
+ * is its own ticket. Until then this table tells the truth about itself.
13
43
  * - MAX_UPLOAD_BYTES matches ad2app-backend
14
44
  * src/modules/late/late-media-upload.service.ts MAX_FILE_SIZE_BYTES + FR-105.
15
45
  *
@@ -110,6 +140,8 @@ export const PUBLISH_LIMITS: Record<PublishPlatform, PlatformPublishLimits> = {
110
140
  charLimit: 300,
111
141
  maxImages: 4,
112
142
  maxVideos: 1,
143
+ // 60 is OURS, not Bluesky's. Bluesky allows 180s (its social-app constants.ts sets
144
+ // VIDEO_MAX_DURATION_MS = 3 * 60 * 1000). Left as-is deliberately, see the header.
113
145
  maxVideoDurationSec: 60,
114
146
  acceptsVideo: true,
115
147
  acceptsImages: true,