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:
@@ -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()
|
||||
|
||||
@@ -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\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',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
200
backend/src/routes/reviews.ts
Normal file
200
backend/src/routes/reviews.ts
Normal 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 1–5' })
|
||||
}
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user