ad2app-lib 1.15.0 → 1.20.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.
@@ -6,7 +6,7 @@
6
6
  * the connected social accounts.
7
7
  */
8
8
  Object.defineProperty(exports, "__esModule", { value: true });
9
- exports.SchedulingReplyToCommentDTO = exports.SchedulingSendMessageDTO = exports.SchedulingInboxCommentDTO = exports.SchedulingInboxPostWithCommentsDTO = exports.SchedulingInboxConversationDTO = void 0;
9
+ exports.SchedulingCrossPostCommentDTO = exports.SchedulingInboxCapabilityDTO = exports.SchedulingCreateConversationDTO = exports.SchedulingCommentModerationDTO = exports.SchedulingReplyToReviewDTO = exports.SchedulingInboxReviewDTO = exports.SchedulingReplyToMentionDTO = exports.SchedulingInboxMentionDTO = exports.SchedulingConversationReadDTO = exports.SchedulingInboxMessagePageDTO = exports.SchedulingInboxMessageDTO = exports.SchedulingReplyToCommentDTO = exports.SchedulingSendMessageDTO = exports.SchedulingInboxCommentDTO = exports.SchedulingInboxPostWithCommentsDTO = exports.SchedulingInboxConversationDTO = void 0;
10
10
  // ── SchedulingInboxConversationDTO ────────────────────────────────────────────
