@arcforgelabs/flow 2026.10.25 → 2026.10.27
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 +6 -0
- package/npm-shrinkwrap.json +627 -2
- package/package.json +3 -1
- package/server/api/invoicing.js +586 -0
- package/server/app.js +38 -1
- package/server/auth/people.js +26 -0
- package/server/auth/routes.js +13 -0
- package/server/core/automations.js +140 -0
- package/server/core/invoice-rules.js +224 -0
- package/server/core/invoices.js +11 -1
- package/server/core/invoicing-rules.js +255 -0
- package/server/core/ledger.js +50 -14
- package/server/core/links.js +3 -3
- package/server/core/monitoring.js +34 -0
- package/server/core/pipeline.js +495 -0
- package/server/core/sending.js +282 -0
- package/server/db/migrations/0008_people_appearance.sql +1 -0
- package/server/db/migrations/0009_pipeline_matcher.sql +46 -0
- package/server/db/migrations/0010_invoicing_rules.sql +51 -0
- package/server/db/migrations/0011_invoice_rules.sql +21 -0
- package/server/db/migrations/0012_invoice_sending.sql +9 -0
- package/server/db/migrations/meta/0008_snapshot.json +2479 -0
- package/server/db/migrations/meta/0009_snapshot.json +2809 -0
- package/server/db/migrations/meta/0010_snapshot.json +3158 -0
- package/server/db/migrations/meta/0011_snapshot.json +3314 -0
- package/server/db/migrations/meta/0012_snapshot.json +3380 -0
- package/server/db/migrations/meta/_journal.json +35 -0
- package/server/db/schema.js +186 -2
- package/server/index.js +20 -3
- package/server/integrations/adapters.js +2 -1
- package/server/integrations/port.js +8 -0
- package/server/integrations/seazona/feed.js +11 -0
- package/server/integrations/seazona/index.js +7 -1
- package/server/integrations/seazona/ledger.js +4 -0
- package/server/integrations/sentry/index.js +105 -0
- package/server/integrations/xero/client.js +5 -0
- package/server/integrations/xero/drafts.js +123 -16
- package/server/integrations/xero/import.js +22 -0
- package/server/integrations/xero/index.js +21 -3
- package/server/integrations/xero/lookup.js +94 -0
- package/server/integrations/xero/migrations/0005_xero_send_modes.sql +10 -0
- package/server/integrations/xero/migrations/meta/_journal.json +7 -0
- package/server/theme/color-contrast.js +39 -2
- package/server/theme/contrast-audit.js +63 -0
- package/server/theme/themes.js +10 -2
- package/server/ui/app-page.js +6 -10
- package/server/ui/app.js +364 -105
- package/server/ui/appearance.js +3 -2
- package/server/ui/automations.js +96 -0
- package/server/ui/clerk-appearance.js +18 -2
- package/server/ui/flow.css +334 -22
- package/server/ui/invoice-rules.js +237 -0
- package/server/ui/invoicing-settings.js +108 -0
- package/server/ui/invoicing.js +597 -0
- package/server/ui/jobs.js +172 -0
- package/server/ui/logos/seazona.svg +1 -0
- package/server/ui/logos/sentry.svg +1 -0
- package/server/ui/logos/xero.svg +1 -0
- package/server/ui/monitoring.js +53 -0
- package/server/ui/routes.js +5 -1
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// Jobs (/jobs): every billable job from the lab system, as a board with one
|
|
2
|
+
// column per billing stage, or as a list. A job's stage follows from its
|
|
3
|
+
// evidence (server/core/ledger.js ledgerView), so cards are not dragged: a
|
|
4
|
+
// job moves when its evidence changes, or when someone drafts its invoice
|
|
5
|
+
// here. Data comes from /api/invoicing/jobs (server/api/invoicing.js).
|
|
6
|
+
|
|
7
|
+
import { formatCents } from '/assets/ui/invoicing.js';
|
|
8
|
+
|
|
9
|
+
// Board columns, left to right. Held jobs sit with the jobs that need review.
|
|
10
|
+
const STAGES = [
|
|
11
|
+
{ id: 'work', label: 'In work', views: ['in_work'], tone: 'mute' },
|
|
12
|
+
{ id: 'review', label: 'Needs review', views: ['review_required', 'held'], tone: 'warn' },
|
|
13
|
+
{ id: 'pending', label: 'Checking', views: ['pending'], tone: 'mute' },
|
|
14
|
+
{ id: 'ready', label: 'Ready to bill', views: ['ready'], tone: 'info' },
|
|
15
|
+
{ id: 'drafted', label: 'Drafted', views: ['drafted'], tone: 'info' },
|
|
16
|
+
{ id: 'invoiced', label: 'Invoiced', views: ['invoiced'], tone: 'success' },
|
|
17
|
+
{ id: 'paid', label: 'Paid', views: ['paid'], tone: 'success' },
|
|
18
|
+
];
|
|
19
|
+
const STAGE_OF = Object.fromEntries(STAGES.flatMap((stage) => stage.views.map((view) => [view, stage])));
|
|
20
|
+
const LAYOUT_KEY = 'flow_jobs_layout';
|
|
21
|
+
|
|
22
|
+
const showDate = (iso) => (iso ? new Date(`${iso}T00:00:00`).toLocaleDateString('en-AU', { day: 'numeric', month: 'short' }) : '—');
|
|
23
|
+
|
|
24
|
+
function readLayout() {
|
|
25
|
+
try { return localStorage.getItem(LAYOUT_KEY) === 'list' ? 'list' : 'board'; } catch { return 'board'; }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function saveLayout(layout) {
|
|
29
|
+
try { localStorage.setItem(LAYOUT_KEY, layout); } catch { /* the choice is only a convenience */ }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function renderJobs(main, ui) {
|
|
33
|
+
const { h, flow, pageHeader, notice } = ui;
|
|
34
|
+
const state = { layout: readLayout(), q: '', jobs: [], canDraft: false };
|
|
35
|
+
const toggle = h('div', { class: 'fx-seg', role: 'radiogroup', 'aria-label': 'Layout' });
|
|
36
|
+
const search = h('input', { class: 'fx-input fx-search', type: 'search', placeholder: 'Find a case, client or practitioner', 'aria-label': 'Find a job' });
|
|
37
|
+
const host = h('div', { class: 'fx-jobs' }, h('div', { class: 'fx-skeleton' }));
|
|
38
|
+
main.classList.add('fx-main--wide');
|
|
39
|
+
main.append(
|
|
40
|
+
pageHeader('Jobs', { subtitle: 'Every billable job from the lab system, by billing stage.' }),
|
|
41
|
+
h('div', { class: 'fx-toolbar fx-toolbar--plain' }, search, toggle),
|
|
42
|
+
host);
|
|
43
|
+
|
|
44
|
+
const drawToggle = () => toggle.replaceChildren(...[['board', 'Board'], ['list', 'List']].map(([id, label]) => h('button', {
|
|
45
|
+
type: 'button', role: 'radio', class: 'fx-seg__item', 'aria-checked': String(state.layout === id),
|
|
46
|
+
onclick: () => { state.layout = id; saveLayout(id); drawToggle(); draw(); },
|
|
47
|
+
}, label)));
|
|
48
|
+
drawToggle();
|
|
49
|
+
search.addEventListener('input', () => { state.q = search.value.trim().toLowerCase(); draw(); });
|
|
50
|
+
|
|
51
|
+
const visible = () => (state.q
|
|
52
|
+
? state.jobs.filter((job) => [job.caseRef, job.sourceJobRef, job.contactName, job.practitioner, job.invoiceNumber]
|
|
53
|
+
.some((value) => value && String(value).toLowerCase().includes(state.q)))
|
|
54
|
+
: state.jobs);
|
|
55
|
+
|
|
56
|
+
function draw() {
|
|
57
|
+
if (!state.jobs.length) {
|
|
58
|
+
host.replaceChildren(h('div', { class: 'fx-panel fx-empty' },
|
|
59
|
+
h('p', {}, 'No jobs yet. Jobs appear here once the lab system is connected under Integrations.'),
|
|
60
|
+
h('a', { class: 'fx-btn', href: '/invoices/new', onclick: ui.go }, 'Make an invoice by hand')));
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
host.replaceChildren(state.layout === 'list' ? list(visible()) : board(visible()));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function load() {
|
|
67
|
+
try {
|
|
68
|
+
const [{ jobs }, context] = await Promise.all([flow('/api/invoicing/jobs'), flow('/api/invoicing/context')]);
|
|
69
|
+
state.jobs = jobs;
|
|
70
|
+
state.canDraft = context.canIssue;
|
|
71
|
+
} catch (error) {
|
|
72
|
+
host.replaceChildren(notice(error.message, 'danger'));
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
draw();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function action(job) {
|
|
79
|
+
if (job.invoiceId) {
|
|
80
|
+
const href = `/invoices/${job.invoiceId}`;
|
|
81
|
+
return h('a', { class: 'fx-btn fx-btn--ghost fx-btn--sm', href, onclick: ui.go }, job.invoiceNumber ?? 'Open draft');
|
|
82
|
+
}
|
|
83
|
+
// Flow's own stage: a person marks a job ready to invoice, or an
|
|
84
|
+
// automation does (Settings, Automations).
|
|
85
|
+
if (job.view === 'in_work') {
|
|
86
|
+
return h('button', {
|
|
87
|
+
class: 'fx-btn fx-btn--sm', type: 'button',
|
|
88
|
+
onclick: async (event) => {
|
|
89
|
+
event.stopPropagation();
|
|
90
|
+
event.currentTarget.disabled = true;
|
|
91
|
+
try {
|
|
92
|
+
await flow(`/api/invoicing/jobs/${job.id}/ready`, { method: 'POST', body: JSON.stringify({ ready: true }) });
|
|
93
|
+
ui.toast('Marked ready to invoice.', 'success');
|
|
94
|
+
} catch (error) {
|
|
95
|
+
ui.toast(error.message, 'danger');
|
|
96
|
+
}
|
|
97
|
+
await load();
|
|
98
|
+
},
|
|
99
|
+
}, 'Mark ready');
|
|
100
|
+
}
|
|
101
|
+
if (job.view !== 'ready' || !state.canDraft) return null;
|
|
102
|
+
return h('button', {
|
|
103
|
+
class: 'fx-btn fx-btn--primary fx-btn--sm', type: 'button',
|
|
104
|
+
onclick: async (event) => {
|
|
105
|
+
event.stopPropagation();
|
|
106
|
+
const button = event.currentTarget;
|
|
107
|
+
button.disabled = true;
|
|
108
|
+
try {
|
|
109
|
+
const { invoice } = await flow(`/api/invoicing/jobs/${job.id}/draft`, { method: 'POST', body: '{}' });
|
|
110
|
+
ui.toast('Draft invoice made.', 'success');
|
|
111
|
+
ui.go(`/invoices/${invoice.id}`);
|
|
112
|
+
} catch (error) {
|
|
113
|
+
const reasons = error.details?.reasons;
|
|
114
|
+
ui.toast(reasons?.length ? `Not drafted: ${reasons.join('; ')}.` : error.message, 'danger');
|
|
115
|
+
button.disabled = false;
|
|
116
|
+
await load();
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
}, 'Draft invoice');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function card(job) {
|
|
123
|
+
const stage = STAGE_OF[job.view];
|
|
124
|
+
const reasons = ['review', 'pending'].includes(stage?.id) ? job.reasons?.join('; ') : '';
|
|
125
|
+
const why = reasons ? reasons[0].toUpperCase() + reasons.slice(1) + '.' : null;
|
|
126
|
+
return h('article', { class: `fx-job${stage?.id === 'review' ? ' fx-job--flag' : ''}` },
|
|
127
|
+
h('div', { class: 'fx-job__top' },
|
|
128
|
+
h('span', { class: 'fx-strong' }, job.caseRef ?? job.sourceJobRef),
|
|
129
|
+
h('span', { class: 'fx-job__amount' }, formatCents(job.totalCents))),
|
|
130
|
+
h('div', { class: 'fx-job__client' }, job.contactName ?? 'No client'),
|
|
131
|
+
h('div', { class: 'fx-job__meta' },
|
|
132
|
+
job.practitioner ? h('span', {}, job.practitioner) : null,
|
|
133
|
+
h('span', {}, `Shipped ${showDate(job.shippedOn)}`)),
|
|
134
|
+
why ? h('p', { class: 'fx-job__why' }, why) : null,
|
|
135
|
+
action(job));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function board(jobs) {
|
|
139
|
+
return h('div', { class: 'fx-board' }, STAGES.map((stage) => {
|
|
140
|
+
const inStage = jobs.filter((job) => stage.views.includes(job.view));
|
|
141
|
+
const total = inStage.reduce((sum, job) => sum + (job.totalCents ?? 0), 0);
|
|
142
|
+
return h('section', { class: 'fx-board__col', 'aria-label': stage.label },
|
|
143
|
+
h('header', { class: 'fx-board__head' },
|
|
144
|
+
h('span', { class: 'fx-board__dot', 'data-tone': stage.tone }),
|
|
145
|
+
h('span', { class: 'fx-strong' }, stage.label),
|
|
146
|
+
h('span', { class: 'fx-board__count' }, String(inStage.length)),
|
|
147
|
+
h('span', { class: 'fx-board__sum' }, inStage.length ? formatCents(total) : '')),
|
|
148
|
+
h('div', { class: 'fx-board__cards' }, inStage.length ? inStage.map(card) : h('p', { class: 'fx-board__none' }, 'None')));
|
|
149
|
+
}));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function list(jobs) {
|
|
153
|
+
if (!jobs.length) return h('div', { class: 'fx-panel' }, h('p', { class: 'fx-empty' }, 'No job matches that search.'));
|
|
154
|
+
return h('div', { class: 'fx-panel' }, h('table', { class: 'fx-table' },
|
|
155
|
+
h('thead', {}, h('tr', {},
|
|
156
|
+
h('th', {}, 'Case'), h('th', {}, 'Client'), h('th', {}, 'Practitioner'), h('th', {}, 'Shipped'),
|
|
157
|
+
h('th', {}, 'Stage'), h('th', { class: 'fx-num' }, 'Total'), h('th', { class: 'fx-table__actions' }, h('span', { class: 'fx-sr' }, 'Action')))),
|
|
158
|
+
h('tbody', {}, jobs.map((job) => {
|
|
159
|
+
const stage = STAGE_OF[job.view];
|
|
160
|
+
return h('tr', {},
|
|
161
|
+
h('td', { class: 'fx-strong' }, job.caseRef ?? job.sourceJobRef),
|
|
162
|
+
h('td', {}, job.contactName ?? '—'),
|
|
163
|
+
h('td', { class: 'fx-dim' }, job.practitioner ?? '—'),
|
|
164
|
+
h('td', { class: 'fx-dim' }, showDate(job.shippedOn)),
|
|
165
|
+
h('td', {}, h('span', { class: 'fx-status', 'data-tone': stage?.tone ?? 'mute', title: job.reasons?.join('; ') ?? '' }, stage?.label ?? job.view)),
|
|
166
|
+
h('td', { class: 'fx-num fx-strong' }, formatCents(job.totalCents)),
|
|
167
|
+
h('td', { class: 'fx-table__actions' }, action(job)));
|
|
168
|
+
}))));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
await load();
|
|
172
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img"><title>Seazona</title><path fill="#F04037" d="M6 3H26L22 13H21L18 24H22L23 23V27L22 29H8L9 25L11 19V13H7L6 9Z"/></svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="#362D59"><title>Sentry</title><path d="M13.91 2.505c-.873-1.448-2.972-1.448-3.844 0L6.904 7.92a15.478 15.478 0 0 1 8.53 12.811h-2.221A13.301 13.301 0 0 0 5.784 9.814l-2.926 5.06a7.65 7.65 0 0 1 4.435 5.848H2.194a.365.365 0 0 1-.298-.534l1.413-2.402a5.16 5.16 0 0 0-1.614-.913L.296 19.275a2.182 2.182 0 0 0 .812 2.999 2.24 2.24 0 0 0 1.086.288h6.983a9.322 9.322 0 0 0-3.845-8.318l1.11-1.922a11.47 11.47 0 0 1 4.95 10.24h5.915a17.242 17.242 0 0 0-7.885-15.28l2.244-3.845a.37.37 0 0 1 .504-.13c.255.14 9.75 16.708 9.928 16.9a.365.365 0 0 1-.327.543h-2.287c.029.612.029 1.223 0 1.831h2.297a2.206 2.206 0 0 0 1.922-3.31z"/></svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="#13B5EA"><title>Xero</title><path d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm6.585 14.655c-1.485 0-2.69-1.206-2.69-2.689 0-1.485 1.207-2.691 2.69-2.691 1.485 0 2.69 1.207 2.69 2.691s-1.207 2.689-2.69 2.689zM7.53 14.644c-.099 0-.192-.041-.267-.116l-2.043-2.04-2.052 2.047c-.069.068-.16.108-.258.108-.202 0-.368-.166-.368-.368 0-.099.04-.191.111-.263l2.04-2.05-2.038-2.047c-.075-.069-.113-.162-.113-.261 0-.203.166-.366.368-.366.098 0 .188.037.258.105l2.055 2.048 2.048-2.045c.069-.071.162-.108.26-.108.211 0 .375.165.375.366 0 .098-.029.188-.104.258l-2.056 2.055 2.055 2.051c.068.069.104.16.104.258 0 .202-.165.368-.365.368h-.01zm8.017-4.591c-.796.101-.882.476-.882 1.404v2.787c0 .202-.165.366-.366.366-.203 0-.367-.165-.368-.366v-4.53c0-.204.16-.366.362-.366.166 0 .316.125.346.289.27-.209.6-.317.93-.317h.105c.195 0 .359.165.359.368 0 .201-.164.352-.375.359 0 0-.09 0-.164.008l.053-.002zm-3.091 2.205H8.625c0 .019.003.037.006.057.02.105.045.211.083.31.194.531.765 1.275 1.829 1.29.33-.003.631-.086.9-.229.21-.12.391-.271.525-.428.045-.058.09-.112.12-.168.18-.229.405-.186.54-.083.164.135.18.391.045.57l-.016.016c-.21.27-.435.495-.689.66-.255.164-.525.284-.811.345-.33.09-.645.104-.975.06-1.095-.135-2.01-.93-2.28-2.01-.06-.21-.09-.42-.09-.645 0-.855.421-1.695 1.125-2.205.885-.615 2.085-.66 3-.075.63.405 1.035 1.021 1.185 1.771.075.419-.21.794-.734.81l.068-.046zm6.129-2.223c-1.064 0-1.931.865-1.931 1.931 0 1.064.866 1.931 1.931 1.931s1.931-.867 1.931-1.931c0-1.065-.866-1.933-1.931-1.933v.002zm0 2.595c-.367 0-.666-.297-.666-.666 0-.367.3-.665.666-.665.367 0 .667.299.667.665 0 .369-.3.667-.667.666zm-8.04-2.603c-.91 0-1.672.623-1.886 1.466v.03h3.776c-.203-.855-.973-1.494-1.891-1.494v-.002z"/></svg>
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Errors and problem reports from the browser, to the installation's own
|
|
2
|
+
// Sentry project (docs/SENTRY.md). Only while the Sentry integration is on:
|
|
3
|
+
// GET /api/monitoring says so and gives the project's public DSN. The SDK
|
|
4
|
+
// comes from Sentry's CDN, pinned and checked against its integrity hash.
|
|
5
|
+
// With the integration off, nothing loads and the report button stays hidden.
|
|
6
|
+
|
|
7
|
+
function loadScript({ src, integrity }) {
|
|
8
|
+
return new Promise((resolve, reject) => {
|
|
9
|
+
const script = document.createElement('script');
|
|
10
|
+
script.src = src;
|
|
11
|
+
script.integrity = integrity;
|
|
12
|
+
script.crossOrigin = 'anonymous';
|
|
13
|
+
script.onload = resolve;
|
|
14
|
+
script.onerror = () => reject(new Error('The Sentry SDK could not load.'));
|
|
15
|
+
document.head.append(script);
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function startMonitoring({ flow, person, button }) {
|
|
20
|
+
const config = await flow('/api/monitoring');
|
|
21
|
+
if (!config.on || config.service !== 'sentry') return;
|
|
22
|
+
await loadScript(config.script);
|
|
23
|
+
const Sentry = window.Sentry;
|
|
24
|
+
const integrations = config.feedback ? [Sentry.feedbackIntegration({
|
|
25
|
+
autoInject: false,
|
|
26
|
+
// The form's colours and font come from flow.css (#sentry-feedback), so
|
|
27
|
+
// it follows each person's theme and mode, even when they change it.
|
|
28
|
+
colorScheme: 'light',
|
|
29
|
+
showBranding: false,
|
|
30
|
+
// Flow already knows who is signed in (setUser below).
|
|
31
|
+
showName: false,
|
|
32
|
+
enableScreenshot: true,
|
|
33
|
+
triggerLabel: 'Report a problem',
|
|
34
|
+
formTitle: 'Report a problem',
|
|
35
|
+
messageLabel: 'What went wrong?',
|
|
36
|
+
messagePlaceholder: 'What you did, what you expected, and what happened.',
|
|
37
|
+
submitButtonLabel: 'Send report',
|
|
38
|
+
successMessageText: 'Thank you. Your report is sent.',
|
|
39
|
+
})] : [];
|
|
40
|
+
Sentry.init({
|
|
41
|
+
dsn: config.dsn,
|
|
42
|
+
environment: config.environment,
|
|
43
|
+
release: config.release ?? undefined,
|
|
44
|
+
sendDefaultPii: false,
|
|
45
|
+
integrations,
|
|
46
|
+
});
|
|
47
|
+
// Who reported or met the error, so the lab can follow up.
|
|
48
|
+
Sentry.setUser({ id: person.id, email: person.email });
|
|
49
|
+
if (config.feedback) {
|
|
50
|
+
Sentry.getFeedback()?.attachTo(button);
|
|
51
|
+
button.hidden = false;
|
|
52
|
+
}
|
|
53
|
+
}
|
package/server/ui/routes.js
CHANGED
|
@@ -17,8 +17,10 @@ import { themeStylesheet } from '../theme/themes.js';
|
|
|
17
17
|
const require = createRequire(import.meta.url);
|
|
18
18
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
19
19
|
const themeDir = join(here, '..', 'theme');
|
|
20
|
+
const logoDir = join(here, 'logos');
|
|
21
|
+
const LOGO_FILES = new Set(['xero.svg', 'seazona.svg', 'sentry.svg']);
|
|
20
22
|
const THEME_FILES = new Set(['themes.js', 'color-contrast.js', 'openclaw-palettes.js', 'font-families.js']);
|
|
21
|
-
const UI_FILES = new Set(['flow.css', 'appearance.js', 'clerk-appearance.js', 'app.js', 'flow-mark.svg']);
|
|
23
|
+
const UI_FILES = new Set(['flow.css', 'appearance.js', 'clerk-appearance.js', 'app.js', 'invoicing.js', 'invoicing-settings.js', 'automations.js', 'invoice-rules.js', 'jobs.js', 'monitoring.js', 'flow-mark.svg']);
|
|
22
24
|
const MAX_AGE = '1h';
|
|
23
25
|
|
|
24
26
|
// Send one named file from dir. Passing dir as root limits the dotfile check
|
|
@@ -44,6 +46,8 @@ export function createUiAssetsRouter({ rateLimitPerMinute = 600 } = {}) {
|
|
|
44
46
|
};
|
|
45
47
|
router.get('/assets/theme/:file', sendFrom(themeDir, THEME_FILES));
|
|
46
48
|
router.get('/assets/ui/:file', sendFrom(here, UI_FILES));
|
|
49
|
+
// Each integration's logo, the maker's own mark (Integrations).
|
|
50
|
+
router.get('/assets/ui/logos/:file', sendFrom(logoDir, LOGO_FILES));
|
|
47
51
|
|
|
48
52
|
for (const name of FONT_PACKAGES) {
|
|
49
53
|
const dir = dirname(require.resolve(`@fontsource-variable/${name}/LICENSE`));
|