40346-vm/backend/src/routes/reviewflow.js
2026-06-30 02:45:14 +00:00

656 lines
25 KiB
JavaScript

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: `<iframe src="${origin}/api/reviewflow-public/widgets/${req.params.businessId}" style="width:100%;border:0;min-height:320px;border-radius:16px;" loading="lazy"></iframe>`,
});
}));
router.use('/', require('../helpers').commonErrorHandler);
module.exports = router;