ad2app-lib 1.23.0 → 1.24.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.
@@ -36,6 +36,7 @@ export declare const EVENTS: {
36
36
  readonly SUBSCRIPTION_CANCELED: "subscription_canceled";
37
37
  readonly PAYWALL_SHOWN: "paywall_shown";
38
38
  readonly PAYWALL_DISMISSED: "paywall_dismissed";
39
+ readonly ACCESS_DENIED: "access_denied";
39
40
  readonly ONBOARDING_STEP_COMPLETED: "onboarding_step_completed";
40
41
  readonly EXIT_INTENT_SHOWN: "exit_intent_shown";
41
42
  readonly EXIT_INTENT_DISMISSED: "exit_intent_dismissed";
@@ -194,6 +195,11 @@ export interface EventProperties {
194
195
  page_views?: number;
195
196
  app_locale: AppLocale;
196
197
  };
198
+ [EVENTS.ACCESS_DENIED]: {
199
+ reason: 'beta-required' | 'subscription-required';
200
+ path?: string;
201
+ app_locale: AppLocale;
202
+ };
197
203
  [EVENTS.PAYWALL_DISMISSED]: {
198
204
  surface: 'upgrade_modal' | 'pricing_page';
199
205
  trigger: 'user_click' | 'limit_reached' | 'connect_wall';
@@ -45,6 +45,13 @@ exports.EVENTS = {
45
45
  // Measurement baseline (080) — paywall / onboarding / exit-intent (frontend-owned)
46
46
  PAYWALL_SHOWN: 'paywall_shown', // any monetization surface is shown (upgrade modal / pricing page)
47
47
  PAYWALL_DISMISSED: 'paywall_dismissed', // closed without converting
48
+ // AD2-1284: an entitlement wall rejected an API READ, which is control flow,
49
+ // not an error. Distinct from PAYWALL_SHOWN because nothing was shown: the
50
+ // driver classified a 403 at the choke point and the redirect follows. Before
51
+ // this event existed the only record was a captured $exception, which put
52
+ // expected paywall hits into Error Tracking and made that dashboard harder to
53
+ // trust — it is part of why a REAL retry defect sat unnoticed in the same list.
54
+ ACCESS_DENIED: 'access_denied', // entitlement 403 at the API choke point (beta wall / subscription)
48
55
  ONBOARDING_STEP_COMPLETED: 'onboarding_step_completed', // one step of a multi-step flow (complete-profile)
49
56
  EXIT_INTENT_SHOWN: 'exit_intent_shown', // exit-intent capture modal armed (065, verbatim wire values)
50
57
  EXIT_INTENT_DISMISSED: 'exit_intent_dismissed',
@@ -10,6 +10,24 @@
10
10
  */
11
11
  export declare const ANALYTICS_SOURCES: readonly ["daily-metrics", "follower-stats", "content-decay", "top-posts"];
12
12
  export type AnalyticsSource = (typeof ANALYTICS_SOURCES)[number];
13
+ /**
14
+ * The metrics whose visibility is decided by the connected platform mix rather
15
+ * than by the data itself.
16
+ *
17
+ * Not every metric is here. `engagements` is our own roll-up (likes + comments
18
+ * + shares + saves) and `followerGrowth` comes from the follower-stats surface,
19
+ * so neither is platform-reported and neither is gated.
20
+ */
21
+ export declare const GATED_METRICS: readonly ["impressions", "reach", "likes", "comments", "shares", "saves", "clicks", "views"];
22
+ export type GatedMetric = (typeof GATED_METRICS)[number];
23
+ /**
24
+ * Which gated metrics the current platform mix can actually report.
25
+ *
26
+ * An ABSENT entry means UNKNOWN, never unavailable — the producer omits the map
27
+ * when it could not determine the platform mix, and a consumer must fall
28
+ * through to showing the figure rather than withhold on a guess.
29
+ */
30
+ export type MetricAvailability = Partial<Record<GatedMetric, boolean>>;
13
31
  /**
14
32
  * Aggregated KPI figures for the selected date range.
15
33
  * Returned by GET /social/analytics.
@@ -50,6 +68,16 @@ export declare class SchedulingAnalyticsKpiDTO {
50
68
  * Absent/true = available.
51
69
  */
52
70
  reachAvailable?: boolean;
71
+ /**
72
+ * Structural availability for every gated metric, from the backend's
73
+ * per-platform capability map (spec 111). Supersedes the two flags above,
74
+ * which stay for consumers that only ever read those two.
75
+ *
76
+ * ABSENT means UNKNOWN, not unavailable: the backend omits this whole map
77
+ * when it could not determine the connected platform mix, and a consumer
78
+ * must show the figure rather than withhold on a guess.
79
+ */
80
+ metricAvailability?: MetricAvailability;
53
81
  constructor(data: SchedulingAnalyticsKpiDTO);
54
82
  }
55
83
  /**
@@ -6,7 +6,7 @@
6
6
  * per-post timeline snapshots, content decay windows, and follower stats.
7
7
  */
8
8
  Object.defineProperty(exports, "__esModule", { value: true });
9
- exports.SchedulingFollowerStatDTO = exports.SchedulingContentDecayDTO = exports.SchedulingPostTimelineEntryDTO = exports.SchedulingBestTimeSlotDTO = exports.SchedulingAnalyticsParamsDTO = exports.SchedulingAnalyticsEntryDTO = exports.SchedulingAnalyticsKpiDTO = exports.ANALYTICS_SOURCES = void 0;
9
+ exports.SchedulingFollowerStatDTO = exports.SchedulingContentDecayDTO = exports.SchedulingPostTimelineEntryDTO = exports.SchedulingBestTimeSlotDTO = exports.SchedulingAnalyticsParamsDTO = exports.SchedulingAnalyticsEntryDTO = exports.SchedulingAnalyticsKpiDTO = exports.GATED_METRICS = exports.ANALYTICS_SOURCES = void 0;
10
10
  // ── Analytics sources (AD2-1045) ──────────────────────────────────────────────
11
11
  /**
12
12
  * The upstream sources an analytics response aggregates. Used by
@@ -18,6 +18,25 @@ exports.ANALYTICS_SOURCES = [
18
18
  'content-decay',
19
19
  'top-posts',
20
20
  ];
21
+ // ── Per-metric structural availability (spec 111) ─────────────────────────────
22
+ /**
23
+ * The metrics whose visibility is decided by the connected platform mix rather
24
+ * than by the data itself.
25
+ *
26
+ * Not every metric is here. `engagements` is our own roll-up (likes + comments
27
+ * + shares + saves) and `followerGrowth` comes from the follower-stats surface,
28
+ * so neither is platform-reported and neither is gated.
29
+ */
30
+ exports.GATED_METRICS = [
31
+ 'impressions',
32
+ 'reach',
33
+ 'likes',
34
+ 'comments',
35
+ 'shares',
36
+ 'saves',
37
+ 'clicks',
38
+ 'views',
39
+ ];
21
40
  // ── SchedulingAnalyticsKpiDTO ─────────────────────────────────────────────────
22
41
  /**
23
42
  * Aggregated KPI figures for the selected date range.
@@ -43,6 +62,9 @@ class SchedulingAnalyticsKpiDTO {
43
62
  if (data.reachAvailable !== undefined) {
44
63
  this.reachAvailable = data.reachAvailable;
45
64
  }
65
+ if (data.metricAvailability !== undefined) {
66
+ this.metricAvailability = data.metricAvailability;
67
+ }
46
68
  }
47
69
  }
48
70
  exports.SchedulingAnalyticsKpiDTO = SchedulingAnalyticsKpiDTO;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ad2app-lib",
3
- "version": "1.23.0",
3
+ "version": "1.24.0",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "commonjs",
@@ -88,6 +88,7 @@ const EVENT_PROPERTY_WITNESS: { [E in keyof EventProperties]: EventProperties[E]
88
88
  // 080 measurement baseline
89
89
  [EVENTS.PAYWALL_SHOWN]: { surface: "upgrade_modal", trigger: "user_click", hit_number: 1, app_locale: "en" },
90
90
  [EVENTS.PAYWALL_DISMISSED]: { surface: "upgrade_modal", trigger: "user_click", hit_number: 1, app_locale: "en" },
91
+ [EVENTS.ACCESS_DENIED]: { reason: "subscription-required", app_locale: "en" },
91
92
  [EVENTS.ONBOARDING_STEP_COMPLETED]: { flow: "complete_profile", step: "role", step_index: 1, app_locale: "en" },
92
93
  [EVENTS.EXIT_INTENT_SHOWN]: { surface: "connect_wall" },
93
94
  [EVENTS.EXIT_INTENT_DISMISSED]: { surface: "connect_wall" },
@@ -202,6 +203,28 @@ test("080 exit_intent_* wire values match the 065 component verbatim (fidelity g
202
203
  assert.equal(shown.surface, "connect_wall");
203
204
  });
204
205
 
206
+ test("AD2-1284 access_denied is locked, and its reasons mirror the backend's two 403s", () => {
207
+ // The wire name is final: Error Tracking noise was traded for this event, and
208
+ // renaming it would orphan whatever reads it.
209
+ assert.equal(EVENTS.ACCESS_DENIED, "access_denied");
210
+
211
+ // Both reasons must type-check — they are the two DIFFERENT 403s the backend
212
+ // sends, and accessDenied.ts's AccessDenial union is the other copy of this
213
+ // fact. A drift between them is the defect this pins.
214
+ const beta: EventProperties["access_denied"] = {
215
+ reason: "beta-required",
216
+ path: "/social/inbox/messages",
217
+ app_locale: "pl",
218
+ };
219
+ const sub: EventProperties["access_denied"] = {
220
+ reason: "subscription-required",
221
+ app_locale: "en",
222
+ };
223
+ assert.equal(beta.reason, "beta-required");
224
+ assert.equal(beta.path, "/social/inbox/messages");
225
+ assert.equal(sub.path, undefined);
226
+ });
227
+
205
228
  test("080 PAYWALL_HITS person prop is locked; hit_number stays the event-level truth", () => {
206
229
  assert.equal(PERSON_PROPS.PAYWALL_HITS, "paywall_hits");
207
230
  // The analytical counter lives on the event (person props are query-time-latest).
@@ -48,6 +48,13 @@ export const EVENTS = {
48
48
  // Measurement baseline (080) — paywall / onboarding / exit-intent (frontend-owned)
49
49
  PAYWALL_SHOWN: 'paywall_shown', // any monetization surface is shown (upgrade modal / pricing page)
50
50
  PAYWALL_DISMISSED: 'paywall_dismissed', // closed without converting
51
+ // AD2-1284: an entitlement wall rejected an API READ, which is control flow,
52
+ // not an error. Distinct from PAYWALL_SHOWN because nothing was shown: the
53
+ // driver classified a 403 at the choke point and the redirect follows. Before
54
+ // this event existed the only record was a captured $exception, which put
55
+ // expected paywall hits into Error Tracking and made that dashboard harder to
56
+ // trust — it is part of why a REAL retry defect sat unnoticed in the same list.
57
+ ACCESS_DENIED: 'access_denied', // entitlement 403 at the API choke point (beta wall / subscription)
51
58
  ONBOARDING_STEP_COMPLETED: 'onboarding_step_completed', // one step of a multi-step flow (complete-profile)
52
59
  EXIT_INTENT_SHOWN: 'exit_intent_shown', // exit-intent capture modal armed (065, verbatim wire values)
53
60
  EXIT_INTENT_DISMISSED: 'exit_intent_dismissed',
@@ -241,6 +248,14 @@ export interface EventProperties {
241
248
  page_views?: number;
242
249
  app_locale: AppLocale;
243
250
  };
251
+ // `reason` mirrors accessDenied.ts's AccessDenial union exactly — the two
252
+ // different 403s the backend sends. `path` is the request path, never a URL
253
+ // with query params (no PII in analytics).
254
+ [EVENTS.ACCESS_DENIED]: {
255
+ reason: 'beta-required' | 'subscription-required';
256
+ path?: string;
257
+ app_locale: AppLocale;
258
+ };
244
259
  [EVENTS.PAYWALL_DISMISSED]: {
245
260
  surface: 'upgrade_modal' | 'pricing_page';
246
261
  trigger: 'user_click' | 'limit_reached' | 'connect_wall';
@@ -2,7 +2,9 @@ import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
3
  import {
4
4
  ANALYTICS_SOURCES,
5
+ GATED_METRICS,
5
6
  SchedulingAnalyticsKpiDTO,
7
+ type MetricAvailability,
6
8
  } from './I_SchedulingAnalytics';
7
9
 
8
10
  // AD2-1045 — the analytics partial-failure signal: a KPI response can name
@@ -89,3 +91,58 @@ test('impressionsAvailable/reachAvailable are optional and absent by default (ba
89
91
  assert.equal('impressionsAvailable' in serialized, false);
90
92
  assert.equal('reachAvailable' in serialized, false);
91
93
  });
94
+
95
+ // Spec 111 — per-metric structural availability. The two AD2-1078 flags above
96
+ // only ever described impressions and reach; the capability map the backend
97
+ // now derives covers eight metrics, and it travelled as an undeclared field
98
+ // cast onto the DTO on one side and re-widened on the other until this landed.
99
+
100
+ test('SchedulingAnalyticsKpiDTO carries metricAvailability when provided', () => {
101
+ const kpi = new SchedulingAnalyticsKpiDTO({
102
+ impressions: 10,
103
+ reach: 5,
104
+ engagements: 4,
105
+ views: 20,
106
+ engagementRate: 1.2,
107
+ followerGrowth: 3,
108
+ metricAvailability: { reach: false, saves: true, clicks: false },
109
+ });
110
+ assert.equal(kpi.metricAvailability?.reach, false);
111
+ assert.equal(kpi.metricAvailability?.saves, true);
112
+ assert.equal(kpi.metricAvailability?.clicks, false);
113
+ assert.deepEqual(
114
+ JSON.parse(JSON.stringify(kpi)).metricAvailability,
115
+ { reach: false, saves: true, clicks: false },
116
+ );
117
+ });
118
+
119
+ test('metricAvailability is optional and absent by default — absent means UNKNOWN', () => {
120
+ const kpi = new SchedulingAnalyticsKpiDTO({
121
+ impressions: 10,
122
+ reach: 5,
123
+ engagements: 4,
124
+ views: 20,
125
+ engagementRate: 1.2,
126
+ followerGrowth: 3,
127
+ });
128
+ assert.equal(kpi.metricAvailability, undefined);
129
+ assert.equal('metricAvailability' in JSON.parse(JSON.stringify(kpi)), false);
130
+ });
131
+
132
+ test('GATED_METRICS names every metric the capability map gates, and nothing else', () => {
133
+ // engagements is our own roll-up and followerGrowth is a different surface —
134
+ // neither is platform-reported, so neither may appear here.
135
+ assert.deepEqual([...GATED_METRICS], [
136
+ 'impressions',
137
+ 'reach',
138
+ 'likes',
139
+ 'comments',
140
+ 'shares',
141
+ 'saves',
142
+ 'clicks',
143
+ 'views',
144
+ ]);
145
+ const availability: MetricAvailability = {};
146
+ for (const metric of GATED_METRICS) availability[metric] = true;
147
+ assert.equal(Object.keys(availability).length, 8);
148
+ });
@@ -20,6 +20,38 @@ export const ANALYTICS_SOURCES = [
20
20
 
21
21
  export type AnalyticsSource = (typeof ANALYTICS_SOURCES)[number];
22
22
 
23
+ // ── Per-metric structural availability (spec 111) ─────────────────────────────
24
+
25
+ /**
26
+ * The metrics whose visibility is decided by the connected platform mix rather
27
+ * than by the data itself.
28
+ *
29
+ * Not every metric is here. `engagements` is our own roll-up (likes + comments
30
+ * + shares + saves) and `followerGrowth` comes from the follower-stats surface,
31
+ * so neither is platform-reported and neither is gated.
32
+ */
33
+ export const GATED_METRICS = [
34
+ 'impressions',
35
+ 'reach',
36
+ 'likes',
37
+ 'comments',
38
+ 'shares',
39
+ 'saves',
40
+ 'clicks',
41
+ 'views',
42
+ ] as const;
43
+
44
+ export type GatedMetric = (typeof GATED_METRICS)[number];
45
+
46
+ /**
47
+ * Which gated metrics the current platform mix can actually report.
48
+ *
49
+ * An ABSENT entry means UNKNOWN, never unavailable — the producer omits the map
50
+ * when it could not determine the platform mix, and a consumer must fall
51
+ * through to showing the figure rather than withhold on a guess.
52
+ */
53
+ export type MetricAvailability = Partial<Record<GatedMetric, boolean>>;
54
+
23
55
  // ── SchedulingAnalyticsKpiDTO ─────────────────────────────────────────────────
24
56
 
25
57
  /**
@@ -62,6 +94,16 @@ export class SchedulingAnalyticsKpiDTO {
62
94
  * Absent/true = available.
63
95
  */
64
96
  reachAvailable?: boolean;
97
+ /**
98
+ * Structural availability for every gated metric, from the backend's
99
+ * per-platform capability map (spec 111). Supersedes the two flags above,
100
+ * which stay for consumers that only ever read those two.
101
+ *
102
+ * ABSENT means UNKNOWN, not unavailable: the backend omits this whole map
103
+ * when it could not determine the connected platform mix, and a consumer
104
+ * must show the figure rather than withhold on a guess.
105
+ */
106
+ metricAvailability?: MetricAvailability;
65
107
 
66
108
  constructor(data: SchedulingAnalyticsKpiDTO) {
67
109
  this.impressions = data.impressions;
@@ -82,6 +124,9 @@ export class SchedulingAnalyticsKpiDTO {
82
124
  if (data.reachAvailable !== undefined) {
83
125
  this.reachAvailable = data.reachAvailable;
84
126
  }
127
+ if (data.metricAvailability !== undefined) {
128
+ this.metricAvailability = data.metricAvailability;
129
+ }
85
130
  }
86
131
  }
87
132