backend-manager 5.0.103 → 5.0.105

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 (60) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/CLAUDE.md +113 -24
  3. package/README.md +8 -0
  4. package/TODO-PAYMENT-v2.md +5 -2
  5. package/package.json +1 -1
  6. package/src/cli/commands/deploy.js +2 -4
  7. package/src/cli/commands/emulator.js +30 -1
  8. package/src/cli/commands/test.js +33 -2
  9. package/src/manager/events/firestore/payments-webhooks/on-write.js +17 -3
  10. package/src/manager/events/firestore/payments-webhooks/transitions/index.js +6 -0
  11. package/src/manager/libraries/payment/processors/paypal.js +587 -0
  12. package/src/manager/libraries/{payment-processors → payment/processors}/stripe.js +86 -18
  13. package/src/manager/libraries/{payment-processors → payment/processors}/test.js +15 -8
  14. package/src/manager/routes/payments/cancel/processors/paypal.js +30 -0
  15. package/src/manager/routes/payments/cancel/processors/stripe.js +1 -1
  16. package/src/manager/routes/payments/cancel/processors/test.js +4 -6
  17. package/src/manager/routes/payments/intent/post.js +3 -3
  18. package/src/manager/routes/payments/intent/processors/paypal.js +150 -0
  19. package/src/manager/routes/payments/intent/processors/stripe.js +3 -5
  20. package/src/manager/routes/payments/intent/processors/test.js +7 -8
  21. package/src/manager/routes/payments/portal/processors/paypal.js +24 -0
  22. package/src/manager/routes/payments/portal/processors/stripe.js +1 -1
  23. package/src/manager/routes/payments/refund/post.js +85 -0
  24. package/src/manager/routes/payments/refund/processors/paypal.js +117 -0
  25. package/src/manager/routes/payments/refund/processors/stripe.js +103 -0
  26. package/src/manager/routes/payments/refund/processors/test.js +98 -0
  27. package/src/manager/routes/payments/webhook/processors/paypal.js +137 -0
  28. package/src/manager/schemas/payments/refund/post.js +18 -0
  29. package/src/test/test-accounts.js +46 -0
  30. package/templates/backend-manager-config.json +20 -24
  31. package/test/events/payments/journey-payments-cancel.js +3 -3
  32. package/test/events/payments/journey-payments-failure.js +1 -1
  33. package/test/events/payments/journey-payments-one-time.js +1 -1
  34. package/test/events/payments/journey-payments-plan-change.js +4 -4
  35. package/test/events/payments/journey-payments-suspend.js +3 -3
  36. package/test/events/payments/journey-payments-trial.js +2 -2
  37. package/test/fixtures/paypal/order-approved.json +62 -0
  38. package/test/fixtures/paypal/order-completed.json +110 -0
  39. package/test/fixtures/paypal/subscription-active.json +76 -0
  40. package/test/fixtures/paypal/subscription-cancelled.json +50 -0
  41. package/test/fixtures/paypal/subscription-suspended.json +65 -0
  42. package/test/helpers/payment/paypal/parse-webhook.js +539 -0
  43. package/test/helpers/payment/paypal/to-unified-one-time.js +382 -0
  44. package/test/helpers/payment/paypal/to-unified-subscription.js +820 -0
  45. package/test/helpers/{stripe-parse-webhook.js → payment/stripe/parse-webhook.js} +4 -4
  46. package/test/helpers/{stripe-to-unified-one-time.js → payment/stripe/to-unified-one-time.js} +8 -6
  47. package/test/helpers/{stripe-to-unified.js → payment/stripe/to-unified-subscription.js} +40 -33
  48. package/test/routes/payments/refund.js +174 -0
  49. package/src/manager/libraries/payment-processors/resolve-price-id.js +0 -19
  50. package/src/manager/routes/forms/delete.js +0 -37
  51. package/src/manager/routes/forms/get.js +0 -46
  52. package/src/manager/routes/forms/post.js +0 -45
  53. package/src/manager/routes/forms/public/get.js +0 -37
  54. package/src/manager/routes/forms/put.js +0 -52
  55. package/src/manager/schemas/forms/delete.js +0 -6
  56. package/src/manager/schemas/forms/get.js +0 -6
  57. package/src/manager/schemas/forms/post.js +0 -9
  58. package/src/manager/schemas/forms/public/get.js +0 -6
  59. package/src/manager/schemas/forms/put.js +0 -10
  60. /package/src/manager/libraries/{payment-processors → payment}/order-id.js +0 -0
