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

@@ -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(() => {