domma-cms 0.55.1 → 0.66.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/CLAUDE.md +130 -2
- package/README.md +4 -4
- package/admin/css/admin.css +1 -1
- package/admin/dist/domma/domma-tools.css +3 -3
- package/admin/dist/domma/domma-tools.min.js +3 -3
- package/admin/js/api.js +1 -1
- package/admin/js/app.js +3 -3
- package/admin/js/lib/page-picker.js +1 -0
- package/admin/js/lib/plugin-accent.js +1 -0
- package/admin/js/lib/plugin-chrome.js +1 -0
- package/admin/js/lib/shortcode-context-menu.js +2 -2
- package/admin/js/lib/sidebar-grouping.js +1 -1
- package/admin/js/lib/sidebar-grouping.test.js +1 -1
- package/admin/js/lib/sidebar-renderer.js +4 -4
- package/admin/js/lib/slideover-resizable.js +1 -0
- package/admin/js/templates/context-menu-editor.html +212 -0
- package/admin/js/templates/context-menus.html +16 -0
- package/admin/js/templates/plugin-code.html +1 -1
- package/{plugins/site-search/admin/templates/site-search.html → admin/js/templates/search.html} +50 -4
- package/admin/js/templates/settings.html +16 -97
- package/admin/js/templates/theme.html +173 -0
- package/admin/js/views/context-menu-editor.js +55 -0
- package/admin/js/views/context-menus.js +5 -0
- package/admin/js/views/form-editor.js +7 -7
- package/admin/js/views/index.js +1 -1
- package/admin/js/views/plugin-marketplace.js +1 -1
- package/admin/js/views/plugins.js +25 -23
- package/admin/js/views/search.js +1 -0
- package/admin/js/views/settings.js +3 -3
- package/admin/js/views/theme.js +1 -0
- package/bin/cli.js +2 -0
- package/bin/update.js +13 -2
- package/config/menus/admin-sidebar.json +129 -23
- package/config/plugins.json +5 -5
- package/config/search.json +13 -0
- package/config/theme.json +18 -0
- package/package.json +12 -4
- package/plugins/_lib/admin/mail/compose-window.js +914 -0
- package/plugins/_lib/admin/mail/contacts.js +301 -0
- package/plugins/_lib/admin/mail/diagnostics-section.js +133 -0
- package/plugins/_lib/admin/mail/folder-tree.js +254 -0
- package/plugins/_lib/admin/mail/identity.js +480 -0
- package/plugins/_lib/admin/mail/image-senders-section.js +131 -0
- package/plugins/_lib/admin/mail/keyboard.js +136 -0
- package/plugins/_lib/admin/mail/mail.css +1 -0
- package/plugins/_lib/admin/mail/mail.html +71 -0
- package/plugins/_lib/admin/mail/panes.js +253 -0
- package/plugins/_lib/admin/mail/reader-view.js +4453 -0
- package/plugins/_lib/admin/mail/resizable.js +26 -0
- package/plugins/_lib/admin/mail/rules.js +343 -0
- package/plugins/_lib/admin/mail/scheduling.js +203 -0
- package/plugins/_lib/admin/mail/section-kit.js +277 -0
- package/plugins/_lib/admin/mail/templates.js +238 -0
- package/plugins/_lib/admin/mail/threads.js +200 -0
- package/plugins/_lib/admin/mail/vacation.js +269 -0
- package/plugins/_lib/admin/ui/help.css +1 -0
- package/plugins/_lib/admin/ui/help.js +174 -0
- package/plugins/_lib/admin/ui/resizable.js +151 -0
- package/plugins/_lib/dataStore.js +117 -0
- package/plugins/_lib/mail/accounts.js +919 -0
- package/plugins/_lib/mail/bodyTokens.js +101 -0
- package/plugins/_lib/mail/compose.js +256 -0
- package/plugins/_lib/mail/defaults.js +57 -0
- package/plugins/_lib/mail/diagnostics.js +154 -0
- package/plugins/_lib/mail/envelope.js +161 -0
- package/plugins/_lib/mail/folders.js +192 -0
- package/plugins/_lib/mail/handoff.js +274 -0
- package/plugins/_lib/mail/imapPool.js +293 -0
- package/plugins/_lib/mail/mbox.js +74 -0
- package/plugins/_lib/mail/pollSchedule.js +79 -0
- package/plugins/_lib/mail/poller.js +135 -0
- package/plugins/_lib/mail/priority.js +138 -0
- package/plugins/_lib/mail/readRoutes.js +680 -0
- package/plugins/_lib/mail/render.js +291 -0
- package/plugins/_lib/mail/ruleRunner.js +152 -0
- package/plugins/_lib/mail/scheduler.js +254 -0
- package/plugins/_lib/mail/secretbox.js +229 -0
- package/plugins/_lib/mail/send.js +396 -0
- package/plugins/_lib/mail/store.js +1002 -0
- package/plugins/_lib/mail/sync.js +292 -0
- package/plugins/_lib/mail/syncPlan.js +126 -0
- package/plugins/_lib/mail/syncSelection.js +82 -0
- package/plugins/_lib/mail/unsubscribe.js +183 -0
- package/plugins/_lib/mail/vacationRunner.js +114 -0
- package/plugins/_lib/mail/write.js +473 -0
- package/plugins/_lib/schemaSync.js +83 -0
- package/plugins/_template/admin/css/index.css +0 -0
- package/plugins/_template/admin/templates/index.html +4 -4
- package/plugins/_template/admin/views/index.js +7 -0
- package/plugins/analytics/admin/css/index.css +1 -0
- package/plugins/analytics/admin/templates/analytics.html +22 -13
- package/plugins/analytics/plugin.json +3 -0
- package/plugins/blog/admin/css/index.css +1 -0
- package/plugins/blog/admin/templates/blog.html +30 -18
- package/plugins/blog/admin/templates/categories.html +2 -2
- package/plugins/blog/admin/templates/comments.html +2 -2
- package/plugins/blog/admin/templates/post-editor.html +34 -34
- package/plugins/blog/admin/templates/settings.html +6 -3
- package/plugins/blog/admin/views/blog.js +8 -5
- package/plugins/blog/admin/views/categories.js +5 -10
- package/plugins/blog/admin/views/comments.js +5 -5
- package/plugins/blog/admin/views/post-editor.js +39 -20
- package/plugins/blog/admin/views/settings.js +52 -50
- package/plugins/blog/collections/categories/schema.json +7 -6
- package/plugins/blog/collections/comments/schema.json +11 -10
- package/plugins/blog/collections/posts/schema.json +14 -13
- package/plugins/blog/plugin.js +36 -13
- package/plugins/blog/plugin.json +13 -5
- package/plugins/blog/plugin.public.js +312 -0
- package/plugins/contacts/admin/css/index.css +1 -0
- package/plugins/contacts/admin/templates/contacts.html +128 -0
- package/plugins/contacts/admin/views/contacts.js +237 -4
- package/plugins/contacts/collections/user-contacts/schema.json +108 -0
- package/plugins/contacts/plugin.js +214 -27
- package/plugins/contacts/plugin.json +4 -1
- package/plugins/invoice/admin/css/index.css +1 -0
- package/plugins/invoice/admin/templates/editor.html +140 -49
- package/plugins/invoice/admin/templates/index.html +153 -23
- package/plugins/invoice/admin/templates/issuers.html +2 -5
- package/plugins/invoice/admin/templates/receivers.html +2 -5
- package/plugins/invoice/admin/views/contacts-source.js +266 -0
- package/plugins/invoice/admin/views/editor.js +366 -199
- package/plugins/invoice/admin/views/export.js +199 -0
- package/plugins/invoice/admin/views/help-content.js +61 -0
- package/plugins/invoice/admin/views/index.js +582 -94
- package/plugins/invoice/admin/views/issuers.js +24 -17
- package/plugins/invoice/admin/views/media.js +172 -0
- package/plugins/invoice/admin/views/party-view.js +305 -67
- package/plugins/invoice/admin/views/payments.js +127 -0
- package/plugins/invoice/admin/views/print.js +130 -0
- package/plugins/invoice/admin/views/receivers.js +49 -16
- package/plugins/invoice/admin/views/send.js +212 -0
- package/plugins/invoice/admin/views/settings.js +594 -0
- package/plugins/invoice/admin/views/view-lifecycle.js +33 -0
- package/plugins/invoice/collections/invoice-issuers/schema.json +77 -11
- package/plugins/invoice/collections/invoice-receivers/schema.json +10 -9
- package/plugins/invoice/collections/invoices/schema.json +19 -13
- package/plugins/invoice/config.js +27 -6
- package/plugins/invoice/pdf.js +164 -0
- package/plugins/invoice/plugin.js +1217 -44
- package/plugins/invoice/plugin.json +10 -9
- package/plugins/invoice/templates/_base.css +1 -0
- package/plugins/invoice/templates/classic-nologo.html +100 -0
- package/plugins/invoice/templates/classic.html +91 -0
- package/plugins/invoice/templates/invoice-print.html +24 -0
- package/plugins/invoice/templates/minimal.html +99 -0
- package/plugins/invoice/templates/modern-nologo.html +114 -0
- package/plugins/invoice/templates/modern.html +113 -0
- package/plugins/invoice/templates/templates.json +11 -0
- package/plugins/mail-reader/admin/views/mail.js +19 -0
- package/plugins/mail-reader/config.js +7 -0
- package/plugins/mail-reader/plugin.js +48 -0
- package/plugins/mail-reader/plugin.json +33 -0
- package/plugins/notes/admin/views/notes.js +1 -1
- package/plugins/notes/plugin.json +2 -2
- package/plugins/surveys/lib/audience.js +37 -0
- package/plugins/surveys/lib/campaigns.js +43 -0
- package/plugins/surveys/lib/ledger.js +110 -0
- package/plugins/surveys/lib/sending.js +106 -0
- package/plugins/surveys/lib/stats.js +62 -0
- package/plugins/surveys/lib/submit.js +95 -0
- package/plugins/surveys/lib/tokens.js +28 -0
- package/plugins/surveys/plugin.public.js +149 -0
- package/plugins/theme-switcher/admin/templates/theme-switcher.html +1 -1
- package/public/css/forms.css +1 -1
- package/public/css/search.css +1 -0
- package/public/css/site.css +1 -1
- package/public/js/collection-context.js +2 -2
- package/public/js/context-menus.js +1 -0
- package/public/js/form-logic-engine.js +1 -1
- package/public/js/forms.js +2 -2
- package/public/js/search.js +1 -0
- package/public/js/site.js +1 -1
- package/scripts/build.js +37 -3
- package/scripts/copy-domma.js +48 -0
- package/scripts/seed.js +1996 -0
- package/server/routes/api/collections.js +34 -0
- package/server/routes/api/context-menus.js +104 -0
- package/server/routes/api/forms.js +42 -3
- package/server/routes/api/notifications.js +69 -19
- package/server/routes/api/plugins.js +50 -6
- package/server/routes/api/search.js +43 -0
- package/server/routes/api/theme.js +69 -0
- package/server/routes/public.js +42 -7
- package/server/server.js +74 -0
- package/server/services/adapters/FileAdapter.js +6 -1
- package/server/services/content.js +26 -0
- package/server/services/contextMenus.js +477 -0
- package/server/services/markdown.js +70 -9
- package/server/services/permissionRegistry.js +24 -0
- package/server/services/pluginFiles.js +52 -11
- package/server/services/plugins.js +229 -6
- package/server/services/renderer.js +144 -22
- package/server/services/roles.js +1 -1
- package/server/services/search-migration.js +82 -0
- package/server/services/search.js +413 -0
- package/server/services/sidebar-migration.js +1 -0
- package/server/services/themeSettings.js +541 -0
- package/server/services/users.js +8 -0
- package/server/templates/page.html +4 -2
- package/plugins/contacts/data/contacts.json +0 -20
- package/plugins/notes/data/notes.json +0 -1
- package/plugins/site-search/admin/views/site-search.js +0 -116
- package/plugins/site-search/config.js +0 -15
- package/plugins/site-search/plugin.js +0 -188
- package/plugins/site-search/plugin.json +0 -40
- package/plugins/site-search/public/inject-body.html +0 -17
- package/plugins/site-search/public/inject-head.html +0 -1
- package/plugins/site-search/public/search.css +0 -1
- package/plugins/site-search/public/search.js +0 -1
- package/plugins/todo/data/todos.json +0 -1
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mail Reader plugin - read-only IMAP mailbox reader.
|
|
3
|
+
*
|
|
4
|
+
* Almost nothing lives here. The routes are `registerReadRoutes()` from
|
|
5
|
+
* `plugins/_lib/mail/`, shared with `email-pro`, which registers the same
|
|
6
|
+
* surface and adds writing, sending and a synchronised store on top. This file
|
|
7
|
+
* is the free edition's wiring: its defaults, its connection pool, and the
|
|
8
|
+
* decision to register the read surface and nothing else.
|
|
9
|
+
*
|
|
10
|
+
* Routes mount at /api/plugins/mail-reader. See
|
|
11
|
+
* `plugins/_lib/mail/readRoutes.js` for what they do and why they are
|
|
12
|
+
* read-only at the protocol level rather than merely by omission.
|
|
13
|
+
*
|
|
14
|
+
* @module mail-reader/plugin
|
|
15
|
+
*/
|
|
16
|
+
import {createPool} from '../_lib/mail/imapPool.js';
|
|
17
|
+
import {registerReadRoutes} from '../_lib/mail/readRoutes.js';
|
|
18
|
+
import defaultConfig from './config.js';
|
|
19
|
+
|
|
20
|
+
export default async function mailReaderPlugin(fastify, options) {
|
|
21
|
+
const config = {...defaultConfig, ...(options.settings || {})};
|
|
22
|
+
const pool = createPool(config);
|
|
23
|
+
|
|
24
|
+
// Connections are process state; a reload or shutdown should close them
|
|
25
|
+
// rather than leave the mail server holding dead sessions.
|
|
26
|
+
fastify.addHook('onClose', async () => pool.closeAll());
|
|
27
|
+
|
|
28
|
+
registerReadRoutes(fastify, {
|
|
29
|
+
auth: options.auth,
|
|
30
|
+
hooks: options.hooks,
|
|
31
|
+
config,
|
|
32
|
+
pool,
|
|
33
|
+
pluginName: 'mail-reader'
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Nothing to provision - the plugin's only state is the shared mail store,
|
|
39
|
+
* which is created on first save.
|
|
40
|
+
*
|
|
41
|
+
* @returns {Promise<void>}
|
|
42
|
+
*/
|
|
43
|
+
export async function onEnable() {}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @returns {Promise<void>}
|
|
47
|
+
*/
|
|
48
|
+
export async function onDisable() {}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "mail-reader",
|
|
3
|
+
"displayName": "Mail Reader",
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"description": "Read-only IMAP mailbox reader. Connects to a standard IMAP server and reads mail without ever writing to it.",
|
|
6
|
+
"author": "Darryl Waterhouse",
|
|
7
|
+
"date": "2026-09-19",
|
|
8
|
+
"icon": "mail",
|
|
9
|
+
"admin": {
|
|
10
|
+
"sidebar": [
|
|
11
|
+
{
|
|
12
|
+
"id": "mail-reader",
|
|
13
|
+
"text": "Mail Reader",
|
|
14
|
+
"icon": "mail",
|
|
15
|
+
"url": "#/plugins/mail-reader",
|
|
16
|
+
"section": "#/plugins/mail-reader"
|
|
17
|
+
}
|
|
18
|
+
],
|
|
19
|
+
"routes": [
|
|
20
|
+
{
|
|
21
|
+
"path": "/plugins/mail-reader",
|
|
22
|
+
"view": "plugin-mail-reader",
|
|
23
|
+
"title": "Mail Reader - Domma CMS"
|
|
24
|
+
}
|
|
25
|
+
],
|
|
26
|
+
"views": {
|
|
27
|
+
"plugin-mail-reader": {
|
|
28
|
+
"entry": "mail-reader/admin/views/mail.js?v=c5c6483f",
|
|
29
|
+
"exportName": "mailView"
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -111,7 +111,7 @@ export const notesView = {
|
|
|
111
111
|
render: (v) => {
|
|
112
112
|
if (!Array.isArray(v) || v.length === 0) return '<span style="opacity:0.35;">-</span>';
|
|
113
113
|
return v.map((c) =>
|
|
114
|
-
`<span class="badge badge-
|
|
114
|
+
`<span class="badge badge-secondary notes-cat-tag" data-cat="${esc(c)}" `
|
|
115
115
|
+ `style="font-size:0.7rem;margin-right:2px;cursor:pointer;">${esc(c)}</span>`
|
|
116
116
|
).join('');
|
|
117
117
|
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"description": "Rich note-taking with categories, search, and markdown content.",
|
|
6
6
|
"author": "Darryl Waterhouse",
|
|
7
7
|
"date": "2026-03-24",
|
|
8
|
-
"icon": "
|
|
8
|
+
"icon": "edit-3",
|
|
9
9
|
"admin": {
|
|
10
10
|
"sidebar": [
|
|
11
11
|
{
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
],
|
|
26
26
|
"views": {
|
|
27
27
|
"plugin-notes": {
|
|
28
|
-
"entry": "notes/admin/views/notes.js?v=
|
|
28
|
+
"entry": "notes/admin/views/notes.js?v=ecd1b254",
|
|
29
29
|
"exportName": "notesView"
|
|
30
30
|
}
|
|
31
31
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
const C = 'surveys-contacts';
|
|
2
|
+
|
|
3
|
+
function parseGroups(raw) {
|
|
4
|
+
if (Array.isArray(raw)) return raw;
|
|
5
|
+
if (typeof raw === 'string') { try { return JSON.parse(raw); } catch { return raw ? [raw] : []; } }
|
|
6
|
+
return [];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function createAudience(col) {
|
|
10
|
+
async function listContacts() {
|
|
11
|
+
const {entries} = await col.listEntries(C, {limit: 100000});
|
|
12
|
+
return entries.map(e => ({id: e.id, name: e.data.name ?? '', email: e.data.email ?? '', groups: parseGroups(e.data.groups)}));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function contactIdsForGroups(groupNames) {
|
|
16
|
+
const want = new Set(groupNames);
|
|
17
|
+
const contacts = await listContacts();
|
|
18
|
+
return contacts.filter(c => c.groups.some(g => want.has(g))).map(c => c.id);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function importFromContacts(sourceContacts) {
|
|
22
|
+
let imported = 0;
|
|
23
|
+
for (const c of sourceContacts) {
|
|
24
|
+
const email = (c.email ?? '').trim();
|
|
25
|
+
if (!email) continue;
|
|
26
|
+
await col.createEntry(C, {
|
|
27
|
+
name: (c.name ?? '').trim(),
|
|
28
|
+
email,
|
|
29
|
+
groups: parseGroups(c.groups)
|
|
30
|
+
}, {source: 'import-contacts'});
|
|
31
|
+
imported++;
|
|
32
|
+
}
|
|
33
|
+
return {imported};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return {listContacts, contactIdsForGroups, importFromContacts};
|
|
37
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
const S = 'surveys-surveys';
|
|
2
|
+
const MODES = new Set(['identified', 'anonymous']);
|
|
3
|
+
const STATUSES = new Set(['draft', 'open', 'closed']);
|
|
4
|
+
|
|
5
|
+
export function createCampaigns(col) {
|
|
6
|
+
async function all() { const {entries} = await col.listEntries(S, {limit: 100000}); return entries; }
|
|
7
|
+
async function getBySlug(slug) { return (await all()).find(e => e.data.slug === slug) ?? null; }
|
|
8
|
+
|
|
9
|
+
async function createSurvey(input) {
|
|
10
|
+
if (!input.slug || !input.title || !input.formSlug) throw new Error('slug, title and formSlug are required');
|
|
11
|
+
if (await getBySlug(input.slug)) throw new Error(`A survey with slug "${input.slug}" already exists`);
|
|
12
|
+
return col.createEntry(S, {
|
|
13
|
+
slug: input.slug,
|
|
14
|
+
title: input.title,
|
|
15
|
+
description: input.description ?? '',
|
|
16
|
+
formSlug: input.formSlug,
|
|
17
|
+
groupIds: Array.isArray(input.groupIds) ? input.groupIds : [],
|
|
18
|
+
mode: MODES.has(input.mode) ? input.mode : 'identified',
|
|
19
|
+
status: STATUSES.has(input.status) ? input.status : 'draft',
|
|
20
|
+
opensAt: input.opensAt ?? null,
|
|
21
|
+
closesAt: input.closesAt ?? null,
|
|
22
|
+
reminderAfterDays: Number(input.reminderAfterDays) || 7,
|
|
23
|
+
intro: input.intro ?? '',
|
|
24
|
+
footer: input.footer ?? '',
|
|
25
|
+
thankYou: input.thankYou ?? '',
|
|
26
|
+
inviteEmail: input.inviteEmail ?? {subject: '', body: ''}
|
|
27
|
+
}, {source: 'admin'});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function updateSurvey(id, patch) {
|
|
31
|
+
const entry = await col.getEntry(S, id);
|
|
32
|
+
if (!entry) throw new Error('Survey not found');
|
|
33
|
+
const merged = {...entry.data};
|
|
34
|
+
for (const k of ['title', 'description', 'formSlug', 'groupIds', 'opensAt', 'closesAt', 'reminderAfterDays', 'intro', 'footer', 'thankYou', 'inviteEmail']) {
|
|
35
|
+
if (k in patch) merged[k] = patch[k];
|
|
36
|
+
}
|
|
37
|
+
if (patch.mode && MODES.has(patch.mode)) merged.mode = patch.mode;
|
|
38
|
+
if (patch.status && STATUSES.has(patch.status)) merged.status = patch.status;
|
|
39
|
+
return col.updateEntry(S, id, merged);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return {all, getBySlug, createSurvey, updateSurvey};
|
|
43
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import {generateToken, hashToken, normaliseCode} from './tokens.js';
|
|
2
|
+
|
|
3
|
+
const SLUG = 'surveys-invites';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Ledger over the surveys-invites collection.
|
|
7
|
+
* @param {{createEntry,listEntries,getEntry,updateEntry}} col collections service
|
|
8
|
+
*/
|
|
9
|
+
export function createLedger(col) {
|
|
10
|
+
async function invitesForSurvey(surveyId) {
|
|
11
|
+
const {entries} = await col.listEntries(SLUG, {limit: 100000});
|
|
12
|
+
return entries.filter(e => e.data.surveyId === surveyId);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Issue pending invites for contacts that don't already have one. Returns [{id, contactId, token}]. */
|
|
16
|
+
async function issueInvites(surveyId, contactIds) {
|
|
17
|
+
const existing = await invitesForSurvey(surveyId);
|
|
18
|
+
const seen = new Set(existing.map(e => e.data.contactId));
|
|
19
|
+
const out = [];
|
|
20
|
+
for (const contactId of contactIds) {
|
|
21
|
+
if (seen.has(contactId)) continue;
|
|
22
|
+
const token = generateToken();
|
|
23
|
+
const entry = await col.createEntry(SLUG, {
|
|
24
|
+
surveyId, contactId,
|
|
25
|
+
tokenHash: hashToken(normaliseCode(token)),
|
|
26
|
+
status: 'pending', sentAt: null, completedAt: null, responseId: null
|
|
27
|
+
}, {source: 'surveys'});
|
|
28
|
+
out.push({id: entry.id, contactId, token});
|
|
29
|
+
seen.add(contactId);
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Ensure every listed contact has a FRESH token to email. New contacts get a
|
|
36
|
+
* created invite; existing non-completed invites are re-issued with a new
|
|
37
|
+
* token (so re-sends and reminders carry a working personalised link);
|
|
38
|
+
* contacts who already completed the survey are skipped. The previous token
|
|
39
|
+
* for a re-issued invite stops working - latest link wins.
|
|
40
|
+
*
|
|
41
|
+
* Returns [{id, contactId, token}] for the invites to email.
|
|
42
|
+
*/
|
|
43
|
+
async function issueOrReissue(surveyId, contactIds) {
|
|
44
|
+
const existing = await invitesForSurvey(surveyId);
|
|
45
|
+
const byContact = new Map(existing.map(e => [e.data.contactId, e]));
|
|
46
|
+
const out = [];
|
|
47
|
+
for (const contactId of contactIds) {
|
|
48
|
+
const ex = byContact.get(contactId);
|
|
49
|
+
if (ex && ex.data.status === 'completed') continue;
|
|
50
|
+
const token = generateToken();
|
|
51
|
+
const tokenHash = hashToken(normaliseCode(token));
|
|
52
|
+
if (ex) {
|
|
53
|
+
await col.updateEntry(SLUG, ex.id, {...ex.data, tokenHash});
|
|
54
|
+
out.push({id: ex.id, contactId, token});
|
|
55
|
+
} else {
|
|
56
|
+
const entry = await col.createEntry(SLUG, {
|
|
57
|
+
surveyId, contactId, tokenHash,
|
|
58
|
+
status: 'pending', sentAt: null, completedAt: null, responseId: null
|
|
59
|
+
}, {source: 'surveys'});
|
|
60
|
+
out.push({id: entry.id, contactId, token});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Find an invite by submitted code (any format). Null if unknown. */
|
|
67
|
+
async function findByCode(surveyId, code) {
|
|
68
|
+
const hash = hashToken(normaliseCode(code));
|
|
69
|
+
const invites = await invitesForSurvey(surveyId);
|
|
70
|
+
return invites.find(e => e.data.tokenHash === hash) ?? null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Mark sent / bounced. */
|
|
74
|
+
async function markSent(inviteId, ok, nowIso) {
|
|
75
|
+
const entry = await col.getEntry(SLUG, inviteId);
|
|
76
|
+
if (!entry) return null;
|
|
77
|
+
return col.updateEntry(SLUG, inviteId, {
|
|
78
|
+
...entry.data,
|
|
79
|
+
status: ok ? 'sent' : 'bounced',
|
|
80
|
+
sentAt: ok ? nowIso : entry.data.sentAt
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Mark completed with the stored response id. */
|
|
85
|
+
async function markCompleted(inviteId, responseId, nowIso) {
|
|
86
|
+
const entry = await col.getEntry(SLUG, inviteId);
|
|
87
|
+
if (!entry) return null;
|
|
88
|
+
return col.updateEntry(SLUG, inviteId, {
|
|
89
|
+
...entry.data, status: 'completed', completedAt: nowIso, responseId: responseId ?? null
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Invites that are neither completed nor bounced. */
|
|
94
|
+
async function outstanding(surveyId) {
|
|
95
|
+
const invites = await invitesForSurvey(surveyId);
|
|
96
|
+
return invites.filter(e => e.data.status !== 'completed' && e.data.status !== 'bounced');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Sent invites, not yet completed, whose sentAt is older than afterDays before now. */
|
|
100
|
+
async function dueForReminder(surveyId, afterDays, now) {
|
|
101
|
+
const cutoff = now.getTime() - Number(afterDays) * 86400000;
|
|
102
|
+
const invites = await invitesForSurvey(surveyId);
|
|
103
|
+
return invites.filter(e =>
|
|
104
|
+
e.data.status === 'sent' &&
|
|
105
|
+
e.data.sentAt && new Date(e.data.sentAt).getTime() <= cutoff
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return {invitesForSurvey, issueInvites, issueOrReissue, findByCode, markSent, markCompleted, outstanding, dueForReminder};
|
|
110
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import {formatCode} from './tokens.js';
|
|
2
|
+
|
|
3
|
+
function escapeHtml(s) {
|
|
4
|
+
return String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/** Escape a value for use inside a double-quoted HTML attribute (e.g. href). */
|
|
8
|
+
function escapeAttr(s) {
|
|
9
|
+
return String(s ?? '').replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Render one invite's subject + plain-text + HTML from a template and a link.
|
|
14
|
+
*
|
|
15
|
+
* The HTML version turns each {{link}} into a clickable "Take the survey" button
|
|
16
|
+
* (the raw URL was previously emitted as inert escaped text - unclickable). All
|
|
17
|
+
* non-link copy is escaped so user-authored bodies can't inject markup. The
|
|
18
|
+
* plain-text version keeps the raw URL.
|
|
19
|
+
*/
|
|
20
|
+
export function renderInvite(template, contact, link) {
|
|
21
|
+
const name = contact.name || 'there';
|
|
22
|
+
const subjectTpl = (template.subject && template.subject.trim()) || 'You are invited to a survey';
|
|
23
|
+
const bodyTpl = (template.body && template.body.trim())
|
|
24
|
+
|| "Hi {{name}},\n\nWe'd really value your feedback - it only takes a minute.\n\n{{link}}";
|
|
25
|
+
|
|
26
|
+
const subject = subjectTpl.replaceAll('{{name}}', name).replaceAll('{{link}}', link);
|
|
27
|
+
const text = bodyTpl.replaceAll('{{name}}', name).replaceAll('{{link}}', link);
|
|
28
|
+
|
|
29
|
+
const button = `<p style="margin:24px 0;"><a href="${escapeAttr(link)}" `
|
|
30
|
+
+ `style="display:inline-block;background:#2563eb;color:#ffffff;text-decoration:none;`
|
|
31
|
+
+ `padding:12px 24px;border-radius:6px;font-weight:600;font-size:15px;">Take the survey</a></p>`;
|
|
32
|
+
|
|
33
|
+
const bodyHtml = bodyTpl
|
|
34
|
+
.split('{{link}}')
|
|
35
|
+
.map((seg) => escapeHtml(seg.replaceAll('{{name}}', name)).replace(/\n/g, '<br>'))
|
|
36
|
+
.join(button);
|
|
37
|
+
|
|
38
|
+
const html = `<div style="font-family:-apple-system,Segoe UI,Roboto,Arial,sans-serif;`
|
|
39
|
+
+ `max-width:560px;margin:0 auto;padding:24px;color:#1f2937;line-height:1.6;font-size:15px;">`
|
|
40
|
+
+ bodyHtml
|
|
41
|
+
+ `<hr style="border:none;border-top:1px solid #e5e7eb;margin:24px 0;">`
|
|
42
|
+
+ `<p style="font-size:12px;color:#6b7280;">If the button doesn't work, paste this link into your browser:<br>`
|
|
43
|
+
+ `<a href="${escapeAttr(link)}" style="color:#2563eb;">${escapeHtml(link)}</a></p>`
|
|
44
|
+
+ `</div>`;
|
|
45
|
+
|
|
46
|
+
return {subject, text, html};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Send to each recipient; never fails wholesale. Returns [{inviteId, ok}].
|
|
51
|
+
* @param {Array<{invite,contact,token}>} recipients
|
|
52
|
+
* @param {{subject,body}} template
|
|
53
|
+
* @param {object} deps {sendEmail, transport, from, fromName, baseUrl, surveySlug}
|
|
54
|
+
*/
|
|
55
|
+
export async function sendBatch(recipients, template, deps) {
|
|
56
|
+
const {sendEmail, transport, from, fromName, baseUrl, surveySlug} = deps;
|
|
57
|
+
const results = [];
|
|
58
|
+
for (const r of recipients) {
|
|
59
|
+
const link = `${baseUrl}/survey/${surveySlug}?t=${formatCode(r.token)}`;
|
|
60
|
+
const msg = renderInvite(template, r.contact, link);
|
|
61
|
+
try {
|
|
62
|
+
await sendEmail(transport, {from, fromName, to: r.contact.email, subject: msg.subject, html: msg.html, text: msg.text});
|
|
63
|
+
results.push({inviteId: r.invite.id, ok: true});
|
|
64
|
+
} catch {
|
|
65
|
+
results.push({inviteId: r.invite.id, ok: false});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return results;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Resolve recipients for a survey and send them invites, flipping the ledger.
|
|
73
|
+
* Shared by the admin /send route and the scheduler /run route.
|
|
74
|
+
* @param {object} ctx {survey, mode, audience, ledger, transport, sendEmail, smtp, baseUrl, fromName, nowIso}
|
|
75
|
+
* @returns {Promise<{results: Array<{inviteId,ok}>}>}
|
|
76
|
+
*/
|
|
77
|
+
export async function runSend(ctx) {
|
|
78
|
+
const {survey, mode, audience, ledger, transport, sendEmail, smtp, baseUrl, fromName, nowIso} = ctx;
|
|
79
|
+
const groupNames = Array.isArray(survey.data.groupIds) ? survey.data.groupIds : [];
|
|
80
|
+
const contactIds = await audience.contactIdsForGroups(groupNames);
|
|
81
|
+
const contacts = (await audience.listContacts()).filter(c => contactIds.includes(c.id));
|
|
82
|
+
|
|
83
|
+
// Who to target: 'non-responders' narrows to contacts whose invite is still
|
|
84
|
+
// outstanding (invited, not completed/bounced); 'all' targets everyone.
|
|
85
|
+
let targetIds = contactIds;
|
|
86
|
+
if (mode === 'non-responders') {
|
|
87
|
+
const outstanding = new Set((await ledger.outstanding(survey.id)).map(e => e.data.contactId));
|
|
88
|
+
targetIds = contactIds.filter(id => outstanding.has(id));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// (Re)issue a fresh token for each target so re-sends and reminders carry a
|
|
92
|
+
// working personalised link; contacts who already completed are skipped.
|
|
93
|
+
const issued = await ledger.issueOrReissue(survey.id, targetIds);
|
|
94
|
+
const tokenByContact = new Map(issued.map(i => [i.contactId, i]));
|
|
95
|
+
const recipients = contacts
|
|
96
|
+
.filter(c => tokenByContact.has(c.id))
|
|
97
|
+
.map(c => {
|
|
98
|
+
const inv = tokenByContact.get(c.id);
|
|
99
|
+
return {invite: {id: inv.id}, contact: c, token: inv.token};
|
|
100
|
+
});
|
|
101
|
+
const results = await sendBatch(recipients, survey.data.inviteEmail || {}, {
|
|
102
|
+
sendEmail, transport, from: smtp.fromAddress, fromName: smtp.fromName || fromName, baseUrl, surveySlug: survey.data.slug
|
|
103
|
+
});
|
|
104
|
+
for (const r of results) await ledger.markSent(r.inviteId, r.ok, nowIso);
|
|
105
|
+
return {results};
|
|
106
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
const CHOICE = new Set(['select', 'radio', 'checkbox', 'dropdown']);
|
|
2
|
+
const RATING = new Set(['rating', 'number', 'range']);
|
|
3
|
+
|
|
4
|
+
/** Aggregate one field across response rows into a normalised distribution. */
|
|
5
|
+
export function aggregateField(field, rows) {
|
|
6
|
+
const label = field.label || field.name;
|
|
7
|
+
if (RATING.has(field.type)) {
|
|
8
|
+
const nums = rows.map(r => Number(r[field.name])).filter(n => !Number.isNaN(n));
|
|
9
|
+
const average = nums.length ? Math.round((nums.reduce((a, b) => a + b, 0) / nums.length) * 100) / 100 : 0;
|
|
10
|
+
const counts = tally(rows.map(r => String(r[field.name] ?? '')).filter(Boolean));
|
|
11
|
+
return {field: field.name, label, type: 'rating', average, items: toItems(counts)};
|
|
12
|
+
}
|
|
13
|
+
if (CHOICE.has(field.type)) {
|
|
14
|
+
const counts = tally(rows.flatMap(r => Array.isArray(r[field.name]) ? r[field.name] : [r[field.name]]).filter(Boolean));
|
|
15
|
+
return {field: field.name, label, type: 'choice', items: toItems(counts)};
|
|
16
|
+
}
|
|
17
|
+
return {field: field.name, label, type: 'text', responses: rows.map(r => r[field.name]).filter(Boolean)};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function tally(values) {
|
|
21
|
+
const m = new Map();
|
|
22
|
+
for (const v of values) m.set(v, (m.get(v) || 0) + 1);
|
|
23
|
+
return m;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function toItems(counts) {
|
|
27
|
+
const total = [...counts.values()].reduce((a, b) => a + b, 0) || 1;
|
|
28
|
+
return [...counts.entries()].map(([k, v]) => ({label: String(k), value: v, pct: Math.round((v / total) * 100)}));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* THE CHARTS SEAM. Input shape [{label,value,pct}] is the exact contract a
|
|
33
|
+
* future framework charts() call will accept. Swap this body for that call
|
|
34
|
+
* when the component lands. See docs/superpowers/specs/2026-06-26-domma-charts-upstream-proposal.md.
|
|
35
|
+
*/
|
|
36
|
+
export function renderDistribution(items) {
|
|
37
|
+
return `<div class="survey-dist">` + items.map(i =>
|
|
38
|
+
`<div class="survey-dist-row"><span class="survey-dist-label">${escapeHtml(i.label)}</span>` +
|
|
39
|
+
`<span class="survey-dist-bar"><span class="survey-dist-fill" data-pct="${i.pct}" style="width:${i.pct}%"></span></span>` +
|
|
40
|
+
`<span class="survey-dist-val">${i.value} (${i.pct}%)</span></div>`
|
|
41
|
+
).join('') + `</div>`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function campaignHealth(invites) {
|
|
45
|
+
const invited = invites.length;
|
|
46
|
+
const completed = invites.filter(i => i.data.status === 'completed').length;
|
|
47
|
+
return {invited, completed, responseRate: invited ? Math.round((completed / invited) * 100) : 0};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function toCsv(rows, columns) {
|
|
51
|
+
const esc = (v) => {
|
|
52
|
+
const s = String(v ?? '');
|
|
53
|
+
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
|
54
|
+
};
|
|
55
|
+
const head = columns.join(',');
|
|
56
|
+
const body = rows.map(r => columns.map(c => esc(r[c])).join(',')).join('\n');
|
|
57
|
+
return `${head}\n${body}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function escapeHtml(s) {
|
|
61
|
+
return String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
62
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import {createRequire} from 'module';
|
|
2
|
+
|
|
3
|
+
const require = createRequire(import.meta.url);
|
|
4
|
+
const FormLogicEngine = require('../../../public/js/form-logic-engine.js');
|
|
5
|
+
|
|
6
|
+
class SurveyError extends Error {
|
|
7
|
+
constructor(message, code = 400) { super(message); this.statusCode = code; }
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function withinWindow(data, now) {
|
|
11
|
+
const t = new Date(now).getTime();
|
|
12
|
+
if (data.opensAt && t < new Date(data.opensAt).getTime()) return false;
|
|
13
|
+
if (data.closesAt && t > new Date(data.closesAt).getTime()) return false;
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Validate visible fields with the same engine core uses. Throws SurveyError. */
|
|
18
|
+
function validate(form, body) {
|
|
19
|
+
const values = {};
|
|
20
|
+
for (const f of form.fields || []) {
|
|
21
|
+
if (['page-break', 'spacer'].includes(f.type)) continue;
|
|
22
|
+
const v = body[f.name];
|
|
23
|
+
values[f.name] = typeof v === 'string' ? v.trim() : (v ?? '');
|
|
24
|
+
}
|
|
25
|
+
const missing = [];
|
|
26
|
+
const errors = [];
|
|
27
|
+
for (const f of form.fields || []) {
|
|
28
|
+
if (['page-break', 'spacer'].includes(f.type)) continue;
|
|
29
|
+
if (FormLogicEngine.evaluateFieldVisibility(f, values) === 'hidden') continue;
|
|
30
|
+
const val = values[f.name];
|
|
31
|
+
const empty = !val?.toString().trim();
|
|
32
|
+
if (FormLogicEngine.evaluateFieldRequirement(f, values) && empty) missing.push(f.label || f.name);
|
|
33
|
+
else if (!empty) {
|
|
34
|
+
const e = FormLogicEngine.validateField(f, val, values);
|
|
35
|
+
if (e.length) errors.push(e[0].message);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (missing.length || errors.length) {
|
|
39
|
+
const parts = [];
|
|
40
|
+
if (missing.length) parts.push(`Required fields missing: ${missing.join(', ')}`);
|
|
41
|
+
if (errors.length) parts.push(errors.join('; '));
|
|
42
|
+
throw new SurveyError(`${parts.join('. ')}.`);
|
|
43
|
+
}
|
|
44
|
+
return values;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Process a tokenised survey submission.
|
|
49
|
+
* @param {string} surveySlug
|
|
50
|
+
* @param {string} code submitted invite code (any format)
|
|
51
|
+
* @param {object} body answer values keyed by field name
|
|
52
|
+
* @param {object} deps {col, ledger, getSurveyBySlug, readForm, getCollection, sendCompletionEmail, now}
|
|
53
|
+
* @returns {Promise<{ok:true, message:string}>}
|
|
54
|
+
*/
|
|
55
|
+
export async function processSurveySubmission(surveySlug, code, body, deps) {
|
|
56
|
+
const {col, ledger, getSurveyBySlug, readForm, getCollection, sendCompletionEmail, now} = deps;
|
|
57
|
+
|
|
58
|
+
const survey = await getSurveyBySlug(surveySlug);
|
|
59
|
+
if (!survey) throw new SurveyError('Survey not found.', 404);
|
|
60
|
+
const s = survey.data;
|
|
61
|
+
if (s.status !== 'open') throw new SurveyError('This survey has closed.', 410);
|
|
62
|
+
if (!withinWindow(s, now())) throw new SurveyError('This survey has closed.', 410);
|
|
63
|
+
|
|
64
|
+
const invite = await ledger.findByCode(survey.id, code);
|
|
65
|
+
if (!invite) throw new SurveyError('That code is invalid for this survey.', 403);
|
|
66
|
+
if (invite.data.status === 'completed') throw new SurveyError('This survey has already been completed for that code.', 409);
|
|
67
|
+
|
|
68
|
+
const form = await readForm(s.formSlug);
|
|
69
|
+
const values = validate(form, body);
|
|
70
|
+
|
|
71
|
+
const targetSlug = s.formSlug;
|
|
72
|
+
const targetCollection = await getCollection(targetSlug);
|
|
73
|
+
if (!targetCollection) throw new SurveyError('Survey form is not configured correctly.', 500);
|
|
74
|
+
const data = {};
|
|
75
|
+
for (const f of form.fields || []) {
|
|
76
|
+
if (['page-break', 'spacer'].includes(f.type)) continue;
|
|
77
|
+
if (values[f.name] !== undefined) data[f.name] = values[f.name];
|
|
78
|
+
}
|
|
79
|
+
// NOTE: contactId / token are deliberately NOT written here - anonymity is intrinsic.
|
|
80
|
+
const entry = await col.createEntry(targetSlug, data, {source: `survey:${surveySlug}`});
|
|
81
|
+
|
|
82
|
+
const responseId = s.mode === 'identified' ? entry.id : null;
|
|
83
|
+
await ledger.markCompleted(invite.id, responseId, now());
|
|
84
|
+
|
|
85
|
+
await sendCompletionEmail(form, data).catch((err) => console.warn('[surveys] completion email failed:', err.message));
|
|
86
|
+
|
|
87
|
+
// Prefer the survey's own thank-you message over the underlying form's
|
|
88
|
+
// successMessage, so respondents see a survey-specific confirmation rather
|
|
89
|
+
// than the generic form response.
|
|
90
|
+
const thankYou = (s.thankYou && String(s.thankYou).trim())
|
|
91
|
+
|| 'Thank you - your survey response has been recorded.';
|
|
92
|
+
return {ok: true, message: thankYou};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export {SurveyError};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import crypto from 'crypto';
|
|
2
|
+
|
|
3
|
+
const ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789'; // no O,0,I,1,L
|
|
4
|
+
const LENGTH = 8;
|
|
5
|
+
|
|
6
|
+
/** Generate an 8-char unambiguous uppercase token. */
|
|
7
|
+
export function generateToken() {
|
|
8
|
+
const bytes = crypto.randomBytes(LENGTH);
|
|
9
|
+
let out = '';
|
|
10
|
+
for (let i = 0; i < LENGTH; i++) out += ALPHABET[bytes[i] % ALPHABET.length];
|
|
11
|
+
return out;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** SHA-256 hex of a normalised code. The plaintext is never stored. */
|
|
15
|
+
export function hashToken(code) {
|
|
16
|
+
return crypto.createHash('sha256').update(String(code)).digest('hex');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Display form: ABCD-EFGH. */
|
|
20
|
+
export function formatCode(token) {
|
|
21
|
+
const t = String(token).toUpperCase();
|
|
22
|
+
return t.length === 8 ? `${t.slice(0, 4)}-${t.slice(4)}` : t;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Canonical form for hashing/lookup: strip non-alphanumerics, uppercase. */
|
|
26
|
+
export function normaliseCode(input) {
|
|
27
|
+
return String(input ?? '').toUpperCase().replace(/[^A-Z0-9]/g, '');
|
|
28
|
+
}
|