diff --git a/backend/src/email.ts b/backend/src/email.ts index 5c7f616..e70af38 100644 --- a/backend/src/email.ts +++ b/backend/src/email.ts @@ -243,39 +243,59 @@ export async function sendBookingConfirmedEmail(params: { export async function sendReviewRequestEmail(params: { to: string - guestName: string + guestFirstName: string hotelName: string + brandColor: string reviewUrl: string }): Promise { - const { to, guestName, hotelName, reviewUrl } = params - const firstName = guestName.split(' ')[0] ?? guestName + const { to, guestFirstName, hotelName, brandColor, reviewUrl } = params + const siteUrl = process.env.SITE_URL ?? 'https://hotelsync.ru' - const html = baseHtml( - 'linear-gradient(135deg,#f59e0b,#d97706)', - '⭐', - 'Оставьте отзыв', - `

Как вам у нас?

-

Привет, ${firstName}!

+ const greeting = guestFirstName + ? `Здравствуйте, ${guestFirstName}!` + : 'Здравствуйте!' + + const html = ` + + + + + + +
+
+

${hotelName}

+
+
+

Как вам у нас?

+

${greeting}

- Спасибо, что остановились в ${hotelName}. Нам очень важно ваше мнение — + Спасибо, что остановились в ${hotelName}. Ваше мнение очень важно для нас — это займёт не больше минуты!

- + ⭐ Оставить отзыв

- Будем рады видеть вас снова в ${hotelName}! -

`, - ) + Будем рады видеть вас снова! +

+
+

+ © 2026 ${hotelName} · hotelsync.ru +

+
+
+ +` - 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({ from: `"${hotelName}" <${fromAddr()}>`, to, - subject: `Как вам у нас? Оставьте отзыв — ${hotelName}`, + subject: `${hotelName} — как вам у нас?`, html, text, headers: { diff --git a/backend/src/jobs.ts b/backend/src/jobs.ts index bc1c361..3d92f31 100644 --- a/backend/src/jobs.ts +++ b/backend/src/jobs.ts @@ -196,18 +196,33 @@ async function runAutoCheckouts(): Promise { } } +// 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 { 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 { ) 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 { 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 { [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 { ) 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 { 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 { [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)) } }