@@ -5,14 +5,14 @@
5
5
  * Verifies that parseWebhook() correctly determines category, resourceType, resourceId,
6
6
  * and uid for each supported event type. Uses real Stripe CLI fixtures where available.
7
7
  */
8
- const stripeProcessor = require('../../src/manager/routes/payments/webhook/processors/stripe.js');
8
+ const stripeProcessor = require('../../../../src/manager/routes/payments/webhook/processors/stripe.js');
9
9
 
10
10
  // Real Stripe CLI fixtures
11
- const FIXTURE_INVOICE_MANUAL = require('../fixtures/stripe/invoice-payment-failed.json');
12
- const FIXTURE_CHECKOUT_PAYMENT = require('../fixtures/stripe/checkout-session-completed.json');
11
+ const FIXTURE_INVOICE_MANUAL = require('../../../fixtures/stripe/invoice-payment-failed.json');
12
+ const FIXTURE_CHECKOUT_PAYMENT = require('../../../fixtures/stripe/checkout-session-completed.json');
13
13
 
14
14
  // Hand-crafted fixture (subscription-related invoice failure)
15
- const FIXTURE_INVOICE_SUB = require('../fixtures/stripe/invoice-subscription-payment-failed.json');
15
+ const FIXTURE_INVOICE_SUB = require('../../../fixtures/stripe/invoice-subscription-payment-failed.json');
16
16
 