11
11
  /**
12
12
  * A DM conversation (thread) entry.
@@ -60,6 +60,7 @@ class SchedulingInboxCommentDTO {
60
60
  this.isOwner = data.isOwner;
61
61
  this.parentId = data.parentId;
62
62
  this.replies = data.replies;
63
+ this.canModerate = data.canModerate;
63
64
  }
64
65
  }
65
66
  exports.SchedulingInboxCommentDTO = SchedulingInboxCommentDTO;
@@ -84,3 +85,187 @@ class SchedulingReplyToCommentDTO {
84
85
  }
85
86
  }
86
87
  exports.SchedulingReplyToCommentDTO = SchedulingReplyToCommentDTO;
88
+ // ── 082 Inbox Parity — new inbox surfaces (US1) ───────────────────────────────
89
+ //
90
+ // Shapes below mirror the Zernio OpenAPI v1.0.4 inbox surface (line anchors in
91
+ // each comment); ad2app must NOT invent fields (AD2-1038). Availability is
92
+ // honest-or-absent: an optional field is absent when Zernio does not report it,
93
+ // never zero/false-backfilled.
94
+ // ── SchedulingInboxMessageDTO / SchedulingInboxMessagePageDTO ──────────────────
95
+ /**
96
+ * One message inside a DM thread.
97
+ * Returned (paginated) by GET /social/inbox/messages/:id/thread — backed by
98
+ * Zernio GET /v1/inbox/conversations/{id}/messages (OpenAPI v1.0.4:20845).
99
+ */
100
+ class SchedulingInboxMessageDTO {
101
+ constructor(data) {
102
+ this.id = data.id;
103
+ this.conversationId = data.conversationId;
104
+ this.authorId = data.authorId;
105
+ this.authorName = data.authorName;
106
+ this.text = data.text;
107
+ this.createdAt = data.createdAt;
108
+ this.isOwn = data.isOwn;
109
+ this.mediaUrl = data.mediaUrl;
110
+ }
111
+ }
112
+ exports.SchedulingInboxMessageDTO = SchedulingInboxMessageDTO;
113
+ /**
114
+ * A cursor page of thread messages — kills the prior 100-message truncation.
115
+ * `nextCursor` absent = last page.
116
+ */
117
+ class SchedulingInboxMessagePageDTO {
118
+ constructor(data) {
119
+ this.items = data.items;
120
+ this.nextCursor = data.nextCursor;
121
+ }
122
+ }
123
+ exports.SchedulingInboxMessagePageDTO = SchedulingInboxMessagePageDTO;
124
+ // ── SchedulingConversationReadDTO ─────────────────────────────────────────────
125
+ /**
126
+ * Mark-read acknowledgement — POST /social/inbox/messages/:id/read → Zernio
127
+ * POST /v1/inbox/conversations/{id}/read (OpenAPI v1.0.4:21692). `unreadCount`
128
+ * absent unless Zernio reports the post-read count.
129
+ */
130
+ class SchedulingConversationReadDTO {
131
+ constructor(data) {
132
+ this.conversationId = data.conversationId;
133
+ this.unreadCount = data.unreadCount;
134
+ }
135
+ }
136
+ exports.SchedulingConversationReadDTO = SchedulingConversationReadDTO;
137
+ // ── SchedulingInboxMentionDTO / SchedulingReplyToMentionDTO ────────────────────
138
+ /**
139
+ * An inbound mention.
140
+ * Returned by GET /social/inbox/mentions → Zernio GET /v1/inbox/mentions
141
+ * (OpenAPI v1.0.4:23108). Source post fields absent when the platform omits them.
142
+ */
143
+ class SchedulingInboxMentionDTO {
144
+ constructor(data) {
145
+ this.id = data.id;
146
+ this.platform = data.platform;
147
+ this.accountId = data.accountId;
148
+ this.authorName = data.authorName;
149
+ this.authorAvatarUrl = data.authorAvatarUrl;
150
+ this.text = data.text;
151
+ this.createdAt = data.createdAt;
152
+ this.sourcePostId = data.sourcePostId;
153
+ this.sourcePostUrl = data.sourcePostUrl;
154
+ }
155
+ }
156
+ exports.SchedulingInboxMentionDTO = SchedulingInboxMentionDTO;
157
+ /**
158
+ * Input for replying to a mention. Zernio requires [accountId, mediaId, message]
159
+ * (OpenAPI v1.0.4:23207) — `mediaId` (the mention's target media) is mandatory,
160
+ * unlike a DM reply.
161
+ */
162
+ class SchedulingReplyToMentionDTO {
163
+ constructor(data) {
164
+ this.accountId = data.accountId;
165
+ this.mediaId = data.mediaId;
166
+ this.message = data.message;
167
+ }
168
+ }
169
+ exports.SchedulingReplyToMentionDTO = SchedulingReplyToMentionDTO;
170
+ // ── SchedulingInboxReviewDTO / SchedulingReplyToReviewDTO ──────────────────────
171
+ /**
172
+ * A platform review (e.g. Google Business).
173
+ * Returned by GET /social/inbox/reviews → Zernio GET /v1/inbox/reviews
174
+ * (OpenAPI v1.0.4:23269). `replied` absent unless Zernio reports reply state.
175
+ */
176
+ class SchedulingInboxReviewDTO {
177
+ constructor(data) {
178
+ this.id = data.id;
179
+ this.platform = data.platform;
180
+ this.accountId = data.accountId;
181
+ this.authorName = data.authorName;
182
+ this.rating = data.rating;
183
+ this.text = data.text;
184
+ this.createdAt = data.createdAt;
185
+ this.replied = data.replied;
186
+ }
187
+ }
188
+ exports.SchedulingInboxReviewDTO = SchedulingInboxReviewDTO;
189
+ /**
190
+ * Input for replying to a review — POST /social/inbox/reviews/:reviewId/reply →
191
+ * Zernio POST /v1/inbox/reviews/{reviewId}/reply (OpenAPI v1.0.4:23377).
192
+ */
193
+ class SchedulingReplyToReviewDTO {
194
+ constructor(data) {
195
+ this.reviewId = data.reviewId;
196
+ this.accountId = data.accountId;
197
+ this.text = data.text;
198
+ }
199
+ }
200
+ exports.SchedulingReplyToReviewDTO = SchedulingReplyToReviewDTO;
201
+ class SchedulingCommentModerationDTO {
202
+ constructor(data) {
203
+ this.moderationStatus = data.moderationStatus;
204
+ this.banAuthor = data.banAuthor;
205
+ }
206
+ }
207
+ exports.SchedulingCommentModerationDTO = SchedulingCommentModerationDTO;
208
+ // ── SchedulingCreateConversationDTO ───────────────────────────────────────────
209
+ /**
210
+ * Input for starting a new DM — POST /social/inbox/conversations → Zernio
211
+ * POST /v1/inbox/conversations (OpenAPI v1.0.4:20355). WhatsApp is excluded
212
+ * (it requires an approved template to open a thread; 082 non-goal).
213
+ */
214
+ class SchedulingCreateConversationDTO {
215
+ constructor(data) {
216
+ this.accountId = data.accountId;
217
+ this.recipientId = data.recipientId;
218
+ this.text = data.text;
219
+ }
220
+ }
221
+ exports.SchedulingCreateConversationDTO = SchedulingCreateConversationDTO;
222
+ // ── SchedulingInboxCapabilityDTO ──────────────────────────────────────────────
223
+ /**
224
+ * Per-connected-account inbox capability truth — which surfaces a platform
225
+ * actually supports. Drives honest showing/hiding (082 FR-4): a false/absent
226
+ * surface is HIDDEN, never rendered as a fabricated empty. Reconciled from
227
+ * platform-capabilities + Zernio /accounts/health.
228
+ */
229
+ class SchedulingInboxCapabilityDTO {
230
+ constructor(data) {
231
+ this.platform = data.platform;
232
+ this.dm = data.dm;
233
+ this.comments = data.comments;
234
+ this.mentions = data.mentions;
235
+ this.reviews = data.reviews;
236
+ this.dmInit = data.dmInit;
237
+ this.moderation = data.moderation;
238
+ }
239
+ }
240
+ exports.SchedulingInboxCapabilityDTO = SchedulingInboxCapabilityDTO;
241
+ // ── SchedulingCrossPostCommentDTO ─────────────────────────────────────────────
242
+ /**
243
+ * A comment re-sourced from Zernio's cross-post inbox (GET /v1/inbox/comments),
244
+ * carrying its OWN post attribution so it surfaces even when the post is absent
245
+ * from our local SocialPostCache. The cache is enrichment ONLY (post snippet /
246
+ * thumbnail), never the source of truth (082 US3 / AD2-1110): a comment on an
247
+ * uncached post must still reach the moderator. Comment/engagement fields mirror
248
+ * SchedulingInboxCommentDTO; enrichment fields are honest-or-absent — filled from
249
+ * the cache when present, never zero/empty-backfilled.
250
+ */
251
+ class SchedulingCrossPostCommentDTO {
252
+ constructor(data) {
253
+ this.id = data.id;
254
+ this.authorName = data.authorName;
255
+ this.authorAvatarUrl = data.authorAvatarUrl;
256
+ this.text = data.text;
257
+ this.createdAt = data.createdAt;
258
+ this.likeCount = data.likeCount;
259
+ this.replyCount = data.replyCount;
260
+ this.isOwner = data.isOwner;
261
+ this.parentId = data.parentId;
262
+ this.canModerate = data.canModerate;
263
+ this.replies = data.replies;
264
+ this.postId = data.postId;
265
+ this.platform = data.platform;
266
+ this.accountId = data.accountId;
267
+ this.postContentSnippet = data.postContentSnippet;
268
+ this.postThumbnailUrl = data.postThumbnailUrl;
269
+ }
270
+ }
271
+ exports.SchedulingCrossPostCommentDTO = SchedulingCrossPostCommentDTO;
@@ -9,9 +9,17 @@ export declare enum SchedulingPostStatus {
9
9
  SCHEDULED = "scheduled",
10
10
  PUBLISHED = "published",
11
11
  FAILED = "failed",
12
- PARTIAL = "partial"
12
+ PARTIAL = "partial",
13
+ /**
14
+ * The publisher accepted the post (an id exists) but its outcome could not
15
+ * be confirmed inline — a gateway timeout, or a 409 whose live status
16
+ * couldn't be fetched immediately (AD2-1153). Distinct from SCHEDULED: a
17
+ * PUBLISHING post may already be publishing server-side and must not be
18
+ * treated as still-editable.
19
+ */
20
+ PUBLISHING = "publishing"
13
21
  }
