feat: reviews module — email request after checkout, API, job

- Migration 084: add review_token, review_email_sent_at, review_retry_sent_at to bookings
- Backend route /api/hotels/:slug/reviews (GET list, PATCH reply/status)
- Backend public routes /api/public/review/:token (GET config, POST submit)
- email.ts: sendReviewRequestEmail template
- jobs.ts: runReviewRequestJob — sends email N hours after checkout, retry after 24h
- app.ts: register reviewsRoutes
- api.ts: add ReviewApi, PublicReviewConfig types + reviews/publicReview methods
- ReviewsPage: connect to real API, load/save settings, remove SMS option
- GuestReviewPage: use :token param, fetch config from API, submit to API
- App.tsx: route /review/:slug → /review/:token

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-21 00:04:59 +03:00
parent 696cf4d38c
commit 1ab2751854
9 changed files with 687 additions and 353 deletions

View File

@@ -48,6 +48,7 @@ import paymentGatewaysRoutes from './routes/paymentGateways'
import publicWidgetRoutes from './routes/publicWidget'
import yookassaWebhookRoutes from './routes/yookassaWebhook'
import rolePermissionsRoutes from './routes/role-permissions'
import reviewsRoutes from './routes/reviews'
import { setupAgentWsRoute } from './agent-ws'
import { startJobs } from './jobs'
import { initWebPush } from './push'
@@ -149,6 +150,7 @@ export async function buildApp() {
await fastify.register(publicWidgetRoutes)
await fastify.register(yookassaWebhookRoutes)
await fastify.register(rolePermissionsRoutes)
await fastify.register(reviewsRoutes)
await fastify.register(setupAgentWsRoute)
startJobs()

View File

@@ -240,3 +240,47 @@ export async function sendBookingConfirmedEmail(params: {
headers: { 'Content-Language': 'ru' },
})
}
export async function sendReviewRequestEmail(params: {
to: string
guestName: string
hotelName: string
reviewUrl: string
}): Promise<void> {
const { to, guestName, hotelName, reviewUrl } = params
const firstName = guestName.split(' ')[0] ?? guestName
const html = baseHtml(
'linear-gradient(135deg,#f59e0b,#d97706)',
'⭐',
'Оставьте отзыв',
`<h2 style="color:#1e293b;font-size:20px;margin:0 0 12px;font-weight:600;">Как вам у нас?</h2>
<p style="color:#334155;line-height:1.7;margin:0 0 8px;font-size:15px;">Привет, ${firstName}!</p>
<p style="color:#334155;line-height:1.7;margin:0 0 28px;font-size:15px;">
Спасибо, что остановились в <strong>${hotelName}</strong>. Нам очень важно ваше мнение —
это займёт не больше минуты!
</p>
<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>
</div>
<p style="color:#475569;font-size:13px;margin:0;line-height:1.6;text-align:center;">
Будем рады видеть вас снова в ${hotelName}!
</p>`,
)
const text = `Как вам у нас? — ${hotelName}\n\ривет, ${firstName}!\n\nСпасибо, что остановились в ${hotelName}. Оставьте отзыв — это займёт не больше минуты:\n${reviewUrl}\n\n© 2026 HotelSync`
await transporter.sendMail({
from: `"${hotelName}" <${fromAddr()}>`,
to,
subject: `Как вам у нас? Оставьте отзыв — ${hotelName}`,
html,
text,
headers: {
'List-Unsubscribe': unsubscribeHeader,
'Content-Language': 'ru',
},
})
}

View File

