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.
- package/dist/api/apiDriver.d.ts +17 -1
- package/dist/api/apiDriver.js +31 -2
- package/dist/api/utils.js +12 -3
- package/dist/types/scheduling/I_SchedulingAnalytics.d.ts +6 -0
- package/dist/types/scheduling/I_SchedulingAnalytics.js +3 -0
- package/dist/types/scheduling/I_SchedulingInbox.d.ts +8 -4
- package/dist/types/scheduling/I_SchedulingInbox.js +8 -4
- package/dist/types/scheduling/I_SchedulingPost.d.ts +9 -0
- package/dist/types/scheduling/I_SchedulingPost.js +4 -0
- package/package.json +2 -2
- package/src/api/apiDriver.test.ts +46 -1
- package/src/api/apiDriver.ts +41 -3
- package/src/api/utils.test.ts +49 -0
- package/src/api/utils.ts +21 -3
- package/src/types/scheduling/I_SchedulingAnalytics.ts +9 -0
- package/src/types/scheduling/I_SchedulingInbox.test.ts +24 -5
- package/src/types/scheduling/I_SchedulingInbox.ts +11 -6
- package/src/types/scheduling/I_SchedulingPost.ts +13 -0
package/dist/api/apiDriver.d.ts
CHANGED
|
@@ -1,9 +1,25 @@
|
|
|
1
1
|
import { FetchParams } from "../types";
|
|
2
|
-
type HttpMethod = "get" | "post" | "put" | "
|
|
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;
|
package/dist/api/apiDriver.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
8
|
-
|
|
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:
|
|
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
|
-
|
|
189
|
-
|
|
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:
|
|
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.
|
|
218
|
-
this.
|
|
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.
|
|
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
|
|
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
|
+
});
|
package/src/api/apiDriver.ts
CHANGED
|
@@ -1,13 +1,41 @@
|
|
|
1
1
|
import { FetchParams } from "../types";
|
|
2
2
|
import { concatApiPaths, createQueryString, insertParams } from "./utils";
|
|
3
3
|
|
|
4
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
8
|
-
|
|
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
|
-
|
|
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
|
-
|
|
214
|
-
|
|
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.
|
|
218
|
-
assert.equal(dto.
|
|
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:
|
|
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
|
-
|
|
330
|
-
|
|
332
|
+
participantId?: string;
|
|
333
|
+
participantUsername?: string;
|
|
334
|
+
message: string;
|
|
331
335
|
|
|
332
336
|
constructor(data: SchedulingCreateConversationDTO) {
|
|
333
337
|
this.accountId = data.accountId;
|
|
334
|
-
this.
|
|
335
|
-
this.
|
|
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
|
|