@arcforgelabs/flow 2026.10.48 → 2026.10.49
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/npm-shrinkwrap.json +11 -11
- package/package.json +1 -1
- package/server/core/automations.js +34 -0
- package/server/core/pipeline.js +46 -6
- package/server/integrations/port.js +1 -1
- package/server/integrations/seazona/feed.js +41 -4
- package/server/integrations/seazona/sweep.js +11 -8
- package/server/integrations/sentry/index.js +2 -2
- package/server/integrations/xero/client.js +23 -0
- package/server/integrations/xero/contact-sync.js +289 -0
- package/server/integrations/xero/create-contact.js +42 -2
- package/server/integrations/xero/drafts.js +14 -6
- package/server/integrations/xero/import.js +5 -1
- package/server/integrations/xero/index.js +15 -2
- package/server/integrations/xero/lookup.js +15 -2
- package/server/integrations/xero/migrations/0013_xero_contact_sync.sql +15 -0
- package/server/integrations/xero/migrations/meta/_journal.json +7 -0
- package/server/integrations/xero/routes.js +16 -2
- package/server/ui/app.js +44 -39
- package/server/ui/automations.js +2 -4
- package/server/ui/contacts.js +20 -27
- package/server/ui/flow.css +120 -40
- package/server/ui/invoice-rules.js +2 -4
- package/server/ui/invoicing-settings.js +3 -7
- package/server/ui/invoicing.js +49 -75
- package/server/ui/kit.js +101 -10
- package/server/ui/line-mappings.js +2 -4
- package/server/ui/mobile.js +48 -3
- package/server/ui/price-list.js +3 -5
- package/server/ui/routes.js +1 -1
- package/server/ui/xero-contact.js +101 -0
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcforgelabs/flow",
|
|
3
|
-
"version": "2026.10.
|
|
3
|
+
"version": "2026.10.49",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@arcforgelabs/flow",
|
|
9
|
-
"version": "2026.10.
|
|
9
|
+
"version": "2026.10.49",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@clerk/backend": "3.25.0",
|
|
@@ -1254,18 +1254,18 @@
|
|
|
1254
1254
|
"license": "MIT"
|
|
1255
1255
|
},
|
|
1256
1256
|
"node_modules/body-parser": {
|
|
1257
|
-
"version": "2.
|
|
1258
|
-
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.
|
|
1259
|
-
"integrity": "sha512-
|
|
1257
|
+
"version": "2.4.0",
|
|
1258
|
+
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.4.0.tgz",
|
|
1259
|
+
"integrity": "sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ==",
|
|
1260
1260
|
"license": "MIT",
|
|
1261
1261
|
"dependencies": {
|
|
1262
1262
|
"bytes": "^3.1.2",
|
|
1263
|
-
"content-type": "^2.
|
|
1263
|
+
"content-type": "^2.1.0",
|
|
1264
1264
|
"debug": "^4.4.3",
|
|
1265
1265
|
"http-errors": "^2.0.1",
|
|
1266
|
-
"iconv-lite": "^0.7.
|
|
1266
|
+
"iconv-lite": "^0.7.3",
|
|
1267
1267
|
"on-finished": "^2.4.1",
|
|
1268
|
-
"qs": "^6.
|
|
1268
|
+
"qs": "^6.16.1",
|
|
1269
1269
|
"raw-body": "^3.0.2",
|
|
1270
1270
|
"type-is": "^2.1.0"
|
|
1271
1271
|
},
|
|
@@ -2031,9 +2031,9 @@
|
|
|
2031
2031
|
"license": "ISC"
|
|
2032
2032
|
},
|
|
2033
2033
|
"node_modules/ip-address": {
|
|
2034
|
-
"version": "10.7.
|
|
2035
|
-
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.
|
|
2036
|
-
"integrity": "sha512-
|
|
2034
|
+
"version": "10.7.5",
|
|
2035
|
+
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.5.tgz",
|
|
2036
|
+
"integrity": "sha512-t2v0rkILYv9WXmSL7TpAaU3HXgaFYOPUcH3QHgwQ++gpJuH3bqyMy4Fvt/DJCTKwzH3Cf+bAp5W/mFL39KPLzA==",
|
|
2037
2037
|
"license": "MIT",
|
|
2038
2038
|
"engines": {
|
|
2039
2039
|
"node": ">= 12"
|
package/package.json
CHANGED
|
@@ -22,6 +22,10 @@ import { refreshReferences } from './references.js';
|
|
|
22
22
|
// on the Invoices page, until a person changes it or moves it back.
|
|
23
23
|
// reference_from_rule: a draft made from a job keeps the Reference the
|
|
24
24
|
// lab's Reference rule gives (references.js), as the job's fields arrive.
|
|
25
|
+
// add_contacts, add_xero_contacts: a customer of the work system, or of the
|
|
26
|
+
// accounting system, that no Flow contact is named like becomes one.
|
|
27
|
+
// sync_contact_edits: contact edits on either side of the Xero link reach
|
|
28
|
+
// the other (server/integrations/xero/contact-sync.js).
|
|
25
29
|
|
|
26
30
|
const BATCH = 500;
|
|
27
31
|
|
|
@@ -71,6 +75,36 @@ export const AUTOMATIONS = Object.freeze([
|
|
|
71
75
|
readConfig: (config) => readMappingConfig(config),
|
|
72
76
|
remap: true,
|
|
73
77
|
},
|
|
78
|
+
{
|
|
79
|
+
// Runs inside the pipeline's read of the work system's customers
|
|
80
|
+
// (pipeline.js, linkCustomers), before its jobs are read.
|
|
81
|
+
key: 'add_contacts',
|
|
82
|
+
place: 'seazona',
|
|
83
|
+
name: 'Add new clients',
|
|
84
|
+
hint: 'A new Seazona client becomes a contact.',
|
|
85
|
+
defaults: { enabled: true, config: {} },
|
|
86
|
+
readConfig: () => ({}),
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
// Runs inside the pipeline's read of the accounting system's customers
|
|
90
|
+
// (pipeline.js, linkCustomers). Off until a person turns it on.
|
|
91
|
+
key: 'add_xero_contacts',
|
|
92
|
+
place: 'xero',
|
|
93
|
+
name: 'Add Xero clients',
|
|
94
|
+
hint: 'Each Xero client becomes a contact.',
|
|
95
|
+
defaults: { enabled: false, config: {} },
|
|
96
|
+
readConfig: () => ({}),
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
// Runs on the Xero adapter's own worker (xero/contact-sync.js), which
|
|
100
|
+
// writes to Xero only while writes are allowed. Off until a person turns it on.
|
|
101
|
+
key: 'sync_contact_edits',
|
|
102
|
+
place: 'xero',
|
|
103
|
+
name: 'Two-way edits',
|
|
104
|
+
hint: 'A contact edit in Flow or Xero reaches the other. A clash waits for you.',
|
|
105
|
+
defaults: { enabled: false, config: {} },
|
|
106
|
+
readConfig: () => ({}),
|
|
107
|
+
},
|
|
74
108
|
{
|
|
75
109
|
key: 'mark_ready_from_work_status',
|
|
76
110
|
place: 'seazona',
|
package/server/core/pipeline.js
CHANGED
|
@@ -10,6 +10,7 @@ import { observeJob, observeWithdrawn, recordAccountingEvidence, setHold } from
|
|
|
10
10
|
import { loadMappingContext, mapLines } from './line-mapping.js';
|
|
11
11
|
import { checkPrices, readCatalogues } from './price-list.js';
|
|
12
12
|
import { linkRecord, priorLinkFor } from './links.js';
|
|
13
|
+
import { createContact, readContact } from './contacts.js';
|
|
13
14
|
import { HISTORY_ACTOR, HISTORY_HOLD_REASON, isHistoryHeld } from './history.js';
|
|
14
15
|
import { deadHoldSince, deadSince, enqueueJob, runNextJob } from './queue.js';
|
|
15
16
|
|
|
@@ -88,20 +89,29 @@ function byName(records) {
|
|
|
88
89
|
}
|
|
89
90
|
|
|
90
91
|
// Links each customer of one system to the Flow customer with the same name,
|
|
91
|
-
// when one record on each side has it and neither side is linked yet.
|
|
92
|
-
|
|
92
|
+
// when one record on each side has it and neither side is linked yet. With
|
|
93
|
+
// `adopt`, a customer no Flow contact is named like becomes a new Flow
|
|
94
|
+
// contact, linked to it (`imported`), so its jobs find their customer
|
|
95
|
+
// (docs/CONTACTS.md). Two customers of one name stay for a person.
|
|
96
|
+
export async function linkCustomers(db, organisationId, { system, customers, adopt = false }) {
|
|
93
97
|
const flow = await db.select({ id: contacts.id, name: contacts.name }).from(contacts)
|
|
94
98
|
.where(and(eq(contacts.organisationId, organisationId), eq(contacts.kind, 'customer')));
|
|
95
99
|
const links = await activeLinks(db, organisationId, { system, entity: 'contact' });
|
|
96
100
|
const linkedFlow = new Set(links.map((l) => l.flowId));
|
|
97
101
|
const linkedExternal = new Set(links.map((l) => l.externalId));
|
|
98
102
|
const flowByName = byName(flow);
|
|
99
|
-
const result = { linked: 0, alreadyLinked: 0, unmatched: 0, sameName: 0 };
|
|
103
|
+
const result = { linked: 0, alreadyLinked: 0, unmatched: 0, sameName: 0, added: 0 };
|
|
100
104
|
for (const [key, external] of byName(customers)) {
|
|
101
105
|
const own = flowByName.get(key) ?? [];
|
|
102
106
|
for (const customer of external) {
|
|
103
107
|
if (linkedExternal.has(String(customer.id))) result.alreadyLinked += 1;
|
|
104
108
|
}
|
|
109
|
+
if (adopt && own.length === 0 && external.length === 1 && external[0].adoptable !== false && !linkedExternal.has(String(external[0].id))) {
|
|
110
|
+
if (await adoptCustomer(db, organisationId, { system, customer: external[0] })) {
|
|
111
|
+
result.added += 1;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
105
115
|
if (external.length !== 1 || own.length !== 1) {
|
|
106
116
|
const waiting = external.filter((c) => !linkedExternal.has(String(c.id))).length;
|
|
107
117
|
if (own.length === 0) result.unmatched += waiting;
|
|
@@ -119,6 +129,32 @@ export async function linkCustomers(db, organisationId, { system, customers }) {
|
|
|
119
129
|
return result;
|
|
120
130
|
}
|
|
121
131
|
|
|
132
|
+
// The customer's own fields when Flow can keep them all, else its name only.
|
|
133
|
+
async function adoptCustomer(db, organisationId, { system, customer }) {
|
|
134
|
+
const name = String(customer.name ?? '').trim();
|
|
135
|
+
const offered = { ...(customer.contact ?? {}), kind: 'customer', name };
|
|
136
|
+
let values;
|
|
137
|
+
try {
|
|
138
|
+
values = readContact(offered);
|
|
139
|
+
} catch (error) {
|
|
140
|
+
if (!(error instanceof FlowError)) throw error;
|
|
141
|
+
values = readContact({ kind: 'customer', name });
|
|
142
|
+
}
|
|
143
|
+
return db.transaction(async (tx) => {
|
|
144
|
+
let row;
|
|
145
|
+
try {
|
|
146
|
+
row = await createContact(tx, organisationId, values);
|
|
147
|
+
} catch (error) {
|
|
148
|
+
if (error instanceof FlowError && error.code === 'conflict') return false;
|
|
149
|
+
throw error;
|
|
150
|
+
}
|
|
151
|
+
const { outcome } = await linkRecord(tx, organisationId, {
|
|
152
|
+
entity: 'contact', flowId: row.id, system, externalId: String(customer.id), matchedBy: 'imported',
|
|
153
|
+
});
|
|
154
|
+
return outcome === 'linked' || outcome === 'existing';
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
122
158
|
async function readCursor(db, organisationId, name) {
|
|
123
159
|
const [row] = await db.select().from(pipelineCursors)
|
|
124
160
|
.where(and(eq(pipelineCursors.organisationId, organisationId), eq(pipelineCursors.name, name)));
|
|
@@ -500,9 +536,12 @@ export async function runPipelineOnce(db, integrations, { now = new Date() } = {
|
|
|
500
536
|
if (work) {
|
|
501
537
|
let restart = (summary.prices?.linked ?? 0) > 0;
|
|
502
538
|
if (typeof work.capabilities.listCustomers === 'function') {
|
|
503
|
-
|
|
539
|
+
const adding = (await listAutomations(db, organisationId)).find((a) => a.key === 'add_contacts');
|
|
540
|
+
summary.workCustomers = await linkCustomers(db, organisationId, {
|
|
541
|
+
system: work.id, customers: await work.capabilities.listCustomers({ organisationId }), adopt: Boolean(adding?.enabled),
|
|
542
|
+
});
|
|
504
543
|
// New customer links reach jobs read before them.
|
|
505
|
-
restart ||= summary.workCustomers.linked > 0;
|
|
544
|
+
restart ||= summary.workCustomers.linked + summary.workCustomers.added > 0;
|
|
506
545
|
}
|
|
507
546
|
summary.work = await ingestWork(db, organisationId, { adapter: work, restart });
|
|
508
547
|
// After the live feed, so a job both report is the live one.
|
|
@@ -512,8 +551,9 @@ export async function runPipelineOnce(db, integrations, { now = new Date() } = {
|
|
|
512
551
|
}
|
|
513
552
|
if (accounting) {
|
|
514
553
|
if (typeof accounting.capabilities.listCustomers === 'function') {
|
|
554
|
+
const adding = (await listAutomations(db, organisationId)).find((a) => a.key === 'add_xero_contacts');
|
|
515
555
|
summary.accountingCustomers = await linkCustomers(db, organisationId, {
|
|
516
|
-
system: accounting.id, customers: await accounting.capabilities.listCustomers({ organisationId }),
|
|
556
|
+
system: accounting.id, customers: await accounting.capabilities.listCustomers({ organisationId }), adopt: Boolean(adding?.enabled),
|
|
517
557
|
});
|
|
518
558
|
}
|
|
519
559
|
const settings = await readInvoicingSettings(db, organisationId);
|
|
@@ -32,7 +32,7 @@ export const CAPABILITIES = Object.freeze([
|
|
|
32
32
|
'personSettings', // { personId }: each person's own choices in this integration, as [{ key, label, hint, kind, value }]
|
|
33
33
|
'setPersonSetting', // { personId, key, value }: change one, answers the new list
|
|
34
34
|
'jobIssues', // open issues the integration holds for jobs, by job ref: a short phrase and the page where a person fixes it
|
|
35
|
-
'listCustomers', // the system's customers, by id and name, for the identity map (Seazona: clients; Xero: contacts)
|
|
35
|
+
'listCustomers', // the system's customers, by id and name, for the identity map (Seazona: clients; Xero: contacts). An optional `contact` is the Flow contact a new customer becomes; `adoptable` false keeps one from becoming a contact (docs/CONTACTS.md)
|
|
36
36
|
'createCustomer', // { organisationId, contactId, actor }: create the system's customer from the Flow contact. Answers { outcome, contact }. Xero, when Create missing clients is on.
|
|
37
37
|
'findInvoices', // sales invoices in the accounting system's copy, for the matcher; never a live call
|
|
38
38
|
'readChanges', // { state: fresh | read | unavailable }: the accounting system's latest changes into its copy now, just before Flow drafts or sends (read before act)
|
|
@@ -134,12 +134,19 @@ export async function readBillableOrder(db, orderId) {
|
|
|
134
134
|
// with no company name is listed by its full name. A client the client list
|
|
135
135
|
// never gave (the list can be closed to an API client) is listed by the
|
|
136
136
|
// company name its orders carry, so its jobs still find their customer.
|
|
137
|
+
// `contact` is the Flow contact a new client becomes (docs/CONTACTS.md).
|
|
137
138
|
export async function listClients(db) {
|
|
138
|
-
const rows = await db.select({
|
|
139
|
-
.
|
|
139
|
+
const rows = await db.select({
|
|
140
|
+
id: seazonaClients.id, company: seazonaClients.company, fullName: seazonaClients.fullName, active: seazonaClients.active,
|
|
141
|
+
accountNumber: seazonaClients.accountNumber, email: seazonaClients.email, phone: seazonaClients.phone,
|
|
142
|
+
address: seazonaClients.address, taxRate: seazonaClients.taxRate, doctors: seazonaClients.doctors,
|
|
143
|
+
}).from(seazonaClients);
|
|
140
144
|
const listed = rows
|
|
141
145
|
.filter((r) => r.active !== false)
|
|
142
|
-
.map((r) =>
|
|
146
|
+
.map((r) => {
|
|
147
|
+
const name = (r.company || r.fullName || '').trim();
|
|
148
|
+
return { id: r.id, name, contact: clientContact(r, name) };
|
|
149
|
+
})
|
|
143
150
|
.filter((r) => r.name);
|
|
144
151
|
const known = new Set(rows.map((r) => r.id));
|
|
145
152
|
const fromOrders = await db.selectDistinctOn([seazonaOrders.clientId], { id: seazonaOrders.clientId, company: seazonaOrders.company })
|
|
@@ -148,10 +155,40 @@ export async function listClients(db) {
|
|
|
148
155
|
.orderBy(seazonaOrders.clientId, sql`${seazonaOrders.lastChangedAt} desc`);
|
|
149
156
|
return [
|
|
150
157
|
...listed,
|
|
151
|
-
...fromOrders.filter((r) => !known.has(r.id)).map((r) =>
|
|
158
|
+
...fromOrders.filter((r) => !known.has(r.id)).map((r) => {
|
|
159
|
+
const name = r.company.trim();
|
|
160
|
+
return { id: r.id, name, contact: { kind: 'customer', name } };
|
|
161
|
+
}),
|
|
152
162
|
];
|
|
153
163
|
}
|
|
154
164
|
|
|
165
|
+
// A Seazona client as a Flow contact: the billing fields and the doctors as
|
|
166
|
+
// practitioners. A field Flow cannot keep is left out.
|
|
167
|
+
const cut = (value, max) => {
|
|
168
|
+
const text = String(value ?? '').trim();
|
|
169
|
+
return text && text.length <= max ? text : null;
|
|
170
|
+
};
|
|
171
|
+
export function clientContact(row, name) {
|
|
172
|
+
const address = row.address ?? {};
|
|
173
|
+
const kept = {
|
|
174
|
+
line1: cut(address.address1, 255), line2: cut(address.address2, 255), city: cut(address.city, 255),
|
|
175
|
+
region: cut(address.region, 255), postalCode: cut(address.postalCode, 255), country: cut(address.countryCode, 255),
|
|
176
|
+
};
|
|
177
|
+
const practitioners = (Array.isArray(row.doctors) ? row.doctors : [])
|
|
178
|
+
.map((doctor) => cut(doctor?.fullName, 255)).filter(Boolean).slice(0, 30).map((person) => ({ name: person }));
|
|
179
|
+
const contact = { kind: 'customer', name, practitioners };
|
|
180
|
+
const email = cut(row.email, 255);
|
|
181
|
+
if (email) contact.email = email;
|
|
182
|
+
const phone = cut(row.phone, 50);
|
|
183
|
+
if (phone) contact.phone = phone;
|
|
184
|
+
const account = cut(row.accountNumber, 50);
|
|
185
|
+
if (account) contact.accountNumber = account;
|
|
186
|
+
const taxRate = cut(row.taxRate, 50);
|
|
187
|
+
if (taxRate) contact.taxRate = taxRate;
|
|
188
|
+
if (Object.values(kept).some(Boolean)) contact.address = Object.fromEntries(Object.entries(kept).filter(([, v]) => v));
|
|
189
|
+
return contact;
|
|
190
|
+
}
|
|
191
|
+
|
|
155
192
|
// Seazona's products, from Flow's copy (the products lane reads them daily),
|
|
156
193
|
// for the price list's links and price check (server/core/price-list.js).
|
|
157
194
|
// Seazona says only whether a product is taxable: taxable reads as GST.
|
|
@@ -36,7 +36,7 @@ import {
|
|
|
36
36
|
// withdraws it, so its invoice goes to review.
|
|
37
37
|
// clients the whole list (GET clients/?lastModified=), read on from the
|
|
38
38
|
// last row when it is full.
|
|
39
|
-
// invoices every change since the
|
|
39
|
+
// invoices every change since the invoice lane first read, read on the
|
|
40
40
|
// same way.
|
|
41
41
|
//
|
|
42
42
|
// A client or invoice that Flow holds and the whole list leaves out is read
|
|
@@ -282,12 +282,15 @@ const WHOLE_LISTS = {
|
|
|
282
282
|
list: (client, since) => client.get('invoices/', { lastModified: since }),
|
|
283
283
|
// The guide: on or after lastModified (inclusive).
|
|
284
284
|
stepBackMs: 0,
|
|
285
|
-
//
|
|
286
|
-
//
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
285
|
+
// Where the invoice lane first read: the start of the UTC day of its
|
|
286
|
+
// first run, less SEAZONA_INVOICE_LOOKBACK_DAYS (lanes.js). An invoice
|
|
287
|
+
// last changed before that was never the lane's to read, so it is not a
|
|
288
|
+
// miss (FLOW-HORIZON-K: a day's margin before it counted such invoices).
|
|
289
|
+
// The first row Flow stored marks the first run.
|
|
290
|
+
async firstSince(db, sync) {
|
|
291
|
+
const [{ first }] = await db.select({ first: sql`min(${seazonaInvoices.firstSeenAt})` }).from(seazonaInvoices);
|
|
292
|
+
const firstMs = first ? new Date(first).getTime() : NaN;
|
|
293
|
+
return Number.isFinite(firstMs) ? new Date(floorUtcDay(firstMs) - sync.invoiceLookbackDays * DAY_MS).toISOString() : null;
|
|
291
294
|
},
|
|
292
295
|
},
|
|
293
296
|
};
|
|
@@ -416,7 +419,7 @@ async function sweepWholeList(ctx, resource, cursor, laneStates, helpers, found)
|
|
|
416
419
|
const readsFrom = laneReadsFrom(laneStates.get(resource), sync);
|
|
417
420
|
if (readsFrom === null) return;
|
|
418
421
|
if (!cursor[resource]?.cycleStartedAt) {
|
|
419
|
-
const since = await spec.firstSince(db);
|
|
422
|
+
const since = await spec.firstSince(db, sync);
|
|
420
423
|
if (since === null) {
|
|
421
424
|
cursor[resource] = { lastFinishedAt: at.toISOString(), listed: 0 };
|
|
422
425
|
return;
|
|
@@ -47,8 +47,8 @@ export const SENTRY_SETUP = {
|
|
|
47
47
|
{ name: 'SENTRY_DSN', label: 'DSN', kind: 'url', help: 'From the Sentry project\'s Client Keys. Browsers see it, so it is not a secret.' },
|
|
48
48
|
{ name: 'SENTRY_ENVIRONMENT', label: 'Environment', help: 'How Sentry labels this installation, for example production. Empty: production, or workspace in a Replit workspace.' },
|
|
49
49
|
{ name: 'SENTRY_CLIENT', label: 'Client', help: 'The name your support desk files this installation\'s reports under. Sent as the client tag. Empty: no client tag.' },
|
|
50
|
-
{ name: 'SENTRY_FEEDBACK', label: 'Report
|
|
51
|
-
{ name: 'SENTRY_REPLAY', label: 'Replay
|
|
50
|
+
{ name: 'SENTRY_FEEDBACK', label: 'Report button', kind: 'onoff', help: 'On: everyone signed in can send a report, with a screenshot, to this Sentry project.' },
|
|
51
|
+
{ name: 'SENTRY_REPLAY', label: 'Replay', kind: 'onoff', help: 'On: each report and each browser error keeps up to the last minute of the page, as a replay with every word hidden.' },
|
|
52
52
|
{ name: 'SENTRY_TRACES_SAMPLE_RATE', label: 'Traces', help: 'The share of clicks, page loads and requests timed, from 0 (off) to 1 (all). Empty: 0.2.' },
|
|
53
53
|
],
|
|
54
54
|
};
|
|
@@ -233,6 +233,9 @@ export function createXeroClient({
|
|
|
233
233
|
async listContacts({ maxPages } = {}) {
|
|
234
234
|
return collectPages(async (page) => (await request('GET', 'Contacts', { query: { page } })).Contacts, { maxPages });
|
|
235
235
|
},
|
|
236
|
+
async getContact(contactId) {
|
|
237
|
+
return first((await request('GET', `Contacts/${encodeURIComponent(contactId)}`)).Contacts);
|
|
238
|
+
},
|
|
236
239
|
async findContactsByName(name) {
|
|
237
240
|
return (await request('GET', 'Contacts', { query: { where: `Name==${quote(name)}` } })).Contacts ?? [];
|
|
238
241
|
},
|
|
@@ -329,6 +332,26 @@ export function createXeroClient({
|
|
|
329
332
|
}
|
|
330
333
|
return created;
|
|
331
334
|
},
|
|
335
|
+
// Changes the fields in `changes` on one contact (contact-sync.js). The
|
|
336
|
+
// body holds only those fields and the id; Xero keeps every other field.
|
|
337
|
+
async updateContact(contactId, changes, { idempotencyKey }) {
|
|
338
|
+
if (!contactId) throw new TypeError('updateContact: contactId is required');
|
|
339
|
+
if (!idempotencyKey) throw new TypeError('updateContact: idempotencyKey is required');
|
|
340
|
+
const payload = await request('POST', `Contacts/${encodeURIComponent(contactId)}`, {
|
|
341
|
+
query: { summarizeErrors: 'false' },
|
|
342
|
+
body: { Contacts: [{ ...changes, ContactID: contactId }] },
|
|
343
|
+
headers: { 'Idempotency-Key': idempotencyKey },
|
|
344
|
+
});
|
|
345
|
+
const updated = first(payload.Contacts);
|
|
346
|
+
const messages = validationMessages(updated);
|
|
347
|
+
if (!updated?.ContactID || updated.HasErrors || updated.StatusAttributeString === 'ERROR' || messages.length) {
|
|
348
|
+
throw xeroError('xero_validation_failed', messages.join(' ') || 'Xero refused the contact change.', {
|
|
349
|
+
status: 422,
|
|
350
|
+
details: messages.length ? { validationErrors: messages } : undefined,
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
return updated;
|
|
354
|
+
},
|
|
332
355
|
async createInvoice(invoice, { idempotencyKey }) {
|
|
333
356
|
if (!idempotencyKey) throw new TypeError('createInvoice: idempotencyKey is required');
|
|
334
357
|
const payload = await request('POST', 'Invoices', {
|