@@ -2,6 +2,8 @@ import { db } from './db'
import { broadcast } from './routes/ws'
import { createNotification } from './routes/notifications'
import { getHkSettings } from './routes/housekeeping-settings'
import { sendReviewRequestEmail } from './email'
import { randomUUID } from 'crypto'
const JOB_INTERVAL_MS = 60 * 60 * 1000 // every 1 hour
const PAYMENT_EXPIRY_INTERVAL = 2 * 60 * 1000 // every 2 minutes
@@ -10,6 +12,7 @@ async function runAutoJobs(): Promise<void> {
try {
await runAutoCancelNoShows()
await runAutoCheckouts()
await runReviewRequestJob()
} catch (err) {
console.error('[jobs] error running auto jobs:', err)
}
@@ -193,6 +196,95 @@ async function runAutoCheckouts(): Promise<void> {
}
}
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 }>(
`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
FROM hotels h
WHERE EXISTS (
SELECT 1 FROM hotel_settings
WHERE hotel_id = h.id AND key = 'review_request_enabled' AND value = 'true'::jsonb
)`,
)
for (const hotel of hotels) {
// Bookings that checked out >= hotel.hours ago, have guest email, no token yet
const { rows: bookings } = await db.query<{
id: string; guest_name: string | null; guest_email: string | null
}>(
`SELECT b.id, b.guest_name, b.guest_email
FROM bookings b
WHERE b.hotel_id = $1
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()`,
[hotel.id, hotel.hours],
)
for (const b of bookings) {
const token = randomUUID()
await db.query(
`UPDATE bookings SET review_token = $1, review_email_sent_at = NOW() WHERE id = $2`,
[token, b.id],
)
await sendReviewRequestEmail({
to: b.guest_email!,
guestName: b.guest_name ?? 'Гость',
hotelName: hotel.name,
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`,
[hotel.id],
)
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
}>(
`SELECT b.id, b.guest_name, b.guest_email, b.review_token
FROM bookings b
WHERE b.hotel_id = $1
AND b.status = 'checked_out'
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 NOT EXISTS (
SELECT 1 FROM reviews r WHERE r.booking_id = b.id
)`,
[hotel.id],
)
for (const b of retryBookings) {
await db.query(
`UPDATE bookings SET review_retry_sent_at = NOW() WHERE id = $1`,
[b.id],
)
await sendReviewRequestEmail({
to: b.guest_email!,
guestName: b.guest_name ?? 'Гость',
hotelName: hotel.name,
reviewUrl: `${appUrl}/review/${b.review_token}`,
}).catch(err => console.error('[jobs] review retry email error:', err))
}
}
}
export function startJobs(): void {
// Small delay to let DB migrations finish on startup
setTimeout(() => {

View File

@@ -0,0 +1,200 @@
import { FastifyPluginAsync } from 'fastify'
import { db } from '../db'
type SlugParam = { Params: { slug: string } }
type SlugIdParam = { Params: { slug: string; id: string } }
type TokenParam = { Params: { token: string } }
const canAccess = (userSlug: string | null, role: string, slug: string) =>
role === 'super_admin' || userSlug === slug
const getHotelId = async (slug: string): Promise<string | null> => {
const { rows } = await db.query('SELECT id FROM hotels WHERE slug = $1', [slug])
return rows[0]?.id ?? null
}
const reviewsRoutes: FastifyPluginAsync = async (fastify) => {
// ── GET /api/hotels/:slug/reviews ─────────────────────────────────────────
fastify.get<SlugParam>(
'/api/hotels/:slug/reviews',
{ onRequest: [fastify.authenticate] },
async (request, reply) => {
const { slug } = request.params
if (!canAccess(request.user.hotelSlug, request.user.role, slug)) {
return reply.code(403).send({ error: 'Forbidden' })
}
const hotelId = await getHotelId(slug)
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
const { rows } = await db.query(
`SELECT r.id, r.booking_id, r.guest_id, r.guest_name, r.source,
r.rating, r.text, r.reply, r.replied_at, r.is_public,
r.created_at, r.updated_at,
b.room_id,
rm.number AS room_number,
b.source AS booking_source
FROM reviews r
LEFT JOIN bookings b ON b.id = r.booking_id
LEFT JOIN rooms rm ON rm.id = b.room_id
WHERE r.hotel_id = $1
ORDER BY r.created_at DESC`,
[hotelId],
)
return rows
},
)
// ── PATCH /api/hotels/:slug/reviews/:id ───────────────────────────────────
fastify.patch<SlugIdParam & { Body: { reply?: string; isPublic?: boolean } }>(
'/api/hotels/:slug/reviews/:id',
{ onRequest: [fastify.authenticate] },
async (request, reply) => {
const { slug, id } = request.params
if (!canAccess(request.user.hotelSlug, request.user.role, slug)) {
return reply.code(403).send({ error: 'Forbidden' })
}
const hotelId = await getHotelId(slug)
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
const { reply: replyText, isPublic } = request.body
const updates: string[] = []
const vals: unknown[] = [id, hotelId]
if (replyText !== undefined) {
vals.push(replyText)
updates.push(`reply = $${vals.length}, replied_at = NOW()`)
}
if (isPublic !== undefined) {
vals.push(isPublic)
updates.push(`is_public = $${vals.length}`)
}
if (updates.length === 0) return reply.code(400).send({ error: 'Nothing to update' })
const { rows } = await db.query(
`UPDATE reviews SET ${updates.join(', ')}, updated_at = NOW()
WHERE id = $1 AND hotel_id = $2
RETURNING *`,
vals,
)
if (!rows[0]) return reply.code(404).send({ error: 'Review not found' })
return rows[0]
},
)
// ── GET /api/public/review/:token ─────────────────────────────────────────
// Returns hotel config + guest name for the public review form
fastify.get<TokenParam>(
'/api/public/review/:token',
async (request, reply) => {
const { token } = request.params
const { rows } = await db.query<{
hotel_id: string; slug: string; hotel_name: string
guest_name: string | null; guest_email: string | null
booking_id: string; booking_source: string | null
}>(
`SELECT b.id AS booking_id, b.guest_name, b.guest_email,
b.source AS booking_source,
h.id AS hotel_id, h.slug, h.name AS hotel_name
FROM bookings b
JOIN hotels h ON h.id = b.hotel_id
WHERE b.review_token = $1`,
[token],
)
const row = rows[0]
if (!row) return reply.code(404).send({ error: 'Invalid or expired link' })
// Check if review already submitted
const { rows: existing } = await db.query(
`SELECT id FROM reviews WHERE booking_id = $1 LIMIT 1`,
[row.booking_id],
)
if (existing[0]) return reply.code(410).send({ error: 'Review already submitted' })
// Load review settings for this hotel
const { rows: settings } = await db.query(
`SELECT key, value FROM hotel_settings
WHERE hotel_id = $1
AND key IN (
'review_redirect_threshold','review_platforms',
'review_show_text_field','review_welcome_text',
'review_brand_color'
)`,
[row.hotel_id],
)
const s: Record<string, unknown> = {}
for (const r of settings) s[r.key] = r.value
return {
hotelName: row.hotel_name,
logoLetter: (row.hotel_name as string)[0] ?? 'О',
welcomeText: (s.review_welcome_text as string | undefined) ?? 'Как вам у нас?',
color: (s.review_brand_color as string | undefined) ?? '#2563eb',
redirectThreshold: Number(s.review_redirect_threshold ?? 4),
showTextField: s.review_show_text_field !== false,
platforms: Array.isArray(s.review_platforms)
? (s.review_platforms as { name: string; url: string; enabled: boolean }[])
.filter(p => p.enabled)
.map(p => ({ name: p.name, url: p.url }))
: [],
guestName: row.guest_name ?? '',
bookingSource: row.booking_source ?? 'direct',
}
},
)
// ── POST /api/public/review/:token ────────────────────────────────────────
fastify.post<TokenParam & { Body: { rating: number; text?: string } }>(
'/api/public/review/:token',
async (request, reply) => {
const { token } = request.params
const { rating, text } = request.body
if (!rating || rating < 1 || rating > 5) {
return reply.code(400).send({ error: 'Rating must be 15' })
}
const { rows } = await db.query<{
id: string; hotel_id: string; guest_name: string | null; guest_id: string | null
}>(
`SELECT b.id, b.hotel_id, b.guest_name, bg.guest_id
FROM bookings b
LEFT JOIN (
SELECT DISTINCT ON (booking_id) booking_id, guest_id
FROM booking_guests ORDER BY booking_id, created_at
) bg ON bg.booking_id = b.id
WHERE b.review_token = $1`,
[token],
)
const booking = rows[0]
if (!booking) return reply.code(404).send({ error: 'Invalid link' })
// Prevent duplicate
const { rows: dup } = await db.query(
`SELECT id FROM reviews WHERE booking_id = $1 LIMIT 1`,
[booking.id],
)
if (dup[0]) return reply.code(409).send({ error: 'Already submitted' })
await db.query(
`INSERT INTO reviews
(hotel_id, booking_id, guest_id, guest_name, source, rating, text, is_public)
VALUES ($1, $2, $3, $4, 'direct', $5, $6, $7)`,
[
booking.hotel_id,
booking.id,
booking.guest_id ?? null,
booking.guest_name ?? 'Гость',
rating,
text ?? null,
rating >= 4, // auto-publish positive reviews
],
)
return { ok: true }
},
)
}
export default reviewsRoutes