fix: review email — hotel-branded header, proper first name, 30d guard, brand color
- Email no longer shows HotelSync branding, shows hotel name instead - Greeting uses second word of guest_name as first name (Фамилия Имя format) - Footer link changed to hotelsync.ru - Brand color from hotel settings applied to email button/header - Job: only process bookings checked out within last 30 days (prevents bulk send on first activation) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -243,39 +243,59 @@ export async function sendBookingConfirmedEmail(params: {
|
|||||||
|
|
||||||
export async function sendReviewRequestEmail(params: {
|
export async function sendReviewRequestEmail(params: {
|
||||||
to: string
|
to: string
|
||||||
guestName: string
|
guestFirstName: string
|
||||||
hotelName: string
|
hotelName: string
|
||||||
|
brandColor: string
|
||||||
reviewUrl: string
|
reviewUrl: string
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const { to, guestName, hotelName, reviewUrl } = params
|
const { to, guestFirstName, hotelName, brandColor, reviewUrl } = params
|
||||||
const firstName = guestName.split(' ')[0] ?? guestName
|
const siteUrl = process.env.SITE_URL ?? 'https://hotelsync.ru'
|
||||||
|
|
||||||
const html = baseHtml(
|
const greeting = guestFirstName
|
||||||
'linear-gradient(135deg,#f59e0b,#d97706)',
|
? `Здравствуйте, ${guestFirstName}!`
|
||||||
'⭐',
|
: 'Здравствуйте!'
|
||||||
'Оставьте отзыв',
|
|
||||||
`<h2 style="color:#1e293b;font-size:20px;margin:0 0 12px;font-weight:600;">Как вам у нас?</h2>
|
const html = `<!DOCTYPE html>
|
||||||
<p style="color:#334155;line-height:1.7;margin:0 0 8px;font-size:15px;">Привет, ${firstName}!</p>
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
</head>
|
||||||
|
<body style="font-family:Arial,sans-serif;background:#f1f5f9;margin:0;padding:40px 16px;">
|
||||||
|
<div style="max-width:520px;margin:0 auto;background:#ffffff;border-radius:16px;overflow:hidden;box-shadow:0 4px 24px rgba(0,0,0,0.08);">
|
||||||
|
<div style="background:${brandColor};padding:28px 40px;text-align:center;">
|
||||||
|
<h1 style="color:#ffffff;margin:0;font-size:24px;font-weight:700;letter-spacing:-0.5px;">${hotelName}</h1>
|
||||||
|
</div>
|
||||||
|
<div style="padding:40px;">
|
||||||
|
<h2 style="color:#1e293b;font-size:20px;margin:0 0 16px;font-weight:600;">Как вам у нас?</h2>
|
||||||
|
<p style="color:#334155;line-height:1.7;margin:0 0 8px;font-size:15px;">${greeting}</p>
|
||||||
<p style="color:#334155;line-height:1.7;margin:0 0 28px;font-size:15px;">
|
<p style="color:#334155;line-height:1.7;margin:0 0 28px;font-size:15px;">
|
||||||
Спасибо, что остановились в <strong>${hotelName}</strong>. Нам очень важно ваше мнение —
|
Спасибо, что остановились в <strong>${hotelName}</strong>. Ваше мнение очень важно для нас —
|
||||||
это займёт не больше минуты!
|
это займёт не больше минуты!
|
||||||
</p>
|
</p>
|
||||||
<div style="text-align:center;margin:0 0 28px;">
|
<div style="text-align:center;margin:0 0 28px;">
|
||||||
<a href="${reviewUrl}" style="display:inline-block;background:#d97706;color:#ffffff;text-decoration:none;padding:14px 36px;border-radius:10px;font-weight:600;font-size:16px;letter-spacing:0.2px;">
|
<a href="${reviewUrl}" style="display:inline-block;background:${brandColor};color:#ffffff;text-decoration:none;padding:14px 36px;border-radius:10px;font-weight:600;font-size:16px;letter-spacing:0.2px;">
|
||||||
⭐ Оставить отзыв
|
⭐ Оставить отзыв
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<p style="color:#475569;font-size:13px;margin:0;line-height:1.6;text-align:center;">
|
<p style="color:#475569;font-size:13px;margin:0;line-height:1.6;text-align:center;">
|
||||||
Будем рады видеть вас снова в ${hotelName}!
|
Будем рады видеть вас снова!
|
||||||
</p>`,
|
</p>
|
||||||
)
|
<hr style="border:none;border-top:1px solid #e2e8f0;margin:20px 0;">
|
||||||
|
<p style="color:#64748b;font-size:11px;margin:0;line-height:1.6;text-align:center;">
|
||||||
|
© 2026 ${hotelName} · <a href="${siteUrl}" style="color:#6366f1;text-decoration:none;">hotelsync.ru</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
|
||||||
const text = `Как вам у нас? — ${hotelName}\n\nПривет, ${firstName}!\n\nСпасибо, что остановились в ${hotelName}. Оставьте отзыв — это займёт не больше минуты:\n${reviewUrl}\n\n© 2026 HotelSync`
|
const text = `${hotelName} — оставьте отзыв\n\n${greeting}\n\nСпасибо, что остановились в ${hotelName}. Оставьте отзыв — это займёт не больше минуты:\n${reviewUrl}\n\n© 2026 ${hotelName}`
|
||||||
|
|
||||||
await transporter.sendMail({
|
await transporter.sendMail({
|
||||||
from: `"${hotelName}" <${fromAddr()}>`,
|
from: `"${hotelName}" <${fromAddr()}>`,
|
||||||
to,
|
to,
|
||||||
subject: `Как вам у нас? Оставьте отзыв — ${hotelName}`,
|
subject: `${hotelName} — как вам у нас?`,
|
||||||
html,
|
html,
|
||||||
text,
|
text,
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@@ -196,18 +196,33 @@ async function runAutoCheckouts(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract first name from "Фамилия Имя" or "Имя Фамилия" or single word
|
||||||
|
function extractFirstName(fullName: string | null): string {
|
||||||
|
if (!fullName) return ''
|
||||||
|
const parts = fullName.trim().split(/\s+/)
|
||||||
|
// If 2+ words, prefer the second word (typically Имя in Russian "Фамилия Имя" format)
|
||||||
|
// If only 1 word, return empty so greeting is generic
|
||||||
|
return parts.length >= 2 ? parts[1] : ''
|
||||||
|
}
|
||||||
|
|
||||||
async function runReviewRequestJob(): Promise<void> {
|
async function runReviewRequestJob(): Promise<void> {
|
||||||
const appUrl = process.env.APP_URL ?? 'https://app.hotelsync.ru'
|
const appUrl = process.env.APP_URL ?? 'https://app.hotelsync.ru'
|
||||||
|
|
||||||
// ── First send ─────────────────────────────────────────────────────────────
|
|
||||||
// Hotels with review_request_enabled = true
|
// Hotels with review_request_enabled = true
|
||||||
const { rows: hotels } = await db.query<{ id: string; name: string; hours: number }>(
|
const { rows: hotels } = await db.query<{
|
||||||
|
id: string; name: string; hours: number; brandColor: string
|
||||||
|
}>(
|
||||||
`SELECT h.id, h.name,
|
`SELECT h.id, h.name,
|
||||||
COALESCE(
|
COALESCE(
|
||||||
(SELECT (value::text)::int FROM hotel_settings
|
(SELECT (value::text)::int FROM hotel_settings
|
||||||
WHERE hotel_id = h.id AND key = 'review_request_hours'),
|
WHERE hotel_id = h.id AND key = 'review_request_hours'),
|
||||||
2
|
2
|
||||||
) AS hours
|
) AS hours,
|
||||||
|
COALESCE(
|
||||||
|
(SELECT value #>> '{}' FROM hotel_settings
|
||||||
|
WHERE hotel_id = h.id AND key = 'review_brand_color'),
|
||||||
|
'#2563eb'
|
||||||
|
) AS "brandColor"
|
||||||
FROM hotels h
|
FROM hotels h
|
||||||
WHERE EXISTS (
|
WHERE EXISTS (
|
||||||
SELECT 1 FROM hotel_settings
|
SELECT 1 FROM hotel_settings
|
||||||
@@ -216,7 +231,7 @@ async function runReviewRequestJob(): Promise<void> {
|
|||||||
)
|
)
|
||||||
|
|
||||||
for (const hotel of hotels) {
|
for (const hotel of hotels) {
|
||||||
// Bookings that checked out >= hotel.hours ago, have guest email, no token yet
|
// Bookings checked out >= hotel.hours ago, within last 30 days, email not yet sent
|
||||||
const { rows: bookings } = await db.query<{
|
const { rows: bookings } = await db.query<{
|
||||||
id: string; guest_name: string | null; guest_email: string | null
|
id: string; guest_name: string | null; guest_email: string | null
|
||||||
}>(
|
}>(
|
||||||
@@ -226,7 +241,8 @@ async function runReviewRequestJob(): Promise<void> {
|
|||||||
AND b.status = 'checked_out'
|
AND b.status = 'checked_out'
|
||||||
AND b.guest_email IS NOT NULL AND b.guest_email <> ''
|
AND b.guest_email IS NOT NULL AND b.guest_email <> ''
|
||||||
AND b.review_email_sent_at IS NULL
|
AND b.review_email_sent_at IS NULL
|
||||||
AND b.updated_at + ($2 || ' hours')::interval < NOW()`,
|
AND b.updated_at + ($2 || ' hours')::interval < NOW()
|
||||||
|
AND b.updated_at > NOW() - interval '30 days'`,
|
||||||
[hotel.id, hotel.hours],
|
[hotel.id, hotel.hours],
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -238,14 +254,14 @@ async function runReviewRequestJob(): Promise<void> {
|
|||||||
)
|
)
|
||||||
await sendReviewRequestEmail({
|
await sendReviewRequestEmail({
|
||||||
to: b.guest_email!,
|
to: b.guest_email!,
|
||||||
guestName: b.guest_name ?? 'Гость',
|
guestFirstName: extractFirstName(b.guest_name),
|
||||||
hotelName: hotel.name,
|
hotelName: hotel.name,
|
||||||
|
brandColor: hotel.brandColor,
|
||||||
reviewUrl: `${appUrl}/review/${token}`,
|
reviewUrl: `${appUrl}/review/${token}`,
|
||||||
}).catch(err => console.error('[jobs] review email error:', err))
|
}).catch(err => console.error('[jobs] review email error:', err))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Retry send ───────────────────────────────────────────────────────────
|
// ── Retry send ───────────────────────────────────────────────────────────
|
||||||
// Check if retry is enabled for this hotel
|
|
||||||
const { rows: retrySetting } = await db.query(
|
const { rows: retrySetting } = await db.query(
|
||||||
`SELECT value FROM hotel_settings
|
`SELECT value FROM hotel_settings
|
||||||
WHERE hotel_id = $1 AND key = 'review_request_retry' LIMIT 1`,
|
WHERE hotel_id = $1 AND key = 'review_request_retry' LIMIT 1`,
|
||||||
@@ -253,7 +269,6 @@ async function runReviewRequestJob(): Promise<void> {
|
|||||||
)
|
)
|
||||||
if (retrySetting[0]?.value !== true) continue
|
if (retrySetting[0]?.value !== true) continue
|
||||||
|
|
||||||
// Sent 24h+ ago, retry not yet sent, no review submitted yet
|
|
||||||
const { rows: retryBookings } = await db.query<{
|
const { rows: retryBookings } = await db.query<{
|
||||||
id: string; guest_name: string | null; guest_email: string | null; review_token: string
|
id: string; guest_name: string | null; guest_email: string | null; review_token: string
|
||||||
}>(
|
}>(
|
||||||
@@ -264,6 +279,7 @@ async function runReviewRequestJob(): Promise<void> {
|
|||||||
AND b.review_email_sent_at IS NOT NULL
|
AND b.review_email_sent_at IS NOT NULL
|
||||||
AND b.review_retry_sent_at IS NULL
|
AND b.review_retry_sent_at IS NULL
|
||||||
AND b.review_email_sent_at + interval '24 hours' < NOW()
|
AND b.review_email_sent_at + interval '24 hours' < NOW()
|
||||||
|
AND b.updated_at > NOW() - interval '30 days'
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM reviews r WHERE r.booking_id = b.id
|
SELECT 1 FROM reviews r WHERE r.booking_id = b.id
|
||||||
)`,
|
)`,
|
||||||
@@ -277,8 +293,9 @@ async function runReviewRequestJob(): Promise<void> {
|
|||||||
)
|
)
|
||||||
await sendReviewRequestEmail({
|
await sendReviewRequestEmail({
|
||||||
to: b.guest_email!,
|
to: b.guest_email!,
|
||||||
guestName: b.guest_name ?? 'Гость',
|
guestFirstName: extractFirstName(b.guest_name),
|
||||||
hotelName: hotel.name,
|
hotelName: hotel.name,
|
||||||
|
brandColor: hotel.brandColor,
|
||||||
reviewUrl: `${appUrl}/review/${b.review_token}`,
|
reviewUrl: `${appUrl}/review/${b.review_token}`,
|
||||||
}).catch(err => console.error('[jobs] review retry email error:', err))
|
}).catch(err => console.error('[jobs] review retry email error:', err))
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user