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:
2026-04-21 10:14:22 +03:00
parent 2de9b9e27d
commit 67c47a6d01
2 changed files with 68 additions and 31 deletions

View File

@@ -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> {
const appUrl = process.env.APP_URL ?? 'https://app.hotelsync.ru'
// ── First send ─────────────────────────────────────────────────────────────
// 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,
COALESCE(
(SELECT (value::text)::int FROM hotel_settings
WHERE hotel_id = h.id AND key = 'review_request_hours'),
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
WHERE EXISTS (
SELECT 1 FROM hotel_settings
@@ -216,7 +231,7 @@ async function runReviewRequestJob(): Promise<void> {
)
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<{
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.guest_email IS NOT NULL AND b.guest_email <> ''
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],
)
@@ -237,15 +253,15 @@ async function runReviewRequestJob(): Promise<void> {
[token, b.id],
)
await sendReviewRequestEmail({
to: b.guest_email!,
guestName: b.guest_name ?? 'Гость',
hotelName: hotel.name,
reviewUrl: `${appUrl}/review/${token}`,
to: b.guest_email!,
guestFirstName: extractFirstName(b.guest_name),
hotelName: hotel.name,
brandColor: hotel.brandColor,
reviewUrl: `${appUrl}/review/${token}`,
}).catch(err => console.error('[jobs] review email error:', err))
}
// ── Retry send ───────────────────────────────────────────────────────────
// Check if retry is enabled for this hotel
const { rows: retrySetting } = await db.query(
`SELECT value FROM hotel_settings
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
// Sent 24h+ ago, retry not yet sent, no review submitted yet
const { rows: retryBookings } = await db.query<{
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_retry_sent_at IS NULL
AND b.review_email_sent_at + interval '24 hours' < NOW()
AND b.updated_at > NOW() - interval '30 days'
AND NOT EXISTS (
SELECT 1 FROM reviews r WHERE r.booking_id = b.id
)`,
@@ -276,10 +292,11 @@ async function runReviewRequestJob(): Promise<void> {
[b.id],
)
await sendReviewRequestEmail({
to: b.guest_email!,
guestName: b.guest_name ?? 'Гость',
hotelName: hotel.name,
reviewUrl: `${appUrl}/review/${b.review_token}`,
to: b.guest_email!,
guestFirstName: extractFirstName(b.guest_name),
hotelName: hotel.name,
brandColor: hotel.brandColor,
reviewUrl: `${appUrl}/review/${b.review_token}`,
}).catch(err => console.error('[jobs] review retry email error:', err))
}
}