backend-manager 5.0.96 → 5.0.98

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.
Files changed (33) hide show
  1. package/package.json +2 -1
  2. package/src/cli/commands/setup-tests/firestore-indexes-required.js +105 -0
  3. package/src/cli/commands/setup-tests/firestore-indexes-synced.js +37 -27
  4. package/src/cli/commands/setup-tests/index.js +3 -1
  5. package/src/cli/commands/setup-tests/required-indexes.js +38 -0
  6. package/src/manager/events/firestore/payments-webhooks/transitions/one-time/purchase-completed.js +4 -1
  7. package/src/manager/events/firestore/payments-webhooks/transitions/send-email.js +10 -0
  8. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/cancellation-requested.js +1 -1
  9. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/new-subscription.js +2 -1
  10. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/payment-failed.js +1 -1
  11. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/payment-recovered.js +1 -1
  12. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/plan-changed.js +1 -1
  13. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/subscription-cancelled.js +3 -1
  14. package/src/manager/libraries/email.js +25 -16
  15. package/src/manager/routes/forms/delete.js +37 -0
  16. package/src/manager/routes/forms/get.js +46 -0
  17. package/src/manager/routes/forms/post.js +45 -0
  18. package/src/manager/routes/forms/public/get.js +37 -0
  19. package/src/manager/routes/forms/put.js +52 -0
  20. package/src/manager/routes/user/data-request/delete.js +39 -0
  21. package/src/manager/routes/user/data-request/get.js +181 -0
  22. package/src/manager/routes/user/data-request/post.js +133 -0
  23. package/src/manager/routes/user/delete.js +53 -0
  24. package/src/manager/routes/user/signup/post.js +16 -22
  25. package/src/manager/schemas/forms/delete.js +6 -0
  26. package/src/manager/schemas/forms/get.js +6 -0
  27. package/src/manager/schemas/forms/post.js +9 -0
  28. package/src/manager/schemas/forms/public/get.js +6 -0
  29. package/src/manager/schemas/forms/put.js +10 -0
  30. package/src/manager/schemas/user/data-request/delete.js +1 -0
  31. package/src/manager/schemas/user/data-request/get.js +7 -0
  32. package/src/manager/schemas/user/data-request/post.js +10 -0
  33. package/templates/backend-manager-config.json +7 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backend-manager",
3
- "version": "5.0.96",
3
+ "version": "5.0.98",
4
4
  "description": "Quick tools for developing Firebase functions",
5
5
  "main": "src/manager/index.js",
