frontpage-host 0.1.28 → 0.2.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/AGENTS.md +10 -2
- package/CUSTOMER-INBOX.md +237 -0
- package/PAYMENTS.md +334 -0
- package/PEOPLE.md +34 -14
- package/README.md +3 -0
- package/dist/argv.d.ts +2 -0
- package/dist/argv.js +8 -0
- package/dist/bin.js +6 -0
- package/dist/commands/customer-inbox.d.ts +14 -0
- package/dist/commands/customer-inbox.js +157 -0
- package/dist/commands/payments.d.ts +1 -0
- package/dist/commands/payments.js +303 -0
- package/dist/commands/people-workspace.js +1 -2
- package/dist/commands/people.js +5 -1
- package/dist/commands/shop.js +42 -87
- package/dist/completions/generate.js +9 -6
- package/dist/registry.js +100 -42
- package/llms.txt +7 -2
- package/package.json +5 -3
package/PEOPLE.md
CHANGED
|
@@ -411,6 +411,23 @@ For a reviewed cohort, submit at most 25 people in each bounded batch. Each row
|
|
|
411
411
|
|
|
412
412
|
Run the same `person permission --file reviewed-batch.json` command. Read every row in `results`: domain conflicts include `error` and `status`, while successful rows include `emailPermission` and `duplicate`. The batch can partly succeed; receipts make a retry safe. Malformed batch input fails before any permission writes. Infrastructure errors can leave completed receipts, so retry the exact batch rather than inventing new keys. This capability works through the public API and `manage_people` too; it has no company-only endpoint.
|
|
413
413
|
|
|
414
|
+
## Safe sync through public customer commands
|
|
415
|
+
|
|
416
|
+
Integrations read their own sources and write through the same CLI/API/agent operations as owners. No product database write credential is needed.
|
|
417
|
+
|
|
418
|
+
- `person.lookup` takes a valid `email` and/or `phone`, includes hidden matches, and refuses identities that point to different people. It never changes permission or unhides someone.
|
|
419
|
+
- `person.update` takes `personId`, a `patch` and optional `expected` contact values. `expected` supports exact name/email/phone values, including null. A changed value or hidden contact returns a conflict; it does not overwrite the owner's edit. Use `people workspace person.update --data` for this conditional operation.
|
|
420
|
+
- `notes.add` takes `personId`, `body`, optional `requestKey` (1–120 characters), and optional `historical` boolean. Reuse the key for the exact same note after a lost response. A changed person/body/history flag returns a conflict. A retry never restores a deleted note. Historical notes skip automation and record their processed receipt immediately.
|
|
421
|
+
- `membership.add` accepts optional `historical:true` to import a membership without replaying rules. Ordinary additions keep normal automation. Preserve an owner's removed membership rather than adding it again on every sync.
|
|
422
|
+
|
|
423
|
+
```sh
|
|
424
|
+
frontpage people workspace person.lookup --data '{"email":"alex@example.com"}' --site YOUR_SITE --json
|
|
425
|
+
frontpage people workspace person.update --data '{"personId":"PERSON_ID","patch":{"phone":"+12025550100"},"expected":{"phone":null}}' --site YOUR_SITE --json
|
|
426
|
+
frontpage people workspace notes.add --data '{"personId":"PERSON_ID","body":"Verified billing context","requestKey":"NOTE_UUID","historical":true}' --site YOUR_SITE --json
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
Use a saved CSV import for a new historical cohort. Its list-wide permission choice applies only to new contacts; row values take priority and existing decisions remain saved. Check the real import status before writing notes or starting delivery. Source account or billing facts never imply marketing permission. Preserve hidden state and review identity conflicts rather than merging contacts.
|
|
430
|
+
|
|
414
431
|
## Import a customer list from CSV
|
|
415
432
|
|
|
416
433
|
CSV imports accept up to 5 MB and 10,000 contact rows. Quoted commas, quoted newlines, UTF-8 BOM, and CRLF are supported. Larger lists need separate files; the UI does not render an unbounded list of failures.
|
|
@@ -508,18 +525,6 @@ frontpage mail campaign get --id DRAFT_ID --site YOUR_SITE --json
|
|
|
508
525
|
|
|
509
526
|
Preview reports matched, eligible, no-email, suppressed, and permission-missing counts and sends nothing. Sending freezes content and eligible recipients, deduplicates email addresses, and uses the existing paced email queue. Reuse the send request key after a lost response. Check `campaign get` for queued, sent, pending, failed, and skipped work; queued does not mean delivered.
|
|
510
527
|
|
|
511
|
-
## Frontpage account sync
|
|
512
|
-
|
|
513
|
-
This admin-only feature adds Frontpage’s own account holders to the `landing` People workspace and keeps later signups in sync. It is not available to ordinary businesses. Businesses import their customer lists with CSV.
|
|
514
|
-
|
|
515
|
-
```
|
|
516
|
-
frontpage people company get --site landing --json
|
|
517
|
-
frontpage people company set --enabled true --site landing --json
|
|
518
|
-
frontpage people company set --enabled false --site landing --json
|
|
519
|
-
```
|
|
520
|
-
|
|
521
|
-
Sync uses saved batches and Frontpage user IDs to avoid duplicates. The backfill does not fire rules or grant email permission. Later signups can trigger group entry rules, subject to normal eligibility. Sync preserves owner edits. Pausing it leaves existing contacts saved. Supply an existing `groupId` through `--data` if you want a particular sync group.
|
|
522
|
-
|
|
523
528
|
## CLI, API, and agent reference
|
|
524
529
|
|
|
525
530
|
The editor, public API, CLI, and site agent call the same application service. Every operation below is available without opening the UI.
|
|
@@ -612,8 +617,6 @@ The agent can create and edit saved templates and series with the existing Email
|
|
|
612
617
|
| import.start | csv, mapping?, groupIds?, requestKey | Start a saved import. |
|
|
613
618
|
| import.get | id | Read the import cursor, outcomes, and errors. |
|
|
614
619
|
| import.retry | id | Retry failed rows after correcting saved identity conflicts. |
|
|
615
|
-
| company.get | — | Read the admin-only Frontpage account sync. |
|
|
616
|
-
| company.set | enabled, groupId? | Start or pause that sync in landing; platform admin only. |
|
|
617
620
|
|
|
618
621
|
## Limits, timing, and recovery
|
|
619
622
|
|
|
@@ -628,3 +631,20 @@ The agent can create and edit saved templates and series with the existing Email
|
|
|
628
631
|
**Keep scope clear.**
|
|
629
632
|
|
|
630
633
|
People does not replace the raw Inbox, Store orders, or the email send ledger. It combines the contact’s history and organization. Those source tools and their existing CLI commands remain available.
|
|
634
|
+
|
|
635
|
+
## Manual saved emails from People
|
|
636
|
+
|
|
637
|
+
Templates store reusable content; series store ordered templates and delays. Neither definition stores a customer audience. Their existing API/CLI sends and rule callers remain supported. In People, use **Email → Use a template** to create an independent campaign draft with the current audience. Review the draft before sending; editing it never changes its source template. A template's **Create campaign** shortcut copies saved content, then asks for a campaign audience.
|
|
638
|
+
|
|
639
|
+
The shared workspace operations are:
|
|
640
|
+
|
|
641
|
+
```
|
|
642
|
+
frontpage people workspace template.campaign --site YOUR_SITE --data '{"templateId":"TEMPLATE_ID","requestKey":"YOUR_STABLE_KEY","audience":{"personIds":["PERSON_ID"]}}' --json
|
|
643
|
+
frontpage people workspace series.preview --site YOUR_SITE --data '{"seriesId":"SERIES_ID","personIds":["PERSON_ID"]}' --json
|
|
644
|
+
```
|
|
645
|
+
|
|
646
|
+
To enrol, copy each returned batch's `personIds` and `reviewToken` into `series.enrol`, adding `seriesId` and a unique stable `requestKey`. Preview returns batches of five; the service accepts at most 25 per request. Reuse the original payload and key after a lost response. Results distinguish enrolled, already enrolled, skipped and failed. A new preview uses new keys. Never enrol or send without the user's explicit instruction.
|
|
647
|
+
|
|
648
|
+
Manual series starts use selected person IDs, an active series and the normal queue. Confirmation queues its first email; saved delays schedule later steps. The review lists permission exclusions and remaining allowance. It never activates a paused series or restarts an existing enrolment, including one bound to a person whose email changed. Delivery rechecks saved restrictions. Contact groups and permission stay unchanged.
|
|
649
|
+
|
|
650
|
+
People supplies first-name and unsubscribe values. If content uses order, booking or other caller-only variables, edit the independent campaign or choose another series. Do not invent values or send empty required context.
|
package/README.md
CHANGED
|
@@ -60,3 +60,6 @@ See [AGENTS.md](./AGENTS.md) and [llms.txt](./llms.txt) in this package.
|
|
|
60
60
|
## People groups, stages, rules, and imports
|
|
61
61
|
|
|
62
62
|
Use `frontpage people overview`, `groups`, `stages`, `membership`, `rules`, `attachments`, `import`, and `company`. Use `frontpage mail campaign` for one-off group/stage/list audiences in Emails. Every editor action is available through these commands or `frontpage people workspace OPERATION --file payload.json`. Full operations and examples: [PEOPLE.md](./PEOPLE.md); public guide: https://frontpage.host/developers/people. Automated emails select a saved template or series. Campaigns use their own drafts.
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
Payments, invoices and recurring billing: see packaged [PAYMENTS.md](./PAYMENTS.md), `frontpage help payments`, and https://frontpage.host/developers/payments. Version 0.2.0 removes writable currency, Store checkout mode and Store-owned money commands; Store stays cart-only. Preview/review before sends; keep stable operation keys on retries. Autopay requires the customer’s own verified authorization.
|
package/dist/argv.d.ts
CHANGED
|
@@ -10,3 +10,5 @@ export declare function looksLikeFlag(token: string): boolean;
|
|
|
10
10
|
* caller also checks them.
|
|
11
11
|
*/
|
|
12
12
|
export declare function assertNoUnknownFlags(rest: string[], command: string): void;
|
|
13
|
+
/** Required value for new command parsers; keep existing parsers unchanged. */
|
|
14
|
+
export declare function takeFlagValue(rest: string[], name: string): string | undefined;
|
package/dist/argv.js
CHANGED
|
@@ -46,3 +46,11 @@ export function assertNoUnknownFlags(rest, command) {
|
|
|
46
46
|
return;
|
|
47
47
|
throw new CliHttpError(`Unknown flag: ${unknown}. Run: frontpage help ${command}`, EXIT_USAGE, 0, "bad_request");
|
|
48
48
|
}
|
|
49
|
+
/** Required value for new command parsers; keep existing parsers unchanged. */
|
|
50
|
+
export function takeFlagValue(rest, name) {
|
|
51
|
+
const present = rest.some(token => token === name || token.startsWith(`${name}=`));
|
|
52
|
+
const value = takeFlag(rest, name);
|
|
53
|
+
if (present && (!value || looksLikeFlag(value)))
|
|
54
|
+
throw new CliHttpError(`${name} requires a value.`, EXIT_USAGE, 0, "bad_request");
|
|
55
|
+
return value;
|
|
56
|
+
}
|
package/dist/bin.js
CHANGED
|
@@ -26,11 +26,13 @@ import { runExtensions } from "./commands/extensions.js";
|
|
|
26
26
|
import { runMedia } from "./commands/media.js";
|
|
27
27
|
import { runImages } from "./commands/images.js";
|
|
28
28
|
import { runInbox } from "./commands/inbox.js";
|
|
29
|
+
import { runCustomerInbox } from "./commands/customer-inbox.js";
|
|
29
30
|
import { runSeo } from "./commands/seo.js";
|
|
30
31
|
import { runRedirects } from "./commands/redirects.js";
|
|
31
32
|
import { runPeople } from "./commands/people.js";
|
|
32
33
|
import { runRoutines } from "./commands/routines.js";
|
|
33
34
|
import { runShop } from "./commands/shop.js";
|
|
35
|
+
import { runPayments } from "./commands/payments.js";
|
|
34
36
|
import { runMail } from "./commands/mail.js";
|
|
35
37
|
import { runArticles } from "./commands/articles.js";
|
|
36
38
|
import { runSettings } from "./commands/settings.js";
|
|
@@ -62,6 +64,8 @@ async function main() {
|
|
|
62
64
|
return runHelp([cmd, ...args]);
|
|
63
65
|
}
|
|
64
66
|
switch (cmd) {
|
|
67
|
+
case "payments":
|
|
68
|
+
return runPayments(args);
|
|
65
69
|
case "login":
|
|
66
70
|
return runLogin(args);
|
|
67
71
|
case "logout":
|
|
@@ -117,6 +121,8 @@ async function main() {
|
|
|
117
121
|
return runImages(args);
|
|
118
122
|
case "inbox":
|
|
119
123
|
return runInbox(args);
|
|
124
|
+
case "customer-inbox":
|
|
125
|
+
return runCustomerInbox(args);
|
|
120
126
|
case "people":
|
|
121
127
|
return runPeople(args);
|
|
122
128
|
case "routines":
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export declare function inboxCommand(args: string[]): {
|
|
2
|
+
method: string;
|
|
3
|
+
resource: string;
|
|
4
|
+
file: undefined;
|
|
5
|
+
key: undefined;
|
|
6
|
+
out: string;
|
|
7
|
+
} | {
|
|
8
|
+
method: string;
|
|
9
|
+
resource: string;
|
|
10
|
+
file: string | undefined;
|
|
11
|
+
key: string | undefined;
|
|
12
|
+
out: undefined;
|
|
13
|
+
};
|
|
14
|
+
export declare function runCustomerInbox(args: string[]): Promise<number>;
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { readFile, open, link, unlink } from "node:fs/promises";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { resolveSlug } from "../site.js";
|
|
4
|
+
import { takeFlag, assertNoUnknownFlags } from "../argv.js";
|
|
5
|
+
import { getApiKey } from "../auth/keychain.js";
|
|
6
|
+
import { apiBase, v1Get, httpErrorFromV1, CliHttpError, EXIT_AUTH, EXIT_USAGE, EXIT_OK, exitCodeForError, } from "../http/client.js";
|
|
7
|
+
import { emitOk, emitErr, isJson } from "../runtime.js";
|
|
8
|
+
const RESOURCE = /^(?:status|activation|address|widget|settings|knowledge(?:\/[0-9a-f-]+(?:\/history)?)?|sources|blocks(?:\/[0-9a-f-]+\/unblock)?|jobs(?:\/[0-9a-f-]+(?:\/resume)?)?|files(?:\/[0-9a-f-]+)?|receipts(?:\/[0-9a-f-]+)?|conversations(?:\/[0-9a-f-]+(?:\/(?:messages|routing|guidance|reply|control|state|revoke-sessions))?)?)(?:\?[^#]*)?$/;
|
|
9
|
+
export function inboxCommand(args) {
|
|
10
|
+
const work = [...args], verb = work.shift(), resource = work.shift(), file = takeFlag(work, "--file"), key = takeFlag(work, "--request-key"), out = takeFlag(work, "--out");
|
|
11
|
+
assertNoUnknownFlags(work, "customer-inbox");
|
|
12
|
+
if (verb === "export" || verb === "download") {
|
|
13
|
+
if (work.length ||
|
|
14
|
+
!resource ||
|
|
15
|
+
!out ||
|
|
16
|
+
file ||
|
|
17
|
+
key ||
|
|
18
|
+
!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(resource))
|
|
19
|
+
throw new CliHttpError("Use customer-inbox export|download <id> --out <new-file>.", EXIT_USAGE, 400, "bad_request");
|
|
20
|
+
return {
|
|
21
|
+
method: verb.toUpperCase(),
|
|
22
|
+
resource,
|
|
23
|
+
file: undefined,
|
|
24
|
+
key: undefined,
|
|
25
|
+
out,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
if (out ||
|
|
29
|
+
work.length ||
|
|
30
|
+
!verb ||
|
|
31
|
+
!["get", "put", "post"].includes(verb) ||
|
|
32
|
+
!resource ||
|
|
33
|
+
!RESOURCE.test(resource))
|
|
34
|
+
throw new CliHttpError("Usage: frontpage customer-inbox get|put|post <resource> [--file <json>] [--request-key <key>] [--site <slug>]", EXIT_USAGE, 400, "bad_request");
|
|
35
|
+
if (verb !== "get" && (!file || !key || !/^[A-Za-z0-9_-]{8,128}$/.test(key)))
|
|
36
|
+
throw new CliHttpError("Writes require --file and an 8–128 character --request-key. Reuse both on retries.", EXIT_USAGE, 400, "bad_request");
|
|
37
|
+
if (verb === "get" && (file || key))
|
|
38
|
+
throw new CliHttpError("Reads do not take --file or --request-key.", EXIT_USAGE, 400, "bad_request");
|
|
39
|
+
return { method: verb.toUpperCase(), resource, file, key, out: undefined };
|
|
40
|
+
}
|
|
41
|
+
export async function runCustomerInbox(args) {
|
|
42
|
+
try {
|
|
43
|
+
const { slug, rest } = await resolveSlug(args), operation = inboxCommand(rest), path = `/api/v1/sites/${encodeURIComponent(slug)}/customer-inbox/${operation.resource}`;
|
|
44
|
+
let result;
|
|
45
|
+
if (operation.method === "EXPORT" || operation.method === "DOWNLOAD") {
|
|
46
|
+
const target = operation.out, temp = target + ".inbox-" + randomUUID() + ".tmp", handle = await open(temp, "wx", 0o600);
|
|
47
|
+
let count = 0;
|
|
48
|
+
try {
|
|
49
|
+
const prefix = `/api/v1/sites/${encodeURIComponent(slug)}/customer-inbox`;
|
|
50
|
+
if (operation.method === "DOWNLOAD") {
|
|
51
|
+
const record = await v1Get(`${prefix}/files/${operation.resource}`);
|
|
52
|
+
const response = await fetch(record.data.downloadUrl, {
|
|
53
|
+
redirect: "error",
|
|
54
|
+
signal: AbortSignal.timeout(60000),
|
|
55
|
+
});
|
|
56
|
+
if (!response.ok || !response.body)
|
|
57
|
+
throw new CliHttpError("Attachment download failed.", EXIT_USAGE, response.status, "download_failed");
|
|
58
|
+
for await (const chunk of response.body) {
|
|
59
|
+
count += chunk.byteLength;
|
|
60
|
+
if (count > 25 * 1024 * 1024)
|
|
61
|
+
throw new CliHttpError("Attachment exceeds 25 MB.", EXIT_USAGE, 413, "payload_too_large");
|
|
62
|
+
await handle.writeFile(chunk);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
const conversation = await v1Get(`${prefix}/conversations/${operation.resource}`);
|
|
67
|
+
await handle.writeFile(JSON.stringify({ type: "conversation", value: conversation.data }) +
|
|
68
|
+
"\n");
|
|
69
|
+
let cursor = 0;
|
|
70
|
+
for (;;) {
|
|
71
|
+
const page = await v1Get(`${prefix}/conversations/${operation.resource}/messages?after=${cursor}&limit=100&audience=all`);
|
|
72
|
+
for (const message of page.data.messages) {
|
|
73
|
+
const attachments = await v1Get(`${prefix}/files?messageId=${message.id}`);
|
|
74
|
+
await handle.writeFile(JSON.stringify({
|
|
75
|
+
type: "message",
|
|
76
|
+
value: message,
|
|
77
|
+
attachments: attachments.data,
|
|
78
|
+
}) + "\n");
|
|
79
|
+
count++;
|
|
80
|
+
}
|
|
81
|
+
if (page.data.nextCursor === null)
|
|
82
|
+
break;
|
|
83
|
+
if (page.data.nextCursor <= cursor)
|
|
84
|
+
throw new Error("History cursor did not advance.");
|
|
85
|
+
cursor = page.data.nextCursor;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
await handle.sync();
|
|
89
|
+
await handle.close();
|
|
90
|
+
await link(temp, target);
|
|
91
|
+
await unlink(temp);
|
|
92
|
+
}
|
|
93
|
+
catch (e) {
|
|
94
|
+
await handle.close().catch(() => { });
|
|
95
|
+
await unlink(temp).catch(() => { });
|
|
96
|
+
throw e;
|
|
97
|
+
}
|
|
98
|
+
result = {
|
|
99
|
+
path: target,
|
|
100
|
+
...(operation.method === "EXPORT"
|
|
101
|
+
? { messages: count, format: "jsonl" }
|
|
102
|
+
: { bytes: count }),
|
|
103
|
+
};
|
|
104
|
+
if (isJson())
|
|
105
|
+
emitOk("customer-inbox", result);
|
|
106
|
+
else
|
|
107
|
+
console.log(JSON.stringify(result, null, 2));
|
|
108
|
+
return EXIT_OK;
|
|
109
|
+
}
|
|
110
|
+
if (operation.method === "GET")
|
|
111
|
+
result = await v1Get(path, { timeoutMs: 20000 });
|
|
112
|
+
else {
|
|
113
|
+
const bytes = await readFile(operation.file);
|
|
114
|
+
if (bytes.length > 65536)
|
|
115
|
+
throw new CliHttpError("Inbox JSON exceeds 64 KiB.", EXIT_USAGE, 413, "payload_too_large");
|
|
116
|
+
let value;
|
|
117
|
+
try {
|
|
118
|
+
value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
throw new CliHttpError("Use a valid UTF-8 JSON object.", EXIT_USAGE, 400, "bad_request");
|
|
122
|
+
}
|
|
123
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
124
|
+
throw new CliHttpError("Use a JSON object.", EXIT_USAGE, 400, "bad_request");
|
|
125
|
+
const token = await getApiKey();
|
|
126
|
+
if (!token)
|
|
127
|
+
throw new CliHttpError("Not logged in. Run: frontpage login", EXIT_AUTH, 401);
|
|
128
|
+
const response = await fetch(apiBase() + path, {
|
|
129
|
+
method: operation.method,
|
|
130
|
+
headers: {
|
|
131
|
+
Authorization: `Bearer ${token}`,
|
|
132
|
+
"Content-Type": "application/json",
|
|
133
|
+
"Idempotency-Key": operation.key,
|
|
134
|
+
},
|
|
135
|
+
body: JSON.stringify(value),
|
|
136
|
+
redirect: "error",
|
|
137
|
+
signal: AbortSignal.timeout(20000),
|
|
138
|
+
});
|
|
139
|
+
const body = (await response.json());
|
|
140
|
+
if (!response.ok)
|
|
141
|
+
throw httpErrorFromV1(response.status, body);
|
|
142
|
+
result = body;
|
|
143
|
+
}
|
|
144
|
+
if (isJson())
|
|
145
|
+
emitOk("customer-inbox", result);
|
|
146
|
+
else
|
|
147
|
+
console.log(JSON.stringify(result, null, 2));
|
|
148
|
+
return EXIT_OK;
|
|
149
|
+
}
|
|
150
|
+
catch (e) {
|
|
151
|
+
if (isJson())
|
|
152
|
+
emitErr("customer-inbox", e);
|
|
153
|
+
else
|
|
154
|
+
console.error(e instanceof Error ? e.message : "Inbox request failed.");
|
|
155
|
+
return exitCodeForError(e);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function runPayments(args: string[]): Promise<number>;
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { resolveSlug } from "../site.js";
|
|
3
|
+
import { takeFlagValue as takeFlag, hasFlag, assertNoUnknownFlags } from "../argv.js";
|
|
4
|
+
import { CliHttpError, EXIT_OK, EXIT_USAGE, v1Get, v1Post, v1Patch, LONG_MODULE_TIMEOUT_MS, } from "../http/client.js";
|
|
5
|
+
import { emitErr, emitOk, emitUsage, isJson, requireYesOrTty, } from "../runtime.js";
|
|
6
|
+
import { openBrowser, resolveOpenUrl } from "../modules/oauth.js";
|
|
7
|
+
const USAGE = "frontpage payments status|connect|availability|activity|tax|invoices|recurring|customers … [--site <slug>] [--json]";
|
|
8
|
+
function usage(message) {
|
|
9
|
+
throw new CliHttpError(message, EXIT_USAGE, 0, "bad_request");
|
|
10
|
+
}
|
|
11
|
+
function idOf(work) {
|
|
12
|
+
const id = takeFlag(work, "--id") ??
|
|
13
|
+
(!work[0]?.startsWith("-") ? work.shift() : undefined);
|
|
14
|
+
if (!id || !/^[a-zA-Z0-9_-]{2,80}$/.test(id))
|
|
15
|
+
usage("A valid record id is required. Use the id returned by list or create.");
|
|
16
|
+
return encodeURIComponent(id);
|
|
17
|
+
}
|
|
18
|
+
async function payload(work) {
|
|
19
|
+
const file = takeFlag(work, "--file");
|
|
20
|
+
let body = {};
|
|
21
|
+
if (file) {
|
|
22
|
+
try {
|
|
23
|
+
const value = JSON.parse(await readFile(file, "utf8"));
|
|
24
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
25
|
+
usage("--file must contain a JSON object.");
|
|
26
|
+
body = value;
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
if (error instanceof CliHttpError)
|
|
30
|
+
throw error;
|
|
31
|
+
usage("Could not read a JSON object from --file.");
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
for (const [flag, key] of [
|
|
35
|
+
["--title", "title"],
|
|
36
|
+
["--amount", "amount"],
|
|
37
|
+
["--operation-key", "operationKey"],
|
|
38
|
+
["--preview-hash", "previewHash"],
|
|
39
|
+
["--description", "description"],
|
|
40
|
+
["--kind", "kind"],
|
|
41
|
+
["--collection", "collection"],
|
|
42
|
+
["--cadence", "cadence"],
|
|
43
|
+
["--first-date", "firstDate"],
|
|
44
|
+
["--time-zone", "timeZone"],
|
|
45
|
+
["--country", "country"],
|
|
46
|
+
["--state", "state"],
|
|
47
|
+
["--line1", "line1"],
|
|
48
|
+
["--city", "city"],
|
|
49
|
+
["--postal", "postal"],
|
|
50
|
+
["--tax-code", "taxCode"],
|
|
51
|
+
]) {
|
|
52
|
+
const value = takeFlag(work, flag);
|
|
53
|
+
if (value !== undefined) {
|
|
54
|
+
if (!value || value.startsWith("--"))
|
|
55
|
+
usage(`${flag} requires a value.`);
|
|
56
|
+
body[key] = value;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
for (const [flag, key] of [
|
|
60
|
+
["--price-cents", "priceCents"],
|
|
61
|
+
["--min-cents", "minCents"],
|
|
62
|
+
["--amount-cents", "amountCents"],
|
|
63
|
+
["--due-days", "dueDays"],
|
|
64
|
+
]) {
|
|
65
|
+
const value = takeFlag(work, flag);
|
|
66
|
+
if (value !== undefined) {
|
|
67
|
+
if (!/^\d+$/.test(value) || !Number.isSafeInteger(Number(value)))
|
|
68
|
+
usage(`${flag} requires a whole number.`);
|
|
69
|
+
body[key] = Number(value);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const personId = takeFlag(work, "--person");
|
|
73
|
+
const email = takeFlag(work, "--email"), name = takeFlag(work, "--name");
|
|
74
|
+
if (personId && (email || name))
|
|
75
|
+
usage("Use either --person or an inline --email/--name customer.");
|
|
76
|
+
if (personId)
|
|
77
|
+
body.recipient = { personId };
|
|
78
|
+
else if (email || name)
|
|
79
|
+
body.recipient = {
|
|
80
|
+
...(body.recipient && typeof body.recipient === "object"
|
|
81
|
+
? body.recipient
|
|
82
|
+
: {}),
|
|
83
|
+
...(email ? { email } : {}),
|
|
84
|
+
...(name ? { name } : {}),
|
|
85
|
+
};
|
|
86
|
+
if (hasFlag(work, "--confirm-billing-contact"))
|
|
87
|
+
body.confirmBillingContact = true;
|
|
88
|
+
return body;
|
|
89
|
+
}
|
|
90
|
+
function noRest(work) {
|
|
91
|
+
assertNoUnknownFlags(work, "payments");
|
|
92
|
+
if (work.length)
|
|
93
|
+
usage(`Unexpected argument: ${work[0]}. Run frontpage help payments.`);
|
|
94
|
+
}
|
|
95
|
+
function confirm(body, action) {
|
|
96
|
+
const problem = requireYesOrTty(action);
|
|
97
|
+
if (problem)
|
|
98
|
+
usage(problem);
|
|
99
|
+
body.confirm = true;
|
|
100
|
+
}
|
|
101
|
+
function requireKey(body) {
|
|
102
|
+
if (typeof body.operationKey !== "string" ||
|
|
103
|
+
!/^[a-zA-Z0-9._:-]{16,120}$/.test(body.operationKey))
|
|
104
|
+
usage("Supply --operation-key with 16–120 letters, numbers, dots, colons, hyphens or underscores. Keep the same key when retrying.");
|
|
105
|
+
}
|
|
106
|
+
export async function runPayments(args) {
|
|
107
|
+
try {
|
|
108
|
+
const { slug, rest } = await resolveSlug(args);
|
|
109
|
+
const work = [...rest];
|
|
110
|
+
const group = work.shift();
|
|
111
|
+
if (!group)
|
|
112
|
+
return emitUsage("payments", USAGE);
|
|
113
|
+
const base = `/api/v1/sites/${encodeURIComponent(slug)}/payments`;
|
|
114
|
+
let path = "", method = "GET", body = {};
|
|
115
|
+
if (group === "status" || group === "settings")
|
|
116
|
+
path = group;
|
|
117
|
+
else if (group === "connect") {
|
|
118
|
+
const noOpen = hasFlag(work, "--no-open");
|
|
119
|
+
noRest(work);
|
|
120
|
+
const result = await v1Post(`${base}/connect`, {});
|
|
121
|
+
const url = resolveOpenUrl(result.url);
|
|
122
|
+
if (!url)
|
|
123
|
+
usage("The API did not return a trusted browser setup link.");
|
|
124
|
+
if (!noOpen && !isJson())
|
|
125
|
+
openBrowser(url);
|
|
126
|
+
if (isJson())
|
|
127
|
+
emitOk("payments", result);
|
|
128
|
+
else
|
|
129
|
+
console.log(`${result.message}\n${url}`);
|
|
130
|
+
return EXIT_OK;
|
|
131
|
+
}
|
|
132
|
+
else if (group === "availability") {
|
|
133
|
+
const mode = work.shift() ?? "preview";
|
|
134
|
+
if (!["preview", "on", "off"].includes(mode))
|
|
135
|
+
usage("Use payments availability preview|on|off.");
|
|
136
|
+
path = "availability";
|
|
137
|
+
if (mode !== "preview") {
|
|
138
|
+
body = await payload(work);
|
|
139
|
+
confirm(body, mode === "off"
|
|
140
|
+
? "Stop new payments, pause recurring billing and void unpaid invoices"
|
|
141
|
+
: "Restore Payments without resuming stopped billing");
|
|
142
|
+
body.enabled = mode === "on";
|
|
143
|
+
if (!body.enabled && !body.previewHash) {
|
|
144
|
+
const preview = await v1Get(`${base}/availability`);
|
|
145
|
+
body.previewHash = preview.previewHash;
|
|
146
|
+
}
|
|
147
|
+
method = "POST";
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
else if (group === "buttons") {
|
|
151
|
+
const sub = work.shift() ?? "list";
|
|
152
|
+
if (sub === "list") {
|
|
153
|
+
const query = new URLSearchParams();
|
|
154
|
+
for (const key of ["limit", "offset", "archived"]) {
|
|
155
|
+
const value = takeFlag(work, `--${key}`);
|
|
156
|
+
if (value !== undefined)
|
|
157
|
+
query.set(key, value);
|
|
158
|
+
}
|
|
159
|
+
path = `buttons?${query}`;
|
|
160
|
+
}
|
|
161
|
+
else if (sub === "get")
|
|
162
|
+
path = `buttons/${idOf(work)}`;
|
|
163
|
+
else if (sub === "create") {
|
|
164
|
+
const id = takeFlag(work, "--id");
|
|
165
|
+
path = "buttons";
|
|
166
|
+
method = "POST";
|
|
167
|
+
body = await payload(work);
|
|
168
|
+
if (id)
|
|
169
|
+
body.itemId = id;
|
|
170
|
+
}
|
|
171
|
+
else if (sub === "update") {
|
|
172
|
+
path = `buttons/${idOf(work)}`;
|
|
173
|
+
method = "PATCH";
|
|
174
|
+
body = await payload(work);
|
|
175
|
+
}
|
|
176
|
+
else if (sub === "archive") {
|
|
177
|
+
path = `buttons/${idOf(work)}/archive`;
|
|
178
|
+
method = "POST";
|
|
179
|
+
body = await payload(work);
|
|
180
|
+
confirm(body, "Archive this Pay now button");
|
|
181
|
+
}
|
|
182
|
+
else
|
|
183
|
+
usage("Use payments buttons list|get|create|update|archive.");
|
|
184
|
+
}
|
|
185
|
+
else if (group === "activity") {
|
|
186
|
+
const sub = work.shift() ?? "list";
|
|
187
|
+
if (sub === "list")
|
|
188
|
+
path = "activity";
|
|
189
|
+
else if (sub === "get") {
|
|
190
|
+
const source = takeFlag(work, "--source");
|
|
191
|
+
if (source !== "order" && source !== "payment")
|
|
192
|
+
usage("Use --source order|payment.");
|
|
193
|
+
const id = idOf(work);
|
|
194
|
+
path = `activity/${source}/${id}`;
|
|
195
|
+
}
|
|
196
|
+
else
|
|
197
|
+
usage("Use payments activity list|get.");
|
|
198
|
+
}
|
|
199
|
+
else if (group === "tax") {
|
|
200
|
+
const sub = work.shift() ?? "get";
|
|
201
|
+
if (sub === "get")
|
|
202
|
+
path = "tax";
|
|
203
|
+
else if (sub === "settings") {
|
|
204
|
+
path = "tax/settings";
|
|
205
|
+
method = "POST";
|
|
206
|
+
body = await payload(work);
|
|
207
|
+
confirm(body, "Save the supplied business tax settings");
|
|
208
|
+
}
|
|
209
|
+
else if (sub === "registrations") {
|
|
210
|
+
const verb = work.shift() ?? "list";
|
|
211
|
+
if (verb === "list")
|
|
212
|
+
path = "tax/registrations";
|
|
213
|
+
else if (verb === "add") {
|
|
214
|
+
path = "tax/registrations";
|
|
215
|
+
method = "POST";
|
|
216
|
+
body = await payload(work);
|
|
217
|
+
confirm(body, "Add the supplied tax registration");
|
|
218
|
+
}
|
|
219
|
+
else if (verb === "end") {
|
|
220
|
+
path = `tax/registrations/${idOf(work)}/end`;
|
|
221
|
+
method = "POST";
|
|
222
|
+
body = await payload(work);
|
|
223
|
+
confirm(body, "End this tax registration");
|
|
224
|
+
}
|
|
225
|
+
else
|
|
226
|
+
usage("Use payments tax registrations list|add|end.");
|
|
227
|
+
}
|
|
228
|
+
else
|
|
229
|
+
usage("Use payments tax get|settings|registrations.");
|
|
230
|
+
}
|
|
231
|
+
else if (group === "invoices" || group === "recurring") {
|
|
232
|
+
const sub = work.shift() ?? "list";
|
|
233
|
+
if (sub === "list") {
|
|
234
|
+
const query = new URLSearchParams();
|
|
235
|
+
for (const key of ["limit", "offset", "arrangementId"]) {
|
|
236
|
+
const value = takeFlag(work, `--${key === "arrangementId" ? "arrangement" : key}`);
|
|
237
|
+
if (value !== undefined)
|
|
238
|
+
query.set(key, value);
|
|
239
|
+
}
|
|
240
|
+
path = `${group}?${query}`;
|
|
241
|
+
}
|
|
242
|
+
else if (sub === "get")
|
|
243
|
+
path = `${group}/${idOf(work)}`;
|
|
244
|
+
else if (sub === "preview" ||
|
|
245
|
+
(group === "invoices" && sub === "send") ||
|
|
246
|
+
(group === "recurring" && sub === "create")) {
|
|
247
|
+
path = sub === "create" ? "recurring" : `${group}/${sub}`;
|
|
248
|
+
method = "POST";
|
|
249
|
+
body = await payload(work);
|
|
250
|
+
if (group === "recurring")
|
|
251
|
+
body.kind = "recurring";
|
|
252
|
+
if (sub !== "preview") {
|
|
253
|
+
requireKey(body);
|
|
254
|
+
confirm(body, sub === "send"
|
|
255
|
+
? "Send this reviewed invoice"
|
|
256
|
+
: "Create this reviewed recurring billing request");
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
else if ((group === "invoices" && ["refresh", "resend", "void"].includes(sub)) ||
|
|
260
|
+
(group === "recurring" &&
|
|
261
|
+
["refresh", "cancel", "resume", "resend-invitation"].includes(sub))) {
|
|
262
|
+
path = `${group}/${idOf(work)}/${sub}`;
|
|
263
|
+
method = "POST";
|
|
264
|
+
body = await payload(work);
|
|
265
|
+
if (["resend", "resume", "resend-invitation"].includes(sub))
|
|
266
|
+
requireKey(body);
|
|
267
|
+
if (sub !== "refresh")
|
|
268
|
+
confirm(body, `${sub} this ${group === "invoices" ? "invoice" : "billing arrangement"}`);
|
|
269
|
+
}
|
|
270
|
+
else
|
|
271
|
+
usage(`Unknown ${group} command. Run frontpage help payments.`);
|
|
272
|
+
}
|
|
273
|
+
else if (group === "customers") {
|
|
274
|
+
const sub = work.shift();
|
|
275
|
+
if (sub !== "billing-link" && sub !== "send-billing-link")
|
|
276
|
+
usage("Use payments customers billing-link|send-billing-link.");
|
|
277
|
+
path = `customers/${idOf(work)}/${sub}`;
|
|
278
|
+
method = "POST";
|
|
279
|
+
body = await payload(work);
|
|
280
|
+
requireKey(body);
|
|
281
|
+
if (sub === "send-billing-link")
|
|
282
|
+
confirm(body, "Email a private billing link to the customer's saved billing email");
|
|
283
|
+
}
|
|
284
|
+
else
|
|
285
|
+
return emitUsage("payments", USAGE);
|
|
286
|
+
noRest(work);
|
|
287
|
+
const result = method === "GET"
|
|
288
|
+
? await v1Get(`${base}/${path}`)
|
|
289
|
+
: method === "PATCH"
|
|
290
|
+
? await v1Patch(`${base}/${path}`, body)
|
|
291
|
+
: await v1Post(`${base}/${path}`, body, {
|
|
292
|
+
timeoutMs: LONG_MODULE_TIMEOUT_MS,
|
|
293
|
+
});
|
|
294
|
+
if (isJson())
|
|
295
|
+
emitOk("payments", result);
|
|
296
|
+
else
|
|
297
|
+
console.log(JSON.stringify(result, null, 2));
|
|
298
|
+
return EXIT_OK;
|
|
299
|
+
}
|
|
300
|
+
catch (error) {
|
|
301
|
+
return emitErr("payments", error);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
@@ -10,7 +10,6 @@ const AREAS = [
|
|
|
10
10
|
"import",
|
|
11
11
|
"attachments",
|
|
12
12
|
"person",
|
|
13
|
-
"company",
|
|
14
13
|
];
|
|
15
14
|
export function isPeopleWorkspaceCommand(sub) {
|
|
16
15
|
return (!!sub &&
|
|
@@ -23,7 +22,7 @@ export async function runPeopleWorkspace(slug, args) {
|
|
|
23
22
|
? work.shift()
|
|
24
23
|
: area === "overview" || area === "sources"
|
|
25
24
|
? area
|
|
26
|
-
: `${area}.${work.shift() ?? (area === "
|
|
25
|
+
: `${area}.${work.shift() ?? (area === "audience" ? "preview" : "list")}`;
|
|
27
26
|
const file = takeFlag(work, "--file"), json = takeFlag(work, "--data");
|
|
28
27
|
if (file && json)
|
|
29
28
|
throw new CliHttpError("Use --file or --data, not both.", EXIT_USAGE, 0, "bad_request");
|
package/dist/commands/people.js
CHANGED
|
@@ -232,12 +232,16 @@ export async function runPeople(args) {
|
|
|
232
232
|
const work = rest.slice(1);
|
|
233
233
|
const id = requireId(work);
|
|
234
234
|
const body = takeFlag(work, "--body") ?? takeFlag(work, "--note");
|
|
235
|
+
const requestKey = takeFlag(work, "--request-key");
|
|
236
|
+
const historical = takeFlag(work, "--historical");
|
|
237
|
+
if (historical !== undefined && !["true", "false"].includes(historical))
|
|
238
|
+
throw new Error("historical must be true or false.");
|
|
235
239
|
assertNoUnknownFlags(work, "people");
|
|
236
240
|
if (!body) {
|
|
237
241
|
console.error("Usage: frontpage people note <id> --body \"…\"");
|
|
238
242
|
return EXIT_USAGE;
|
|
239
243
|
}
|
|
240
|
-
const data = await v1Post(`${base}/${encodeURIComponent(id)}/events`, { body });
|
|
244
|
+
const data = await v1Post(`${base}/${encodeURIComponent(id)}/events`, { body, requestKey, ...(historical !== undefined ? { historical: historical === "true" } : {}) });
|
|
241
245
|
if (isJson()) {
|
|
242
246
|
emitOk("people", data);
|
|
243
247
|
return EXIT_OK;
|