adapty 0.9.4 → 0.10.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/README.md +55 -0
- package/bin/dev.js +6 -0
- package/bin/run.js +6 -0
- package/dist/cli/commands/feedback.d.ts +16 -0
- package/dist/cli/commands/feedback.js +36 -0
- package/dist/cli/input/segment.d.ts +20 -0
- package/dist/cli/input/segment.js +61 -0
- package/dist/commands/asa/segments/create.d.ts +17 -0
- package/dist/commands/asa/segments/create.js +53 -0
- package/dist/commands/asa/segments/list.d.ts +16 -0
- package/dist/commands/asa/segments/list.js +59 -0
- package/dist/commands/asa/segments/update.d.ts +20 -0
- package/dist/commands/asa/segments/update.js +60 -0
- package/dist/commands/feedback.d.ts +1 -0
- package/dist/commands/feedback.js +1 -0
- package/dist/commands/segments/create.d.ts +14 -0
- package/dist/commands/segments/create.js +30 -0
- package/dist/commands/segments/update.d.ts +17 -0
- package/dist/commands/segments/update.js +35 -0
- package/dist/lib/api-schemas.d.ts +24 -0
- package/dist/lib/asa-client.d.ts +3 -0
- package/dist/lib/asa-client.js +26 -0
- package/dist/lib/asa-flags.d.ts +31 -0
- package/dist/lib/asa-flags.js +69 -0
- package/dist/sdk/adapty/feedback/index.d.ts +4 -0
- package/dist/sdk/adapty/feedback/index.js +2 -0
- package/dist/sdk/adapty/feedback/resource.d.ts +6 -0
- package/dist/sdk/adapty/feedback/resource.js +8 -0
- package/dist/sdk/adapty/feedback/send.d.ts +18 -0
- package/dist/sdk/adapty/feedback/send.js +36 -0
- package/dist/sdk/adapty/index.d.ts +4 -0
- package/dist/sdk/adapty/index.js +3 -0
- package/oclif.manifest.json +2018 -1588
- package/package.json +1 -1
package/dist/lib/asa-client.js
CHANGED
|
@@ -49,3 +49,29 @@ export function noteReplay(replayed, log) {
|
|
|
49
49
|
log('Already applied earlier — showing the stored result.');
|
|
50
50
|
}
|
|
51
51
|
}
|
|
52
|
+
/** The ASA entities behind a segment source, with the Apple ids the portal filters on. */
|
|
53
|
+
export async function fetchAsaSegmentEntities(client, selection) {
|
|
54
|
+
if (selection.source === 'campaign') {
|
|
55
|
+
const campaigns = await Promise.all(selection.ids.map(id => client.get(`/campaigns/${id}`)));
|
|
56
|
+
return campaigns.map(campaign => ({ id: String(campaign.campaign_id), name: campaign.name }));
|
|
57
|
+
}
|
|
58
|
+
if (selection.source === 'ad-group') {
|
|
59
|
+
const adGroups = await Promise.all(selection.ids.map(id => client.get(`/ad-groups/${id}`)));
|
|
60
|
+
return adGroups.map(adGroup => ({ id: String(adGroup.ad_group_id), name: adGroup.name }));
|
|
61
|
+
}
|
|
62
|
+
// No single-keyword read exists; the ad group is small, so one scoped page covers the lookup
|
|
63
|
+
const page = await client.get('/keywords', {
|
|
64
|
+
'ad_group_id': selection.scopeAdGroup,
|
|
65
|
+
'page[size]': '1000',
|
|
66
|
+
});
|
|
67
|
+
const byId = new Map(page.data.map(keyword => [keyword.internal_id, keyword]));
|
|
68
|
+
const missing = selection.ids.filter(id => !byId.has(id));
|
|
69
|
+
if (missing.length > 0) {
|
|
70
|
+
throw new Error(`Keyword(s) not found in ad group ${selection.scopeAdGroup}: ${missing.join(', ')}`);
|
|
71
|
+
}
|
|
72
|
+
return selection.ids.map((id) => {
|
|
73
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- membership checked above
|
|
74
|
+
const keyword = byId.get(id);
|
|
75
|
+
return { id: String(keyword.keyword_id), name: keyword.text };
|
|
76
|
+
});
|
|
77
|
+
}
|
package/dist/lib/asa-flags.d.ts
CHANGED
|
@@ -131,4 +131,35 @@ type BulkOutcome = {
|
|
|
131
131
|
kind: string;
|
|
132
132
|
};
|
|
133
133
|
export declare function reportBulkOutcome({ applied, errors, isValidationFailure, kind }: BulkOutcome, log: (msg: string) => void): void;
|
|
134
|
+
export type AsaSegmentSource = 'ad-group' | 'campaign' | 'keyword';
|
|
135
|
+
export declare const asaSegmentSourceFlags: {
|
|
136
|
+
'ad-group': import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
137
|
+
campaign: import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
138
|
+
keyword: import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
139
|
+
};
|
|
140
|
+
export type AsaSegmentSourceFlags = {
|
|
141
|
+
'ad-group'?: string[] | undefined;
|
|
142
|
+
'campaign'?: string[] | undefined;
|
|
143
|
+
'keyword'?: string[] | undefined;
|
|
144
|
+
};
|
|
145
|
+
/**
|
|
146
|
+
* One source kind per segment, as in the Ads Manager modal. Keywords are looked up inside one ad group, so
|
|
147
|
+
* `--keyword` borrows `--ad-group` as its scope instead of competing with it.
|
|
148
|
+
*/
|
|
149
|
+
export type AsaSegmentSelection = {
|
|
150
|
+
ids: string[];
|
|
151
|
+
scopeAdGroup: string | undefined;
|
|
152
|
+
source: AsaSegmentSource;
|
|
153
|
+
};
|
|
154
|
+
export declare function resolveAsaSegmentSource(flags: AsaSegmentSourceFlags): AsaSegmentSelection;
|
|
155
|
+
export declare const ASA_SEGMENT_FIELD: Record<AsaSegmentSource, string>;
|
|
156
|
+
export type AsaSegmentEntity = {
|
|
157
|
+
id: string;
|
|
158
|
+
name: string;
|
|
159
|
+
};
|
|
160
|
+
/** The dashboard's own title and description for an Ads Manager segment, so it recognises the segment as its own. */
|
|
161
|
+
export declare function buildAsaSegmentNaming(source: AsaSegmentSource, entities: AsaSegmentEntity[]): {
|
|
162
|
+
description: string;
|
|
163
|
+
title: string;
|
|
164
|
+
};
|
|
134
165
|
export {};
|
package/dist/lib/asa-flags.js
CHANGED
|
@@ -247,3 +247,72 @@ export function reportBulkOutcome({ applied, errors, isValidationFailure, kind }
|
|
|
247
247
|
log(` ${describeListedError(error).text}`);
|
|
248
248
|
}
|
|
249
249
|
}
|
|
250
|
+
export const asaSegmentSourceFlags = {
|
|
251
|
+
'ad-group': idFilter('ad group'),
|
|
252
|
+
'campaign': idFilter('campaign'),
|
|
253
|
+
'keyword': idFilter('keyword'),
|
|
254
|
+
};
|
|
255
|
+
export function resolveAsaSegmentSource(flags) {
|
|
256
|
+
const keywords = flags.keyword ?? [];
|
|
257
|
+
const adGroups = flags['ad-group'] ?? [];
|
|
258
|
+
const campaigns = flags.campaign ?? [];
|
|
259
|
+
if (keywords.length > 0) {
|
|
260
|
+
const [scopeAdGroup] = adGroups;
|
|
261
|
+
if (campaigns.length > 0 || adGroups.length !== 1 || scopeAdGroup === undefined) {
|
|
262
|
+
throw new Error('--keyword takes exactly one --ad-group as the lookup scope and no --campaign.');
|
|
263
|
+
}
|
|
264
|
+
return { ids: [...new Set(keywords)], scopeAdGroup, source: 'keyword' };
|
|
265
|
+
}
|
|
266
|
+
if (campaigns.length > 0 && adGroups.length > 0) {
|
|
267
|
+
throw new Error('Pass either --campaign or --ad-group, not both: a segment is built from one kind of entity.');
|
|
268
|
+
}
|
|
269
|
+
if (campaigns.length > 0) {
|
|
270
|
+
return { ids: [...new Set(campaigns)], scopeAdGroup: undefined, source: 'campaign' };
|
|
271
|
+
}
|
|
272
|
+
if (adGroups.length > 0) {
|
|
273
|
+
return { ids: [...new Set(adGroups)], scopeAdGroup: undefined, source: 'ad-group' };
|
|
274
|
+
}
|
|
275
|
+
throw new Error('Pass at least one --campaign, --ad-group or --keyword.');
|
|
276
|
+
}
|
|
277
|
+
export const ASA_SEGMENT_FIELD = {
|
|
278
|
+
'ad-group': 'ad_group',
|
|
279
|
+
'campaign': 'campaign',
|
|
280
|
+
// The portal calls a keyword attribution "creative"
|
|
281
|
+
'keyword': 'creative',
|
|
282
|
+
};
|
|
283
|
+
const ASA_TITLE_MAX = 120;
|
|
284
|
+
const ASA_DESCRIPTION_MAX = 240;
|
|
285
|
+
const ASA_PREVIEW_COUNT = 3;
|
|
286
|
+
const ASA_SOURCE_WORDS = {
|
|
287
|
+
'ad-group': { many: 'ad groups', one: 'ad group', title: 'Ad group' },
|
|
288
|
+
'campaign': { many: 'campaigns', one: 'campaign', title: 'Campaign' },
|
|
289
|
+
'keyword': { many: 'keywords', one: 'keyword', title: 'Keyword' },
|
|
290
|
+
};
|
|
291
|
+
function truncate(text, max) {
|
|
292
|
+
if (text.length <= max) {
|
|
293
|
+
return text;
|
|
294
|
+
}
|
|
295
|
+
return max <= 3 ? text.slice(0, max) : `${text.slice(0, max - 3)}...`;
|
|
296
|
+
}
|
|
297
|
+
function withRest(preview, rest) {
|
|
298
|
+
return rest > 0 ? `${preview} +${rest}...` : preview;
|
|
299
|
+
}
|
|
300
|
+
/** The dashboard's own title and description for an Ads Manager segment, so it recognises the segment as its own. */
|
|
301
|
+
export function buildAsaSegmentNaming(source, entities) {
|
|
302
|
+
const words = ASA_SOURCE_WORDS[source];
|
|
303
|
+
if (entities.length === 1) {
|
|
304
|
+
const [entity] = entities;
|
|
305
|
+
return {
|
|
306
|
+
description: truncate(`Created from Apple Search Ads ${words.one} "${entity.name}".\n\nID: ${entity.id}`, ASA_DESCRIPTION_MAX),
|
|
307
|
+
title: truncate(`[ASA] ${words.title}: ${entity.name}`, ASA_TITLE_MAX),
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
const shown = entities.slice(0, ASA_PREVIEW_COUNT);
|
|
311
|
+
const rest = entities.length - shown.length;
|
|
312
|
+
const names = withRest(shown.map(entity => entity.name).join(', '), rest);
|
|
313
|
+
const ids = withRest(shown.map(entity => entity.id).join(', '), rest);
|
|
314
|
+
return {
|
|
315
|
+
description: truncate(`Created from Apple Search Ads ${words.many} (${entities.length}).\n\nPreview: ${names}\n\nIDs: ${ids}`, ASA_DESCRIPTION_MAX),
|
|
316
|
+
title: truncate(`[ASA] ${entities.length} ${words.many}: ${names}`, ASA_TITLE_MAX),
|
|
317
|
+
};
|
|
318
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { SendFeedbackInput } from './send.js';
|
|
2
|
+
import type { Http } from '../../core/http/index.js';
|
|
3
|
+
export declare const feedback: (http: Http) => {
|
|
4
|
+
send: (input: SendFeedbackInput) => Promise<void>;
|
|
5
|
+
};
|
|
6
|
+
export type FeedbackApi = ReturnType<typeof feedback>;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { assertValid } from '../../core/validation.js';
|
|
2
|
+
import { toSendRequest, validateSendFeedback } from './send.js';
|
|
3
|
+
export const feedback = (http) => ({
|
|
4
|
+
send: async (input) => {
|
|
5
|
+
assertValid(validateSendFeedback(input));
|
|
6
|
+
await http.post('/feedback', toSendRequest(input));
|
|
7
|
+
},
|
|
8
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Issue } from '../../core/errors.js';
|
|
2
|
+
export declare const sentiments: readonly ["positive", "neutral", "negative"];
|
|
3
|
+
export type Sentiment = (typeof sentiments)[number];
|
|
4
|
+
export type SendFeedbackInput = {
|
|
5
|
+
context?: string | undefined;
|
|
6
|
+
feature?: string | undefined;
|
|
7
|
+
message: string;
|
|
8
|
+
sentiment: Sentiment;
|
|
9
|
+
};
|
|
10
|
+
type SendFeedbackRequest = {
|
|
11
|
+
context?: string;
|
|
12
|
+
feature?: string;
|
|
13
|
+
message: string;
|
|
14
|
+
sentiment: Sentiment;
|
|
15
|
+
};
|
|
16
|
+
export declare const validateSendFeedback: (input: SendFeedbackInput) => Issue[];
|
|
17
|
+
export declare const toSendRequest: (input: SendFeedbackInput) => SendFeedbackRequest;
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export const sentiments = ['positive', 'neutral', 'negative'];
|
|
2
|
+
// A coarse first pass: the server scrubs reports more thoroughly.
|
|
3
|
+
const secrets = [
|
|
4
|
+
{ kind: 'an API key or token', pattern: /(?<![a-z\d])(?:dev|secret|sk|pk|rk)_(?:live|test)_(?=[a-z\d]{0,64}\d)[a-z\d]{6,}/i },
|
|
5
|
+
{ kind: 'a bearer token', pattern: /\bbearer\s+(?=[\w.~+-]{0,64}\d)[\w.~+-]{16,}/i },
|
|
6
|
+
{ kind: 'a JWT', pattern: /(?<![\w-])eyJ[\w-]{8,}\.[\w-]{8,}\.[\w-]+/ },
|
|
7
|
+
// Lookbehinds and bounded repeats keep matching linear on a long pasted log.
|
|
8
|
+
{ kind: 'an email address', pattern: /(?<![\w.%+-])(?!git@)[\w.%+-]{1,64}@[a-z][a-z\d-]*(?:\.[a-z\d-]+)*\.[a-z]{2,}\b/i },
|
|
9
|
+
];
|
|
10
|
+
const checkSecrets = (issues, path, value) => {
|
|
11
|
+
if (value === undefined) {
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
for (const { kind, pattern } of secrets) {
|
|
15
|
+
if (pattern.test(value)) {
|
|
16
|
+
issues.push({ message: `looks like it contains ${kind}; remove it and send again`, path });
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
export const validateSendFeedback = (input) => {
|
|
21
|
+
const issues = [];
|
|
22
|
+
checkSecrets(issues, 'message', input.message);
|
|
23
|
+
checkSecrets(issues, 'context', input.context);
|
|
24
|
+
checkSecrets(issues, 'feature', input.feature);
|
|
25
|
+
return issues;
|
|
26
|
+
};
|
|
27
|
+
export const toSendRequest = (input) => {
|
|
28
|
+
const body = { message: input.message, sentiment: input.sentiment };
|
|
29
|
+
if (input.feature !== undefined) {
|
|
30
|
+
body.feature = input.feature;
|
|
31
|
+
}
|
|
32
|
+
if (input.context !== undefined) {
|
|
33
|
+
body.context = input.context;
|
|
34
|
+
}
|
|
35
|
+
return body;
|
|
36
|
+
};
|
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
import type { AccessLevelsApi } from './access-levels.js';
|
|
2
2
|
import type { AppsApi } from './apps/index.js';
|
|
3
3
|
import type { AuthApi } from './auth/index.js';
|
|
4
|
+
import type { FeedbackApi } from './feedback/index.js';
|
|
4
5
|
import type { MigrationApi } from './migrations/index.js';
|
|
5
6
|
import type { Clock } from '../core/clock.js';
|
|
6
7
|
import type { RetryAttempt } from '../core/http/index.js';
|
|
7
8
|
export { platforms } from './apps/index.js';
|
|
8
9
|
export { developerErrorParser } from './errors.js';
|
|
10
|
+
export { sentiments } from './feedback/index.js';
|
|
9
11
|
export type { AccessLevel, AccessLevelList, AccessLevelsApi } from './access-levels.js';
|
|
10
12
|
export type { AppDetail, AppsApi, AppSummary, CreateAppInput, UpdateAppInput } from './apps/index.js';
|
|
11
13
|
export type { AuthApi, AuthUser, IssuedToken } from './auth/index.js';
|
|
14
|
+
export type { FeedbackApi, SendFeedbackInput, Sentiment } from './feedback/index.js';
|
|
12
15
|
export type { Action, ActionKind, AvailableFlow, CreateMigrationInput, Envelope, Issue, JsonSchema, Migration, MigrationApi, MigrationList, MigrationState, Progress, ResourceRef, Step, StepStatus, WaitOptions, WizardError, } from './migrations/index.js';
|
|
13
16
|
export type { PageParams, Paginated, Pagination } from './pagination.js';
|
|
14
17
|
/** Exported because the adapter compares the resolved URL with it and warns when they differ. */
|
|
@@ -35,6 +38,7 @@ export type Adapty = {
|
|
|
35
38
|
accessLevels: AccessLevelsApi;
|
|
36
39
|
apps: AppsApi;
|
|
37
40
|
auth: AuthApi;
|
|
41
|
+
feedback: FeedbackApi;
|
|
38
42
|
migrations: MigrationApi;
|
|
39
43
|
};
|
|
40
44
|
/** The assembly point of the developer API: one transport, resources on top of it. */
|
package/dist/sdk/adapty/index.js
CHANGED
|
@@ -4,9 +4,11 @@ import { accessLevels } from './access-levels.js';
|
|
|
4
4
|
import { apps } from './apps/index.js';
|
|
5
5
|
import { auth } from './auth/index.js';
|
|
6
6
|
import { developerErrorParser } from './errors.js';
|
|
7
|
+
import { feedback } from './feedback/index.js';
|
|
7
8
|
import { migrations } from './migrations/index.js';
|
|
8
9
|
export { platforms } from './apps/index.js';
|
|
9
10
|
export { developerErrorParser } from './errors.js';
|
|
11
|
+
export { sentiments } from './feedback/index.js';
|
|
10
12
|
/** Exported because the adapter compares the resolved URL with it and warns when they differ. */
|
|
11
13
|
export const DEFAULT_ADAPTY_API_URL = 'https://api-admin.adapty.io/api/v1/developer';
|
|
12
14
|
/**
|
|
@@ -46,6 +48,7 @@ export const createAdapty = (options = {}) => {
|
|
|
46
48
|
accessLevels: accessLevels(http),
|
|
47
49
|
apps: apps(http),
|
|
48
50
|
auth: auth(http),
|
|
51
|
+
feedback: feedback(http),
|
|
49
52
|
// The clock is a dependency here too, not only in the transport: `waitFor` sleeps
|
|
50
53
|
// between polls, and a test must be able to do that instantly.
|
|
51
54
|
migrations: migrations(wizard, options.clock ?? systemClock),
|