domma-cms 0.66.0 → 0.69.4
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 +64 -1
- package/README.md +14 -10
- package/admin/css/admin.css +1 -1
- package/admin/index.html +1 -0
- package/admin/js/api.js +1 -1
- package/admin/js/app.js +4 -4
- package/admin/js/lib/analytics-shape.js +1 -0
- package/admin/js/lib/contacts-arrange.js +1 -0
- package/admin/js/lib/help-popover.js +1 -0
- package/admin/js/lib/notes-arrange.js +1 -0
- package/admin/js/lib/plugin-accent.js +1 -1
- package/admin/js/lib/plugin-chrome.js +1 -1
- 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 +3 -3
- package/admin/js/lib/todo-arrange.js +1 -0
- package/admin/js/lib/tool-kit.js +1 -0
- package/admin/js/templates/analytics.html +138 -0
- package/admin/js/templates/contacts.html +156 -0
- package/admin/js/templates/notes.html +137 -0
- package/admin/js/templates/plugin-marketplace.html +17 -4
- package/admin/js/templates/plugins.html +13 -7
- package/admin/js/templates/theme.html +38 -0
- package/admin/js/templates/todo.html +113 -0
- package/admin/js/views/analytics.js +62 -0
- package/admin/js/views/contacts.js +174 -0
- package/admin/js/views/index.js +1 -1
- package/admin/js/views/notes.js +104 -0
- package/admin/js/views/plugin-marketplace.js +1 -1
- package/admin/js/views/plugins.js +24 -22
- package/admin/js/views/theme.js +1 -1
- package/admin/js/views/todo.js +96 -0
- package/bin/cli.js +4 -10
- package/bin/lib/plugin-version.js +28 -0
- package/bin/update.js +14 -0
- package/config/menus/admin-sidebar.json +1 -1
- package/config/server.json +3 -1
- package/package.json +3 -7
- package/plugins/_lib/admin/mail/reader-view.js +20 -0
- package/plugins/_lib/mail/store.js +21 -1
- package/plugins/blog/CLAUDE.md +237 -0
- package/plugins/blog/admin/css/index.css +178 -1
- package/plugins/blog/admin/templates/blog.html +35 -53
- package/plugins/blog/admin/templates/categories.html +19 -6
- package/plugins/blog/admin/templates/comments.html +26 -9
- package/plugins/blog/admin/templates/post-editor.html +36 -13
- package/plugins/blog/admin/templates/settings.html +2 -11
- package/plugins/blog/admin/views/blog.js +288 -162
- package/plugins/blog/admin/views/categories.js +212 -201
- package/plugins/blog/admin/views/comments.js +207 -158
- package/plugins/blog/admin/views/kit.js +372 -0
- package/plugins/blog/admin/views/post-editor.js +269 -21
- package/plugins/blog/admin/views/settings-cog.js +56 -0
- package/plugins/blog/admin/views/settings.js +16 -86
- package/plugins/blog/blocks/blog-card-compact.css +17 -0
- package/plugins/blog/blocks/blog-card-compact.html +8 -0
- package/plugins/blog/blocks/blog-card-row.css +22 -0
- package/plugins/blog/blocks/blog-card-row.html +11 -0
- package/plugins/blog/blocks/blog-card.css +35 -0
- package/plugins/blog/blocks/blog-card.html +16 -0
- package/plugins/blog/blocks/blog-post-classic.css +15 -0
- package/plugins/blog/blocks/blog-post-classic.html +28 -0
- package/plugins/blog/blocks/blog-post-feature.css +20 -0
- package/plugins/blog/blocks/blog-post-feature.html +30 -0
- package/plugins/blog/blocks/blog-post-minimal.css +10 -0
- package/plugins/blog/blocks/blog-post-minimal.html +23 -0
- package/plugins/blog/blocks/blog-post-sidebar.css +22 -0
- package/plugins/blog/blocks/blog-post-sidebar.html +36 -0
- package/plugins/blog/collections/posts/schema.json +3 -1
- package/plugins/blog/config.js +6 -0
- package/plugins/blog/lib/layouts.js +304 -0
- package/plugins/blog/lib/page.js +158 -0
- package/plugins/blog/lib/render.js +119 -0
- package/plugins/blog/lib/samples.js +234 -0
- package/plugins/blog/plugin.js +225 -15
- package/plugins/blog/plugin.json +21 -81
- package/plugins/blog/plugin.public.js +115 -210
- package/plugins/blog/public/blog.css +177 -0
- package/plugins/blog/public/blog.js +586 -0
- package/plugins/blog/public/samples/aurora.svg +7 -0
- package/plugins/blog/public/samples/dusk.svg +7 -0
- package/plugins/blog/public/samples/ember.svg +7 -0
- package/plugins/blog/public/samples/harbour.svg +7 -0
- package/plugins/blog/public/samples/meadow.svg +7 -0
- package/plugins/blog/public/samples/rosewood.svg +7 -0
- package/plugins/blog/templates/index.html +9 -8
- package/plugins/blog/templates/post.html +4 -17
- package/plugins/blog/tests/layouts.test.js +97 -0
- package/plugins/blog/tests/public.test.js +71 -0
- package/plugins/free-tier.lock.json +74 -0
- package/plugins/mail-reader/CLAUDE.md +78 -0
- package/plugins/mail-reader/plugin.json +2 -1
- package/public/css/theme-switcher.css +1 -0
- package/public/js/analytics.js +1 -0
- package/public/js/theme-switcher.js +1 -0
- package/scripts/build.js +41 -2
- package/scripts/run-tests.mjs +94 -0
- package/{plugins/analytics/plugin.js → server/routes/api/analytics.js} +89 -41
- package/{plugins/contacts/plugin.js → server/routes/api/contacts.js} +71 -43
- package/server/routes/api/dashboard.js +8 -2
- package/{plugins/notes/plugin.js → server/routes/api/notes.js} +61 -17
- package/server/routes/api/plugin-marketplace.js +78 -23
- package/server/routes/api/plugins.js +148 -3
- package/server/routes/api/todo.js +178 -0
- package/server/server.js +61 -1
- package/server/services/collections.js +13 -3
- package/server/services/managerClient.js +72 -136
- package/server/services/markdown.js +54 -1
- package/server/services/permissionRegistry.js +50 -0
- package/server/services/pluginEntitlement.js +171 -0
- package/server/services/pluginEnvelope.js +242 -0
- package/server/services/pluginInstaller.js +246 -26
- package/server/services/pluginScaffold.js +5 -0
- package/server/services/plugins.js +117 -7
- package/server/services/presetCollections.js +344 -0
- package/server/services/promoted-plugins-migration.js +254 -0
- package/server/services/publicCors.js +46 -0
- package/server/services/renderer.js +83 -3
- package/server/services/sidebarBadges.js +37 -0
- package/server/services/siteGitignore.js +310 -0
- package/server/services/themeSettings.js +39 -0
- package/config/theme.json +0 -18
- package/plugins/analytics/admin/css/index.css +0 -1
- package/plugins/analytics/admin/templates/analytics.html +0 -70
- package/plugins/analytics/admin/views/analytics.js +0 -176
- package/plugins/analytics/config.js +0 -14
- package/plugins/analytics/plugin.json +0 -60
- package/plugins/analytics/public/inject-body.html +0 -60
- package/plugins/analytics/public/inject-head.html +0 -1
- package/plugins/blog/templates/author.html +0 -9
- package/plugins/blog/templates/category.html +0 -9
- package/plugins/blog/templates/tag.html +0 -9
- package/plugins/contacts/admin/templates/contacts.html +0 -254
- package/plugins/contacts/admin/views/contacts.js +0 -943
- package/plugins/contacts/collections/user-contact-groups/schema.json +0 -35
- package/plugins/contacts/collections/user-contacts/schema.json +0 -179
- package/plugins/contacts/config.js +0 -6
- package/plugins/contacts/plugin.json +0 -36
- package/plugins/demo-viewer/config.js +0 -4
- package/plugins/demo-viewer/plugin.js +0 -36
- package/plugins/demo-viewer/plugin.json +0 -9
- package/plugins/invoice/admin/css/index.css +0 -1
- package/plugins/invoice/admin/templates/editor.html +0 -220
- package/plugins/invoice/admin/templates/index.html +0 -173
- package/plugins/invoice/admin/templates/issuers.html +0 -2
- package/plugins/invoice/admin/templates/receivers.html +0 -2
- package/plugins/invoice/admin/views/contacts-source.js +0 -266
- package/plugins/invoice/admin/views/editor.js +0 -434
- package/plugins/invoice/admin/views/export.js +0 -199
- package/plugins/invoice/admin/views/help-content.js +0 -61
- package/plugins/invoice/admin/views/index.js +0 -643
- package/plugins/invoice/admin/views/issuers.js +0 -30
- package/plugins/invoice/admin/views/media.js +0 -172
- package/plugins/invoice/admin/views/party-view.js +0 -386
- package/plugins/invoice/admin/views/payments.js +0 -127
- package/plugins/invoice/admin/views/print.js +0 -130
- package/plugins/invoice/admin/views/receivers.js +0 -55
- package/plugins/invoice/admin/views/send.js +0 -212
- package/plugins/invoice/admin/views/settings.js +0 -594
- package/plugins/invoice/admin/views/view-lifecycle.js +0 -33
- package/plugins/invoice/collections/invoice-issuers/schema.json +0 -82
- package/plugins/invoice/collections/invoice-receivers/schema.json +0 -16
- package/plugins/invoice/collections/invoices/schema.json +0 -33
- package/plugins/invoice/config.js +0 -37
- package/plugins/invoice/pdf.js +0 -164
- package/plugins/invoice/plugin.js +0 -1456
- package/plugins/invoice/plugin.json +0 -86
- package/plugins/invoice/templates/_base.css +0 -1
- package/plugins/invoice/templates/classic-nologo.html +0 -100
- package/plugins/invoice/templates/classic.html +0 -91
- package/plugins/invoice/templates/invoice-print.html +0 -237
- package/plugins/invoice/templates/minimal.html +0 -99
- package/plugins/invoice/templates/modern-nologo.html +0 -114
- package/plugins/invoice/templates/modern.html +0 -113
- package/plugins/invoice/templates/templates.json +0 -11
- package/plugins/notes/admin/templates/notes.html +0 -83
- package/plugins/notes/admin/views/notes.js +0 -281
- package/plugins/notes/collections/user-notes/schema.json +0 -54
- package/plugins/notes/config.js +0 -6
- package/plugins/notes/plugin.json +0 -33
- package/plugins/surveys/admin/templates/audience.html +0 -47
- package/plugins/surveys/admin/templates/results.html +0 -58
- package/plugins/surveys/admin/templates/survey-editor.html +0 -141
- package/plugins/surveys/admin/templates/surveys.html +0 -25
- package/plugins/surveys/admin/views/audience.js +0 -301
- package/plugins/surveys/admin/views/results.js +0 -172
- package/plugins/surveys/admin/views/survey-editor.js +0 -211
- package/plugins/surveys/admin/views/surveys.js +0 -161
- package/plugins/surveys/collections/survey-contacts/schema.json +0 -13
- package/plugins/surveys/collections/survey-groups/schema.json +0 -11
- package/plugins/surveys/collections/survey-invites/schema.json +0 -16
- package/plugins/surveys/collections/surveys/schema.json +0 -23
- package/plugins/surveys/config.js +0 -8
- package/plugins/surveys/lib/audience.js +0 -37
- package/plugins/surveys/lib/campaigns.js +0 -43
- package/plugins/surveys/lib/ledger.js +0 -110
- package/plugins/surveys/lib/sending.js +0 -106
- package/plugins/surveys/lib/stats.js +0 -62
- package/plugins/surveys/lib/submit.js +0 -95
- package/plugins/surveys/lib/tokens.js +0 -28
- package/plugins/surveys/plugin.js +0 -174
- package/plugins/surveys/plugin.json +0 -65
- package/plugins/surveys/plugin.public.js +0 -149
- package/plugins/surveys/public/dist-shared.mjs +0 -1
- package/plugins/surveys/public/survey.css +0 -1
- package/plugins/surveys/public/survey.mjs +0 -1
- package/plugins/surveys/templates/survey-page.html +0 -63
- package/plugins/theme-switcher/admin/templates/theme-switcher.html +0 -86
- package/plugins/theme-switcher/admin/views/theme-switcher.js +0 -65
- package/plugins/theme-switcher/config.js +0 -10
- package/plugins/theme-switcher/plugin.js +0 -26
- package/plugins/theme-switcher/plugin.json +0 -74
- package/plugins/theme-switcher/public/inject-body.html +0 -153
- package/plugins/theme-switcher/public/inject-head.html +0 -260
- package/plugins/todo/admin/templates/todo.html +0 -158
- package/plugins/todo/admin/views/todo.js +0 -343
- package/plugins/todo/collections/todos/schema.json +0 -60
- package/plugins/todo/config.js +0 -7
- package/plugins/todo/plugin.js +0 -102
- package/plugins/todo/plugin.json +0 -33
- /package/{plugins/contacts/admin/css/index.css → admin/css/contacts.css} +0 -0
|
@@ -1,43 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,110 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,106 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,62 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,95 +0,0 @@
|
|
|
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};
|
|
@@ -1,28 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,174 +0,0 @@
|
|
|
1
|
-
import defaultConfig from './config.js';
|
|
2
|
-
import {createEntry, deleteEntry, getEntry, listEntries, updateEntry} from '../../server/services/collections.js';
|
|
3
|
-
import {createCampaigns} from './lib/campaigns.js';
|
|
4
|
-
import {createAudience} from './lib/audience.js';
|
|
5
|
-
import {createLedger} from './lib/ledger.js';
|
|
6
|
-
import {createTransport, sendEmail} from '../../server/services/email.js';
|
|
7
|
-
import {getConfig} from '../../server/config.js';
|
|
8
|
-
import {readForm} from '../../server/services/forms.js';
|
|
9
|
-
import {runSend} from './lib/sending.js';
|
|
10
|
-
import {aggregateField, campaignHealth, toCsv} from './lib/stats.js';
|
|
11
|
-
import {registerPluginResource} from '../../server/services/permissionRegistry.js';
|
|
12
|
-
|
|
13
|
-
const col = {createEntry, deleteEntry, getEntry, listEntries, updateEntry};
|
|
14
|
-
|
|
15
|
-
// Register the `surveys` permission family (idempotent - no-op if already present).
|
|
16
|
-
// Custom roles are NOT back-filled; admins grant it in the role editor (same gotcha
|
|
17
|
-
// as Menus/Projects).
|
|
18
|
-
registerPluginResource({
|
|
19
|
-
key: 'surveys',
|
|
20
|
-
label: 'Surveys',
|
|
21
|
-
description: 'Create and manage survey campaigns, audiences, and results.',
|
|
22
|
-
icon: 'clipboard-list',
|
|
23
|
-
group: 'Plugins',
|
|
24
|
-
actions: [
|
|
25
|
-
{key: 'read', label: 'View', description: 'View surveys, audience, and results'},
|
|
26
|
-
{key: 'create', label: 'Create', description: 'Create surveys, contacts, and groups'},
|
|
27
|
-
{key: 'update', label: 'Edit', description: 'Edit surveys and send invitations'},
|
|
28
|
-
{key: 'delete', label: 'Delete', description: 'Delete surveys, contacts, and groups'}
|
|
29
|
-
]
|
|
30
|
-
}, 'surveys');
|
|
31
|
-
|
|
32
|
-
export default async function surveysPlugin(fastify, options) {
|
|
33
|
-
const {authenticate} = options.auth;
|
|
34
|
-
const config = {...defaultConfig, ...(options.settings || {})};
|
|
35
|
-
// Email transport is injectable (defaults to the real SMTP service). Tests
|
|
36
|
-
// pass a deterministic, network-free stub so the send path can't time out on
|
|
37
|
-
// real SMTP / Ethereal account creation and mark every invite "bounced".
|
|
38
|
-
const makeTransport = options.email?.createTransport || createTransport;
|
|
39
|
-
const sendMail = options.email?.sendEmail || sendEmail;
|
|
40
|
-
const campaigns = createCampaigns(col);
|
|
41
|
-
const audience = createAudience(col);
|
|
42
|
-
const ledger = createLedger(col);
|
|
43
|
-
const auth = {preHandler: [authenticate]};
|
|
44
|
-
|
|
45
|
-
// ---- [survey] embed shortcode ----
|
|
46
|
-
// Drops a survey mount point into any page. The client (survey.mjs) resolves
|
|
47
|
-
// the form slug at runtime, so `data-form-slug` may safely be left empty.
|
|
48
|
-
// Guarded so the registration never throws when hooks are absent (test harness).
|
|
49
|
-
if (options.hooks?.registerShortcode) {
|
|
50
|
-
options.hooks.registerShortcode('survey', async (attrStr, body, context) => {
|
|
51
|
-
const attrs = context.parseShortcodeAttrs(attrStr);
|
|
52
|
-
const slug = attrs.slug || '';
|
|
53
|
-
const esc = context.escapeAttr || ((v) => String(v ?? ''));
|
|
54
|
-
let formSlug = '';
|
|
55
|
-
try {
|
|
56
|
-
const survey = slug ? await campaigns.getBySlug(slug) : null;
|
|
57
|
-
formSlug = survey?.data?.formSlug || '';
|
|
58
|
-
} catch {
|
|
59
|
-
formSlug = '';
|
|
60
|
-
}
|
|
61
|
-
return `<div class="survey-mount" data-survey-slug="${esc(slug)}" data-form-slug="${esc(formSlug)}"></div>`
|
|
62
|
-
+ `<script type="module" src="/plugins/surveys/public/survey.mjs"></script>`;
|
|
63
|
-
});
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
// ---- Surveys (campaigns) ----
|
|
67
|
-
fastify.get('/surveys', auth, async () => (await campaigns.all()).map(e => ({id: e.id, ...e.data})));
|
|
68
|
-
|
|
69
|
-
fastify.post('/surveys', auth, async (req, reply) => {
|
|
70
|
-
try {
|
|
71
|
-
const s = await campaigns.createSurvey(req.body ?? {});
|
|
72
|
-
return reply.code(201).send({id: s.id, ...s.data});
|
|
73
|
-
} catch (err) { return reply.code(400).send({error: err.message}); }
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
fastify.get('/surveys/:slug', auth, async (req, reply) => {
|
|
77
|
-
const s = await campaigns.getBySlug(req.params.slug);
|
|
78
|
-
if (!s) return reply.code(404).send({error: 'Survey not found'});
|
|
79
|
-
return {id: s.id, ...s.data};
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
fastify.put('/surveys/:slug', auth, async (req, reply) => {
|
|
83
|
-
const s = await campaigns.getBySlug(req.params.slug);
|
|
84
|
-
if (!s) return reply.code(404).send({error: 'Survey not found'});
|
|
85
|
-
const updated = await campaigns.updateSurvey(s.id, req.body ?? {});
|
|
86
|
-
return {id: updated.id, ...updated.data};
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
// ---- Audience: contacts ----
|
|
90
|
-
fastify.get('/contacts', auth, async () => audience.listContacts());
|
|
91
|
-
fastify.post('/contacts', auth, async (req, reply) => {
|
|
92
|
-
const {name, email, groups} = req.body ?? {};
|
|
93
|
-
if (!email) return reply.code(400).send({error: 'email is required'});
|
|
94
|
-
const e = await createEntry('surveys-contacts', {name: name ?? '', email, groups: Array.isArray(groups) ? groups : []}, {source: 'admin'});
|
|
95
|
-
return reply.code(201).send({id: e.id, ...e.data});
|
|
96
|
-
});
|
|
97
|
-
fastify.delete('/contacts/:id', auth, async (req, reply) => {
|
|
98
|
-
await deleteEntry('surveys-contacts', req.params.id).catch(() => {});
|
|
99
|
-
return {ok: true};
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
// ---- Audience: groups ----
|
|
103
|
-
fastify.get('/groups', auth, async () => {
|
|
104
|
-
const {entries} = await listEntries('surveys-groups', {limit: 10000});
|
|
105
|
-
return entries.map(e => ({id: e.id, name: e.data.name, description: e.data.description ?? ''}));
|
|
106
|
-
});
|
|
107
|
-
fastify.post('/groups', auth, async (req, reply) => {
|
|
108
|
-
const {name, description} = req.body ?? {};
|
|
109
|
-
if (!name) return reply.code(400).send({error: 'name is required'});
|
|
110
|
-
const e = await createEntry('surveys-groups', {name, description: description ?? ''}, {source: 'admin'});
|
|
111
|
-
return reply.code(201).send({id: e.id, ...e.data});
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
// ---- Import from Contacts plugin ----
|
|
115
|
-
fastify.post('/import-from-contacts', auth, async (req, reply) => {
|
|
116
|
-
const {entries} = await listEntries('contacts-contacts', {limit: 100000}).catch(() => ({entries: []}));
|
|
117
|
-
const source = entries.map(e => ({name: e.data.name, email: e.data.email, groups: e.data.groups}));
|
|
118
|
-
const result = await audience.importFromContacts(source);
|
|
119
|
-
return reply.send(result);
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
fastify.post('/surveys/:slug/send', auth, async (req, reply) => {
|
|
123
|
-
const survey = await campaigns.getBySlug(req.params.slug);
|
|
124
|
-
if (!survey) return reply.code(404).send({error: 'Survey not found'});
|
|
125
|
-
const mode = (req.body?.audience === 'non-responders') ? 'non-responders' : 'all';
|
|
126
|
-
const smtp = getConfig('site').smtp || {};
|
|
127
|
-
const transport = await makeTransport(smtp);
|
|
128
|
-
// Survey links in emails must be ABSOLUTE. Prefer the configured canonical
|
|
129
|
-
// site URL; fall back to the origin of this admin request so links work
|
|
130
|
-
// out of the box even when config/site.json has no `url`.
|
|
131
|
-
const baseUrl = (getConfig('site').url || '').replace(/\/$/, '')
|
|
132
|
-
|| `${req.protocol}://${req.headers.host}`;
|
|
133
|
-
const {results} = await runSend({
|
|
134
|
-
survey, mode, audience, ledger, transport, sendEmail: sendMail, smtp,
|
|
135
|
-
baseUrl, fromName: config.fromName, nowIso: new Date().toISOString()
|
|
136
|
-
});
|
|
137
|
-
return reply.send({results});
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
fastify.get('/surveys/:slug/stats', auth, async (req, reply) => {
|
|
141
|
-
const survey = await campaigns.getBySlug(req.params.slug);
|
|
142
|
-
if (!survey) return reply.code(404).send({error: 'Survey not found'});
|
|
143
|
-
const form = await readForm(survey.data.formSlug).catch(() => null);
|
|
144
|
-
const {entries} = await listEntries(survey.data.formSlug, {limit: 100000}).catch(() => ({entries: []}));
|
|
145
|
-
// Scope to THIS survey's responses (tagged `survey:<slug>` at submit time),
|
|
146
|
-
// so direct submissions to a shared form are excluded.
|
|
147
|
-
const tag = `survey:${survey.data.slug}`;
|
|
148
|
-
const rows = entries.filter(e => e.meta?.source === tag).map(e => e.data);
|
|
149
|
-
const fields = (form?.fields || [])
|
|
150
|
-
.filter(f => !['page-break', 'spacer', 'heading', 'paragraph', 'html'].includes(f.type))
|
|
151
|
-
.map(f => aggregateField(f, rows));
|
|
152
|
-
const health = campaignHealth(await ledger.invitesForSurvey(survey.id));
|
|
153
|
-
return reply.send({health, fields});
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
fastify.get('/surveys/:slug/export', auth, async (req, reply) => {
|
|
157
|
-
const survey = await campaigns.getBySlug(req.params.slug);
|
|
158
|
-
if (!survey) return reply.code(404).send({error: 'Survey not found'});
|
|
159
|
-
const form = await readForm(survey.data.formSlug).catch(() => null);
|
|
160
|
-
const cols = (form?.fields || []).map(f => f.name).filter(Boolean);
|
|
161
|
-
const {entries} = await listEntries(survey.data.formSlug, {limit: 100000}).catch(() => ({entries: []}));
|
|
162
|
-
// Export only this survey's tagged responses, not stray form submissions.
|
|
163
|
-
const tag = `survey:${survey.data.slug}`;
|
|
164
|
-
const rows = entries.filter(e => e.meta?.source === tag).map(e => e.data);
|
|
165
|
-
if ((req.query.format || 'csv') === 'json') {
|
|
166
|
-
return reply.header('Content-Disposition', `attachment; filename="${survey.data.slug}.json"`).send(rows);
|
|
167
|
-
}
|
|
168
|
-
return reply.header('Content-Type', 'text/csv')
|
|
169
|
-
.header('Content-Disposition', `attachment; filename="${survey.data.slug}.csv"`)
|
|
170
|
-
.send(toCsv(rows, cols));
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
fastify.surveys = {campaigns, audience, ledger, col, config};
|
|
174
|
-
}
|