diff --git a/backend/migrations/084_reviews.sql b/backend/migrations/084_reviews.sql new file mode 100644 index 0000000..f95c7bb --- /dev/null +++ b/backend/migrations/084_reviews.sql @@ -0,0 +1,8 @@ +-- Migration 084 — review tokens on bookings + +ALTER TABLE bookings + ADD COLUMN IF NOT EXISTS review_token TEXT UNIQUE, + ADD COLUMN IF NOT EXISTS review_email_sent_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS review_retry_sent_at TIMESTAMPTZ; + +CREATE INDEX IF NOT EXISTS idx_bookings_review_token ON bookings(review_token); diff --git a/backend/src/app.ts b/backend/src/app.ts index b195d2c..0fe38e3 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -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() diff --git a/backend/src/email.ts b/backend/src/email.ts index 47eba03..5c7f616 100644 --- a/backend/src/email.ts +++ b/backend/src/email.ts @@ -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 { + const { to, guestName, hotelName, reviewUrl } = params + const firstName = guestName.split(' ')[0] ?? guestName + + const html = baseHtml( + 'linear-gradient(135deg,#f59e0b,#d97706)', + '⭐', + 'Оставьте отзыв', + `

Как вам у нас?

+

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

+

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

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

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

`, + ) + + 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', + }, + }) +} diff --git a/backend/src/jobs.ts b/backend/src/jobs.ts index 2c2cb54..bc1c361 100644 --- a/backend/src/jobs.ts +++ b/backend/src/jobs.ts @@ -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 { 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 { } } +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 }>( + `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(() => { diff --git a/backend/src/routes/reviews.ts b/backend/src/routes/reviews.ts new file mode 100644 index 0000000..f3f4640 --- /dev/null +++ b/backend/src/routes/reviews.ts @@ -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 => { + 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( + '/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( + '/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( + '/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 = {} + 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( + '/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 diff --git a/src/App.tsx b/src/App.tsx index 83c2ed9..0248582 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -88,7 +88,7 @@ export default function App() { } /> } /> } /> - } /> + } /> } /> } /> } /> diff --git a/src/lib/api.ts b/src/lib/api.ts index 33234ac..f425af1 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -477,6 +477,24 @@ export const api = { req<{ ok: boolean }>('PATCH', `/api/hotels/${slug}/hotel-settings`, data), }, + // ── Reviews ─────────────────────────────────────────────────────────────── + reviews: { + list: (slug: string) => + req('GET', `/api/hotels/${slug}/reviews`), + + update: (slug: string, id: string, data: { reply?: string; isPublic?: boolean }) => + req('PATCH', `/api/hotels/${slug}/reviews/${id}`, data), + }, + + // ── Public review (no auth) ─────────────────────────────────────────────── + publicReview: { + getConfig: (token: string) => + req('GET', `/api/public/review/${token}`), + + submit: (token: string, data: { rating: number; text?: string }) => + req<{ ok: boolean }>('POST', `/api/public/review/${token}`, data), + }, + // ── Guests ──────────────────────────────────────────────────────────────── guests: { list: (slug: string, q?: string, passport?: string) => { @@ -1843,3 +1861,33 @@ function toHotelPayload(h: HotelPayload): Record { if (h.checkOutTime !== undefined) out.check_out_time = h.checkOutTime return out } + + +export interface ReviewApi { + id: string + bookingId: string | null + guestId: string | null + guestName: string + source: string + rating: number + text: string | null + reply: string | null + repliedAt: string | null + isPublic: boolean + roomNumber: string | null + bookingSource: string | null + createdAt: string + updatedAt: string +} + +export interface PublicReviewConfig { + hotelName: string + logoLetter: string + welcomeText: string + color: string + redirectThreshold: number + showTextField: boolean + platforms: { name: string; url: string }[] + guestName: string + bookingSource: string +} diff --git a/src/pages/GuestReviewPage.tsx b/src/pages/GuestReviewPage.tsx index d4e3ef4..ed238f7 100644 --- a/src/pages/GuestReviewPage.tsx +++ b/src/pages/GuestReviewPage.tsx @@ -1,100 +1,117 @@ -import { useState } from 'react' +import { useState, useEffect } from 'react' import { useParams } from 'react-router-dom' -import { Star, ExternalLink, CheckCircle2, Heart } from 'lucide-react' +import { Star, ExternalLink, CheckCircle2, Heart, Loader2 } from 'lucide-react' import { cn } from '../lib/utils' +import { api } from '../lib/api' +import type { PublicReviewConfig } from '../lib/api' -// ── Mock hotel config (in real app — fetched by slug from API) ──────────────── - -interface HotelConfig { - name: string - color: string - logoLetter: string - redirectThreshold: number // min rating (1–5) to show redirect buttons - platforms: { name: string; url: string }[] - welcomeText: string - showTextField: boolean -} - -const MOCK_HOTELS: Record = { - 'grand-palace': { - name: 'Гранд Палас', - color: '#2563eb', - logoLetter: 'Г', - redirectThreshold: 4, - welcomeText: 'Как вам у нас?', - showTextField: true, - platforms: [ - { name: 'Booking.com', url: 'https://www.booking.com/hotel/ru/' }, - { name: 'Яндекс Путешествия', url: 'https://travel.yandex.ru/hotels/' }, - { name: 'Google', url: 'https://g.page/r/' }, - ], - }, -} - -const DEFAULT_CONFIG: HotelConfig = { - name: 'Отель', - color: '#2563eb', - logoLetter: 'О', - redirectThreshold: 4, - welcomeText: 'Как вам у нас?', - showTextField: true, - platforms: [ - { name: 'Booking.com', url: 'https://www.booking.com' }, - { name: 'Google', url: 'https://google.com' }, - ], -} - -// ── Component ───────────────────────────────────────────────────────────────── +const BASE = (import.meta.env.VITE_API_URL as string | undefined) ?? 'https://api.hotelsync.ru' export function GuestReviewPage() { - const { slug } = useParams<{ slug: string }>() - const hotel: HotelConfig = (slug ? MOCK_HOTELS[slug] : null) ?? DEFAULT_CONFIG + const { token } = useParams<{ token: string }>() + const [config, setConfig] = useState(null) + const [status, setStatus] = useState<'loading' | 'ready' | 'already' | 'invalid'>('loading') const [rating, setRating] = useState(0) const [hover, setHover] = useState(0) const [text, setText] = useState('') const [submitted, setSubmitted] = useState(false) + const [submitting, setSubmitting] = useState(false) - const isPositive = rating >= hotel.redirectThreshold + useEffect(() => { + if (!token) { setStatus('invalid'); return } + fetch(`${BASE}/api/public/review/${token}`) + .then(async res => { + if (res.status === 410) { setStatus('already'); return } + if (!res.ok) { setStatus('invalid'); return } + const data = await res.json() as PublicReviewConfig + setConfig(data) + setStatus('ready') + }) + .catch(() => setStatus('invalid')) + }, [token]) - const handleSubmit = () => { - if (rating === 0) return - setSubmitted(true) - // In real app: POST /api/reviews { slug, rating, text } + const handleSubmit = async () => { + if (rating === 0 || !token) return + setSubmitting(true) + try { + await api.publicReview.submit(token, { rating, text: text.trim() || undefined }) + setSubmitted(true) + } catch { + // ignore — show submitted anyway to avoid confusion + setSubmitted(true) + } finally { + setSubmitting(false) + } + } + + const color = config?.color ?? '#2563eb' + const threshold = config?.redirectThreshold ?? 4 + const isPositive = rating >= threshold + + // ── Loading ───────────────────────────────────────────────────────────────── + if (status === 'loading') { + return ( +
+ +
+ ) + } + + // ── Invalid / expired ─────────────────────────────────────────────────────── + if (status === 'invalid') { + return ( +
+
+
🔗
+

Ссылка недействительна

+

Ссылка устарела или была использована.

+
+
+ ) + } + + // ── Already submitted ─────────────────────────────────────────────────────── + if (status === 'already') { + return ( +
+
+ +

Отзыв уже получен

+

Вы уже оставляли отзыв. Спасибо!

+
+
+ ) } return (
- - {/* Card */}
- - {/* Top color bar */} -
- +
{/* Logo */}
- {hotel.logoLetter} + {config?.logoLetter ?? 'О'}
- {/* Hotel name */}
-

{hotel.name}

+

{config?.hotelName}

+ {!submitted && config?.guestName && ( +

Привет, {config.guestName.split(' ')[0]}!

+ )} {!submitted && ( -

{hotel.welcomeText}

+

{config?.welcomeText ?? 'Как вам у нас?'}

)}
{/* ── NOT YET SUBMITTED ── */} {!submitted && ( <> - {/* Stars */}
{Array.from({ length: 5 }, (_, i) => (