303 lines
9.2 KiB
JavaScript
303 lines
9.2 KiB
JavaScript
const express = require('express');
|
|
const crypto = require('crypto');
|
|
const db = require('../db/models');
|
|
const ReviewFlowService = require('../services/reviewflow');
|
|
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',
|
|
};
|
|
|
|
function normalizeReviewDestination(value) {
|
|
const destination = normalizeString(value).toLowerCase();
|
|
|
|
if (ReviewFlowService.REVIEW_CHANNELS[destination]) {
|
|
return destination;
|
|
}
|
|
|
|
return 'google';
|
|
}
|
|
|
|
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,
|
|
] = 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,
|
|
}),
|
|
]);
|
|
|
|
res.status(200).send({
|
|
stats: { pending, sent, clicked, reviewed, customers, transactions, paymentEvents },
|
|
requests,
|
|
recentTransactions,
|
|
recentEvents,
|
|
});
|
|
}));
|
|
|
|
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 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();
|
|
|
|
try {
|
|
const businessDefaults = {
|
|
name: businessName,
|
|
review_destination: reviewDestination,
|
|
shopify_hosted_reviews_enabled: isHostedReviewDestination,
|
|
delay_days: delayDays,
|
|
email_subject_template: `How was your experience with ${businessName}?`,
|
|
email_body_template: buildEmailBody('{customerName}', businessName, '{reviewLink}'),
|
|
is_active: true,
|
|
createdById: currentUser.id,
|
|
updatedById: 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 = {
|
|
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 = `How was your experience with ${businessName}?`;
|
|
const reviewRequest = await db.review_requests.create({
|
|
status: 'pending',
|
|
scheduled_for: scheduledFor,
|
|
email_subject: emailSubject,
|
|
email_body: buildEmailBody(customerName, businessName, effectiveReviewLink),
|
|
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();
|
|
|
|
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 });
|
|
} catch (error) {
|
|
await transaction.rollback();
|
|
throw error;
|
|
}
|
|
}));
|
|
|
|
router.use('/', require('../helpers').commonErrorHandler);
|
|
|
|
module.exports = router;
|