656 lines
25 KiB
JavaScript
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;
|