ad2app-lib 1.17.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.
- package/dist/analytics/index.d.ts +206 -0
- package/dist/analytics/index.js +41 -0
- package/dist/types/scheduling/I_SchedulingInbox.d.ts +165 -0
- package/dist/types/scheduling/I_SchedulingInbox.js +186 -1
- package/dist/types/scheduling/I_SchedulingPost.d.ts +7 -1
- package/dist/types/scheduling/I_SchedulingPost.js +1 -0
- package/package.json +2 -2
- package/src/analytics/index.test.ts +199 -2
- package/src/analytics/index.ts +187 -1
- package/src/types/scheduling/I_SchedulingInbox.test.ts +218 -1
- package/src/types/scheduling/I_SchedulingInbox.ts +289 -0
- package/src/types/scheduling/I_SchedulingPost.test.ts +24 -0
- package/src/types/scheduling/I_SchedulingPost.ts +13 -1
package/src/analytics/index.ts
CHANGED
|
@@ -35,6 +35,7 @@ export const EVENTS = {
|
|
|
35
35
|
COMPOSE_STARTED: 'compose_started',
|
|
36
36
|
POST_SCHEDULED: 'post_scheduled',
|
|
37
37
|
POST_PUBLISHED: 'post_published', // server-confirmed (backend)
|
|
38
|
+
PUBLISH_FAILED: 'publish_failed', // per-platform publish failure, server-owned (backend, AD2-999)
|
|
38
39
|
ANALYTICS_VIEWED: 'analytics_viewed',
|
|
39
40
|
|
|
40
41
|
// Revenue (backend = source of truth). Paid-only product: no trial.
|
|
@@ -43,6 +44,48 @@ export const EVENTS = {
|
|
|
43
44
|
CHECKOUT_STARTED: 'checkout_started',
|
|
44
45
|
SUBSCRIPTION_STARTED: 'subscription_started',
|
|
45
46
|
SUBSCRIPTION_CANCELED: 'subscription_canceled',
|
|
47
|
+
|
|
48
|
+
// Measurement baseline (080) — paywall / onboarding / exit-intent (frontend-owned)
|
|
49
|
+
PAYWALL_SHOWN: 'paywall_shown', // any monetization surface is shown (upgrade modal / pricing page)
|
|
50
|
+
PAYWALL_DISMISSED: 'paywall_dismissed', // closed without converting
|
|
51
|
+
ONBOARDING_STEP_COMPLETED: 'onboarding_step_completed', // one step of a multi-step flow (complete-profile)
|
|
52
|
+
EXIT_INTENT_SHOWN: 'exit_intent_shown', // exit-intent capture modal armed (065, verbatim wire values)
|
|
53
|
+
EXIT_INTENT_DISMISSED: 'exit_intent_dismissed',
|
|
54
|
+
EXIT_INTENT_CAPTURED: 'exit_intent_captured', // email captured into the beta list
|
|
55
|
+
|
|
56
|
+
// Measurement baseline (080) — subscription lifecycle (backend = source of truth)
|
|
57
|
+
BETA_SIGNUP_CONFIRMED: 'beta_signup_confirmed', // double-opt-in confirm click
|
|
58
|
+
PAYMENT_FAILED: 'payment_failed', // invoice.payment_failed (excl. unexecuted hard-decline retries)
|
|
59
|
+
DUNNING_RECOVERED: 'dunning_recovered', // open→paid after ≥1 failure on the same invoice
|
|
60
|
+
REFUND_ISSUED: 'refund_issued',
|
|
61
|
+
DISPUTE_OPENED: 'dispute_opened',
|
|
62
|
+
CANCEL_INITIATED: 'cancel_initiated', // start of the cancel funnel
|
|
63
|
+
RETENTION_OFFER_SHOWN: 'retention_offer_shown', // emitted ONLY when an offer is actually shown — no fabrication
|
|
64
|
+
CANCEL_COMPLETED: 'cancel_completed',
|
|
65
|
+
|
|
66
|
+
// Onboarding flow (promoted from the frontend-local onboardingEvents.ts into the
|
|
67
|
+
// governed taxonomy, 2026-07-19 — ONBOARDING_STEP_COMPLETED is defined above).
|
|
68
|
+
ONBOARDING_STARTED: 'onboarding_started',
|
|
69
|
+
ONBOARDING_COMPLETED: 'onboarding_completed',
|
|
70
|
+
ONBOARDING_SKIPPED: 'onboarding_skipped',
|
|
71
|
+
|
|
72
|
+
// Inbox engagement (tracking-coverage gap: the inbox surface had ZERO events).
|
|
73
|
+
COMMENT_REPLIED: 'comment_replied',
|
|
74
|
+
COMMENT_LIKED: 'comment_liked',
|
|
75
|
+
COMMENT_HIDDEN: 'comment_hidden',
|
|
76
|
+
COMMENT_DELETED: 'comment_deleted',
|
|
77
|
+
COMMENT_PRIVATE_REPLIED: 'comment_private_replied',
|
|
78
|
+
DM_REPLIED: 'dm_replied',
|
|
79
|
+
INBOX_MARKED_READ: 'inbox_marked_read', // sanctioned; wired when 082 mark-read lands
|
|
80
|
+
|
|
81
|
+
// Post lifecycle + account (tracking-coverage gap).
|
|
82
|
+
POST_EDITED: 'post_edited',
|
|
83
|
+
POST_DELETED: 'post_deleted',
|
|
84
|
+
POST_RESCHEDULED: 'post_rescheduled',
|
|
85
|
+
ACCOUNT_DISCONNECTED: 'account_disconnected',
|
|
86
|
+
|
|
87
|
+
// Settings.
|
|
88
|
+
NOTIFICATION_PREFERENCES_UPDATED: 'notification_preferences_updated',
|
|
46
89
|
} as const;
|
|
47
90
|
|
|
48
91
|
export type EventName = (typeof EVENTS)[keyof typeof EVENTS];
|
|
@@ -54,6 +97,46 @@ export interface EmailEventProperties {
|
|
|
54
97
|
link?: string;
|
|
55
98
|
}
|
|
56
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Where a paywall / upgrade / checkout moment was triggered from (080). A named,
|
|
102
|
+
* stable, closed set so the revenue funnel can attribute conversions to their
|
|
103
|
+
* origin surface. `checkout_abandon_email` is the recovery-email re-entry point
|
|
104
|
+
* (Superwall transaction-abandonment pattern). Wire values — do not rename.
|
|
105
|
+
*/
|
|
106
|
+
export type TriggerSource =
|
|
107
|
+
| 'connect_wall'
|
|
108
|
+
| 'limit_reached'
|
|
109
|
+
| 'exit_intent'
|
|
110
|
+
| 'pricing_page'
|
|
111
|
+
| 'checkout_abandon_email'
|
|
112
|
+
| 'other';
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The USER-CHOSEN i18n locale (080) — NOT `$browser_language`. Localization is
|
|
116
|
+
* the top structural conversion lever in the corpus (Adapty), so every wall /
|
|
117
|
+
* paywall insight slices by it. Stamped on the surfaces the emitter controls.
|
|
118
|
+
*/
|
|
119
|
+
export type AppLocale = 'en' | 'pl';
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Coarse failure-reason buckets for the `publish_failed` event (AD2-999).
|
|
123
|
+
*
|
|
124
|
+
* Deliberately a small, stable, closed set: it lets publish outcomes be sliced
|
|
125
|
+
* per failure class in PostHog WITHOUT putting raw provider (Zernio/platform)
|
|
126
|
+
* error text — which can carry user content or PII — on the event. The emitter
|
|
127
|
+
* (backend) maps a raw provider error string to exactly one of these; an
|
|
128
|
+
* absent/unclassifiable error is `'unknown'`. Do NOT rename these values after
|
|
129
|
+
* they ship — they are wire values, historical data does not migrate.
|
|
130
|
+
*/
|
|
131
|
+
export type PublishFailureReason =
|
|
132
|
+
| 'auth' // token expired / revoked / missing scope / permission denied
|
|
133
|
+
| 'rate_limit' // provider throttled us (429 / quota)
|
|
134
|
+
| 'media' // media rejected (format, size, duration, resolution)
|
|
135
|
+
| 'content' // caption/content rejected, duplicate, or policy violation
|
|
136
|
+
| 'network' // timeout / gateway / transport failure reaching the provider
|
|
137
|
+
| 'platform' // provider-side error (5xx / internal / unexpected)
|
|
138
|
+
| 'unknown'; // no error detail or unclassifiable
|
|
139
|
+
|
|
57
140
|
/** Property shape per event. Keeps emitters honest across repos. */
|
|
58
141
|
export interface EventProperties {
|
|
59
142
|
[EVENTS.LANDING_CTA_CLICKED]: {
|
|
@@ -82,17 +165,47 @@ export interface EventProperties {
|
|
|
82
165
|
[EVENTS.SOCIAL_ACCOUNT_CONNECTED]: {
|
|
83
166
|
platform: string;
|
|
84
167
|
account_count?: number;
|
|
168
|
+
// 080: 1 = first-ever account, 2 = second… — the "second action" activation hook.
|
|
169
|
+
connection_ordinal?: number;
|
|
85
170
|
};
|
|
86
171
|
[EVENTS.COMPOSE_STARTED]: { source?: 'wizard' | 'quick' };
|
|
87
172
|
[EVENTS.POST_SCHEDULED]: {
|
|
88
173
|
platforms: string[];
|
|
89
174
|
platform_count: number;
|
|
90
175
|
scheduled_for?: string;
|
|
176
|
+
// 080: nth scheduled post for this user (server-derived where owner=backend).
|
|
177
|
+
post_ordinal?: number;
|
|
91
178
|
};
|
|
92
179
|
[EVENTS.POST_PUBLISHED]: {
|
|
93
180
|
platforms: string[];
|
|
94
181
|
platform_count: number;
|
|
95
182
|
status: 'success' | 'partial' | 'failed';
|
|
183
|
+
// 080: nth published post for this user (server-derived where owner=backend).
|
|
184
|
+
post_ordinal?: number;
|
|
185
|
+
// Single-platform attribution (AD2-999): set to the sole target platform
|
|
186
|
+
// when platform_count === 1, so a per-platform outcome is directly
|
|
187
|
+
// sliceable. Omitted for multi-platform posts, where the aggregate `status`
|
|
188
|
+
// plus the per-platform `publish_failed` events carry the breakdown.
|
|
189
|
+
// (AD2-1154 FR-5 also relies on this: Paweł's incident showed post_published
|
|
190
|
+
// firing with NO platform property, so a partial publish couldn't be sliced.)
|
|
191
|
+
platform?: string;
|
|
192
|
+
// The publish path that confirmed the outcome. Only the scheduled/reconcile
|
|
193
|
+
// path stamps this today; the immediate-publish path omits it (AD2-999).
|
|
194
|
+
source?: 'scheduled_reconcile';
|
|
195
|
+
// Optional per-platform outcome breakdown for a future consumer swap that
|
|
196
|
+
// fans post_published out once per platform (AD2-1154 FR-5; lib prep now,
|
|
197
|
+
// emission-site change stays a separate story per doctrine).
|
|
198
|
+
outcome?: 'success' | 'failed' | 'unknown';
|
|
199
|
+
reason_class?: PublishFailureReason;
|
|
200
|
+
};
|
|
201
|
+
// Per-platform publish failure (AD2-999). Emitted once per failed platform on
|
|
202
|
+
// a failed or partial publish (immediate path and scheduled reconcile), so a
|
|
203
|
+
// wholesale failure surfaces as explicit events, never a silent absence.
|
|
204
|
+
[EVENTS.PUBLISH_FAILED]: {
|
|
205
|
+
platform: string;
|
|
206
|
+
reason_class: PublishFailureReason;
|
|
207
|
+
post_id: string;
|
|
208
|
+
platform_count: number;
|
|
96
209
|
};
|
|
97
210
|
[EVENTS.ANALYTICS_VIEWED]: { scope?: 'overview' | 'account' | 'post' };
|
|
98
211
|
[EVENTS.LIMIT_REACHED]: {
|
|
@@ -104,14 +217,83 @@ export interface EventProperties {
|
|
|
104
217
|
from_plan?: string;
|
|
105
218
|
to_plan?: string;
|
|
106
219
|
surface: string;
|
|
220
|
+
trigger_source?: TriggerSource; // 080: revenue-funnel origin attribution
|
|
107
221
|
};
|
|
108
|
-
[EVENTS.CHECKOUT_STARTED]: { plan: string };
|
|
222
|
+
[EVENTS.CHECKOUT_STARTED]: { plan: string; trigger_source?: TriggerSource };
|
|
109
223
|
[EVENTS.SUBSCRIPTION_STARTED]: {
|
|
110
224
|
plan: string;
|
|
111
225
|
mrr?: number;
|
|
112
226
|
accounts_included?: number;
|
|
227
|
+
trigger_source?: TriggerSource; // 080
|
|
113
228
|
};
|
|
114
229
|
[EVENTS.SUBSCRIPTION_CANCELED]: { plan: string; reason?: string };
|
|
230
|
+
|
|
231
|
+
// ── Measurement baseline (080) — new event property shapes ──────────────────
|
|
232
|
+
// Paywall surfaces (frontend). hit_number is the analytical truth (an event
|
|
233
|
+
// property, NOT a person prop, since person props return latest-value for
|
|
234
|
+
// historical events). app_locale is the user-chosen i18n locale, required here.
|
|
235
|
+
[EVENTS.PAYWALL_SHOWN]: {
|
|
236
|
+
surface: 'upgrade_modal' | 'pricing_page';
|
|
237
|
+
trigger: 'user_click' | 'limit_reached' | 'connect_wall';
|
|
238
|
+
plan?: string;
|
|
239
|
+
hit_number: number;
|
|
240
|
+
dwell_ms?: number;
|
|
241
|
+
page_views?: number;
|
|
242
|
+
app_locale: AppLocale;
|
|
243
|
+
};
|
|
244
|
+
[EVENTS.PAYWALL_DISMISSED]: {
|
|
245
|
+
surface: 'upgrade_modal' | 'pricing_page';
|
|
246
|
+
trigger: 'user_click' | 'limit_reached' | 'connect_wall';
|
|
247
|
+
hit_number: number;
|
|
248
|
+
app_locale: AppLocale;
|
|
249
|
+
};
|
|
250
|
+
[EVENTS.ONBOARDING_STEP_COMPLETED]: {
|
|
251
|
+
flow: 'complete_profile'; // closed set, extensible when a new flow ships
|
|
252
|
+
step: string; // stable slug derived from the real wizard, not guessed
|
|
253
|
+
step_index: number; // 1-based
|
|
254
|
+
app_locale: AppLocale;
|
|
255
|
+
};
|
|
256
|
+
// Exit-intent (065). surface value is VERBATIM from the ExitIntentModal
|
|
257
|
+
// component ('connect_wall'); dwell_ms / page_views / app_locale are optional
|
|
258
|
+
// additions T003 wires (the component does not emit them yet — fidelity gate).
|
|
259
|
+
[EVENTS.EXIT_INTENT_SHOWN]: { surface: 'connect_wall'; dwell_ms?: number; page_views?: number; app_locale?: AppLocale };
|
|
260
|
+
[EVENTS.EXIT_INTENT_DISMISSED]: { surface: 'connect_wall'; dwell_ms?: number; page_views?: number; app_locale?: AppLocale };
|
|
261
|
+
[EVENTS.EXIT_INTENT_CAPTURED]: { surface: 'connect_wall'; dwell_ms?: number; page_views?: number; app_locale?: AppLocale };
|
|
262
|
+
// Subscription lifecycle (backend). days_since_start / attempts / days_late are
|
|
263
|
+
// server-derived; retention_offer_shown emits ONLY when an offer is really shown.
|
|
264
|
+
[EVENTS.BETA_SIGNUP_CONFIRMED]: { source: 'exit_intent' | 'free_skills' };
|
|
265
|
+
[EVENTS.PAYMENT_FAILED]: { plan: string; attempt: number };
|
|
266
|
+
[EVENTS.DUNNING_RECOVERED]: { plan: string; attempts: number; days_late: number };
|
|
267
|
+
[EVENTS.REFUND_ISSUED]: { plan: string; days_since_start: number };
|
|
268
|
+
[EVENTS.DISPUTE_OPENED]: { plan: string; days_since_start: number };
|
|
269
|
+
[EVENTS.CANCEL_INITIATED]: { plan: string; days_since_start: number };
|
|
270
|
+
[EVENTS.RETENTION_OFFER_SHOWN]: { plan: string; offer: string };
|
|
271
|
+
[EVENTS.CANCEL_COMPLETED]: { plan: string; days_since_start: number; reason?: string };
|
|
272
|
+
|
|
273
|
+
// Onboarding flow (promoted 2026-07-19). flow defaults to the main app onboarding.
|
|
274
|
+
[EVENTS.ONBOARDING_STARTED]: { flow?: string };
|
|
275
|
+
[EVENTS.ONBOARDING_COMPLETED]: { flow?: string; steps_completed?: number };
|
|
276
|
+
[EVENTS.ONBOARDING_SKIPPED]: { flow?: string; step?: string };
|
|
277
|
+
|
|
278
|
+
// Inbox engagement — platform is the social platform the comment/DM is on.
|
|
279
|
+
// `success` is emitted on BOTH the success and failure path so a broken action
|
|
280
|
+
// is visible (slice by success=false), not a silent absence.
|
|
281
|
+
[EVENTS.COMMENT_REPLIED]: { platform?: string; success: boolean };
|
|
282
|
+
[EVENTS.COMMENT_LIKED]: { platform?: string; liked: boolean; success: boolean };
|
|
283
|
+
[EVENTS.COMMENT_HIDDEN]: { platform?: string; hidden: boolean; success: boolean };
|
|
284
|
+
[EVENTS.COMMENT_DELETED]: { platform?: string; success: boolean };
|
|
285
|
+
[EVENTS.COMMENT_PRIVATE_REPLIED]: { platform?: string; success: boolean };
|
|
286
|
+
[EVENTS.DM_REPLIED]: { platform?: string; success: boolean };
|
|
287
|
+
[EVENTS.INBOX_MARKED_READ]: { platform?: string; success: boolean };
|
|
288
|
+
|
|
289
|
+
// Post lifecycle + account.
|
|
290
|
+
[EVENTS.POST_EDITED]: { platforms?: string[]; platform_count?: number; success: boolean };
|
|
291
|
+
[EVENTS.POST_DELETED]: { platform_count?: number; success: boolean };
|
|
292
|
+
[EVENTS.POST_RESCHEDULED]: { platform_count?: number; success: boolean };
|
|
293
|
+
[EVENTS.ACCOUNT_DISCONNECTED]: { platform: string; success: boolean };
|
|
294
|
+
|
|
295
|
+
// Settings — which preference toggled and its new state.
|
|
296
|
+
[EVENTS.NOTIFICATION_PREFERENCES_UPDATED]: { preference?: string; enabled?: boolean; success: boolean };
|
|
115
297
|
}
|
|
116
298
|
|
|
117
299
|
/** Canonical person property keys (set via identify / $set). */
|
|
@@ -120,6 +302,10 @@ export const PERSON_PROPS = {
|
|
|
120
302
|
ROLE: 'role',
|
|
121
303
|
PLAN: 'plan', // 'free' | 'starter' | 'pro' (SchedulingSubscriptionTier)
|
|
122
304
|
ACCOUNT_COUNT: 'account_count',
|
|
305
|
+
// 080: convenience $set mirror of paywall exposure. The event-level `hit_number`
|
|
306
|
+
// is the analytical truth — person props are query-time-latest here, so a counter
|
|
307
|
+
// stored here would rewrite the value on every historical paywall_shown event.
|
|
308
|
+
PAYWALL_HITS: 'paywall_hits',
|
|
123
309
|
} as const;
|
|
124
310
|
|
|
125
311
|
export type PersonPropKey = (typeof PERSON_PROPS)[keyof typeof PERSON_PROPS];
|
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
import { test } from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
SchedulingInboxCommentDTO,
|
|
5
|
+
SchedulingInboxMentionDTO,
|
|
6
|
+
SchedulingReplyToMentionDTO,
|
|
7
|
+
SchedulingInboxReviewDTO,
|
|
8
|
+
SchedulingReplyToReviewDTO,
|
|
9
|
+
SchedulingInboxMessageDTO,
|
|
10
|
+
SchedulingInboxMessagePageDTO,
|
|
11
|
+
SchedulingCommentModerationDTO,
|
|
12
|
+
SchedulingInboxCapabilityDTO,
|
|
13
|
+
SchedulingCreateConversationDTO,
|
|
14
|
+
SchedulingConversationReadDTO,
|
|
15
|
+
SchedulingCrossPostCommentDTO,
|
|
16
|
+
} from './I_SchedulingInbox';
|
|
4
17
|
|
|
5
18
|
// AD2-1088 — engagement + ownership pass-through on inbox comments, verbatim
|
|
6
19
|
// from Zernio GET /v1/inbox/comments/{postId} (OpenAPI v1.0.4:20799). Set
|
|
@@ -53,3 +66,207 @@ test('SchedulingInboxCommentDTO nests replies with the same optional fields', ()
|
|
|
53
66
|
});
|
|
54
67
|
assert.equal(comment.replies?.[0].parentId, 'c1');
|
|
55
68
|
});
|
|
69
|
+
|
|
70
|
+
// ── 082 Inbox Parity — new inbox surfaces (US1) ───────────────────────────────
|
|
71
|
+
|
|
72
|
+
// canModerate is YouTube-only (OpenAPI v1.0.4:22489); absent for everything else,
|
|
73
|
+
// never fabricated to false-as-if-known.
|
|
74
|
+
test('SchedulingInboxCommentDTO.canModerate is absent by default and pass-through when set', () => {
|
|
75
|
+
const plain = new SchedulingInboxCommentDTO({
|
|
76
|
+
id: 'c1',
|
|
77
|
+
authorName: 'Jane',
|
|
78
|
+
text: 'hi',
|
|
79
|
+
createdAt: '2026-07-19T00:00:00.000Z',
|
|
80
|
+
});
|
|
81
|
+
assert.equal(plain.canModerate, undefined);
|
|
82
|
+
const yt = new SchedulingInboxCommentDTO({
|
|
83
|
+
id: 'c2',
|
|
84
|
+
authorName: 'Jane',
|
|
85
|
+
text: 'hi',
|
|
86
|
+
createdAt: '2026-07-19T00:00:00.000Z',
|
|
87
|
+
canModerate: true,
|
|
88
|
+
});
|
|
89
|
+
assert.equal(yt.canModerate, true);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// Mentions — GET /v1/inbox/mentions (23108); reply body [accountId, mediaId, message] (23207).
|
|
93
|
+
test('SchedulingInboxMentionDTO keeps source fields optional (honest-or-absent)', () => {
|
|
94
|
+
const bare = new SchedulingInboxMentionDTO({
|
|
95
|
+
id: 'm1',
|
|
96
|
+
platform: 'instagram',
|
|
97
|
+
accountId: 'a1',
|
|
98
|
+
authorName: 'fan',
|
|
99
|
+
text: '@me great post',
|
|
100
|
+
createdAt: '2026-07-19T00:00:00.000Z',
|
|
101
|
+
});
|
|
102
|
+
assert.equal(bare.sourcePostId, undefined);
|
|
103
|
+
assert.equal(bare.sourcePostUrl, undefined);
|
|
104
|
+
const full = new SchedulingInboxMentionDTO({
|
|
105
|
+
id: 'm2',
|
|
106
|
+
platform: 'instagram',
|
|
107
|
+
accountId: 'a1',
|
|
108
|
+
authorName: 'fan',
|
|
109
|
+
text: '@me nice',
|
|
110
|
+
createdAt: '2026-07-19T00:00:00.000Z',
|
|
111
|
+
sourcePostId: 'p9',
|
|
112
|
+
sourcePostUrl: 'https://example/p9',
|
|
113
|
+
});
|
|
114
|
+
assert.equal(full.sourcePostId, 'p9');
|
|
115
|
+
assert.equal(full.sourcePostUrl, 'https://example/p9');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('SchedulingReplyToMentionDTO requires accountId, mediaId and message', () => {
|
|
119
|
+
const dto = new SchedulingReplyToMentionDTO({
|
|
120
|
+
accountId: 'a1',
|
|
121
|
+
mediaId: 'md1',
|
|
122
|
+
message: 'thanks!',
|
|
123
|
+
});
|
|
124
|
+
assert.equal(dto.accountId, 'a1');
|
|
125
|
+
assert.equal(dto.mediaId, 'md1');
|
|
126
|
+
assert.equal(dto.message, 'thanks!');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// Reviews — GET /v1/inbox/reviews (23269); reply POST /v1/inbox/reviews/{reviewId}/reply (23377).
|
|
130
|
+
test('SchedulingInboxReviewDTO carries rating + optional replied flag', () => {
|
|
131
|
+
const r = new SchedulingInboxReviewDTO({
|
|
132
|
+
id: 'rv1',
|
|
133
|
+
platform: 'googlebusiness',
|
|
134
|
+
accountId: 'a1',
|
|
135
|
+
authorName: 'Customer',
|
|
136
|
+
rating: 5,
|
|
137
|
+
text: 'great',
|
|
138
|
+
createdAt: '2026-07-19T00:00:00.000Z',
|
|
139
|
+
});
|
|
140
|
+
assert.equal(r.rating, 5);
|
|
141
|
+
assert.equal(r.replied, undefined);
|
|
142
|
+
const replied = new SchedulingInboxReviewDTO({
|
|
143
|
+
id: 'rv2',
|
|
144
|
+
platform: 'googlebusiness',
|
|
145
|
+
accountId: 'a1',
|
|
146
|
+
authorName: 'Customer',
|
|
147
|
+
rating: 4,
|
|
148
|
+
text: 'good',
|
|
149
|
+
createdAt: '2026-07-19T00:00:00.000Z',
|
|
150
|
+
replied: true,
|
|
151
|
+
});
|
|
152
|
+
assert.equal(replied.replied, true);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('SchedulingReplyToReviewDTO carries reviewId, accountId, text', () => {
|
|
156
|
+
const dto = new SchedulingReplyToReviewDTO({
|
|
157
|
+
reviewId: 'rv1',
|
|
158
|
+
accountId: 'a1',
|
|
159
|
+
text: 'thank you',
|
|
160
|
+
});
|
|
161
|
+
assert.equal(dto.reviewId, 'rv1');
|
|
162
|
+
assert.equal(dto.accountId, 'a1');
|
|
163
|
+
assert.equal(dto.text, 'thank you');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// Cursor pagination — kills the 100-message thread truncation.
|
|
167
|
+
test('SchedulingInboxMessageDTO + page keep nextCursor optional', () => {
|
|
168
|
+
const msg = new SchedulingInboxMessageDTO({
|
|
169
|
+
id: 'msg1',
|
|
170
|
+
conversationId: 'conv1',
|
|
171
|
+
authorId: 'u1',
|
|
172
|
+
authorName: 'fan',
|
|
173
|
+
text: 'hi',
|
|
174
|
+
createdAt: '2026-07-19T00:00:00.000Z',
|
|
175
|
+
isOwn: false,
|
|
176
|
+
});
|
|
177
|
+
const lastPage = new SchedulingInboxMessagePageDTO({ items: [msg] });
|
|
178
|
+
assert.equal(lastPage.items.length, 1);
|
|
179
|
+
assert.equal(lastPage.nextCursor, undefined);
|
|
180
|
+
const midPage = new SchedulingInboxMessagePageDTO({ items: [msg], nextCursor: 'cur2' });
|
|
181
|
+
assert.equal(midPage.nextCursor, 'cur2');
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// Moderation — closed set [published, rejected, heldForReview]; banAuthor only with rejected (22489).
|
|
185
|
+
test('SchedulingCommentModerationDTO carries a closed-set status and optional banAuthor', () => {
|
|
186
|
+
const approve = new SchedulingCommentModerationDTO({ moderationStatus: 'published' });
|
|
187
|
+
assert.equal(approve.moderationStatus, 'published');
|
|
188
|
+
assert.equal(approve.banAuthor, undefined);
|
|
189
|
+
const reject = new SchedulingCommentModerationDTO({ moderationStatus: 'rejected', banAuthor: true });
|
|
190
|
+
assert.equal(reject.moderationStatus, 'rejected');
|
|
191
|
+
assert.equal(reject.banAuthor, true);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// Per-platform capability — drives honest show/hide (FR-4); absent/false = surface hidden.
|
|
195
|
+
test('SchedulingInboxCapabilityDTO exposes per-surface booleans', () => {
|
|
196
|
+
const cap = new SchedulingInboxCapabilityDTO({
|
|
197
|
+
platform: 'youtube',
|
|
198
|
+
dm: false,
|
|
199
|
+
comments: true,
|
|
200
|
+
mentions: false,
|
|
201
|
+
reviews: false,
|
|
202
|
+
dmInit: false,
|
|
203
|
+
moderation: true,
|
|
204
|
+
});
|
|
205
|
+
assert.equal(cap.comments, true);
|
|
206
|
+
assert.equal(cap.moderation, true);
|
|
207
|
+
assert.equal(cap.dm, false);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test('SchedulingCreateConversationDTO carries account, recipient, text', () => {
|
|
211
|
+
const dto = new SchedulingCreateConversationDTO({
|
|
212
|
+
accountId: 'a1',
|
|
213
|
+
recipientId: 'r1',
|
|
214
|
+
text: 'hello',
|
|
215
|
+
});
|
|
216
|
+
assert.equal(dto.accountId, 'a1');
|
|
217
|
+
assert.equal(dto.recipientId, 'r1');
|
|
218
|
+
assert.equal(dto.text, 'hello');
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// Mark-read acknowledgement — unreadCount absent unless Zernio reports it.
|
|
222
|
+
test('SchedulingConversationReadDTO keeps unreadCount optional', () => {
|
|
223
|
+
const ack = new SchedulingConversationReadDTO({ conversationId: 'conv1' });
|
|
224
|
+
assert.equal(ack.conversationId, 'conv1');
|
|
225
|
+
assert.equal(ack.unreadCount, undefined);
|
|
226
|
+
const ackWithCount = new SchedulingConversationReadDTO({ conversationId: 'conv1', unreadCount: 0 });
|
|
227
|
+
assert.equal(ackWithCount.unreadCount, 0);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// 082 US3 (T301 / AD2-1110): comments re-sourced from Zernio cross-post carry their OWN post
|
|
231
|
+
// attribution so an uncached post still surfaces its comments; the cache is enrichment only.
|
|
232
|
+
test('SchedulingCrossPostCommentDTO carries post attribution; enrichment + engagement absent by default', () => {
|
|
233
|
+
const c = new SchedulingCrossPostCommentDTO({
|
|
234
|
+
id: 'x1', authorName: 'Jane', text: 'nice', createdAt: '2026-07-19T00:00:00.000Z',
|
|
235
|
+
postId: 'p1', platform: 'instagram', accountId: 'acc1',
|
|
236
|
+
});
|
|
237
|
+
assert.equal(c.postId, 'p1');
|
|
238
|
+
assert.equal(c.platform, 'instagram');
|
|
239
|
+
assert.equal(c.accountId, 'acc1');
|
|
240
|
+
assert.equal(c.postContentSnippet, undefined, 'enrichment absent, never backfilled');
|
|
241
|
+
assert.equal(c.postThumbnailUrl, undefined);
|
|
242
|
+
assert.equal(c.likeCount, undefined);
|
|
243
|
+
assert.equal(c.canModerate, undefined);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test('SchedulingCrossPostCommentDTO fills enrichment + engagement when provided', () => {
|
|
247
|
+
const c = new SchedulingCrossPostCommentDTO({
|
|
248
|
+
id: 'x2', authorName: 'Jane', text: 'nice', createdAt: '2026-07-19T00:00:00.000Z',
|
|
249
|
+
postId: 'p2', platform: 'youtube', accountId: 'acc2',
|
|
250
|
+
postContentSnippet: 'My launch post', postThumbnailUrl: 'https://cdn/x.jpg',
|
|
251
|
+
likeCount: 5, replyCount: 2, isOwner: false, canModerate: true,
|
|
252
|
+
});
|
|
253
|
+
assert.equal(c.postContentSnippet, 'My launch post');
|
|
254
|
+
assert.equal(c.postThumbnailUrl, 'https://cdn/x.jpg');
|
|
255
|
+
assert.equal(c.likeCount, 5);
|
|
256
|
+
assert.equal(c.replyCount, 2);
|
|
257
|
+
assert.equal(c.canModerate, true);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test('SchedulingCrossPostCommentDTO nests replies of its own type', () => {
|
|
261
|
+
const c = new SchedulingCrossPostCommentDTO({
|
|
262
|
+
id: 'x3', authorName: 'Jane', text: 'top', createdAt: '2026-07-19T00:00:00.000Z',
|
|
263
|
+
postId: 'p3', platform: 'instagram', accountId: 'acc3',
|
|
264
|
+
replies: [new SchedulingCrossPostCommentDTO({
|
|
265
|
+
id: 'x3r1', authorName: 'Bob', text: 'reply', createdAt: '2026-07-19T00:01:00.000Z',
|
|
266
|
+
postId: 'p3', platform: 'instagram', accountId: 'acc3', parentId: 'x3',
|
|
267
|
+
})],
|
|
268
|
+
});
|
|
269
|
+
assert.equal(c.replies?.length, 1);
|
|
270
|
+
assert.equal(c.replies?.[0].parentId, 'x3');
|
|
271
|
+
assert.equal(c.replies?.[0].postId, 'p3', 'nested reply keeps its post attribution');
|
|
272
|
+
});
|