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.
- package/package.json +2 -1
- package/src/cli/commands/setup-tests/firestore-indexes-required.js +105 -0
- package/src/cli/commands/setup-tests/firestore-indexes-synced.js +37 -27
- package/src/cli/commands/setup-tests/index.js +3 -1
- package/src/cli/commands/setup-tests/required-indexes.js +38 -0
- package/src/manager/events/firestore/payments-webhooks/transitions/one-time/purchase-completed.js +4 -1
- package/src/manager/events/firestore/payments-webhooks/transitions/send-email.js +10 -0
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/cancellation-requested.js +1 -1
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/new-subscription.js +2 -1
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/payment-failed.js +1 -1
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/payment-recovered.js +1 -1
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/plan-changed.js +1 -1
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/subscription-cancelled.js +3 -1
- package/src/manager/libraries/email.js +25 -16
- package/src/manager/routes/forms/delete.js +37 -0
- package/src/manager/routes/forms/get.js +46 -0
- package/src/manager/routes/forms/post.js +45 -0
- package/src/manager/routes/forms/public/get.js +37 -0
- package/src/manager/routes/forms/put.js +52 -0
- package/src/manager/routes/user/data-request/delete.js +39 -0
- package/src/manager/routes/user/data-request/get.js +181 -0
- package/src/manager/routes/user/data-request/post.js +133 -0
- package/src/manager/routes/user/delete.js +53 -0
- package/src/manager/routes/user/signup/post.js +16 -22
- package/src/manager/schemas/forms/delete.js +6 -0
- package/src/manager/schemas/forms/get.js +6 -0
- package/src/manager/schemas/forms/post.js +9 -0
- package/src/manager/schemas/forms/public/get.js +6 -0
- package/src/manager/schemas/forms/put.js +10 -0
- package/src/manager/schemas/user/data-request/delete.js +1 -0
- package/src/manager/schemas/user/data-request/get.js +7 -0
- package/src/manager/schemas/user/data-request/post.js +10 -0
- 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.
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
+
];
|
package/src/manager/events/firestore/payments-webhooks/transitions/one-time/purchase-completed.js
CHANGED
|
@@ -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
|
|
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
|
|
12
|
+
subject: `Your cancellation is confirmed #${order?.id || ''}`,
|
|
13
13
|
categories: ['order/cancellation-requested'],
|
|
14
14
|
userDoc,
|
|
15
15
|
assistant,
|
package/src/manager/events/firestore/payments-webhooks/transitions/subscription/new-subscription.js
CHANGED
|
@@ -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
|
|
17
|
+
subject: `Your ${brandName} ${planName} order #${order?.id || ''}`,
|
|
17
18
|
categories: ['order/confirmation'],
|
|
18
19
|
userDoc,
|
|
19
20
|
assistant,
|
package/src/manager/events/firestore/payments-webhooks/transitions/subscription/payment-failed.js
CHANGED
|
@@ -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: `
|
|
12
|
+
subject: `Payment failed for order #${order?.id || ''}`,
|
|
13
13
|
categories: ['order/payment-failed'],
|
|
14
14
|
userDoc,
|
|
15
15
|
assistant,
|
package/src/manager/events/firestore/payments-webhooks/transitions/subscription/payment-recovered.js
CHANGED
|
@@ -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: `
|
|
12
|
+
subject: `Payment received for order #${order?.id || ''}`,
|
|
13
13
|
categories: ['order/payment-recovered'],
|
|
14
14
|
userDoc,
|
|
15
15
|
assistant,
|
package/src/manager/events/firestore/payments-webhooks/transitions/subscription/plan-changed.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
95
|
-
|
|
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 (!
|
|
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:
|
|
112
|
-
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
|
-
|
|
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=${
|
|
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
|
-
|
|
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:
|
|
208
|
-
replyTo: settings.replyTo ||
|
|
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.
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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,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,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 = () => ({});
|
|
@@ -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
|
}
|