14
- export type SchedulingPlatformStatusMap = Record<string, 'scheduled' | 'published' | 'failed'>;
22
+ export type SchedulingPlatformStatusMap = Record<string, 'scheduled' | 'published' | 'failed' | 'unknown'>;
15
23
  export declare class SchedulingPlatformTargetDTO {
16
24
  platform: string;
17
25
  accountId: string;
@@ -86,6 +94,12 @@ export declare class SchedulingPostDTO {
86
94
  postUrls?: Record<string, string>;
87
95
  /** Per-platform error messages for platforms that failed to publish. */
88
96
  platformErrors?: Record<string, string>;
97
+ /**
98
+ * Per-platform retry-attempt counts (AD2-1154 FR-3a). A platform that has
99
+ * used its 3rd attempt and is still failed is terminal — the retry
100
+ * affordance withdraws and the last real error is shown as permanent.
101
+ */
102
+ platformRetryCounts?: Record<string, number>;
89
103
  scheduledAt?: string;
90
104
  publishedAt?: string;
91
105
  status: SchedulingPostStatus;
@@ -15,6 +15,14 @@ var SchedulingPostStatus;
15
15
  SchedulingPostStatus["PUBLISHED"] = "published";
16
16
  SchedulingPostStatus["FAILED"] = "failed";
17
17
  SchedulingPostStatus["PARTIAL"] = "partial";
18
+ /**
19
+ * The publisher accepted the post (an id exists) but its outcome could not
20
+ * be confirmed inline — a gateway timeout, or a 409 whose live status
21
+ * couldn't be fetched immediately (AD2-1153). Distinct from SCHEDULED: a
22
+ * PUBLISHING post may already be publishing server-side and must not be
23
+ * treated as still-editable.
24
+ */
25
+ SchedulingPostStatus["PUBLISHING"] = "publishing";
18
26
  })(SchedulingPostStatus || (exports.SchedulingPostStatus = SchedulingPostStatus = {}));
