diff --git a/backend/package-lock.json b/backend/package-lock.json index cade46b..0fd9842 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -16,6 +16,7 @@ "dotenv": "^16.3.1", "express": "^4.18.2", "express-rate-limit": "^8.2.1", + "express-validator": "^7.3.1", "helmet": "^8.1.0", "jsonwebtoken": "^9.0.2", "multer": "^1.4.5-lts.1", @@ -2283,6 +2284,19 @@ "express": ">= 4.11" } }, + "node_modules/express-validator": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.1.tgz", + "integrity": "sha512-IGenaSf+DnWc69lKuqlRE9/i/2t5/16VpH5bXoqdxWz1aCpRvEdrBuu1y95i/iL5QP8ZYVATiwLFhwk3EDl5vg==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.21", + "validator": "~13.15.23" + }, + "engines": { + "node": ">= 8.0.0" + } + }, "node_modules/fast-xml-parser": { "version": "5.2.5", "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.2.5.tgz", @@ -2703,6 +2717,12 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -3603,6 +3623,15 @@ "node": ">= 0.4.0" } }, + "node_modules/validator": { + "version": "13.15.26", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.26.tgz", + "integrity": "sha512-spH26xU080ydGggxRyR1Yhcbgx+j3y5jbNXk/8L+iRvdIEQ4uTRH2Sgf2dokud6Q4oAtsbNvJ1Ft+9xmm6IZcA==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", diff --git a/backend/package.json b/backend/package.json index 3b6ee54..37f08f1 100644 --- a/backend/package.json +++ b/backend/package.json @@ -16,6 +16,7 @@ "dotenv": "^16.3.1", "express": "^4.18.2", "express-rate-limit": "^8.2.1", + "express-validator": "^7.3.1", "helmet": "^8.1.0", "jsonwebtoken": "^9.0.2", "multer": "^1.4.5-lts.1", diff --git a/backend/src/routes/beneficiaries.js b/backend/src/routes/beneficiaries.js index 406ac8d..c4730ad 100644 --- a/backend/src/routes/beneficiaries.js +++ b/backend/src/routes/beneficiaries.js @@ -2,6 +2,7 @@ const express = require('express'); const router = express.Router(); const jwt = require('jsonwebtoken'); const Stripe = require('stripe'); +const { body, validationResult } = require('express-validator'); const { supabase } = require('../config/supabase'); const storage = require('../services/storage'); const legacyAPI = require('../services/legacyAPI'); @@ -362,15 +363,32 @@ router.get('/:id', async (req, res) => { * Now uses the proper beneficiaries table (not users) * AUTO-CREATES FIRST DEPLOYMENT */ -router.post('/', async (req, res) => { +router.post('/', + [ + body('name') + .isString().withMessage('name must be a string') + .trim() + .isLength({ min: 1, max: 200 }).withMessage('name must be between 1 and 200 characters'), + body('phone') + .optional({ nullable: true }) + .isString().withMessage('phone must be a string') + .trim(), + body('address') + .optional({ nullable: true }) + .isString().withMessage('address must be a string') + .trim() + ], + async (req, res) => { try { + // Check validation errors + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ error: errors.array()[0].msg }); + } + const userId = req.user.userId; const { name, phone, address } = req.body; - if (!name) { - return res.status(400).json({ error: 'name is required' }); - } - console.log('[BENEFICIARY] Creating beneficiary:', { userId, name }); // Create beneficiary in the proper beneficiaries table (not users!) @@ -563,8 +581,35 @@ router.post('/', async (req, res) => { * - name, phone, address: beneficiary data (custodian only) * - customName: user's personal alias for this beneficiary (any role) */ -router.patch('/:id', async (req, res) => { +router.patch('/:id', + [ + body('name') + .optional() + .isString().withMessage('name must be a string') + .trim() + .isLength({ min: 1, max: 200 }).withMessage('name must be between 1 and 200 characters'), + body('phone') + .optional({ nullable: true }) + .isString().withMessage('phone must be a string') + .trim(), + body('address') + .optional({ nullable: true }) + .isString().withMessage('address must be a string') + .trim(), + body('customName') + .optional({ nullable: true }) + .isString().withMessage('customName must be a string') + .trim() + .isLength({ max: 100 }).withMessage('customName must be 100 characters or less') + ], + async (req, res) => { try { + // Check validation errors + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ error: errors.array()[0].msg }); + } + const userId = req.user.userId; const beneficiaryId = parseInt(req.params.id, 10); diff --git a/backend/src/routes/invitations.js b/backend/src/routes/invitations.js index 5caa423..8353cf6 100644 --- a/backend/src/routes/invitations.js +++ b/backend/src/routes/invitations.js @@ -2,6 +2,7 @@ const express = require('express'); const router = express.Router(); const jwt = require('jsonwebtoken'); const crypto = require('crypto'); +const { body, validationResult } = require('express-validator'); const { supabase } = require('../config/supabase'); const { sendInvitationEmail } = require('../services/email'); @@ -78,14 +79,23 @@ router.get('/info/:code', async (req, res) => { * POST /api/invitations/accept-public * Used from web page - no login required */ -router.post('/accept-public', async (req, res) => { +router.post('/accept-public', + [ + body('code') + .notEmpty().withMessage('code is required') + .isString().withMessage('code must be a string') + .trim() + ], + async (req, res) => { try { - const { code } = req.body; - - if (!code) { - return res.status(400).json({ error: 'Code is required' }); + // Check validation errors + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ error: errors.array()[0].msg }); } + const { code } = req.body; + console.log('[INVITE] Public accept:', { code }); // Find invitation by code @@ -232,21 +242,37 @@ function generateInviteToken() { * POST /api/invitations * Creates an invitation for someone to access a beneficiary */ -router.post('/', async (req, res) => { +router.post('/', + [ + body('beneficiaryId') + .notEmpty().withMessage('beneficiaryId is required') + .isInt({ min: 1 }).withMessage('beneficiaryId must be a positive integer'), + body('role') + .notEmpty().withMessage('role is required') + .isIn(['caretaker', 'guardian']).withMessage('role must be caretaker or guardian'), + body('email') + .optional({ nullable: true }) + .isEmail().withMessage('email must be a valid email address') + .normalizeEmail(), + body('label') + .optional({ nullable: true }) + .isString().withMessage('label must be a string') + .trim() + .isLength({ max: 100 }).withMessage('label must be 100 characters or less') + ], + async (req, res) => { try { + // Check validation errors + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ error: errors.array()[0].msg }); + } + const userId = req.user.userId; const { beneficiaryId, role, email, label } = req.body; console.log('[INVITE] Creating invitation:', { userId, beneficiaryId, role, email }); - if (!beneficiaryId || !role) { - return res.status(400).json({ error: 'beneficiaryId and role are required' }); - } - - if (!['caretaker', 'guardian'].includes(role)) { - return res.status(400).json({ error: 'Invalid role. Must be: caretaker or guardian' }); - } - // Get current user's email to check self-invite const { data: currentUser, error: userError } = await supabase .from('users') @@ -439,15 +465,24 @@ router.get('/beneficiary/:beneficiaryId', async (req, res) => { * POST /api/invitations/accept * Accepts an invitation code */ -router.post('/accept', async (req, res) => { +router.post('/accept', + [ + body('code') + .notEmpty().withMessage('code is required') + .isString().withMessage('code must be a string') + .trim() + ], + async (req, res) => { try { + // Check validation errors + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ error: errors.array()[0].msg }); + } + const userId = req.user.userId; const { code } = req.body; - if (!code) { - return res.status(400).json({ error: 'Code is required' }); - } - // Find valid invitation (no expiration check - invitations are permanent) const { data: invitation, error: findError } = await supabase .from('invitations') @@ -606,18 +641,26 @@ router.get('/', async (req, res) => { * PATCH /api/invitations/:id * Updates an invitation's role (before it's accepted) */ -router.patch('/:id', async (req, res) => { +router.patch('/:id', + [ + body('role') + .notEmpty().withMessage('role is required') + .isIn(['caretaker', 'guardian']).withMessage('role must be caretaker or guardian') + ], + async (req, res) => { try { + // Check validation errors + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ error: errors.array()[0].msg }); + } + const userId = req.user.userId; const invitationId = parseInt(req.params.id, 10); const { role } = req.body; console.log('[INVITE] Update invitation:', { userId, invitationId, role }); - if (!role || !['caretaker', 'guardian'].includes(role)) { - return res.status(400).json({ error: 'Valid role is required (caretaker or guardian)' }); - } - // Check invitation belongs to user const { data: invitation, error: findError } = await supabase .from('invitations') diff --git a/backend/src/routes/stripe.js b/backend/src/routes/stripe.js index e6ae2fa..2fd1505 100644 --- a/backend/src/routes/stripe.js +++ b/backend/src/routes/stripe.js @@ -1,6 +1,7 @@ const express = require('express'); const router = express.Router(); const Stripe = require('stripe'); +const { body, validationResult } = require('express-validator'); const { supabase } = require('../config/supabase'); const stripe = new Stripe(process.env.STRIPE_SECRET_KEY); @@ -9,8 +10,42 @@ const stripe = new Stripe(process.env.STRIPE_SECRET_KEY); * POST /api/stripe/create-checkout-session * Creates a Stripe Checkout session for purchasing Starter Kit + optional Premium subscription */ -router.post('/create-checkout-session', async (req, res) => { +router.post('/create-checkout-session', + [ + body('userId') + .notEmpty().withMessage('userId is required') + .isString().withMessage('userId must be a string'), + body('beneficiaryName') + .notEmpty().withMessage('beneficiaryName is required') + .isString().withMessage('beneficiaryName must be a string') + .trim(), + body('beneficiaryAddress') + .notEmpty().withMessage('beneficiaryAddress is required') + .isString().withMessage('beneficiaryAddress must be a string') + .trim(), + body('beneficiaryPhone') + .optional({ nullable: true }) + .isString().withMessage('beneficiaryPhone must be a string') + .trim(), + body('beneficiaryNotes') + .optional({ nullable: true }) + .isString().withMessage('beneficiaryNotes must be a string') + .trim(), + body('includePremium') + .optional() + .isBoolean().withMessage('includePremium must be a boolean'), + body('email') + .optional() + .isEmail().withMessage('email must be a valid email address') + ], + async (req, res) => { try { + // Check validation errors + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ error: errors.array()[0].msg }); + } + const { userId, beneficiaryName, @@ -21,14 +56,6 @@ router.post('/create-checkout-session', async (req, res) => { includePremium = true } = req.body; - if (!userId) { - return res.status(400).json({ error: 'userId is required' }); - } - - if (!beneficiaryName || !beneficiaryAddress) { - return res.status(400).json({ error: 'Beneficiary name and address are required' }); - } - // Build line items const lineItems = [ { @@ -111,14 +138,23 @@ router.post('/create-checkout-session', async (req, res) => { * POST /api/stripe/create-portal-session * Creates a Stripe Customer Portal session for managing subscriptions */ -router.post('/create-portal-session', async (req, res) => { +router.post('/create-portal-session', + [ + body('customerId') + .notEmpty().withMessage('customerId is required') + .isString().withMessage('customerId must be a string') + .trim() + ], + async (req, res) => { try { - const { customerId } = req.body; - - if (!customerId) { - return res.status(400).json({ error: 'customerId is required' }); + // Check validation errors + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ error: errors.array()[0].msg }); } + const { customerId } = req.body; + const session = await stripe.billingPortal.sessions.create({ customer: customerId, return_url: `${process.env.FRONTEND_URL}/settings`, @@ -136,8 +172,23 @@ router.post('/create-portal-session', async (req, res) => { * POST /api/stripe/create-payment-sheet * Creates PaymentIntent for in-app Payment Sheet (React Native) */ -router.post('/create-payment-sheet', async (req, res) => { +router.post('/create-payment-sheet', + [ + body('email') + .optional() + .isEmail().withMessage('email must be a valid email address'), + body('amount') + .optional() + .isInt({ min: 1 }).withMessage('amount must be a positive integer') + ], + async (req, res) => { try { + // Check validation errors + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ error: errors.array()[0].msg }); + } + const { email, amount = 24900 } = req.body; // $249.00 default (Starter Kit) // Create or retrieve customer @@ -249,14 +300,26 @@ async function getOrCreateStripeCustomer(beneficiaryId) { * Creates a Stripe Subscription for a beneficiary * Uses Stripe as the source of truth - no local subscription table needed! */ -router.post('/create-subscription', async (req, res) => { +router.post('/create-subscription', + [ + body('beneficiaryId') + .notEmpty().withMessage('beneficiaryId is required') + .isInt({ min: 1 }).withMessage('beneficiaryId must be a positive integer'), + body('paymentMethodId') + .optional() + .isString().withMessage('paymentMethodId must be a string') + .trim() + ], + async (req, res) => { try { - const { beneficiaryId, paymentMethodId } = req.body; - - if (!beneficiaryId) { - return res.status(400).json({ error: 'beneficiaryId is required' }); + // Check validation errors + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ error: errors.array()[0].msg }); } + const { beneficiaryId, paymentMethodId } = req.body; + // Get or create Stripe customer for this beneficiary const customerId = await getOrCreateStripeCustomer(beneficiaryId); @@ -421,15 +484,23 @@ router.get('/subscription-status/:beneficiaryId', async (req, res) => { * POST /api/stripe/cancel-subscription * Cancels subscription at period end */ -router.post('/cancel-subscription', async (req, res) => { +router.post('/cancel-subscription', + [ + body('beneficiaryId') + .notEmpty().withMessage('beneficiaryId is required') + .isInt({ min: 1 }).withMessage('beneficiaryId must be a positive integer') + ], + async (req, res) => { try { + // Check validation errors + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ error: errors.array()[0].msg }); + } + const { beneficiaryId } = req.body; console.log('[CANCEL] Request received for beneficiaryId:', beneficiaryId); - if (!beneficiaryId) { - return res.status(400).json({ error: 'beneficiaryId is required' }); - } - // Get beneficiary's stripe_customer_id const { data: beneficiary, error: dbError } = await supabase .from('beneficiaries') @@ -482,14 +553,22 @@ router.post('/cancel-subscription', async (req, res) => { * POST /api/stripe/reactivate-subscription * Reactivates a subscription that was set to cancel */ -router.post('/reactivate-subscription', async (req, res) => { +router.post('/reactivate-subscription', + [ + body('beneficiaryId') + .notEmpty().withMessage('beneficiaryId is required') + .isInt({ min: 1 }).withMessage('beneficiaryId must be a positive integer') + ], + async (req, res) => { try { - const { beneficiaryId } = req.body; - - if (!beneficiaryId) { - return res.status(400).json({ error: 'beneficiaryId is required' }); + // Check validation errors + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ error: errors.array()[0].msg }); } + const { beneficiaryId } = req.body; + const { data: beneficiary } = await supabase .from('beneficiaries') .select('stripe_customer_id') @@ -532,14 +611,22 @@ router.post('/reactivate-subscription', async (req, res) => { * Creates a SetupIntent for collecting payment method in React Native app * Then creates subscription with that payment method */ -router.post('/create-subscription-payment-sheet', async (req, res) => { +router.post('/create-subscription-payment-sheet', + [ + body('beneficiaryId') + .notEmpty().withMessage('beneficiaryId is required') + .isInt({ min: 1 }).withMessage('beneficiaryId must be a positive integer') + ], + async (req, res) => { try { - const { beneficiaryId } = req.body; - - if (!beneficiaryId) { - return res.status(400).json({ error: 'beneficiaryId is required' }); + // Check validation errors + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ error: errors.array()[0].msg }); } + const { beneficiaryId } = req.body; + // Get or create Stripe customer for this beneficiary const customerId = await getOrCreateStripeCustomer(beneficiaryId); @@ -644,14 +731,23 @@ router.post('/create-subscription-payment-sheet', async (req, res) => { * POST /api/stripe/confirm-subscription-payment * Confirms the latest invoice PaymentIntent for a subscription if needed */ -router.post('/confirm-subscription-payment', async (req, res) => { +router.post('/confirm-subscription-payment', + [ + body('subscriptionId') + .notEmpty().withMessage('subscriptionId is required') + .isString().withMessage('subscriptionId must be a string') + .trim() + ], + async (req, res) => { try { - const { subscriptionId } = req.body; - - if (!subscriptionId) { - return res.status(400).json({ error: 'subscriptionId is required' }); + // Check validation errors + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ error: errors.array()[0].msg }); } + const { subscriptionId } = req.body; + const subscription = await stripe.subscriptions.retrieve(subscriptionId, { expand: ['latest_invoice.payment_intent'] });