17
17
  function parseWebhook(event) {
18
18
  return stripeProcessor.parseWebhook({ body: event });
@@ -4,24 +4,26 @@
4
4
  *
5
5
  * Tests the pure function directly — no emulator, no Firestore, no HTTP
6
6
  */
7
- const Stripe = require('../../src/manager/libraries/payment-processors/stripe.js');
7
+ const Stripe = require('../../../../src/manager/libraries/payment/processors/stripe.js');
8
8
 
9
9
  // Real Stripe CLI fixtures (generated via `stripe trigger`)
10
- const FIXTURE_SESSION = require('../fixtures/stripe/checkout-session-completed.json');
11
- const FIXTURE_INVOICE_FAILED = require('../fixtures/stripe/invoice-payment-failed.json');
10
+ const FIXTURE_SESSION = require('../../../fixtures/stripe/checkout-session-completed.json');
11
+ const FIXTURE_INVOICE_FAILED = require('../../../fixtures/stripe/invoice-payment-failed.json');
12
12
 
13
- // Mock config matching the BEM template
13
+ // Mock config matching the BEM template (new flat price structure)
14
14
  const MOCK_CONFIG = {
15
15
  payment: {
16
16
  products: [
17
17
  { id: 'basic', name: 'Basic', type: 'subscription', limits: { requests: 100 } },
18
18
  {
19
19
  id: 'credits-100', name: '100 Credits', type: 'one-time',
20
- prices: { once: { amount: 9.99, stripe: 'price_credits_100' } },
20
+ prices: { once: 9.99 },
21
+ stripe: { productId: 'prod_credits_100' },
21
22
  },
22
23
  {
23
24
  id: 'credits-500', name: '500 Credits', type: 'one-time',
24
- prices: { once: { amount: 39.99, stripe: 'price_credits_500' } },
25
+ prices: { once: 39.99 },
26
+ stripe: { productId: 'prod_credits_500' },
25
27
  },
26
28
  ],
27
29
  },
@@ -4,31 +4,27 @@
4
4
  *
5
5
  * Tests the pure function directly — no emulator, no Firestore, no HTTP
6
6
  */
7
- const Stripe = require('../../src/manager/libraries/payment-processors/stripe.js');
7
+ const Stripe = require('../../../../src/manager/libraries/payment/processors/stripe.js');
8
8
 
9
9
  // Real Stripe CLI fixtures (generated via `stripe trigger`)
10
- const FIXTURE_ACTIVE = require('../fixtures/stripe/subscription-active.json');
11
- const FIXTURE_CANCELED = require('../fixtures/stripe/subscription-canceled.json');
12
- const FIXTURE_TRIALING = require('../fixtures/stripe/subscription-trialing.json');
10
+ const FIXTURE_ACTIVE = require('../../../fixtures/stripe/subscription-active.json');
11
+ const FIXTURE_CANCELED = require('../../../fixtures/stripe/subscription-canceled.json');
12
+ const FIXTURE_TRIALING = require('../../../fixtures/stripe/subscription-trialing.json');
13
13
 
14
- // Mock config matching the BEM template
14
+ // Mock config matching the BEM template (new flat price structure)
15
15
  const MOCK_CONFIG = {
16
16
  payment: {
17
17
  products: [
18
18
  { id: 'basic', name: 'Basic', type: 'subscription', limits: { requests: 100 } },
19
19
  {
20
20
  id: 'plus', name: 'Plus', type: 'subscription',
21
- prices: {
22
- monthly: { amount: 9.99, stripe: 'price_plus_monthly' },
23
- annually: { amount: 99.99, stripe: 'price_plus_annually' },
24
- },
21
+ prices: { monthly: 9.99, annually: 99.99 },
22
+ stripe: { productId: 'prod_plus' },
25
23
  },
26
24
  {
27
25
  id: 'pro', name: 'Pro', type: 'subscription',
28
- prices: {
29
- monthly: { amount: 29.99, stripe: 'price_pro_monthly' },
30
- annually: { amount: 299.99, stripe: 'price_pro_annually' },
31
- },
26
+ prices: { monthly: 29.99, annually: 299.99 },
27
+ stripe: { productId: 'prod_pro', legacyProductIds: ['prod_pro_old'] },
32
28
  },
33
29
  ],
34
30
  },
@@ -112,18 +108,18 @@ module.exports = {
112
108
  // ─── Product resolution ───
113
109
 
114
110
  {
115
- name: 'product-resolves-monthly-price',
111
+ name: 'product-resolves-from-plan-product',
116
112
  async run({ assert }) {
117
- const result = toUnifiedSubscription({ plan: { id: 'price_plus_monthly' } });
113
+ const result = toUnifiedSubscription({ plan: { product: 'prod_plus' } });
118
114
  assert.equal(result.product.id, 'plus', 'Should resolve to plus');
119
115
  assert.equal(result.product.name, 'Plus', 'Should have correct name');
120
116
  },
121
117
  },
122
118
 
123
119
  {
124
- name: 'product-resolves-annual-price',
120
+ name: 'product-resolves-pro-from-plan-product',
125
121
  async run({ assert }) {
126
- const result = toUnifiedSubscription({ plan: { id: 'price_pro_annually' } });
122
+ const result = toUnifiedSubscription({ plan: { product: 'prod_pro' } });
127
123
  assert.equal(result.product.id, 'pro', 'Should resolve to pro');
128
124
  assert.equal(result.product.name, 'Pro', 'Should have correct name');
129
125
  },
@@ -133,18 +129,29 @@ module.exports = {
133
129
  name: 'product-resolves-from-items-array',
134
130
  async run({ assert }) {
135
131
  const result = toUnifiedSubscription({
136
- items: { data: [{ price: { id: 'price_plus_monthly' } }] },
132
+ items: { data: [{ price: { product: 'prod_plus' } }] },
137
133
  });
138
- assert.equal(result.product.id, 'plus', 'Should resolve from items.data[0].price.id');
134
+ assert.equal(result.product.id, 'plus', 'Should resolve from items.data[0].price.product');
139
135
  },
140
136
  },
141
137
 
142
138
  {
143
- name: 'product-falls-back-to-basic-on-unknown-price',
139
+ name: 'product-resolves-legacy-product-id',
144
140
  async run({ assert }) {
145
- const result = toUnifiedSubscription({ plan: { id: 'price_nonexistent' } });
146
- assert.equal(result.product.id, 'basic', 'Unknown price → basic');
147
- assert.equal(result.product.name, 'Basic', 'Unknown price → Basic name');
141
+ const result = toUnifiedSubscription({
142
+ items: { data: [{ price: { product: 'prod_pro_old' } }] },
143
+ });
144
+ assert.equal(result.product.id, 'pro', 'Legacy product ID → pro');
145
+ assert.equal(result.product.name, 'Pro', 'Legacy product ID → Pro name');
146
+ },
147
+ },
148
+
149
+ {
150
+ name: 'product-falls-back-to-basic-on-unknown-product',
151
+ async run({ assert }) {
152
+ const result = toUnifiedSubscription({ plan: { product: 'prod_nonexistent' } });
153
+ assert.equal(result.product.id, 'basic', 'Unknown product → basic');
154
+ assert.equal(result.product.name, 'Basic', 'Unknown product → Basic name');
148
155
  },
149
156
  },
150
157
 
@@ -159,7 +166,7 @@ module.exports = {
159
166
  {
160
167
  name: 'product-falls-back-to-basic-without-config',
161
168
  async run({ assert }) {
162
- const result = Stripe.toUnifiedSubscription({ plan: { id: 'price_plus_monthly' } }, {});
169
+ const result = Stripe.toUnifiedSubscription({ plan: { product: 'prod_plus' } }, {});
163
170
  assert.equal(result.product.id, 'basic', 'No config → basic');
164
171
  },
165
172
  },
@@ -408,7 +415,7 @@ module.exports = {
408
415
  const result = toUnifiedSubscription({
409
416
  id: 'sub_full_test',
410
417
  status: 'active',
411
- plan: { id: 'price_pro_monthly', interval: 'month' },
418
+ plan: { product: 'prod_pro', interval: 'month' },
412
419
  current_period_end: now + 86400 * 30,
413
420
  current_period_start: now,
414
421
  start_date: now - 86400 * 60,
@@ -488,9 +495,9 @@ module.exports = {
488
495
  {
489
496
  name: 'fixture-active-product-falls-back',
490
497
  async run({ assert }) {
491
- // Fixture price IDs won't match our mock config, so it should fall back to basic
498
+ // Fixture product IDs won't match our mock config, so it should fall back to basic
492
499
  const result = toUnifiedSubscription(FIXTURE_ACTIVE);
493
- assert.equal(result.product.id, 'basic', 'Unknown price → basic fallback');
500
+ assert.equal(result.product.id, 'basic', 'Unknown product → basic fallback');
494
501
  },
495
502
  },
496
503
 
@@ -576,7 +583,7 @@ module.exports = {
576
583
  canceled_at: null,
577
584
  current_period_end: now + 86400 * 11,
578
585
  start_date: now - 86400 * 3,
579
- plan: { id: 'price_plus_monthly', interval: 'month' },
586
+ plan: { product: 'prod_plus', interval: 'month' },
580
587
  });
581
588
 
582
589
  assert.equal(result.status, 'active', 'Trialing + cancel → still active');
@@ -599,7 +606,7 @@ module.exports = {
599
606
  canceled_at: now,
600
607
  current_period_end: now - 86400,
601
608
  start_date: now - 86400 * 14,
602
- plan: { id: 'price_plus_monthly', interval: 'month' },
609
+ plan: { product: 'prod_plus', interval: 'month' },
603
610
  });
604
611
 
605
612
  assert.equal(result.status, 'cancelled', 'Failed trial → cancelled');
@@ -622,7 +629,7 @@ module.exports = {
622
629
  canceled_at: null,
623
630
  current_period_end: now + 86400 * 14,
624
631
  start_date: now - 86400 * 30,
625
- plan: { id: 'price_pro_monthly', interval: 'month' },
632
+ plan: { product: 'prod_pro', interval: 'month' },
626
633
  });
627
634
 
628
635
  assert.equal(result.status, 'active', 'Active with past trial → active');
@@ -646,7 +653,7 @@ module.exports = {
646
653
  start_date: now - 86400 * 40,
647
654
  trial_start: null,
648
655
  trial_end: null,
649
- plan: { id: 'price_pro_monthly', interval: 'month' },
656
+ plan: { product: 'prod_pro', interval: 'month' },
650
657
  });
651
658
 
652
659
  assert.equal(result.status, 'active', 'Reactivated → active');
@@ -668,7 +675,7 @@ module.exports = {
668
675
  start_date: now - 86400 * 60,
669
676
  trial_start: null,
670
677
  trial_end: null,
671
- plan: { id: 'price_plus_monthly', interval: 'month' },
678
+ plan: { product: 'prod_plus', interval: 'month' },
672
679
  });
673
680
 
674
681
  assert.equal(result.status, 'suspended', 'Past due → suspended');
@@ -689,7 +696,7 @@ module.exports = {
689
696
  canceled_at: null,
690
697
  current_period_end: now + 86400,
691
698
  start_date: now - 86400 * 14,
692
- plan: { id: 'price_plus_monthly', interval: 'month' },
699
+ plan: { product: 'prod_plus', interval: 'month' },
693
700
  });
694
701
 
695
702
  assert.equal(result.status, 'suspended', 'Trial ended + payment failed → suspended');
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Test: POST /payments/refund
3
+ * Tests rejection cases and a full end-to-end refund flow with the test processor.
4
+ *
5
+ * Refund requires the subscription to be cancelled or pending cancellation.
6
+ * The test processor simulates refund by writing a customer.subscription.deleted
7
+ * webhook which triggers the existing pipeline.
8
+ */
9
+ module.exports = {
10
+ description: 'Payment refund endpoint',
11
+ type: 'group',
12
+ timeout: 30000,
13
+
14
+ tests: [
15
+ {
16
+ name: 'rejects-unauthenticated',
17
+ async run({ http, assert }) {
18
+ const response = await http.as('none').post('payments/refund', {
19
+ confirmed: true,
20
+ reason: 'Too expensive',
21
+ });
22
+
23
+ assert.isError(response, 401, 'Should reject unauthenticated request');
24
+ },
25
+ },
26
+
27
+ {
28
+ name: 'rejects-missing-confirmed',
29
+ async run({ http, assert }) {
30
+ const response = await http.as('basic').post('payments/refund', {
31
+ reason: 'Too expensive',
32
+ });
33
+
34
+ assert.isError(response, 400, 'Should reject missing confirmed field');
35
+ },
36
+ },
37
+
38
+ {
39
+ name: 'rejects-missing-reason',
40
+ async run({ http, assert }) {
41
+ const response = await http.as('basic').post('payments/refund', {
42
+ confirmed: true,
43
+ });
44
+
45
+ assert.isError(response, 400, 'Should reject missing reason field');
46
+ },
47
+ },
48
+
49
+ {
50
+ name: 'rejects-basic-user',
51
+ async run({ http, assert }) {
52
+ const response = await http.as('basic').post('payments/refund', {
53
+ confirmed: true,
54
+ reason: 'Too expensive',
55
+ });
56
+
57
+ assert.isError(response, 400, 'Should reject basic user with no paid subscription');
58
+ },
59
+ },
60
+
61
+ {
62
+ name: 'rejects-active-subscription-without-cancellation',
63
+ async run({ http, assert }) {
64
+ // refund-active-no-cancel has an active subscription without pending cancellation
65
+ const response = await http.as('refund-active-no-cancel').post('payments/refund', {
66
+ confirmed: true,
67
+ reason: 'Too expensive',
68
+ });
69
+
70
+ assert.isError(response, 400, 'Should reject active subscription that is not cancelled or pending cancellation');
71
+ },
72
+ },
73
+
74
+ {
75
+ name: 'rejects-payment-older-than-6-months',
76
+ async run({ http, assert }) {
77
+ // refund-expired-payment has a cancelled subscription with a payment older than 6 months
78
+ const response = await http.as('refund-expired-payment').post('payments/refund', {
79
+ confirmed: true,
80
+ reason: 'Too expensive',
81
+ });
82
+
83
+ assert.isError(response, 400, 'Should reject payment older than 6 months');
84
+ },
85
+ },
86
+
87
+ {
88
+ name: 'rejects-no-processor-or-resource-id',
89
+ async run({ http, assert }) {
90
+ // refund-no-processor has a cancelled subscription but no processor
91
+ const response = await http.as('refund-no-processor').post('payments/refund', {
92
+ confirmed: true,
93
+ reason: 'Too expensive',
94
+ });
95
+
96
+ assert.isError(response, 400, 'Should reject when no processor or resourceId is set');
97
+ },
98
+ },
99
+
100
+ {
101
+ name: 'rejects-unknown-processor',
102
+ async run({ http, assert }) {
103
+ // refund-unknown-processor has a cancelled subscription with unknown processor
104
+ const response = await http.as('refund-unknown-processor').post('payments/refund', {
105
+ confirmed: true,
106
+ reason: 'Too expensive',
107
+ });
108
+
109
+ assert.isError(response, 400, 'Should reject unknown processor');
110
+ },
111
+ },
112
+
113
+ {
114
+ name: 'succeeds-with-test-processor',
115
+ async run({ http, assert, config, accounts, firestore, waitFor }) {
116
+ const uid = accounts['route-refund-success'].uid;
117
+ const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.prices?.monthly);
118
+
119
+ // Step 1: Create a test subscription intent to set up a proper paid subscription
120
+ const intentResponse = await http.as('route-refund-success').post('payments/intent', {
121
+ processor: 'test',
122
+ productId: paidProduct.id,
123
+ frequency: 'monthly',
124
+ });
125
+
126
+ assert.isSuccess(intentResponse, 'Intent should succeed');
127
+
128
+ // Wait for the auto-webhook to activate the subscription
129
+ await waitFor(async () => {
130
+ const userDoc = await firestore.get(`users/${uid}`);
131
+ return userDoc?.subscription?.payment?.processor === 'test'
132
+ && userDoc?.subscription?.payment?.resourceId
133
+ && userDoc?.subscription?.status === 'active';
134
+ }, 15000, 500);
135
+
136
+ // Step 2: Cancel the subscription first (refund requires cancellation)
137
+ const cancelResponse = await http.as('route-refund-success').post('payments/cancel', {
138
+ confirmed: true,
139
+ reason: 'Too expensive',
140
+ });
141
+
142
+ assert.isSuccess(cancelResponse, 'Cancel should succeed');
143
+
144
+ // Wait for cancellation.pending to be set via the webhook pipeline
145
+ await waitFor(async () => {
146
+ const userDoc = await firestore.get(`users/${uid}`);
147
+ return userDoc?.subscription?.cancellation?.pending === true;
148
+ }, 15000, 500);
149
+
150
+ // Step 3: Request a refund
151
+ const refundResponse = await http.as('route-refund-success').post('payments/refund', {
152
+ confirmed: true,
153
+ reason: 'Not satisfied with the service',
154
+ feedback: 'Testing refund flow',
155
+ });
156
+
157
+ assert.isSuccess(refundResponse, 'Refund should succeed');
158
+ assert.ok(refundResponse.data.success, 'Should return success: true');
159
+ assert.ok(refundResponse.data.refund, 'Should return refund details');
160
+ assert.isType(refundResponse.data.refund.amount, 'number', 'Refund amount should be a number');
161
+ assert.equal(refundResponse.data.refund.full, true, 'Should be a full refund (test processor)');
162
+
163
+ // Step 4: Verify subscription is cancelled via the webhook pipeline
164
+ await waitFor(async () => {
165
+ const userDoc = await firestore.get(`users/${uid}`);
166
+ return userDoc?.subscription?.status === 'cancelled';
167
+ }, 15000, 500);
168
+
169
+ const userDoc = await firestore.get(`users/${uid}`);
170
+ assert.equal(userDoc?.subscription?.status, 'cancelled', 'Subscription should be cancelled after refund');
171
+ },
172
+ },
173
+ ],
174
+ };
@@ -1,19 +0,0 @@
1
- /**
2
- * Resolve the Stripe price ID from a product config object
3
- *
4
- * @param {object} product - Product object from config (must have .prices)
5
- * @param {string} productType - 'subscription' or 'one-time'
6
- * @param {string} frequency - 'monthly', 'annually', etc. (subscriptions) — ignored for one-time
7
- * @returns {string} Stripe price ID
8
- * @throws {Error} If no price ID found
9
- */
10
- module.exports = function resolvePriceId(product, productType, frequency) {
11
- const key = productType === 'subscription' ? frequency : 'once';
12
- const priceId = product.prices?.[key]?.stripe;
13
-
14
- if (!priceId) {
15
- throw new Error(`No Stripe price found for ${product.id}/${key}`);
16
- }
17
-
18
- return priceId;
19
- };
@@ -1,37 +0,0 @@
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
- };
@@ -1,46 +0,0 @@
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
- };
@@ -1,45 +0,0 @@
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
- };
@@ -1,37 +0,0 @@
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
- };
@@ -1,52 +0,0 @@
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
- };
@@ -1,6 +0,0 @@
1
- /**
2
- * Schema for DELETE /forms
3
- */
4
- module.exports = () => ({
5
- id: { types: ['string'], default: undefined, required: true },
6
- });
@@ -1,6 +0,0 @@
1
- /**
2
- * Schema for GET /forms
3
- */
4
- module.exports = () => ({
5
- id: { types: ['string'], default: undefined },
6
- });