19
27
  // ── SchedulingPlatformTargetDTO ───────────────────────────────────────────────
20
28
  class SchedulingPlatformTargetDTO {
@@ -114,6 +122,7 @@ class SchedulingPostDTO {
114
122
  this.platformStatuses = data.platformStatuses;
115
123
  this.postUrls = data.postUrls;
116
124
  this.platformErrors = data.platformErrors;
125
+ this.platformRetryCounts = data.platformRetryCounts;
117
126
  this.scheduledAt = data.scheduledAt;
118
127
  this.publishedAt = data.publishedAt;
119
128
  this.status = data.status;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ad2app-lib",
3
- "version": "1.15.0",
3
+ "version": "1.20.0",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "commonjs",
@@ -43,10 +43,11 @@
43
43
  },
44
44
  "scripts": {
45
45
  "build": "npx tsc",
46
- "test": "node --import tsx --test $(find src -name '*.test.ts')"
46
+ "test": "node --import tsx --test $(find src -name '*.test.ts')",
47
+ "prepare": "npm run build"
47
48
  },
48
49
  "keywords": [],
49
- "author": "Maciej Górski@ad2.app",
50
+ "author": "Maciej G\u00f3rski@ad2.app",
50
51
  "license": "ISC",
51
52
  "description": "Package to share types and utils across the ad2app projects",
52
53
  "dependencies": {
@@ -9,7 +9,7 @@ import assert from "node:assert/strict";
9
9
  import { test } from "node:test";
10
10
 
11
11
  import { EVENTS, PERSON_PROPS } from "./index";
12
- import type { EventProperties, EmailEventProperties } from "./index";
12
+ import type { EventProperties, EmailEventProperties, TriggerSource, PublishFailureReason } from "./index";
13
13
 
14
14
  const SNAKE_CASE = /^[a-z][a-z0-9]*(_[a-z0-9]+)*$/;
15
15
 
@@ -77,13 +77,45 @@ const EVENT_PROPERTY_WITNESS: { [E in keyof EventProperties]: EventProperties[E]
77
77
  [EVENTS.SOCIAL_ACCOUNT_CONNECTED]: { platform: "instagram" },
78
78
  [EVENTS.COMPOSE_STARTED]: {},
79
79
  [EVENTS.POST_SCHEDULED]: { platforms: ["instagram"], platform_count: 1 },
80
- [EVENTS.POST_PUBLISHED]: { platforms: ["instagram"], platform_count: 1, status: "success" },
80
+ [EVENTS.POST_PUBLISHED]: { platforms: ["instagram"], platform_count: 1, status: "success", platform: "instagram" },
81
+ [EVENTS.PUBLISH_FAILED]: { platform: "instagram", reason_class: "auth", post_id: "post-1", platform_count: 1 },
81
82
  [EVENTS.ANALYTICS_VIEWED]: {},
82
83
  [EVENTS.LIMIT_REACHED]: { limit: "accounts", plan: "free", cap: 1 },
83
84
  [EVENTS.UPGRADE_CLICKED]: { surface: "compose" },
84
85
  [EVENTS.CHECKOUT_STARTED]: { plan: "starter" },
85
86
  [EVENTS.SUBSCRIPTION_STARTED]: { plan: "starter" },
86
87
  [EVENTS.SUBSCRIPTION_CANCELED]: { plan: "starter" },
88
+ // 080 measurement baseline
89
+ [EVENTS.PAYWALL_SHOWN]: { surface: "upgrade_modal", trigger: "user_click", hit_number: 1, app_locale: "en" },
90
+ [EVENTS.PAYWALL_DISMISSED]: { surface: "upgrade_modal", trigger: "user_click", hit_number: 1, app_locale: "en" },
91
+ [EVENTS.ONBOARDING_STEP_COMPLETED]: { flow: "complete_profile", step: "role", step_index: 1, app_locale: "en" },
92
+ [EVENTS.EXIT_INTENT_SHOWN]: { surface: "connect_wall" },
93
+ [EVENTS.EXIT_INTENT_DISMISSED]: { surface: "connect_wall" },
94
+ [EVENTS.EXIT_INTENT_CAPTURED]: { surface: "connect_wall" },
95
+ [EVENTS.BETA_SIGNUP_CONFIRMED]: { source: "exit_intent" },
96
+ [EVENTS.PAYMENT_FAILED]: { plan: "starter", attempt: 1 },
97
+ [EVENTS.DUNNING_RECOVERED]: { plan: "starter", attempts: 2, days_late: 3 },
98
+ [EVENTS.REFUND_ISSUED]: { plan: "starter", days_since_start: 10 },
99
+ [EVENTS.DISPUTE_OPENED]: { plan: "starter", days_since_start: 10 },
100
+ [EVENTS.CANCEL_INITIATED]: { plan: "starter", days_since_start: 30 },
101
+ [EVENTS.RETENTION_OFFER_SHOWN]: { plan: "starter", offer: "discount_20" },
102
+ [EVENTS.CANCEL_COMPLETED]: { plan: "starter", days_since_start: 30 },
103
+ // Onboarding + inbox + post-lifecycle coverage (2026-07-19)
104
+ [EVENTS.ONBOARDING_STARTED]: {},
105
+ [EVENTS.ONBOARDING_COMPLETED]: {},
106
+ [EVENTS.ONBOARDING_SKIPPED]: {},
107
+ [EVENTS.COMMENT_REPLIED]: { success: true },
108
+ [EVENTS.COMMENT_LIKED]: { liked: true, success: true },
109
+ [EVENTS.COMMENT_HIDDEN]: { hidden: true, success: true },
110
+ [EVENTS.COMMENT_DELETED]: { success: true },
111
+ [EVENTS.COMMENT_PRIVATE_REPLIED]: { success: true },
112
+ [EVENTS.DM_REPLIED]: { success: true },
113
+ [EVENTS.INBOX_MARKED_READ]: { success: true },
114
+ [EVENTS.POST_EDITED]: { success: true },
115
+ [EVENTS.POST_DELETED]: { success: true },
116
+ [EVENTS.POST_RESCHEDULED]: { success: true },
117
+ [EVENTS.ACCOUNT_DISCONNECTED]: { platform: "instagram", success: true },
118
+ [EVENTS.NOTIFICATION_PREFERENCES_UPDATED]: { success: true },
87
119
  };
88
120
 
89
121
  test("EVENTS values are 1:1 with EventProperties keys (no missing or typo'd event)", () => {
@@ -119,3 +151,168 @@ test("the 5 EMAIL_* events are locked and share the EmailEventProperties shape (
119
151
  }
120
152
  }
121
153
  });
154
+
155
+ // ── 080 measurement baseline — wire values are FINAL once shipped ───────────────
156
+
157
+ test("080 paywall / onboarding wire names are locked (rename breaks the baseline dashboards)", () => {
158
+ assert.equal(EVENTS.PAYWALL_SHOWN, "paywall_shown");
159
+ assert.equal(EVENTS.PAYWALL_DISMISSED, "paywall_dismissed");
160
+ assert.equal(EVENTS.ONBOARDING_STEP_COMPLETED, "onboarding_step_completed");
161
+ });
162
+
163
+ test("080 subscription-lifecycle wire names are locked (backend = source of truth)", () => {
164
+ assert.deepEqual(
165
+ [
166
+ EVENTS.BETA_SIGNUP_CONFIRMED,
167
+ EVENTS.PAYMENT_FAILED,
168
+ EVENTS.DUNNING_RECOVERED,
169
+ EVENTS.REFUND_ISSUED,
170
+ EVENTS.DISPUTE_OPENED,
171
+ EVENTS.CANCEL_INITIATED,
172
+ EVENTS.RETENTION_OFFER_SHOWN,
173
+ EVENTS.CANCEL_COMPLETED,
174
+ ],
175
+ [
176
+ "beta_signup_confirmed",
177
+ "payment_failed",
178
+ "dunning_recovered",
179
+ "refund_issued",
180
+ "dispute_opened",
181
+ "cancel_initiated",
182
+ "retention_offer_shown",
183
+ "cancel_completed",
184
+ ],
185
+ );
186
+ });
187
+
188
+ // Fidelity gate (T001): the exit_intent_* wire values are adopted VERBATIM from the
189
+ // 065 ExitIntentModal component. Verified against
190
+ // ad2app-frontend feature/ad2-1132-exit-intent
191
+ // src/components/social/exit-intent/ExitIntentModal.tsx —
192
+ // L38 const SURFACE = 'connect_wall'
193
+ // L67 trackLocal('exit_intent_shown', { surface: SURFACE })
194
+ // L79 trackLocal('exit_intent_dismissed', { surface: SURFACE })
195
+ // L99 trackLocal('exit_intent_captured', { surface: SURFACE })
196
+ // T003 swaps those literals to these constants; the values must not drift here.
197
+ test("080 exit_intent_* wire values match the 065 component verbatim (fidelity gate)", () => {
198
+ assert.equal(EVENTS.EXIT_INTENT_SHOWN, "exit_intent_shown");
199
+ assert.equal(EVENTS.EXIT_INTENT_DISMISSED, "exit_intent_dismissed");
200
+ assert.equal(EVENTS.EXIT_INTENT_CAPTURED, "exit_intent_captured");
201
+ const shown: EventProperties["exit_intent_shown"] = { surface: "connect_wall" };
202
+ assert.equal(shown.surface, "connect_wall");
203
+ });
204
+
205
+ test("080 PAYWALL_HITS person prop is locked; hit_number stays the event-level truth", () => {
206
+ assert.equal(PERSON_PROPS.PAYWALL_HITS, "paywall_hits");
207
+ // The analytical counter lives on the event (person props are query-time-latest).
208
+ const shown: EventProperties["paywall_shown"] = {
209
+ surface: "upgrade_modal",
210
+ trigger: "limit_reached",
211
+ hit_number: 3,
212
+ app_locale: "pl",
213
+ };
214
+ assert.equal(shown.hit_number, 3);
215
+ assert.equal(shown.app_locale, "pl");
216
+ });
217
+
218
+ test("080 trigger_source closed set attributes the revenue funnel across the three intent events", () => {
219
+ const sources: TriggerSource[] = [
220
+ "connect_wall",
221
+ "limit_reached",
222
+ "exit_intent",
223
+ "pricing_page",
224
+ "checkout_abandon_email",
225
+ "other",
226
+ ];
227
+ // Each intent event accepts a trigger_source from the shared closed set.
228
+ const upgrade: EventProperties["upgrade_clicked"] = { surface: "compose", trigger_source: "connect_wall" };
229
+ const checkout: EventProperties["checkout_started"] = { plan: "pro", trigger_source: "exit_intent" };
230
+ const started: EventProperties["subscription_started"] = { plan: "pro", trigger_source: "checkout_abandon_email" };
231
+ assert.ok(sources.includes(upgrade.trigger_source!));
232
+ assert.ok(sources.includes(checkout.trigger_source!));
233
+ assert.ok(sources.includes(started.trigger_source!));
234
+ });
235
+
236
+ /**
237
+ * AD2-999 — per-platform publish instrumentation. Locks the new publish_failed
238
+ * wire name, its payload shape, the reason_class value set, and the optional
239
+ * `platform` attribution added to post_published.
240
+ */
241
+
242
+ test("post_published and publish_failed wire names are locked (AD2-999)", () => {
243
+ assert.equal(EVENTS.POST_PUBLISHED, "post_published");
244
+ assert.equal(EVENTS.PUBLISH_FAILED, "publish_failed");
245
+ });
246
+
247
+ test("publish_failed carries exactly platform, reason_class, post_id, platform_count (AD2-999)", () => {
248
+ const witness: EventProperties[typeof EVENTS.PUBLISH_FAILED] =
249
+ EVENT_PROPERTY_WITNESS[EVENTS.PUBLISH_FAILED];
250
+ assert.deepEqual(Object.keys(witness).sort(), [
251
+ "platform",
252
+ "platform_count",
253
+ "post_id",
254
+ "reason_class",
255
+ ]);
256
+ assert.equal(typeof witness.platform, "string");
257
+ assert.equal(typeof witness.post_id, "string");
258
+ assert.equal(typeof witness.platform_count, "number");
259
+ });
260
+
261
+ test("PublishFailureReason is a stable closed set of snake_case buckets (AD2-999)", () => {
262
+ // The union is compile-time; this pins the wire values a renamed bucket would
263
+ // break. Kept in sync with the exported type via the typed `reasons` array —
264
+ // an added/removed union member fails `tsc --noEmit`, a renamed value fails here.
265
+ const reasons: PublishFailureReason[] = [
266
+ "auth",
267
+ "rate_limit",
268
+ "media",
269
+ "content",
270
+ "network",
271
+ "platform",
272
+ "unknown",
273
+ ];
274
+ assert.equal(new Set(reasons).size, reasons.length, "duplicate reason_class");
275
+ for (const reason of reasons) assert.match(reason, SNAKE_CASE, `not snake_case: ${reason}`);
276
+ });
277
+
278
+ test("post_published accepts optional single-platform attribution (AD2-999)", () => {
279
+ const single: EventProperties[typeof EVENTS.POST_PUBLISHED] = {
280
+ platforms: ["instagram"],
281
+ platform_count: 1,
282
+ status: "success",
283
+ platform: "instagram",
284
+ };
285
+ const multi: EventProperties[typeof EVENTS.POST_PUBLISHED] = {
286
+ platforms: ["instagram", "tiktok"],
287
+ platform_count: 2,
288
+ status: "partial",
289
+ };
290
+ assert.equal(single.platform, "instagram");
291
+ assert.equal(multi.platform, undefined);
292
+ });
293
+
294
+ // AD2-1154 FR-5 — Paweł's incident: post_published fired with NO platform
295
+ // property, so a partial publish (YT ok / TikTok fail) couldn't be sliced by
296
+ // which platform actually failed in PostHog. These are OPTIONAL additions —
297
+ // existing aggregate-only emitters (platforms/platform_count/status) stay
298
+ // valid; a future consumer swap fans this event out once per platform.
299
+ test("POST_PUBLISHED gains optional per-platform properties (AD2-1154 FR-5 lib prep)", () => {
300
+ const aggregateOnly: EventProperties[typeof EVENTS.POST_PUBLISHED] = {
301
+ platforms: ["youtube", "tiktok"],
302
+ platform_count: 2,
303
+ status: "partial",
304
+ };
305
+ assert.equal(aggregateOnly.platform, undefined);
306
+
307
+ const perPlatform: EventProperties[typeof EVENTS.POST_PUBLISHED] = {
308
+ platforms: ["tiktok"],
309
+ platform_count: 1,
310
+ status: "failed",
311
+ platform: "tiktok",
312
+ outcome: "failed",
313
+ reason_class: "media",
314
+ };
315
+ assert.equal(perPlatform.platform, "tiktok");
316
+ assert.equal(perPlatform.outcome, "failed");
317
+ assert.equal(perPlatform.reason_class, "media");
318
+ });