ad2app-lib 1.20.0 → 1.22.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.
@@ -1,9 +1,25 @@
1
1
  import { FetchParams } from "../types";
2
- type HttpMethod = "get" | "post" | "put" | "patch" | "delete";
2
+ type HttpMethod = "get" | "post" | "put" | "PATCH" | "delete";
3
3
  interface DriverConfig {
4
4
  apiUrl: string;
5
5
  getHeaders?: () => HeadersInit;
6
6
  }
7
+ /**
8
+ * Thrown by `fetchCall` for any non-2xx response.
9
+ *
10
+ * Previously this was a bare `Error` carrying only the response body text, so
11
+ * the HTTP status — the one piece of information a caller needs to tell
12
+ * "you sent the wrong thing" apart from "you're not allowed" apart from
13
+ * "reload and try again" — was discarded at the driver. Callers had no way to
14
+ * branch, so every failure collapsed into one generic message. Keeping
15
+ * `message` byte-identical to the old value means existing `catch` blocks that
16
+ * read it are unaffected; `status` is purely additive.
17
+ */
18
+ export declare class ApiError extends Error {
19
+ readonly status: number;
20
+ readonly body: string;
21
+ constructor(message: string, status: number, body: string);
22
+ }
7
23
  export declare function configureApiDriver(config: DriverConfig): void;
