const express = require('express'); const crypto = require('crypto'); const db = require('../db/models'); const ReviewFlowService = require('../services/reviewflow'); const ReviewFlowOAuthService = require('../services/reviewflow-oauth'); const SubscriptionService = require('../services/subscription'); const wrapAsync = require('../helpers').wrapAsync; const router = express.Router(); const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; function normalizeString(value) { return typeof value === 'string' ? value.trim() : ''; } function requireField(value, message) { if (!normalizeString(value)) { const error = new Error(message); error.code = 400; throw error; } } function validateUrl(value, message) { try { const parsed = new URL(value); if (!['http:', 'https:'].includes(parsed.protocol)) { throw new Error(message); } } catch { const validationError = new Error(message); validationError.code = 400; throw validationError; } } const REVIEW_LINK_FIELDS = { google: 'google_review_link', yelp: 'yelp_review_link', facebook: 'facebook_review_link', trustpilot: 'trustpilot_review_link', angi: 'angi_review_link', opentable: 'opentable_review_link', custom: 'custom_review_link', }; const DEFAULT_REVIEW_PLATFORMS = new Set(['google', 'yelp', 'facebook', 'custom']); function normalizeReviewDestination(value) { const destination = normalizeString(value).toLowerCase(); if (ReviewFlowService.REVIEW_CHANNELS[destination]) { return destination; } return 'google'; } function normalizeBusinessType(value, fallback = 'hybrid') { return ReviewFlowService.normalizeBusinessType(value, fallback); } function parseBoolean(value, fallback = false) { if (value === undefined || value === null || value === '') { return fallback; } return value === true || value === 'true' || value === 'on' || value === 1 || value === '1'; } function parseInteger(value, fallback, min, max) { const parsed = Number(value); if (!Number.isFinite(parsed)) { return fallback; } return Math.max(min, Math.min(Math.round(parsed), max)); } async function assertProFeatureForEnabledFlag(currentUser, enabled, featureKey) { if (enabled) { await SubscriptionService.assertFeatureAccess(currentUser, featureKey); } } function normalizeHexColor(value, fallback = ReviewFlowService.DEFAULT_BRAND_PRIMARY_COLOR) { const color = normalizeString(value) || fallback; if (/^#[0-9a-fA-F]{6}$/.test(color) || /^#[0-9a-fA-F]{3}$/.test(color)) { return color; } const error = new Error('Brand color must be a valid hex color, such as #4f46e5.'); error.code = 400; throw error; } function normalizeOptionalUrl(value, message) { const normalized = normalizeString(value); if (!normalized) { return ''; } validateUrl(normalized, message); return normalized; } function normalizeOptionalDate(value, message) { const normalized = normalizeString(value); if (!normalized) { return null; } const date = new Date(normalized); if (Number.isNaN(date.getTime())) { const error = new Error(message); error.code = 400; throw error; } return date; } function normalizeDefaultReviewPlatform(value, fallback = 'google') { const platform = normalizeString(value || fallback).toLowerCase(); if (DEFAULT_REVIEW_PLATFORMS.has(platform)) { return platform; } return DEFAULT_REVIEW_PLATFORMS.has(fallback) ? fallback : 'google'; } function normalizeOptionalEmail(value) { const normalized = normalizeString(value).toLowerCase(); if (normalized && !EMAIL_PATTERN.test(normalized)) { const error = new Error('Reply-to email must be a valid email address.'); error.code = 400; throw error; } return normalized; } function normalizeTemplate(value, fallback) { return normalizeString(value) || fallback; } function sameTemplateValue(value, candidates) { const normalized = normalizeString(value).toLowerCase(); return candidates.some((candidate) => normalizeString(candidate).toLowerCase() === normalized); } function isDefaultBrandedMessagingValue(key, value, businessName) { const defaultSubject = ReviewFlowService.getDefaultEmailSubjectTemplate(); const defaultBody = ReviewFlowService.getDefaultEmailBodyTemplate(); const defaultSms = ReviewFlowService.getDefaultSmsTemplate(); const defaultFooter = ReviewFlowService.getDefaultEmailFooterTemplate(); if (key === 'brand_primary_color') { return normalizeString(value).toLowerCase() === ReviewFlowService.DEFAULT_BRAND_PRIMARY_COLOR; } if (key === 'email_subject_template') { return sameTemplateValue(value, [defaultSubject, `How was your experience with ${businessName}?`]); } if (key === 'email_body_template') { return sameTemplateValue(value, [ defaultBody, buildEmailBody('{customerName}', businessName, '{reviewLink}'), ]); } if (key === 'sms_template') { return sameTemplateValue(value, [ defaultSms, `Thanks for choosing ${businessName}. Please leave a review: {reviewLink}`, ]); } if (key === 'email_footer_text') { return sameTemplateValue(value, [defaultFooter, `Sent by Review Flow for ${businessName}.`]); } return !normalizeString(value); } function hasCustomBrandedMessaging(brandedMessagingPayload, businessName) { return Object.entries(brandedMessagingPayload).some(([key, value]) => ( !isDefaultBrandedMessagingValue(key, value, businessName) )); } function getReviewLinkField(reviewDestination) { return REVIEW_LINK_FIELDS[reviewDestination] || null; } function buildEmailBody(customerName, businessName, reviewLink) { const greetingName = customerName || 'there'; return [ `Hi ${greetingName},`, '', `Thank you for choosing ${businessName}. We would love to hear about your experience.`, '', `Leave a review: ${reviewLink}`, '', `Thank you,`, businessName, ].join('\n'); } router.get('/review-channels', wrapAsync(async (req, res) => { res.status(200).send({ channels: ReviewFlowService.serializeReviewChannels() }); })); router.get('/connectors', wrapAsync(async (req, res) => { const businesses = await ReviewFlowService.listConnectorBusinesses(req.currentUser, req); res.status(200).send({ businesses }); })); router.post('/connectors', wrapAsync(async (req, res) => { const business = await ReviewFlowService.connectProvider(req.currentUser, req.body || {}, req); res.status(200).send({ business }); })); router.post('/connectors/:businessId/:provider/rotate', wrapAsync(async (req, res) => { const business = await ReviewFlowService.rotateWebhookToken( req.currentUser, req.params.businessId, req.params.provider, req, ); res.status(200).send({ business }); })); router.get('/summary', wrapAsync(async (req, res) => { const currentUser = req.currentUser; const limit = Math.min(Number(req.query.limit) || 8, 25); const requests = await db.review_requests.findAll({ where: { createdById: currentUser.id }, include: [ { model: db.businesses, as: 'business' }, { model: db.customers, as: 'customer' }, { model: db.transactions, as: 'transaction' }, ], order: [['createdAt', 'DESC']], limit, }); const [ pending, sent, clicked, reviewed, customers, transactions, paymentEvents, recentTransactions, recentEvents, businesses, ] = await Promise.all([ db.review_requests.count({ where: { createdById: currentUser.id, status: 'pending' } }), db.review_requests.count({ where: { createdById: currentUser.id, status: 'sent' } }), db.review_requests.count({ where: { createdById: currentUser.id, status: 'clicked' } }), db.review_requests.count({ where: { createdById: currentUser.id, status: 'reviewed' } }), db.customers.count({ where: { createdById: currentUser.id } }), db.transactions.count({ where: { createdById: currentUser.id } }), db.stripe_events.count({ where: { createdById: currentUser.id } }), db.transactions.findAll({ where: { createdById: currentUser.id }, include: [ { model: db.businesses, as: 'business' }, { model: db.customers, as: 'customer' }, ], order: [['createdAt', 'DESC']], limit: 6, }), db.stripe_events.findAll({ where: { createdById: currentUser.id }, include: [ { model: db.businesses, as: 'business' }, ], order: [['createdAt', 'DESC']], limit: 6, }), db.businesses.findAll({ where: { createdById: currentUser.id }, order: [['updatedAt', 'DESC']], limit: 25, }), ]); res.status(200).send({ stats: { pending, sent, clicked, reviewed, customers, transactions, paymentEvents }, requests, recentTransactions, recentEvents, businesses: businesses.map((business) => ReviewFlowService.serializeBusiness(req, business)), primaryBusiness: businesses[0] ? ReviewFlowService.serializeBusiness(req, businesses[0]) : null, }); })); router.post('/request', wrapAsync(async (req, res) => { const currentUser = req.currentUser; const body = req.body || {}; const businessName = normalizeString(body.businessName); const reviewLink = normalizeString(body.reviewLink); const reviewDestination = normalizeReviewDestination(body.reviewDestination || body.reviewPlatform || 'google'); const businessType = normalizeBusinessType(body.businessType || body.business_type, 'hybrid'); if (!ReviewFlowService.isReviewDestinationAllowedForBusinessType(businessType, reviewDestination)) { const error = new Error('This review destination does not match the selected business type. Choose Hybrid if this business needs both local and online options.'); error.code = 400; throw error; } const isHostedReviewDestination = reviewDestination === 'shopify_hosted'; const reviewLinkField = getReviewLinkField(reviewDestination); const customerEmail = normalizeString(body.customerEmail).toLowerCase(); const customerName = normalizeString(body.customerName); const phone = normalizeString(body.phone); const delayDays = Math.max(0, Math.min(Number(body.delayDays) || 0, 30)); requireField(businessName, 'Business name is required.'); if (!isHostedReviewDestination) { requireField(reviewLink, 'Review link is required.'); } requireField(customerEmail, 'Customer email is required.'); if (!EMAIL_PATTERN.test(customerEmail)) { const error = new Error('Enter a valid customer email address.'); error.code = 400; throw error; } if (reviewLink) { validateUrl(reviewLink, 'Enter a valid review destination URL.'); } await SubscriptionService.assertCanCreateReviewRequests(currentUser, 1); const existingBusiness = await db.businesses.findOne({ where: { name: businessName, createdById: currentUser.id }, }); if (!existingBusiness) { await SubscriptionService.assertCanCreateBusinesses(currentUser, 1); } const scheduledFor = new Date(Date.now() + delayDays * 24 * 60 * 60 * 1000); const trackingToken = crypto.randomBytes(18).toString('hex'); const effectiveReviewLink = isHostedReviewDestination ? ReviewFlowService.getHostedReviewUrl(req, trackingToken) : reviewLink; const transaction = await db.sequelize.transaction(); let transactionCommitted = false; try { const businessDefaults = { name: businessName, business_type: businessType, review_destination: reviewDestination, shopify_hosted_reviews_enabled: isHostedReviewDestination, delay_days: delayDays, brand_primary_color: ReviewFlowService.DEFAULT_BRAND_PRIMARY_COLOR, email_footer_text: ReviewFlowService.getDefaultEmailFooterTemplate(), email_subject_template: ReviewFlowService.getDefaultEmailSubjectTemplate(), email_body_template: ReviewFlowService.getDefaultEmailBodyTemplate(), sms_template: ReviewFlowService.getDefaultSmsTemplate(), is_active: true, createdById: currentUser.id, updatedById: currentUser.id, ownerId: currentUser.id, }; if (reviewLink && reviewLinkField) { businessDefaults[reviewLinkField] = reviewLink; } const [business] = await db.businesses.findOrCreate({ where: { name: businessName, createdById: currentUser.id }, defaults: businessDefaults, transaction, }); const businessUpdates = { business_type: businessType, review_destination: reviewDestination, shopify_hosted_reviews_enabled: business.shopify_hosted_reviews_enabled || isHostedReviewDestination, delay_days: delayDays, is_active: true, updatedById: currentUser.id, }; if (reviewLink && reviewLinkField) { businessUpdates[reviewLinkField] = reviewLink; } await business.update(businessUpdates, { transaction }); const [customer] = await db.customers.findOrCreate({ where: { email: customerEmail, createdById: currentUser.id }, defaults: { email: customerEmail, name: customerName || null, phone: phone || null, contact_status: 'active', businessId: business.id, createdById: currentUser.id, updatedById: currentUser.id, }, transaction, }); await customer.update({ name: customerName || customer.name, phone: phone || customer.phone, contact_status: customer.contact_status || 'active', businessId: business.id, updatedById: currentUser.id, }, { transaction }); const { emailSubject, emailBody } = ReviewFlowService.buildReviewRequestMessages( business, customerName, effectiveReviewLink, ); const reviewRequest = await db.review_requests.create({ status: 'pending', scheduled_for: scheduledFor, email_subject: emailSubject, email_body: emailBody, review_link: effectiveReviewLink, tracking_token: trackingToken, review_platform: reviewDestination, businessId: business.id, customerId: customer.id, createdById: currentUser.id, updatedById: currentUser.id, }, { transaction }); await transaction.commit(); transactionCommitted = true; let delivery = null; if (scheduledFor.getTime() <= Date.now()) { delivery = await ReviewFlowService.processDueReviewRequests(currentUser, { limit: 1, requestId: reviewRequest.id, }); } const createdRequest = await db.review_requests.findByPk(reviewRequest.id, { include: [ { model: db.businesses, as: 'business' }, { model: db.customers, as: 'customer' }, ], }); res.status(201).send({ request: createdRequest, delivery }); } catch (error) { if (!transactionCommitted) { await transaction.rollback(); } throw error; } })); router.post('/automation/run-due', wrapAsync(async (req, res) => { await SubscriptionService.assertFeatureAccess(req.currentUser, 'set_and_forget_automation'); const result = await ReviewFlowService.processDueReviewRequests(req.currentUser, req.body || {}); res.status(200).send(result); })); router.put('/growth-tools/business', wrapAsync(async (req, res) => { const currentUser = req.currentUser; const body = req.body || {}; const businessId = normalizeString(body.businessId || body.id); const businessName = normalizeString(body.businessName || body.name || 'Review Flow Business'); const ownerId = normalizeString(body.ownerId || body.owner || currentUser.id) || currentUser.id; let business = businessId ? await db.businesses.findOne({ where: { id: businessId, createdById: currentUser.id } }) : await db.businesses.findOne({ where: { name: businessName, createdById: currentUser.id } }); if (businessId && !business) { const error = new Error('Business not found for this account.'); error.code = 404; throw error; } if (!business) { await SubscriptionService.assertCanCreateBusinesses(currentUser, 1); business = await db.businesses.create({ name: businessName, business_type: normalizeBusinessType(body.businessType || body.business_type, 'hybrid'), automation_mode: 'set_and_forget', is_active: parseBoolean(body.isActive ?? body.is_active, true), createdById: currentUser.id, updatedById: currentUser.id, ownerId, }); } const businessType = normalizeBusinessType(body.businessType || body.business_type, business.business_type || 'hybrid'); const reviewDestination = normalizeReviewDestination(body.reviewDestination || body.review_destination || business.review_destination || 'google'); if (!ReviewFlowService.isReviewDestinationAllowedForBusinessType(businessType, reviewDestination)) { const error = new Error('This review destination does not match the selected business type. Choose Hybrid if this business needs both local and online options.'); error.code = 400; throw error; } const aiReplyEnabled = parseBoolean(body.aiReplyEnabled ?? body.ai_reply_enabled, business.ai_reply_enabled); const stripeConnectedAtInput = body.stripeConnectedAt ?? body.stripe_connected_at; const referralEnabled = parseBoolean(body.referralEnabled ?? body.referral_enabled, business.referral_enabled); const npsEnabled = parseBoolean(body.npsEnabled ?? body.nps_enabled, business.nps_enabled); const broadcastEnabled = parseBoolean(body.broadcastEnabled ?? body.broadcast_enabled, business.broadcast_enabled); const rebookingEnabled = parseBoolean(body.rebookingEnabled ?? body.rebooking_enabled, business.rebooking_enabled); const competitorInsightsEnabled = parseBoolean(body.competitorInsightsEnabled ?? body.competitor_insights_enabled, business.competitor_insights_enabled); await assertProFeatureForEnabledFlag(currentUser, aiReplyEnabled, 'ai_review_replies'); await assertProFeatureForEnabledFlag(currentUser, referralEnabled, 'referral_campaigns'); await assertProFeatureForEnabledFlag(currentUser, npsEnabled, 'nps_surveys'); await assertProFeatureForEnabledFlag(currentUser, broadcastEnabled, 'marketing_broadcasts'); await assertProFeatureForEnabledFlag(currentUser, rebookingEnabled, 'rebooking_campaigns'); await assertProFeatureForEnabledFlag(currentUser, competitorInsightsEnabled, 'competitor_insights'); const brandedMessagingPayload = { brand_logo_url: normalizeOptionalUrl(body.brandLogoUrl ?? body.brand_logo_url ?? business.brand_logo_url, 'Brand logo URL must be a valid URL.'), brand_primary_color: normalizeHexColor(body.brandPrimaryColor ?? body.brand_primary_color ?? business.brand_primary_color), email_sender_name: normalizeString(body.emailSenderName ?? body.email_sender_name ?? business.email_sender_name), email_reply_to: normalizeOptionalEmail(body.emailReplyTo ?? body.email_reply_to ?? business.email_reply_to), email_footer_text: normalizeTemplate(body.emailFooterText ?? body.email_footer_text ?? business.email_footer_text, ReviewFlowService.getDefaultEmailFooterTemplate()), email_subject_template: normalizeTemplate(body.emailSubjectTemplate ?? body.email_subject_template ?? business.email_subject_template, ReviewFlowService.getDefaultEmailSubjectTemplate()), email_body_template: normalizeTemplate(body.emailBodyTemplate ?? body.email_body_template ?? business.email_body_template, ReviewFlowService.getDefaultEmailBodyTemplate()), sms_template: normalizeTemplate(body.smsTemplate ?? body.sms_template ?? business.sms_template, ReviewFlowService.getDefaultSmsTemplate()), }; if (hasCustomBrandedMessaging(brandedMessagingPayload, businessName || business.name || 'Review Flow Business')) { await SubscriptionService.assertFeatureAccess(currentUser, 'branded_messaging'); } const updatePayload = { name: businessName || business.name, business_type: businessType, automation_mode: normalizeString(body.automationMode || body.automation_mode) || 'set_and_forget', ownerId, review_destination: reviewDestination, google_review_link: normalizeOptionalUrl(body.googleReviewLink ?? body.google_review_link ?? business.google_review_link, 'Google review link must be a valid URL.'), yelp_review_link: normalizeOptionalUrl(body.yelpReviewLink ?? body.yelp_review_link ?? business.yelp_review_link, 'Yelp review link must be a valid URL.'), facebook_review_link: normalizeOptionalUrl(body.facebookReviewLink ?? body.facebook_review_link ?? business.facebook_review_link, 'Facebook review link must be a valid URL.'), trustpilot_review_link: normalizeOptionalUrl(body.trustpilotReviewLink ?? body.trustpilot_review_link ?? business.trustpilot_review_link, 'Trustpilot review link must be a valid URL.'), angi_review_link: normalizeOptionalUrl(body.angiReviewLink ?? body.angi_review_link ?? business.angi_review_link, 'Angi review link must be a valid URL.'), opentable_review_link: normalizeOptionalUrl(body.opentableReviewLink ?? body.opentable_review_link ?? business.opentable_review_link, 'OpenTable review link must be a valid URL.'), custom_review_link: normalizeOptionalUrl(body.customReviewLink ?? body.custom_review_link ?? business.custom_review_link, 'Custom review page link must be a valid URL.'), delay_days: parseInteger(body.delayDays ?? body.delay_days, business.delay_days || 7, 0, 30), followup_enabled: parseBoolean(body.followupEnabled ?? body.followup_enabled, business.followup_enabled !== false), followup_delay_days: parseInteger(body.followupDelayDays ?? body.followup_delay_days, business.followup_delay_days || 3, 1, 30), max_followups: parseInteger(body.maxFollowups ?? body.max_followups, business.max_followups || 1, 0, 5), ai_reply_enabled: aiReplyEnabled, referral_enabled: referralEnabled, referral_offer: normalizeString(body.referralOffer ?? body.referral_offer) || business.referral_offer || '', nps_enabled: npsEnabled, nps_question: normalizeString(body.npsQuestion ?? body.nps_question) || business.nps_question || 'How likely are you to recommend us to a friend?', social_widget_enabled: parseBoolean(body.socialWidgetEnabled ?? body.social_widget_enabled, business.social_widget_enabled !== false), broadcast_enabled: broadcastEnabled, rebooking_enabled: rebookingEnabled, competitor_insights_enabled: competitorInsightsEnabled, competitor_urls: normalizeString(body.competitorUrls ?? body.competitor_urls) || business.competitor_urls || '', review_widget_theme: normalizeString(body.reviewWidgetTheme ?? body.review_widget_theme) || business.review_widget_theme || 'light', ...brandedMessagingPayload, is_active: parseBoolean(body.isActive ?? body.is_active, business.is_active !== false), stripe_account_reference: normalizeString(body.stripeAccountReference ?? body.stripe_account_reference ?? business.stripe_account_reference), stripe_connected: parseBoolean(body.stripeConnected ?? body.stripe_connected, business.stripe_connected), stripe_connected_at: stripeConnectedAtInput === undefined ? business.stripe_connected_at : normalizeOptionalDate(stripeConnectedAtInput, 'Stripe connected at must be a valid date and time.'), default_review_platform: normalizeDefaultReviewPlatform(body.defaultReviewPlatform ?? body.default_review_platform ?? business.default_review_platform, business.default_review_platform || 'google'), updatedById: currentUser.id, }; await business.update(updatePayload); const refreshedBusiness = await db.businesses.findByPk(business.id); res.status(200).send({ business: ReviewFlowService.serializeBusiness(req, refreshedBusiness) }); })); router.get('/oauth/:provider/status', wrapAsync(async (req, res) => { const status = ReviewFlowOAuthService.getOAuthStatus(req.params.provider, req); res.status(200).send(status); })); router.post('/oauth/:provider/start', wrapAsync(async (req, res) => { const start = ReviewFlowOAuthService.createAuthorizationStart( req.params.provider, req.currentUser, req.body || {}, req, ); res.status(200).send(start); })); router.post('/growth-tools/broadcast', wrapAsync(async (req, res) => { const campaignType = normalizeString(req.body?.campaignType || 'broadcast'); const featureByCampaign = { broadcast: 'marketing_broadcasts', referral: 'referral_campaigns', nps: 'nps_surveys', rebooking: 'rebooking_campaigns', }; await SubscriptionService.assertFeatureAccess(req.currentUser, featureByCampaign[campaignType] || 'marketing_broadcasts'); const result = await ReviewFlowService.queueCustomerCampaign(req.currentUser, req.body || {}); res.status(200).send(result); })); router.post('/growth-tools/competitor-insights', wrapAsync(async (req, res) => { await SubscriptionService.assertFeatureAccess(req.currentUser, 'competitor_insights'); const result = await ReviewFlowService.buildCompetitorInsights(req.currentUser, req.body || {}); res.status(200).send(result); })); router.get('/social-widget/:businessId', wrapAsync(async (req, res) => { await SubscriptionService.assertFeatureAccess(req.currentUser, 'social_proof_widgets'); const result = await ReviewFlowService.getSocialWidgetReviews(req.params.businessId, req.query || {}); const origin = `${req.protocol}://${req.get('host')}`; res.status(200).send({ ...result, embedCode: ``, }); })); router.use('/', require('../helpers').commonErrorHandler); module.exports = router;