6
6
  "bin": {
@@ -67,6 +67,7 @@
67
67
  "lodash": "^4.17.23",
68
68
  "lowdb": "^1.0.0",
69
69
  "mailchimp-api-v3": "^1.15.0",
70
+ "markdown-it": "^14.1.1",
70
71
  "mime-types": "^2.1.35",
71
72
  "mocha": "^11.7.5",
72
73
  "moment": "^2.30.1",
@@ -0,0 +1,105 @@
1
+ const BaseTest = require('./base-test');
2
+ const jetpack = require('fs-jetpack');
3
+ const _ = require('lodash');
4
+ const chalk = require('chalk');
5
+ const requiredIndexes = require('./required-indexes');
6
+
7
+ class FirestoreIndexesRequiredTest extends BaseTest {
8
+ getName() {
9
+ return 'firestore indexes have required BEM indexes';
10
+ }
11
+
12
+ async run() {
13
+ const filePath = `${this.self.firebaseProjectPath}/firestore.indexes.json`;
14
+
15
+ if (!jetpack.exists(filePath)) {
16
+ return false;
17
+ }
18
+
19
+ const indexesFile = JSON.parse(jetpack.read(filePath));
20
+ const existingIndexes = indexesFile.indexes || [];
21
+
22
+ // Check that the first N indexes match the required indexes (must be at the top)
23
+ const topIsCorrect = requiredIndexes.every((required, i) => {
24
+ return this._indexMatches(existingIndexes[i], required);
25
+ });
26
+
27
+ // Check no duplicates exist (each required index should appear exactly once)
28
+ const noDuplicates = requiredIndexes.every(required => {
29
+ const count = existingIndexes.filter(existing => this._indexMatches(existing, required)).length;
30
+ return count === 1;
31
+ });
32
+
33
+ return topIsCorrect && noDuplicates;
34
+ }
35
+
36
+ async fix() {
37
+ const filePath = `${this.self.firebaseProjectPath}/firestore.indexes.json`;
38
+ let indexesFile;
39
+
40
+ if (jetpack.exists(filePath)) {
41
+ indexesFile = JSON.parse(jetpack.read(filePath));
42
+ } else {
43
+ indexesFile = { indexes: [], fieldOverrides: [] };
44
+ }
45
+
46
+ indexesFile.indexes = indexesFile.indexes || [];
47
+
48
+ // Remove any existing copies of required indexes
49
+ for (const required of requiredIndexes) {
50
+ indexesFile.indexes = indexesFile.indexes.filter(existing => !this._indexMatches(existing, required));
51
+ }
52
+
53
+ // Add all required indexes to the top (in reverse so they end up in correct order)
54
+ for (let i = requiredIndexes.length - 1; i >= 0; i--) {
55
+ indexesFile.indexes.unshift(requiredIndexes[i]);
56
+ }
57
+
58
+ jetpack.write(filePath, JSON.stringify(indexesFile, null, 2));
59
+
60
+ console.log(chalk.green(` + Ensured ${requiredIndexes.length} required indexes at top of indexes array`));
61
+ console.log(chalk.yellow(` Remember to deploy indexes: ${chalk.bold('firebase deploy --only firestore:indexes')}`));
62
+ }
63
+
64
+ /**
65
+ * Check if an existing index matches a required index definition
66
+ */
67
+ _indexMatches(existing, required) {
68
+ if (!existing) {
69
+ return false;
70
+ }
71
+
72
+ // Must match collectionGroup
73
+ if (existing.collectionGroup !== required.collectionGroup) {
74
+ return false;
75
+ }
76
+
77
+ // Must match queryScope (default COLLECTION)
78
+ if ((existing.queryScope || 'COLLECTION') !== (required.queryScope || 'COLLECTION')) {
79
+ return false;
80
+ }
81
+
82
+ // Must have same number of fields
83
+ if (!existing.fields || existing.fields.length !== required.fields.length) {
84
+ return false;
85
+ }
86
+
87
+ // Each field must match
88
+ return required.fields.every((reqField, i) => {
89
+ const exField = existing.fields[i];
90
+
91
+ if (exField.fieldPath !== reqField.fieldPath) {
92
+ return false;
93
+ }
94
+
95
+ // Match order or arrayConfig
96
+ if (reqField.arrayConfig) {
97
+ return exField.arrayConfig === reqField.arrayConfig;
98
+ }
99
+
100
+ return exField.order === reqField.order;
101
+ });
102
+ }
103
+ }
104
+
105
+ module.exports = FirestoreIndexesRequiredTest;
@@ -3,6 +3,7 @@ const jetpack = require('fs-jetpack');
3
3
  const chalk = require('chalk');
4
4
  const _ = require('lodash');
5
5
  const inquirer = require('inquirer');
6
+ const powertools = require('node-powertools');
6
7
 
7
8
  class FirestoreIndexesSyncedTest extends BaseTest {
8
9
  getName() {
@@ -26,13 +27,6 @@ class FirestoreIndexesSyncedTest extends BaseTest {
26
27
  }
27
28
  const equal = _.isEqual(liveIndexes, localIndexes);
28
29
 
29
- if (localIndexes_exists && !equal) {
30
- console.log(chalk.red(`To fix this...`));
31
- console.log(chalk.red(` - ${chalk.bold('npx bm indexes:get')} to overwrite Firestore's local indexes with the live indexes`));
32
- console.log(chalk.red(' OR'));
33
- console.log(chalk.red(` - ${chalk.bold('firebase deploy --only firestore:indexes')} to replace the live indexes.`));
34
- }
35
-
36
30
  jetpack.remove(`${self.firebaseProjectPath}/${tempPath}`);
37
31
 
38
32
  return !localIndexes_exists || equal;
@@ -41,28 +35,44 @@ class FirestoreIndexesSyncedTest extends BaseTest {
41
35
  async fix() {
42
36
  const self = this.self;
43
37
 
44
- return new Promise((resolve, reject) => {
45
- inquirer.prompt([
46
- {
47
- type: 'confirm',
48
- name: 'replace',
49
- message: 'Would you like to replace the local indexes?',
50
- default: true,
51
- }
52
- ])
53
- .then(async (answer) => {
54
- if (answer.replace) {
55
- const commands = require('../index');
56
- const IndexesCommand = commands.IndexesCommand;
57
- const indexesCmd = new IndexesCommand(self);
38
+ const answer = await inquirer.prompt([
39
+ {
40
+ type: 'list',
41
+ name: 'direction',
42
+ message: 'Firestore indexes are out of sync. Which direction?',
43
+ choices: [
44
+ {
45
+ name: `Live → Local (replace ${chalk.bold('local')} indexes with ${chalk.bold('live')})`,
46
+ value: 'live-to-local',
47
+ },
48
+ {
49
+ name: `Local Live (replace ${chalk.bold('live')} indexes with ${chalk.bold('local')})`,
50
+ value: 'local-to-live',
51
+ },
52
+ {
53
+ name: 'Skip',
54
+ value: 'skip',
55
+ },
56
+ ],
57
+ },
58
+ ]);
59
+
60
+ if (answer.direction === 'live-to-local') {
61
+ const commands = require('../index');
62
+ const IndexesCommand = commands.IndexesCommand;
63
+ const indexesCmd = new IndexesCommand(self);
58
64
 
59
- await indexesCmd.get(undefined, true);
60
- return resolve();
61
- } else {
62
- return reject();
63
- }
65
+ await indexesCmd.get(undefined, true);
66
+ } else if (answer.direction === 'local-to-live') {
67
+ console.log(chalk.yellow(` Deploying local indexes to live...`));
68
+
69
+ await powertools.execute('firebase deploy --only firestore:indexes', {
70
+ log: true,
71
+ cwd: self.firebaseProjectPath,
64
72
  });
65
- });
73
+
74
+ console.log(chalk.green(` ✓ Live indexes updated from local`));
75
+ }
66
76
  }
67
77
  }
68
78
 
@@ -35,6 +35,7 @@ const StorageRulesFileTest = require('./storage-rules-file');
35
35
  const RemoteconfigTemplateFileTest = require('./remoteconfig-template-file');
36
36
  const HostingFolderTest = require('./hosting-folder');
37
37
  const PublicHtmlFilesTest = require('./public-html-files');
38
+ const FirestoreIndexesRequiredTest = require('./firestore-indexes-required');
38
39
  const LegacyTestsCleanupTest = require('./legacy-tests-cleanup');
39
40
 
40
41
  /**
@@ -66,10 +67,11 @@ function getTests(context) {
66
67
  new RemoteconfigTemplateInJsonTest(context),
67
68
  new EmulatorConfigTest(context),
68
69
  new HostingRewritesTest(context),
69
- new FirestoreIndexesSyncedTest(context),
70
70
  new StorageLifecyclePolicyTest(context),
71
71
  new FirestoreRulesFileTest(context),
72
72
  new FirestoreIndexesFileTest(context),
73
+ new FirestoreIndexesRequiredTest(context),
74
+ new FirestoreIndexesSyncedTest(context),
73
75
  new RealtimeRulesFileTest(context),
74
76
  new StorageRulesFileTest(context),
75
77
  new RemoteconfigTemplateFileTest(context),
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Required Firestore compound indexes for BEM routes
3
+ * These are automatically added to firestore.indexes.json during `npx bm setup`
4
+ */
5
+ module.exports = [
6
+ // All /user/data-request routes — most recent request by created date
7
+ // Serves: .where('owner', '==', uid).orderBy('metadata.created.timestampUNIX', 'desc')
8
+ {
9
+ collectionGroup: 'data-requests',
10
+ queryScope: 'COLLECTION',
11
+ fields: [
12
+ { fieldPath: 'owner', order: 'ASCENDING' },
13
+ { fieldPath: 'metadata.created.timestampUNIX', order: 'DESCENDING' },
14
+ ],
15
+ },
16
+
17
+ // POST /payments/intent — trial eligibility check
18
+ // Query: .where('owner', '==', uid).where('type', '==', 'subscription')
19
+ {
20
+ collectionGroup: 'payments-orders',
21
+ queryScope: 'COLLECTION',
22
+ fields: [
23
+ { fieldPath: 'owner', order: 'ASCENDING' },
24
+ { fieldPath: 'type', order: 'ASCENDING' },
25
+ ],
26
+ },
27
+
28
+ // POST /admin/notification — send to filtered users
29
+ // Query: .where('tags', 'array-contains-any', tags).where('owner', '==', owner)
30
+ {
31
+ collectionGroup: 'notifications',
32
+ queryScope: 'COLLECTION',
33
+ fields: [
34
+ { fieldPath: 'tags', arrayConfig: 'CONTAINS' },
35
+ { fieldPath: 'owner', order: 'ASCENDING' },
36
+ ],
37
+ },
38
+ ];
@@ -5,11 +5,14 @@
5
5
  const { sendOrderEmail, formatDate } = require('../send-email.js');
6
6
 
7
7
  module.exports = async function ({ before, after, order, uid, userDoc, assistant }) {
8
+ const brandName = assistant.Manager.config.brand?.name || '';
9
+ const productName = after.product?.name || '';
10
+
8
11
  assistant.log(`Transition [one-time/purchase-completed]: uid=${uid}, resourceId=${after.payment?.resourceId}`);
9
12
 
10
13
  sendOrderEmail({
11
14
  template: 'main/order/confirmation',
12
- subject: `Your order is confirmed! #${order?.id || ''}`,
15
+ subject: `Your ${brandName} ${productName} order #${order?.id || ''}`,
13
16
  categories: ['order/confirmation'],
14
17
  userDoc,
15
18
  assistant,
@@ -27,12 +27,22 @@ function sendOrderEmail({ template, subject, categories, data, userDoc, assistan
27
27
  return;
28
28
  }
29
29
 
30
+ // Strip sensitive fields before passing to email template
31
+ const safeUser = { ...userDoc };
32
+ delete safeUser.api;
33
+ delete safeUser.oauth2;
34
+ delete safeUser.activity;
35
+ delete safeUser.affiliate;
36
+ delete safeUser.attribution;
37
+ delete safeUser.flags;
38
+
30
39
  const settings = {
31
40
  to: { email: userEmail, ...(userName && { name: userName }) },
32
41
  subject,
33
42
  template,
34
43
  categories,
35
44
  copy: true,
45
+ user: safeUser,
36
46
  data,
37
47
  };
38
48
 
@@ -9,7 +9,7 @@ module.exports = async function ({ before, after, order, uid, userDoc, assistant
9
9
 
10
10
  sendOrderEmail({
11
11
  template: 'main/order/cancellation-requested',
12
- subject: `Your subscription cancellation is confirmed #${order?.id || ''}`,
12
+ subject: `Your cancellation is confirmed #${order?.id || ''}`,
13
13
  categories: ['order/cancellation-requested'],
14
14
  userDoc,
15
15
  assistant,
@@ -8,12 +8,13 @@ const { sendOrderEmail, formatDate } = require('../send-email.js');
8
8
  module.exports = async function ({ before, after, order, uid, userDoc, assistant }) {
9
9
  const isTrial = after.trial?.claimed === true;
10
10
  const brandName = assistant.Manager.config.brand?.name || '';
11
+ const planName = after.product?.name || '';
11
12
 
12
13
  assistant.log(`Transition [subscription/new-subscription]: uid=${uid}, product=${after.product?.id}, frequency=${after.payment?.frequency}, trial=${isTrial}`);
13
14
 
14
15
  sendOrderEmail({
15
16
  template: 'main/order/confirmation',
16
- subject: `Your order from ${brandName} #${order?.id || ''}`,
17
+ subject: `Your ${brandName} ${planName} order #${order?.id || ''}`,
17
18
  categories: ['order/confirmation'],
18
19
  userDoc,
19
20
  assistant,
@@ -9,7 +9,7 @@ module.exports = async function ({ before, after, order, uid, userDoc, assistant
9
9
 
10
10
  sendOrderEmail({
11
11
  template: 'main/order/payment-failed',
12
- subject: `Your payment failed #${order?.id || ''}`,
12
+ subject: `Payment failed for order #${order?.id || ''}`,
13
13
  categories: ['order/payment-failed'],
14
14
  userDoc,
15
15
  assistant,
@@ -9,7 +9,7 @@ module.exports = async function ({ before, after, order, uid, userDoc, assistant
9
9
 
10
10
  sendOrderEmail({
11
11
  template: 'main/order/payment-recovered',
12
- subject: `Your payment was successful #${order?.id || ''}`,
12
+ subject: `Payment received for order #${order?.id || ''}`,
13
13
  categories: ['order/payment-recovered'],
14
14
  userDoc,
15
15
  assistant,
@@ -10,7 +10,7 @@ module.exports = async function ({ before, after, order, uid, userDoc, assistant
10
10
 
11
11
  sendOrderEmail({
12
12
  template: 'main/order/plan-changed',
13
- subject: `Your subscription plan has been updated #${order?.id || ''}`,
13
+ subject: `Your plan has been updated #${order?.id || ''}`,
14
14
  categories: ['order/plan-changed'],
15
15
  userDoc,
16
16
  assistant,
@@ -8,7 +8,9 @@ module.exports = async function ({ before, after, order, uid, userDoc, assistant
8
8
  assistant.log(`Transition [subscription/subscription-cancelled]: uid=${uid}, previousProduct=${before?.product?.id}, previousStatus=${before?.status}`);
9
9
 
10
10
  // Check if subscription has a future expiry (e.g., cancelled at period end)
11
- const hasFutureExpiry = after.expires?.timestamp && new Date(after.expires.timestamp) > new Date();
11
+ // Trials don't get future access cancelling a trial revokes access immediately
12
+ const isTrial = after.trial?.claimed;
13
+ const hasFutureExpiry = !isTrial && after.expires?.timestamp && new Date(after.expires.timestamp) > new Date();
12
14
 
13
15
  sendOrderEmail({
14
16
  template: 'main/order/cancelled',
@@ -13,6 +13,12 @@
13
13
  */
14
14
  const _ = require('lodash');
15
15
  const moment = require('moment');
16
+ const MarkdownIt = require('markdown-it');
17
+ const md = new MarkdownIt({
18
+ html: true,
19
+ breaks: true,
20
+ linkify: true,
21
+ });
16
22
 
17
23
  // SendGrid limit for scheduled emails (72 hours, but use 71 for buffer)
18
24
  const SEND_AT_LIMIT = 71;
@@ -91,15 +97,10 @@ Email.prototype.build = async function (settings) {
91
97
  throw errorWithCode('Missing brand configuration in backend-manager-config.json', 400);
92
98
  }
93
99
 
94
- const app = {
95
- id: brand.id,
96
- name: brand.name,
97
- url: brand.url,
98
- email: brand.contact?.email,
99
- images: sanitizeImagesForEmail(brand.images || {}),
100
- };
100
+ const brandData = _.cloneDeep(brand);
101
+ brandData.images = sanitizeImagesForEmail(brandData.images || {});
101
102
 
102
- if (!app.email) {
103
+ if (!brandData.contact?.email) {
103
104
  throw errorWithCode('Missing brand.contact.email in backend-manager-config.json', 400);
104
105
  }
105
106
 
@@ -108,8 +109,8 @@ Email.prototype.build = async function (settings) {
108
109
  // Add carbon copy recipients
109
110
  if (copy) {
110
111
  cc.push({
111
- email: app.email,
112
- name: app.name,
112
+ email: brandData.contact.email,
113
+ name: brandData.name,
113
114
  });
114
115
  bcc.push(
115
116
  {
@@ -152,7 +153,7 @@ Email.prototype.build = async function (settings) {
152
153
  // Build categories
153
154
  const categories = _.uniq([
154
155
  'transactional',
155
- app.id,
156
+ brandData.id,
156
157
  ...powertools.arrayify(settings.categories),
157
158
  ]);
158
159
 
@@ -160,7 +161,7 @@ Email.prototype.build = async function (settings) {
160
161
  const sendAt = normalizeSendAt(settings.sendAt);
161
162
 
162
163
  // Build unsubscribe URL
163
- const unsubscribeUrl = `${Manager.project.websiteUrl}/portal/account/email-preferences?email=${encode(to[0].email)}&asmId=${encode(groupId)}&templateId=${encode(templateId)}&appName=${app.name}&appUrl=${app.url}`;
164
+ const unsubscribeUrl = `${Manager.project.websiteUrl}/portal/account/email-preferences?email=${encode(to[0].email)}&asmId=${encode(groupId)}&templateId=${encode(templateId)}&appName=${brandData.name}&appUrl=${brandData.url}`;
164
165
 
165
166
  // Build signoff
166
167
  const signoff = settings?.data?.signoff || {};
@@ -174,6 +175,14 @@ Email.prototype.build = async function (settings) {
174
175
  signoff.urlText = signoff.urlText || '@ianwieds';
175
176
  }
176
177
 
178
+ // Process markdown in body fields
179
+ if (settings?.data?.body?.message) {
180
+ settings.data.body.message = md.render(settings.data.body.message);
181
+ }
182
+ if (settings?.data?.email?.body) {
183
+ settings.data.email.body = md.render(settings.data.email.body);
184
+ }
185
+
177
186
  // Build dynamic template data
178
187
  const dynamicTemplateData = {
179
188
  email: {
@@ -194,7 +203,7 @@ Email.prototype.build = async function (settings) {
194
203
  ...settings?.data?.personalization,
195
204
  },
196
205
  signoff,
197
- app,
206
+ brand: brandData,
198
207
  user: userProperties,
199
208
  data: settings.data || {},
200
209
  };
@@ -204,8 +213,8 @@ Email.prototype.build = async function (settings) {
204
213
  to,
205
214
  cc,
206
215
  bcc,
207
- from: settings.from || { email: app.email, name: app.name },
208
- replyTo: settings.replyTo || app.email,
216
+ from: settings.from || { email: brandData.contact.email, name: brandData.name },
217
+ replyTo: settings.replyTo || brandData.contact.email,
209
218
  subject,
210
219
  templateId,
211
220
  asm: { groupId },
@@ -230,7 +239,7 @@ Email.prototype.build = async function (settings) {
230
239
 
231
240
  // Build stringified version for template rendering
232
241
  const clonedData = _.cloneDeep(dynamicTemplateData);
233
- clonedData.app.sponsorships = {};
242
+ clonedData.brand.sponsorships = {};
234
243
  email.dynamicTemplateData._stringified = JSON.stringify(clonedData, null, 2);
235
244
 
236
245
  return email;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * DELETE /forms - Delete a form
3
+ * Requires authentication and ownership.
4
+ */
5
+ module.exports = async ({ assistant, user, settings, analytics, libraries }) => {
6
+ const { admin } = libraries;
7
+
8
+ // Require authentication
9
+ if (!user.authenticated) {
10
+ return assistant.respond('Authentication required', { code: 401 });
11
+ }
12
+
13
+ if (!settings.id) {
14
+ return assistant.respond('Missing required parameter: id', { code: 400 });
15
+ }
16
+
17
+ const uid = user.auth.uid;
18
+ const formRef = admin.firestore().doc(`forms/${settings.id}`);
19
+ const doc = await formRef.get();
20
+
21
+ if (!doc.exists) {
22
+ return assistant.respond('Form not found', { code: 404 });
23
+ }
24
+
25
+ // Ownership check
26
+ if (doc.data().owner !== uid) {
27
+ return assistant.respond('Not authorized to delete this form', { code: 403 });
28
+ }
29
+
30
+ await formRef.delete();
31
+
32
+ assistant.log(`Deleted form ${settings.id} for user ${uid}`);
33
+
34
+ analytics.event('forms', { action: 'delete' });
35
+
36
+ return assistant.respond({ data: { deleted: true } });
37
+ };
@@ -0,0 +1,46 @@
1
+ /**
2
+ * GET /forms - Get a single form or list all forms for the authenticated user
3
+ * Requires authentication.
4
+ * - With ?id=xxx: returns a single form (with ownership check)
5
+ * - Without id: returns all forms owned by the user
6
+ */
7
+ module.exports = async ({ assistant, user, settings, analytics, libraries }) => {
8
+ const { admin } = libraries;
9
+
10
+ // Require authentication
11
+ if (!user.authenticated) {
12
+ return assistant.respond('Authentication required', { code: 401 });
13
+ }
14
+
15
+ const uid = user.auth.uid;
16
+
17
+ // Single form
18
+ if (settings.id) {
19
+ const doc = await admin.firestore().doc(`forms/${settings.id}`).get();
20
+
21
+ if (!doc.exists) {
22
+ return assistant.respond('Form not found', { code: 404 });
23
+ }
24
+
25
+ if (doc.data().owner !== uid) {
26
+ return assistant.respond('Not authorized to view this form', { code: 403 });
27
+ }
28
+
29
+ analytics.event('forms', { action: 'get' });
30
+
31
+ return assistant.respond({ data: { form: doc.data() } });
32
+ }
33
+
34
+ // List all forms
35
+ const snapshot = await admin.firestore()
36
+ .collection('forms')
37
+ .where('owner', '==', uid)
38
+ .orderBy('created.timestampUNIX', 'desc')
39
+ .get();
40
+
41
+ const forms = snapshot.docs.map(doc => doc.data());
42
+
43
+ analytics.event('forms', { action: 'list' });
44
+
45
+ return assistant.respond({ data: { forms } });
46
+ };
@@ -0,0 +1,45 @@
1
+ /**
2
+ * POST /forms - Create a new form
3
+ * Requires authentication. Creates a Firestore doc in the `forms` collection.
4
+ */
5
+ module.exports = async ({ assistant, Manager, user, settings, analytics, libraries }) => {
6
+ const { admin } = libraries;
7
+
8
+ // Require authentication
9
+ if (!user.authenticated) {
10
+ return assistant.respond('Authentication required', { code: 401 });
11
+ }
12
+
13
+ const uid = user.auth.uid;
14
+ const now = new Date().toISOString();
15
+ const nowUnix = Date.now();
16
+
17
+ // Generate a new document reference
18
+ const docRef = admin.firestore().collection('forms').doc();
19
+
20
+ const form = {
21
+ id: docRef.id,
22
+ owner: uid,
23
+ name: settings.name || 'Untitled Form',
24
+ description: settings.description || '',
25
+ settings: settings.settings || {},
26
+ pages: settings.pages || [],
27
+ created: {
28
+ timestamp: now,
29
+ timestampUNIX: nowUnix,
30
+ },
31
+ edited: {
32
+ timestamp: now,
33
+ timestampUNIX: nowUnix,
34
+ },
35
+ metadata: Manager.Metadata().set({ tag: 'forms/post' }),
36
+ };
37
+
38
+ await docRef.set(form);
39
+
40
+ assistant.log(`Created form ${docRef.id} for user ${uid}`);
41
+
42
+ analytics.event('forms', { action: 'create' });
43
+
44
+ return assistant.respond({ data: { id: docRef.id, form } });
45
+ };
@@ -0,0 +1,37 @@
1
+ /**
2
+ * GET /forms/public - Get a public form by ID
3
+ * No authentication required. Only returns forms with settings.public = true.
4
+ */
5
+ module.exports = async ({ assistant, settings, analytics, libraries }) => {
6
+ const { admin } = libraries;
7
+
8
+ if (!settings.id) {
9
+ return assistant.respond('Missing required parameter: id', { code: 400 });
10
+ }
11
+
12
+ const doc = await admin.firestore().doc(`forms/${settings.id}`).get();
13
+
14
+ if (!doc.exists) {
15
+ return assistant.respond('Form not found', { code: 404 });
16
+ }
17
+
18
+ const form = doc.data();
19
+
20
+ // Only allow access to public forms
21
+ if (!form.settings?.public) {
22
+ return assistant.respond('Form not found', { code: 404 });
23
+ }
24
+
25
+ // Strip sensitive fields
26
+ const publicForm = {
27
+ id: form.id,
28
+ name: form.name,
29
+ description: form.description,
30
+ settings: form.settings,
31
+ pages: form.pages,
32
+ };
33
+
34
+ analytics.event('forms/public', { action: 'get' });
35
+
36
+ return assistant.respond({ payload: publicForm });
37
+ };
@@ -0,0 +1,52 @@
1
+ /**
2
+ * PUT /forms - Update an existing form
3
+ * Requires authentication and ownership.
4
+ */
5
+ module.exports = async ({ assistant, Manager, user, settings, analytics, libraries }) => {
6
+ const { admin } = libraries;
7
+
8
+ // Require authentication
9
+ if (!user.authenticated) {
10
+ return assistant.respond('Authentication required', { code: 401 });
11
+ }
12
+
13
+ if (!settings.id) {
14
+ return assistant.respond('Missing required parameter: id', { code: 400 });
15
+ }
16
+
17
+ const uid = user.auth.uid;
18
+ const formRef = admin.firestore().doc(`forms/${settings.id}`);
19
+ const doc = await formRef.get();
20
+
21
+ if (!doc.exists) {
22
+ return assistant.respond('Form not found', { code: 404 });
23
+ }
24
+
25
+ // Ownership check
26
+ if (doc.data().owner !== uid) {
27
+ return assistant.respond('Not authorized to edit this form', { code: 403 });
28
+ }
29
+
30
+ const now = new Date().toISOString();
31
+ const nowUnix = Date.now();
32
+
33
+ const updates = {
34
+ name: settings.name,
35
+ description: settings.description,
36
+ settings: settings.settings,
37
+ pages: settings.pages,
38
+ edited: {
39
+ timestamp: now,
40
+ timestampUNIX: nowUnix,
41
+ },
42
+ metadata: Manager.Metadata().set({ tag: 'forms/put' }),
43
+ };
44
+
45
+ await formRef.update(updates);
46
+
47
+ assistant.log(`Updated form ${settings.id} for user ${uid}`);
48
+
49
+ analytics.event('forms', { action: 'update' });
50
+
51
+ return assistant.respond({ data: { id: settings.id } });
52
+ };
@@ -0,0 +1,39 @@
1
+ /**
2
+ * DELETE /user/data-request - Cancel a pending data request
3
+ * Deletes the most recent pending data request for the authenticated user.
4
+ */
5
+ module.exports = async ({ assistant, user, libraries }) => {
6
+ const { admin } = libraries;
7
+
8
+ // Require authentication
9
+ if (!user.authenticated) {
10
+ return assistant.respond('Authentication required', { code: 401 });
11
+ }
12
+
13
+ const uid = user.auth.uid;
14
+
15
+ // Get the most recent request
16
+ const mostRecentSnapshot = await admin.firestore()
17
+ .collection('data-requests')
18
+ .where('owner', '==', uid)
19
+ .orderBy('metadata.created.timestampUNIX', 'desc')
20
+ .limit(1)
21
+ .get();
22
+
23
+ if (mostRecentSnapshot.empty || mostRecentSnapshot.docs[0].data().status !== 'pending') {
24
+ return assistant.respond('No pending data request found.', { code: 404 });
25
+ }
26
+
27
+ const requestDoc = mostRecentSnapshot.docs[0];
28
+ const request = requestDoc.data();
29
+
30
+ // Delete the request document
31
+ await admin.firestore().doc(`data-requests/${requestDoc.id}`).delete();
32
+
33
+ assistant.log(`Data request cancelled: ${requestDoc.id} for user ${uid}`);
34
+
35
+ return assistant.respond({
36
+ message: 'Your data request has been cancelled.',
37
+ request: { id: requestDoc.id, ...request },
38
+ });
39
+ };
@@ -0,0 +1,181 @@
1
+ /**
2
+ * GET /user/data-request - Check data request status or download data
3
+ *
4
+ * action=status (default): Returns the most recent request with its stored status.
5
+ * action=download: Compiles user data live and returns it. Only works when status is 'complete'.
6
+ *
7
+ * Statuses:
8
+ * pending — request submitted, waiting to be processed (bm_cronDaily sets to 'complete' after 14 days)
9
+ * complete — data is available for download (downloads counter tracks how many times downloaded)
10
+ */
11
+ module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
12
+ const { admin } = libraries;
13
+
14
+ // Require authentication
15
+ if (!user.authenticated) {
16
+ return assistant.respond('Authentication required', { code: 401 });
17
+ }
18
+
19
+ const uid = user.auth.uid;
20
+
21
+ // Get the most recent request
22
+ const requestsSnapshot = await admin.firestore()
23
+ .collection('data-requests')
24
+ .where('owner', '==', uid)
25
+ .orderBy('metadata.created.timestampUNIX', 'desc')
26
+ .limit(1)
27
+ .get();
28
+
29
+ if (requestsSnapshot.empty) {
30
+ return assistant.respond({ request: null });
31
+ }
32
+
33
+ const requestDoc = requestsSnapshot.docs[0];
34
+ const request = requestDoc.data();
35
+ const requestId = requestDoc.id;
36
+
37
+ const status = request.status;
38
+
39
+ // Status check — return the doc as-is
40
+ if (settings.action !== 'download') {
41
+ return assistant.respond({
42
+ request: { id: requestId, ...request },
43
+ });
44
+ }
45
+
46
+ // Download action — only allowed when status is 'complete'
47
+ if (status !== 'complete') {
48
+ return assistant.respond('Your data request is still being processed. Please check back later.', { code: 400 });
49
+ }
50
+
51
+ // Build query list: BEM defaults + project-specific queries from config
52
+ const defaultQueries = [
53
+ { path: 'users/{uid}', redact: ['api.privateKey'] },
54
+ { collection: 'data-requests', where: [['owner', '==', '{uid}']] },
55
+ { collection: 'payments-intents', where: [['owner', '==', '{uid}']] },
56
+ { collection: 'payments-orders', where: [['owner', '==', '{uid}']] },
57
+ ];
58
+ const customQueries = Manager.config.dataRequest?.queries || [];
59
+ const allQueries = defaultQueries.concat(customQueries);
60
+
61
+ // Execute all queries in parallel (+ auth record)
62
+ const queryPromises = allQueries.map(q => {
63
+ if (q.path) {
64
+ // Single document fetch
65
+ const resolvedPath = q.path.replace('{uid}', uid);
66
+ return admin.firestore().doc(resolvedPath).get();
67
+ }
68
+
69
+ // Collection query
70
+ let ref = admin.firestore().collection(q.collection);
71
+
72
+ for (const [field, op, value] of q.where) {
73
+ ref = ref.where(field, op, value === '{uid}' ? uid : value);
74
+ }
75
+
76
+ return ref.get();
77
+ });
78
+
79
+ const results = await Promise.all([
80
+ ...queryPromises,
81
+ admin.auth().getUser(uid).catch(() => null),
82
+ ]);
83
+
84
+ // Compile data from results
85
+ const authRecordResult = results.pop();
86
+ const data = {
87
+ exportedAt: assistant.meta.startTime.timestamp,
88
+ authRecord: null,
89
+ };
90
+
91
+ // Process query results
92
+ allQueries.forEach((q, i) => {
93
+ const result = results[i];
94
+
95
+ if (q.path) {
96
+ // Single document
97
+ const key = q.path.split('/')[0];
98
+ if (!result.exists) {
99
+ data[key] = null;
100
+ return;
101
+ }
102
+
103
+ const docData = result.data();
104
+
105
+ // Redact sensitive fields
106
+ if (q.redact) {
107
+ for (const fieldPath of q.redact) {
108
+ const parts = fieldPath.split('.');
109
+ let obj = docData;
110
+
111
+ for (let j = 0; j < parts.length - 1; j++) {
112
+ obj = obj?.[parts[j]];
113
+ }
114
+
115
+ if (obj) {
116
+ delete obj[parts[parts.length - 1]];
117
+ }
118
+ }
119
+ }
120
+
121
+ data[key] = docData;
122
+ return;
123
+ }
124
+
125
+ // Collection query
126
+ const docs = [];
127
+
128
+ result.forEach(doc => {
129
+ docs.push({ id: doc.id, ...doc.data() });
130
+ });
131
+
132
+ data[q.collection] = docs;
133
+ });
134
+
135
+ // Auth record (always included)
136
+ if (authRecordResult) {
137
+ data.authRecord = {
138
+ uid: authRecordResult.uid,
139
+ email: authRecordResult.email,
140
+ emailVerified: authRecordResult.emailVerified,
141
+ displayName: authRecordResult.displayName,
142
+ photoURL: authRecordResult.photoURL,
143
+ phoneNumber: authRecordResult.phoneNumber,
144
+ disabled: authRecordResult.disabled,
145
+ creationTime: authRecordResult.metadata.creationTime,
146
+ lastSignInTime: authRecordResult.metadata.lastSignInTime,
147
+ providerData: (authRecordResult.providerData || []).map(p => ({
148
+ providerId: p.providerId,
149
+ uid: p.uid,
150
+ displayName: p.displayName,
151
+ email: p.email,
152
+ photoURL: p.photoURL,
153
+ })),
154
+ };
155
+ } else {
156
+ data.authRecord = { error: 'Unable to retrieve auth record' };
157
+ }
158
+
159
+ // Increment downloads counter + set completed timestamp on first download
160
+ const updateData = {
161
+ downloads: admin.firestore.FieldValue.increment(1),
162
+ };
163
+
164
+ if (!request.metadata.completed?.timestampUNIX) {
165
+ updateData['metadata.completed.timestamp'] = assistant.meta.startTime.timestamp;
166
+ updateData['metadata.completed.timestampUNIX'] = assistant.meta.startTime.timestampUNIX;
167
+ }
168
+
169
+ await admin.firestore()
170
+ .doc(`data-requests/${requestId}`)
171
+ .update(updateData);
172
+
173
+ const downloads = (request.downloads || 0) + 1;
174
+
175
+ assistant.log(`Data request ${requestId} downloaded by user ${uid} (download #${downloads})`);
176
+
177
+ return assistant.respond({
178
+ request: { id: requestId, ...request, downloads: downloads },
179
+ data: data,
180
+ });
181
+ };
@@ -0,0 +1,133 @@
1
+ /**
2
+ * POST /user/data-request - Create a new GDPR data request
3
+ * Creates a pending data request record for the authenticated user.
4
+ */
5
+ const uuidv4 = require('uuid').v4;
6
+
7
+ module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
8
+ const { admin } = libraries;
9
+
10
+ // Require authentication
11
+ if (!user.authenticated) {
12
+ return assistant.respond('Authentication required', { code: 401 });
13
+ }
14
+
15
+ // Require confirmation
16
+ if (!settings.confirmed) {
17
+ return assistant.respond('You must confirm the data request acknowledgments.', { code: 400 });
18
+ }
19
+
20
+ const uid = user.auth.uid;
21
+
22
+ // Sanitize reason — strip HTML tags and trim
23
+ const reason = (settings.reason || '').replace(/<[^>]*>/g, '').trim().substring(0, 500);
24
+
25
+ // Get the most recent request
26
+ const mostRecentSnapshot = await admin.firestore()
27
+ .collection('data-requests')
28
+ .where('owner', '==', uid)
29
+ .orderBy('metadata.created.timestampUNIX', 'desc')
30
+ .limit(1)
31
+ .get();
32
+
33
+ if (!mostRecentSnapshot.empty) {
34
+ const mostRecent = mostRecentSnapshot.docs[0].data();
35
+
36
+ // Reject if there's already a pending request
37
+ if (mostRecent.status === 'pending') {
38
+ return assistant.respond('You already have a pending data request. Please wait for it to be processed.', { code: 409 });
39
+ }
40
+
41
+ // Reject if last request was created within 30 days (cooldown)
42
+ const THIRTY_DAYS = 30 * 24 * 60 * 60;
43
+ const elapsedSeconds = Math.round(Date.now() / 1000) - mostRecent.metadata.created.timestampUNIX;
44
+
45
+ if (elapsedSeconds < THIRTY_DAYS) {
46
+ return assistant.respond('You have already received a data export within the last 30 days. Please try again later.', { code: 429 });
47
+ }
48
+ }
49
+
50
+ // Create the request document
51
+ const requestId = uuidv4();
52
+ const now = assistant.meta.startTime.timestamp;
53
+ const nowUNIX = assistant.meta.startTime.timestampUNIX;
54
+
55
+ const docData = {
56
+ status: 'pending',
57
+ reason: reason,
58
+ downloads: 0,
59
+ owner: uid,
60
+ metadata: {
61
+ ...Manager.Metadata().set({ tag: 'user/data-request' }),
62
+ created: {
63
+ timestamp: now,
64
+ timestampUNIX: nowUNIX,
65
+ },
66
+ completed: {
67
+ timestamp: null,
68
+ timestampUNIX: 0,
69
+ },
70
+ },
71
+ };
72
+
73
+ await admin.firestore()
74
+ .doc(`data-requests/${requestId}`)
75
+ .set(docData, { merge: true });
76
+
77
+ assistant.log(`Data request created: ${requestId} for user ${uid}`);
78
+
79
+ // Send confirmation email (fire-and-forget)
80
+ sendConfirmationEmail(assistant, user.auth.email, requestId, reason);
81
+
82
+ return assistant.respond({
83
+ request: { id: requestId, ...docData },
84
+ });
85
+ };
86
+
87
+ /**
88
+ * Send data request confirmation email (fire-and-forget)
89
+ */
90
+ function sendConfirmationEmail(assistant, email, requestId, reason) {
91
+ const Manager = assistant.Manager;
92
+ const brandName = Manager.config.brand.name;
93
+ const mailer = Manager.Email(assistant);
94
+ const reasonLine = reason
95
+ ? `\n\n**Reason provided:** ${reason}`
96
+ : '';
97
+
98
+ mailer.send({
99
+ to: email,
100
+ categories: ['account/data-request'],
101
+ subject: `Your data request has been received #${requestId}`,
102
+ template: 'default',
103
+ group: 'account',
104
+ copy: true,
105
+ data: {
106
+ email: {
107
+ preview: `We've received your data export request. Your data will be available for download within 14 business days.`,
108
+ },
109
+ body: {
110
+ title: 'Data Request Received',
111
+ message: `We've received your request for a copy of your personal data.${reasonLine}
112
+
113
+ **What happens next:**
114
+
115
+ - Your request is now being processed.
116
+ - Processing takes up to **14 business days**.
117
+ - Once ready, you must return to your **account page** to download your data. We will not send the data via email for security reasons.
118
+ - Your data will be available as a JSON file download.
119
+ - Only one request can be active at a time, and you may submit a new request once every 30 days.
120
+
121
+ If you did not make this request, please contact us immediately by replying to this email.
122
+
123
+ Reference: **#${requestId}**`,
124
+ },
125
+ },
126
+ })
127
+ .then((result) => {
128
+ assistant.log(`sendConfirmationEmail(): Success, status=${result.status}`);
129
+ })
130
+ .catch((e) => {
131
+ assistant.error(`sendConfirmationEmail(): Failed: ${e.message}`);
132
+ });
133
+ }
@@ -63,6 +63,11 @@ module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
63
63
  assistant.error(`Sign out of all sessions failed`, e);
64
64
  });
65
65
 
66
+ // Get the user's email before deleting (for confirmation email)
67
+ const email = uid === user.auth.uid
68
+ ? user.auth.email
69
+ : await admin.auth().getUser(uid).then(r => r.email).catch(() => null);
70
+
66
71
  // Delete the user
67
72
  try {
68
73
  await admin.auth().deleteUser(uid);
@@ -70,5 +75,53 @@ module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
70
75
  return assistant.respond(`Failed to delete user: ${e}`, { code: 500 });
71
76
  }
72
77
 
78
+ // Send confirmation email (fire-and-forget)
79
+ if (email) {
80
+ sendConfirmationEmail(assistant, email);
81
+ }
82
+
73
83
  return assistant.respond({ success: true });
74
84
  };
85
+
86
+ /**
87
+ * Send account deletion confirmation email (fire-and-forget)
88
+ */
89
+ function sendConfirmationEmail(assistant, email) {
90
+ const Manager = assistant.Manager;
91
+ const brandName = Manager.config.brand.name;
92
+ const mailer = Manager.Email(assistant);
93
+
94
+ mailer.send({
95
+ to: email,
96
+ categories: ['account/delete'],
97
+ subject: `Your ${brandName} account has been deleted`,
98
+ template: 'default',
99
+ group: 'account',
100
+ data: {
101
+ email: {
102
+ preview: `Your ${brandName} account has been permanently deleted. All associated data has been removed.`,
103
+ },
104
+ body: {
105
+ title: 'Account Deleted',
106
+ message: `Your **${brandName}** account and all associated personal data have been permanently deleted from our systems. This action is irreversible.
107
+
108
+ **What this means:**
109
+
110
+ - Your account credentials and profile information have been removed.
111
+ - Any pending data requests have been cancelled.
112
+ - Subscription and billing records have been deleted.
113
+ - You will no longer be able to sign in with this account.
114
+
115
+ If you did not request this deletion, please contact us immediately by replying to this email.
116
+
117
+ If you wish to use ${brandName} again in the future, you are welcome to create a new account at any time.`,
118
+ },
119
+ },
120
+ })
121
+ .then((result) => {
122
+ assistant.log(`sendConfirmationEmail(): Success, status=${result.status}`);
123
+ })
124
+ .catch((e) => {
125
+ assistant.error(`sendConfirmationEmail(): Failed: ${e.message}`);
126
+ });
127
+ }
@@ -286,17 +286,13 @@ function sendWelcomeEmail(assistant, email) {
286
286
  },
287
287
  body: {
288
288
  title: `Welcome to ${Manager.config.brand.name}!`,
289
- message: `
290
- Welcome aboard!
291
- <br><br>
292
- I'm Ian, the founder and CEO of <strong>${Manager.config.brand.name}</strong>, and I'm thrilled to have you with us.
293
- Your journey begins today, and we are committed to supporting you every step of the way.
294
- <br><br>
295
- We are dedicated to ensuring your experience is exceptional.
296
- Feel free to reply directly to this email with any questions you may have.
297
- <br><br>
298
- Thank you for choosing <strong>${Manager.config.brand.name}</strong>. Here's to new beginnings!
299
- `,
289
+ message: `Welcome aboard!
290
+
291
+ I'm Ian, the founder and CEO of **${Manager.config.brand.name}**, and I'm thrilled to have you with us. Your journey begins today, and we are committed to supporting you every step of the way.
292
+
293
+ We are dedicated to ensuring your experience is exceptional. Feel free to reply directly to this email with any questions you may have.
294
+
295
+ Thank you for choosing **${Manager.config.brand.name}**. Here's to new beginnings!`,
300
296
  },
301
297
  signoff: {
302
298
  type: 'personal',
@@ -333,17 +329,15 @@ function sendCheckupEmail(assistant, email) {
333
329
  },
334
330
  body: {
335
331
  title: `How's everything going?`,
336
- message: `
337
- Hi there,
338
- <br><br>
339
- It's Ian again from <strong>${Manager.config.brand.name}</strong>. Just checking in to see how things are going for you.
340
- <br><br>
341
- Have you had a chance to explore all our features? Any questions or feedback for us?
342
- <br><br>
343
- We're always here to help, so don't hesitate to reach out. Just reply to this email and we'll get back to you as soon as possible.
344
- <br><br>
345
- Thank you for choosing <strong>${Manager.config.brand.name}</strong>. Here's to new beginnings!
346
- `,
332
+ message: `Hi there,
333
+
334
+ It's Ian again from **${Manager.config.brand.name}**. Just checking in to see how things are going for you.
335
+
336
+ Have you had a chance to explore all our features? Any questions or feedback for us?
337
+
338
+ We're always here to help, so don't hesitate to reach out. Just reply to this email and we'll get back to you as soon as possible.
339
+
340
+ Thank you for choosing **${Manager.config.brand.name}**. Here's to new beginnings!`,
347
341
  },
348
342
  signoff: {
349
343
  type: 'personal',
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Schema for DELETE /forms
3
+ */
4
+ module.exports = () => ({
5
+ id: { types: ['string'], default: undefined, required: true },
6
+ });
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Schema for GET /forms
3
+ */
4
+ module.exports = () => ({
5
+ id: { types: ['string'], default: undefined },
6
+ });
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Schema for POST /forms (create)
3
+ */
4
+ module.exports = () => ({
5
+ name: { types: ['string'], default: 'Untitled Form' },
6
+ description: { types: ['string'], default: '' },
7
+ settings: { types: ['object'], default: {} },
8
+ pages: { types: ['array'], default: [] },
9
+ });
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Schema for GET /forms/public
3
+ */
4
+ module.exports = () => ({
5
+ id: { types: ['string'], default: undefined, required: true },
6
+ });
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Schema for PUT /forms (update)
3
+ */
4
+ module.exports = () => ({
5
+ id: { types: ['string'], default: undefined, required: true },
6
+ name: { types: ['string'], default: undefined },
7
+ description: { types: ['string'], default: undefined },
8
+ settings: { types: ['object'], default: undefined },
9
+ pages: { types: ['array'], default: undefined },
10
+ });
@@ -0,0 +1 @@
1
+ module.exports = () => ({});
@@ -0,0 +1,7 @@
1
+ module.exports = () => ({
2
+ action: {
3
+ types: ['string'],
4
+ default: 'status',
5
+ required: false,
6
+ },
7
+ });
@@ -0,0 +1,10 @@
1
+ module.exports = () => ({
2
+ confirmed: {
3
+ types: ['boolean'],
4
+ default: false,
5
+ },
6
+ reason: {
7
+ types: ['string'],
8
+ default: '',
9
+ },
10
+ });
@@ -138,4 +138,11 @@
138
138
  'trustpilot.com',
139
139
  ],
140
140
  },
141
+ dataRequest: {
142
+ // Additional queries to include in GDPR data exports
143
+ // BEM always exports: users, auth record, data-requests, payments-intents, payments-orders
144
+ // Single doc: { path: 'collection/{uid}', redact: ['field.to.redact'] }
145
+ // Collection query: { collection: 'my-data', where: [['ownerField', '==', '{uid}']] }
146
+ queries: [],
147
+ },
141
148
  }