8
24
  type FetchCallArgs<Q> = {
9
25
  path?: string;
@@ -1,9 +1,32 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ApiError = void 0;
3
4
  exports.configureApiDriver = configureApiDriver;
4
5
  exports.fetchCall = fetchCall;
5
6
  exports.apiDriver = apiDriver;
6
7
  const utils_1 = require("./utils");
8
+ /**
9
+ * Thrown by `fetchCall` for any non-2xx response.
10
+ *
11
+ * Previously this was a bare `Error` carrying only the response body text, so
12
+ * the HTTP status — the one piece of information a caller needs to tell
13
+ * "you sent the wrong thing" apart from "you're not allowed" apart from
14
+ * "reload and try again" — was discarded at the driver. Callers had no way to
15
+ * branch, so every failure collapsed into one generic message. Keeping
16
+ * `message` byte-identical to the old value means existing `catch` blocks that
17
+ * read it are unaffected; `status` is purely additive.
18
+ */
19
+ class ApiError extends Error {
20
+ constructor(message, status, body) {
21
+ super(message);
22
+ this.name = "ApiError";
23
+ this.status = status;
24
+ this.body = body;
25
+ // Required for `instanceof` to work when the output is transpiled to ES5.
26
+ Object.setPrototypeOf(this, ApiError.prototype);
27
+ }
28
+ }
29
+ exports.ApiError = ApiError;
7
30
  let CONFIG;
8
31
  function configureApiDriver(config) {
9
32
  CONFIG = config;
@@ -49,7 +72,7 @@ async function fetchCall(args) {
49
72
  catch {
50
73
  // non-JSON error body: keep the raw text
51
74
  }
52
- throw new Error(message || `Request failed with status ${res.status}`);
75
+ throw new ApiError(message || `Request failed with status ${res.status}`, res.status, rawBody);
53
76
  }
54
77
  if (!rawBody)
55
78
  return undefined;
@@ -83,7 +106,13 @@ function apiDriver(baseUrl) {
83
106
  patch: (...args) => fetchCall({
84
107
  ...args[1],
85
108
  path: args[0],
86
- method: "patch",
109
+ // MUST be uppercase: the Fetch spec normalizes get/post/put/delete to
110
+ // uppercase but deliberately NOT patch, so a lowercase "patch" reaches
111
+ // the CORS preflight as-is and fails the server's case-sensitive
112
+ // PATCH allow-list — every cross-origin PATCH from the app failed
113
+ // with net::ERR_FAILED. Caught live by the 015 coverage suite
114
+ // (2026-08-02) on the marketing-consent toggle.
115
+ method: "PATCH",
87
116
  baseUrl,
88
117
  }),
89
118
  delete: (...args) => fetchCall({
package/dist/api/utils.js CHANGED
@@ -4,9 +4,18 @@ exports.insertParams = exports.extractParams = exports.createQueryString = expor
4
4
  const concatApiPaths = (apiUrl, endpoint) => apiUrl + '/' + endpoint;
5
5
  exports.concatApiPaths = concatApiPaths;
6
6
  const createQueryString = (fetchParams) => {
7
- return fetchParams?.query
8
- ? '?' + new URLSearchParams(fetchParams.query).toString()
9
- : '';
7
+ if (!fetchParams?.query) {
8
+ return '';
9
+ }
10
+ // URLSearchParams stringifies missing values ({ platform: undefined } →
11
+ // "platform=undefined"), so optional params must be dropped before
12
+ // serialization. Falsy-but-real values (0, '', false) are kept.
13
+ const definedEntries = Object.entries(fetchParams.query).filter(([, value]) => value !== undefined && value !== null);
14
+ if (definedEntries.length === 0) {
15
+ return '';
16
+ }
17
+ return ('?' +
18
+ new URLSearchParams(definedEntries.map(([key, value]) => [key, String(value)])).toString());
10
19
  };
11
20
  exports.createQueryString = createQueryString;
12
21
  const extractParams = (path) => {
@@ -23,6 +23,12 @@ export declare class SchedulingAnalyticsKpiDTO {
23
23
  views: number;
24
24
  engagementRate: number;
25
25
  followerGrowth: number;
26
+ /**
27
+ * Range growth percentage derived from Zernio's authoritative growth
28
+ * totals (growth ÷ range-start followers — 050 FR-10 fast-follow).
29
+ * Absent when the range-start base is unknown or zero; never fabricated.
30
+ */
31
+ followerGrowthPercentage?: number;
26
32
  /**
27
33
  * Sources that were unavailable when this response was assembled
28
34
  * (AD2-1045). Absent/empty = all sources healthy. When present, the
@@ -31,6 +31,9 @@ class SchedulingAnalyticsKpiDTO {
31
31
  this.views = data.views;
32
32
  this.engagementRate = data.engagementRate;
33
33
  this.followerGrowth = data.followerGrowth;
34
+ if (data.followerGrowthPercentage !== undefined) {
35
+ this.followerGrowthPercentage = data.followerGrowthPercentage;
36
+ }
34
37
  if (data.failedSources !== undefined) {
35
38
  this.failedSources = data.failedSources;
36
39
  }
@@ -180,13 +180,17 @@ export declare class SchedulingCommentModerationDTO {
180
180
  }
181
181
  /**
182
182
  * Input for starting a new DM — POST /social/inbox/conversations → Zernio
183
- * POST /v1/inbox/conversations (OpenAPI v1.0.4:20355). WhatsApp is excluded
184
- * (it requires an approved template to open a thread; 082 non-goal).
183
+ * POST /v1/inbox/conversations (OpenAPI v1.0.4:20488). WhatsApp is excluded
184
+ * (it requires an approved template to open a thread; 082 non-goal). Field
185
+ * names mirror the wire exactly: accountId is required; the recipient is
186
+ * EITHER participantId (numeric id / phone) OR participantUsername (handle) —
187
+ * provide one; the body text is `message`, not `text`.
185
188
  */
186
189
  export declare class SchedulingCreateConversationDTO {
187
190
  accountId: string;
188
- recipientId: string;
189
- text: string;
191
+ participantId?: string;
192
+ participantUsername?: string;
193
+ message: string;
190
194
  constructor(data: SchedulingCreateConversationDTO);
191
195
  }
192
196
  /**
@@ -208,14 +208,18 @@ exports.SchedulingCommentModerationDTO = SchedulingCommentModerationDTO;
208
208
  // ── SchedulingCreateConversationDTO ───────────────────────────────────────────
209
209
  /**
210
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).
211
+ * POST /v1/inbox/conversations (OpenAPI v1.0.4:20488). WhatsApp is excluded
212
+ * (it requires an approved template to open a thread; 082 non-goal). Field
213
+ * names mirror the wire exactly: accountId is required; the recipient is
214
+ * EITHER participantId (numeric id / phone) OR participantUsername (handle) —
215
+ * provide one; the body text is `message`, not `text`.
213
216
  */
214
217
  class SchedulingCreateConversationDTO {
215
218
  constructor(data) {
216
219
  this.accountId = data.accountId;
217
- this.recipientId = data.recipientId;
218
- this.text = data.text;
220
+ this.participantId = data.participantId;
221
+ this.participantUsername = data.participantUsername;
222
+ this.message = data.message;
219
223
  }
220
224
  }
221
225
  exports.SchedulingCreateConversationDTO = SchedulingCreateConversationDTO;
@@ -125,6 +125,15 @@ export declare class SchedulingPostDTO {
125
125
  content?: string;
126
126
  mediaItems?: SchedulingPostMediaItemDTO[];
127
127
  platformTargets?: SchedulingPostTargetDTO[];
128
+ /**
129
+ * Per-post engagement, populated only by the analytics "top posts" path
130
+ * (enriched from Zernio GET /v1/analytics). Absent on ordinary post listings —
131
+ * undefined means "not fetched", distinct from a real 0 (AD2-1079).
132
+ */
133
+ totalEngagements?: number;
134
+ likes?: number;
135
+ comments?: number;
136
+ shares?: number;
128
137
  constructor(data: SchedulingPostDTO);
129
138
  }
130
139
  /** Query parameters for listing posts with optional filters. */
@@ -134,6 +134,10 @@ class SchedulingPostDTO {
134
134
  this.content = data.content;
135
135
  this.mediaItems = data.mediaItems;
136
136
  this.platformTargets = data.platformTargets;
137
+ this.totalEngagements = data.totalEngagements;
138
+ this.likes = data.likes;
139
+ this.comments = data.comments;
140
+ this.shares = data.shares;
137
141
  }
138
142
  }
139
143
  exports.SchedulingPostDTO = SchedulingPostDTO;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ad2app-lib",
3
- "version": "1.20.0",
3
+ "version": "1.22.0",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "commonjs",
@@ -47,7 +47,7 @@
47
47
  "prepare": "npm run build"
48
48
  },
49
49
  "keywords": [],
50
- "author": "Maciej G\u00f3rski@ad2.app",
50
+ "author": "Maciej Górski@ad2.app",
51
51
  "license": "ISC",
52
52
  "description": "Package to share types and utils across the ad2app projects",
53
53
  "dependencies": {
@@ -6,7 +6,7 @@
6
6
  import assert from "node:assert/strict";
7
7
  import { afterEach, beforeEach, test } from "node:test";
8
8
 
9
- import { apiDriver, configureApiDriver, fetchCall } from "./apiDriver";
9
+ import { ApiError, apiDriver, configureApiDriver, fetchCall } from "./apiDriver";
10
10
 
11
11
  configureApiDriver({ apiUrl: "https://default.test" });
12
12
 
@@ -81,3 +81,48 @@ test("fetchCall surfaces a JSON error body in the thrown message", async () => {
81
81
  status = 400;
82
82
  await assert.rejects(() => fetchCall({ path: "x", method: "get" }), /nope/);
83
83
  });
84
+
85
+ /**
86
+ * The status used to be dropped at the driver, so a caller could not tell a
87
+ * 400 ("your input is stale — refetch and retry") from a 403 ("you are not
88
+ * allowed") from a 500. Every failure collapsed into one generic message. These
89
+ * pin the status onto the thrown error for every non-2xx shape.
90
+ */
91
+ test("a non-2xx throws an ApiError carrying the HTTP status", async () => {
92
+ for (const expected of [400, 401, 403, 404, 429, 500, 503]) {
93
+ body = JSON.stringify({ message: "nope", statusCode: expected });
94
+ status = expected;
95
+ const err = await fetchCall({ path: "x", method: "get" }).then(
96
+ () => null,
97
+ (e: unknown) => e
98
+ );
99
+ assert.ok(err instanceof ApiError, `${expected} should throw an ApiError`);
100
+ assert.equal(err.status, expected);
101
+ assert.equal(err.name, "ApiError");
102
+ }
103
+ });
104
+
105
+ test("ApiError is still an Error and keeps the body text in message", async () => {
106
+ body = "Service Unavailable";
107
+ status = 503;
108
+ const err = await fetchCall({ path: "x", method: "get" }).then(
109
+ () => null,
110
+ (e: unknown) => e
111
+ );
112
+ // Existing callers only ever read `.message` — that must not shift.
113
+ assert.ok(err instanceof Error);
114
+ assert.match((err as Error).message, /Service Unavailable/);
115
+ assert.equal((err as ApiError).body, "Service Unavailable");
116
+ });
117
+
118
+ test("an empty error body still carries the status, not just a generic message", async () => {
119
+ body = null;
120
+ status = 502;
121
+ const err = await fetchCall({ path: "x", method: "get" }).then(
122
+ () => null,
123
+ (e: unknown) => e
124
+ );
125
+ assert.ok(err instanceof ApiError);
126
+ assert.equal(err.status, 502);
127
+ assert.match(err.message, /502/);
128
+ });
@@ -1,13 +1,41 @@
1
1
  import { FetchParams } from "../types";
2
2
  import { concatApiPaths, createQueryString, insertParams } from "./utils";
3
3
 
4
- type HttpMethod = "get" | "post" | "put" | "patch" | "delete";
4
+ // PATCH is uppercase by necessity: the Fetch spec normalizes the other four
5
+ // to uppercase but NOT patch, and CORS preflight matching is case-sensitive
6
+ // (see the patch call below).
7
+ type HttpMethod = "get" | "post" | "put" | "PATCH" | "delete";
5
8
 
6
9
  interface DriverConfig {
7
10
  apiUrl: string;
8
11
  getHeaders?: () => HeadersInit;
9
12
  }
10
13
 
14
+ /**
15
+ * Thrown by `fetchCall` for any non-2xx response.
16
+ *
17
+ * Previously this was a bare `Error` carrying only the response body text, so
18
+ * the HTTP status — the one piece of information a caller needs to tell
19
+ * "you sent the wrong thing" apart from "you're not allowed" apart from
20
+ * "reload and try again" — was discarded at the driver. Callers had no way to
21
+ * branch, so every failure collapsed into one generic message. Keeping
22
+ * `message` byte-identical to the old value means existing `catch` blocks that
23
+ * read it are unaffected; `status` is purely additive.
24
+ */
25
+ export class ApiError extends Error {
26
+ readonly status: number;
27
+ readonly body: string;
28
+
29
+ constructor(message: string, status: number, body: string) {
30
+ super(message);
31
+ this.name = "ApiError";
32
+ this.status = status;
33
+ this.body = body;
34
+ // Required for `instanceof` to work when the output is transpiled to ES5.
35
+ Object.setPrototypeOf(this, ApiError.prototype);
36
+ }
37
+ }
38
+
11
39
  let CONFIG: DriverConfig;
12
40
 
13
41
  export function configureApiDriver(config: DriverConfig) {
@@ -77,7 +105,11 @@ export async function fetchCall<T, Q>(args: FetchCallArgs<Q>): Promise<T> {
77
105
  } catch {
78
106
  // non-JSON error body: keep the raw text
79
107
  }
80
- throw new Error(message || `Request failed with status ${res.status}`);
108
+ throw new ApiError(
109
+ message || `Request failed with status ${res.status}`,
110
+ res.status,
111
+ rawBody
112
+ );
81
113
  }
82
114
 
83
115
  if (!rawBody) return undefined as T;
@@ -120,7 +152,13 @@ export function apiDriver(baseUrl?: string) {
120
152
  fetchCall<T, Q>({
121
153
  ...args[1],
122
154
  path: args[0],
123
- method: "patch",
155
+ // MUST be uppercase: the Fetch spec normalizes get/post/put/delete to
156
+ // uppercase but deliberately NOT patch, so a lowercase "patch" reaches
157
+ // the CORS preflight as-is and fails the server's case-sensitive
158
+ // PATCH allow-list — every cross-origin PATCH from the app failed
159
+ // with net::ERR_FAILED. Caught live by the 015 coverage suite
160
+ // (2026-08-02) on the marketing-consent toggle.
161
+ method: "PATCH",
124
162
  baseUrl,
125
163
  }),
126
164
  delete: <T, Q>(...args: MethodCallArgs<Q>) =>
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Unit tests for createQueryString. Covers the AD2 serialization bug found
3
+ * 2026-07-26: URLSearchParams stringifies undefined/null values, so callers
4
+ * passing optional params ({ platform: undefined }) produced live requests
5
+ * carrying the literal strings "?platform=undefined" / "null".
6
+ */
7
+ import assert from "node:assert/strict";
8
+ import { test } from "node:test";
9
+
10
+ import { createQueryString } from "./utils";
11
+
12
+ test("serializes defined params", () => {
13
+ assert.equal(
14
+ createQueryString({ query: { platform: "instagram", limit: 20 } }),
15
+ "?platform=instagram&limit=20"
16
+ );
17
+ });
18
+
19
+ test("omits undefined values instead of serializing the string 'undefined'", () => {
20
+ assert.equal(
21
+ createQueryString({
22
+ query: { fromDate: "2026-06-28", platform: undefined, contentType: undefined },
23
+ }),
24
+ "?fromDate=2026-06-28"
25
+ );
26
+ });
27
+
28
+ test("omits null values instead of serializing the string 'null'", () => {
29
+ assert.equal(
30
+ createQueryString({ query: { platform: null, status: "scheduled" } }),
31
+ "?status=scheduled"
32
+ );
33
+ });
34
+
35
+ test("returns empty string when every value is undefined", () => {
36
+ assert.equal(createQueryString({ query: { platform: undefined } }), "");
37
+ });
38
+
39
+ test("returns empty string with no query at all", () => {
40
+ assert.equal(createQueryString(), "");
41
+ assert.equal(createQueryString({}), "");
42
+ });
43
+
44
+ test("keeps falsy-but-real values (0, empty string, false)", () => {
45
+ assert.equal(
46
+ createQueryString({ query: { offset: 0, q: "", includeRead: false } }),
47
+ "?offset=0&q=&includeRead=false"
48
+ );
49
+ });
package/src/api/utils.ts CHANGED
@@ -4,9 +4,27 @@ export const concatApiPaths = (apiUrl: string, endpoint: string) =>
4
4
  apiUrl + '/' + endpoint;
5
5
 
6
6
  export const createQueryString = <T>(fetchParams?: FetchParams<T>) => {
7
- return fetchParams?.query
8
- ? '?' + new URLSearchParams(fetchParams.query as URLSearchParams).toString()
9
- : '';
7
+ if (!fetchParams?.query) {
8
+ return '';
9
+ }
10
+
11
+ // URLSearchParams stringifies missing values ({ platform: undefined } →
12
+ // "platform=undefined"), so optional params must be dropped before
13
+ // serialization. Falsy-but-real values (0, '', false) are kept.
14
+ const definedEntries = Object.entries(
15
+ fetchParams.query as Record<string, unknown>
16
+ ).filter(([, value]) => value !== undefined && value !== null);
17
+
18
+ if (definedEntries.length === 0) {
19
+ return '';
20
+ }
21
+
22
+ return (
23
+ '?' +
24
+ new URLSearchParams(
25
+ definedEntries.map(([key, value]) => [key, String(value)])
26
+ ).toString()
27
+ );
10
28
  };
11
29
 
12
30
  export const extractParams = (path: string): string[] => {
@@ -35,6 +35,12 @@ export class SchedulingAnalyticsKpiDTO {
35
35
  views: number;
36
36
  engagementRate: number;
37
37
  followerGrowth: number;
38
+ /**
39
+ * Range growth percentage derived from Zernio's authoritative growth
40
+ * totals (growth ÷ range-start followers — 050 FR-10 fast-follow).
41
+ * Absent when the range-start base is unknown or zero; never fabricated.
42
+ */
43
+ followerGrowthPercentage?: number;
38
44
  /**
39
45
  * Sources that were unavailable when this response was assembled
40
46
  * (AD2-1045). Absent/empty = all sources healthy. When present, the
@@ -64,6 +70,9 @@ export class SchedulingAnalyticsKpiDTO {
64
70
  this.views = data.views;
65
71
  this.engagementRate = data.engagementRate;
66
72
  this.followerGrowth = data.followerGrowth;
73
+ if (data.followerGrowthPercentage !== undefined) {
74
+ this.followerGrowthPercentage = data.followerGrowthPercentage;
75
+ }
67
76
  if (data.failedSources !== undefined) {
68
77
  this.failedSources = data.failedSources;
69
78
  }
@@ -207,15 +207,34 @@ test('SchedulingInboxCapabilityDTO exposes per-surface booleans', () => {
207
207
  assert.equal(cap.dm, false);
208
208
  });
209
209
 
210
- test('SchedulingCreateConversationDTO carries account, recipient, text', () => {
210
+ // sig-c276683e78bd9f37 Zernio POST /v1/inbox/conversations (OpenAPI
211
+ // v1.0.4:20488) takes participantId/participantUsername + message, not
212
+ // recipientId/text; assert the SERIALIZED wire shape, not just the DTO's
213
+ // own field names, so a future rename can't drift from the contract again.
214
+ test('SchedulingCreateConversationDTO serializes to the Zernio wire shape (accountId, participantId, message)', () => {
211
215
  const dto = new SchedulingCreateConversationDTO({
212
216
  accountId: 'a1',
213
- recipientId: 'r1',
214
- text: 'hello',
217
+ participantId: 'r1',
218
+ message: 'hello',
215
219
  });
220
+ const wireKeys = Object.keys(JSON.parse(JSON.stringify(dto))).sort();
221
+ assert.deepEqual(wireKeys, ['accountId', 'message', 'participantId']);
216
222
  assert.equal(dto.accountId, 'a1');
217
- assert.equal(dto.recipientId, 'r1');
218
- assert.equal(dto.text, 'hello');
223
+ assert.equal(dto.participantId, 'r1');
224
+ assert.equal(dto.message, 'hello');
225
+ assert.equal((dto as Record<string, unknown>).recipientId, undefined);
226
+ assert.equal((dto as Record<string, unknown>).text, undefined);
227
+ });
228
+
229
+ test('SchedulingCreateConversationDTO accepts participantUsername in place of participantId', () => {
230
+ const dto = new SchedulingCreateConversationDTO({
231
+ accountId: 'a1',
232
+ participantUsername: 'jane',
233
+ message: 'hello',
234
+ });
235
+ const wireKeys = Object.keys(JSON.parse(JSON.stringify(dto))).sort();
236
+ assert.deepEqual(wireKeys, ['accountId', 'message', 'participantUsername']);
237
+ assert.equal(dto.participantUsername, 'jane');
219
238
  });
220
239
 
221
240
  // Mark-read acknowledgement — unreadCount absent unless Zernio reports it.
@@ -321,18 +321,23 @@ export class SchedulingCommentModerationDTO {
321
321
 
322
322
  /**
323
323
  * Input for starting a new DM — POST /social/inbox/conversations → Zernio
324
- * POST /v1/inbox/conversations (OpenAPI v1.0.4:20355). WhatsApp is excluded
325
- * (it requires an approved template to open a thread; 082 non-goal).
324
+ * POST /v1/inbox/conversations (OpenAPI v1.0.4:20488). WhatsApp is excluded
325
+ * (it requires an approved template to open a thread; 082 non-goal). Field
326
+ * names mirror the wire exactly: accountId is required; the recipient is
327
+ * EITHER participantId (numeric id / phone) OR participantUsername (handle) —
328
+ * provide one; the body text is `message`, not `text`.
326
329
  */
327
330
  export class SchedulingCreateConversationDTO {
328
331
  accountId: string;
329
- recipientId: string;
330
- text: string;
332
+ participantId?: string;
333
+ participantUsername?: string;
334
+ message: string;
331
335
 
332
336
  constructor(data: SchedulingCreateConversationDTO) {
333
337
  this.accountId = data.accountId;
334
- this.recipientId = data.recipientId;
335
- this.text = data.text;
338
+ this.participantId = data.participantId;
339
+ this.participantUsername = data.participantUsername;
340
+ this.message = data.message;
336
341
  }
337
342
  }
338
343
 
@@ -199,6 +199,15 @@ export class SchedulingPostDTO {
199
199
  content?: string;
200
200
  mediaItems?: SchedulingPostMediaItemDTO[];
201
201
  platformTargets?: SchedulingPostTargetDTO[];
202
+ /**
203
+ * Per-post engagement, populated only by the analytics "top posts" path
204
+ * (enriched from Zernio GET /v1/analytics). Absent on ordinary post listings —
205
+ * undefined means "not fetched", distinct from a real 0 (AD2-1079).
206
+ */
207
+ totalEngagements?: number;
208
+ likes?: number;
209
+ comments?: number;
210
+ shares?: number;
202
211
 
203
212
  constructor(data: SchedulingPostDTO) {
204
213
  this.id = data.id;
@@ -219,6 +228,10 @@ export class SchedulingPostDTO {
219
228
  this.content = data.content;
220
229
  this.mediaItems = data.mediaItems;
221
230
  this.platformTargets = data.platformTargets;
231
+ this.totalEngagements = data.totalEngagements;
232
+ this.likes = data.likes;
233
+ this.comments = data.comments;
234
+ this.shares = data.shares;
222
235
  }
223
236
  }
224
237