feat: full Room Service backend integration
- Migrations 086-088: room_service_settings, menu_items, orders+order_items - Backend: /api/hotels/:slug/room-service/* routes (settings, menu CRUD, orders CRUD, public config, public order status) - push.ts: sendPushToHotel() — push to all hotel staff on new order - On new order: in-app notification + push to hotel staff - api.ts: RoomService types + roomService API methods - RoomServicePage: replaced mocked state with real API, 30s polling, add/edit/delete menu items modal, real advance/cancel order - GuestRoomServicePage: fetches real menu + config, uses hotel payment methods, submits real order, polls status every 15s Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -49,6 +49,7 @@ import publicWidgetRoutes from './routes/publicWidget'
|
||||
import yookassaWebhookRoutes from './routes/yookassaWebhook'
|
||||
import rolePermissionsRoutes from './routes/role-permissions'
|
||||
import reviewsRoutes from './routes/reviews'
|
||||
import roomServiceRoutes from './routes/room-service'
|
||||
import { setupAgentWsRoute } from './agent-ws'
|
||||
import { startJobs } from './jobs'
|
||||
import { initWebPush } from './push'
|
||||
@@ -151,6 +152,7 @@ export async function buildApp() {
|
||||
await fastify.register(yookassaWebhookRoutes)
|
||||
await fastify.register(rolePermissionsRoutes)
|
||||
await fastify.register(reviewsRoutes)
|
||||
await fastify.register(roomServiceRoutes)
|
||||
await fastify.register(setupAgentWsRoute)
|
||||
|
||||
startJobs()
|
||||
|
||||
@@ -40,6 +40,32 @@ export async function sendPushToUser(userId: string, payload: PushPayload): Prom
|
||||
} catch { /**/ }
|
||||
}
|
||||
|
||||
export async function sendPushToHotel(hotelId: string, payload: PushPayload): Promise<void> {
|
||||
if (!process.env.VAPID_PUBLIC_KEY || !process.env.VAPID_PRIVATE_KEY) return
|
||||
try {
|
||||
const { rows } = await db.query(
|
||||
`SELECT ps.endpoint, ps.p256dh, ps.auth
|
||||
FROM push_subscriptions ps
|
||||
JOIN users u ON u.id = ps.user_id
|
||||
WHERE u.hotel_id = $1`,
|
||||
[hotelId],
|
||||
)
|
||||
for (const sub of rows) {
|
||||
try {
|
||||
await webpush.sendNotification(
|
||||
{ endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } },
|
||||
JSON.stringify(payload),
|
||||
)
|
||||
} catch (err) {
|
||||
const statusCode = (err as { statusCode?: number }).statusCode
|
||||
if (statusCode === 410 || statusCode === 404) {
|
||||
await db.query('DELETE FROM push_subscriptions WHERE endpoint = $1', [sub.endpoint])
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { /**/ }
|
||||
}
|
||||
|
||||
export async function sendPushToRoom(roomId: string, senderUserId: string, payload: PushPayload): Promise<void> {
|
||||
if (!process.env.VAPID_PUBLIC_KEY) return
|
||||
try {
|
||||
|
||||
435
backend/src/routes/room-service.ts
Normal file
435
backend/src/routes/room-service.ts
Normal file
@@ -0,0 +1,435 @@
|
||||
import { FastifyPluginAsync } from 'fastify'
|
||||
import { db } from '../db'
|
||||
import { createNotification } from './notifications'
|
||||
import { sendPushToHotel } from '../push'
|
||||
|
||||
type SlugParam = { Params: { slug: string } }
|
||||
type SlugItemParam = { Params: { slug: string; itemId: string } }
|
||||
type SlugOrderParam = { Params: { slug: string; orderId: string } }
|
||||
|
||||
const getHotelRow = async (slug: string) => {
|
||||
const { rows } = await db.query('SELECT id, name, slug FROM hotels WHERE slug = $1', [slug])
|
||||
return rows[0] as { id: string; name: string; slug: string } | undefined
|
||||
}
|
||||
|
||||
const canAccess = (userSlug: string | null, role: string, slug: string) =>
|
||||
role === 'super_admin' || userSlug === slug
|
||||
|
||||
const isManager = (role: string) =>
|
||||
['super_admin', 'hotel_admin', 'manager'].includes(role)
|
||||
|
||||
const roomServiceRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
|
||||
// ── GET /api/hotels/:slug/room-service/public-config (no auth) ───────────
|
||||
// Used by guest page to get hotel info, settings and payment methods
|
||||
fastify.get<SlugParam>(
|
||||
'/api/hotels/:slug/room-service/public-config',
|
||||
async (request, reply) => {
|
||||
const hotel = await getHotelRow(request.params.slug)
|
||||
if (!hotel) return reply.code(404).send({ error: 'Hotel not found' })
|
||||
|
||||
const [settingsRes, methodsRes] = await Promise.all([
|
||||
db.query(
|
||||
`SELECT service_charge_pct FROM room_service_settings WHERE hotel_id = $1`,
|
||||
[hotel.id],
|
||||
),
|
||||
db.query(
|
||||
`SELECT id, name, type FROM hotel_payment_methods
|
||||
WHERE hotel_id = $1 AND is_active = true
|
||||
ORDER BY sort_order`,
|
||||
[hotel.id],
|
||||
),
|
||||
])
|
||||
|
||||
return {
|
||||
hotelName: hotel.name,
|
||||
hotelSlug: hotel.slug,
|
||||
serviceChargePct: settingsRes.rows[0]?.service_charge_pct ?? 0,
|
||||
paymentMethods: methodsRes.rows,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// ── GET /api/hotels/:slug/room-service/menu (no auth) ────────────────────
|
||||
fastify.get<SlugParam>(
|
||||
'/api/hotels/:slug/room-service/menu',
|
||||
async (request, reply) => {
|
||||
const hotel = await getHotelRow(request.params.slug)
|
||||
if (!hotel) return reply.code(404).send({ error: 'Hotel not found' })
|
||||
|
||||
const { rows } = await db.query(
|
||||
`SELECT id, name, category, price, emoji, prep_time_min,
|
||||
is_popular, is_stop_list, sort_order
|
||||
FROM room_service_menu_items
|
||||
WHERE hotel_id = $1 AND is_active = true
|
||||
ORDER BY sort_order, name`,
|
||||
[hotel.id],
|
||||
)
|
||||
return rows
|
||||
},
|
||||
)
|
||||
|
||||
// ── GET /api/hotels/:slug/room-service/menu (all, with inactive) ─────────
|
||||
fastify.get<SlugParam>(
|
||||
'/api/hotels/:slug/room-service/menu/all',
|
||||
{ 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 hotel = await getHotelRow(slug)
|
||||
if (!hotel) return reply.code(404).send({ error: 'Hotel not found' })
|
||||
|
||||
const { rows } = await db.query(
|
||||
`SELECT * FROM room_service_menu_items
|
||||
WHERE hotel_id = $1
|
||||
ORDER BY sort_order, name`,
|
||||
[hotel.id],
|
||||
)
|
||||
return rows
|
||||
},
|
||||
)
|
||||
|
||||
// ── POST /api/hotels/:slug/room-service/menu ──────────────────────────────
|
||||
fastify.post<SlugParam & {
|
||||
Body: {
|
||||
name: string; category?: string; price: number; emoji?: string
|
||||
prep_time_min?: number; is_popular?: boolean; sort_order?: number
|
||||
}
|
||||
}>(
|
||||
'/api/hotels/:slug/room-service/menu',
|
||||
{ onRequest: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const { slug } = request.params
|
||||
if (!canAccess(request.user.hotelSlug, request.user.role, slug) || !isManager(request.user.role))
|
||||
return reply.code(403).send({ error: 'Forbidden' })
|
||||
const hotel = await getHotelRow(slug)
|
||||
if (!hotel) return reply.code(404).send({ error: 'Hotel not found' })
|
||||
|
||||
const { name, category = 'Основные', price, emoji = '🍽️', prep_time_min = 15, is_popular = false, sort_order = 0 } = request.body
|
||||
if (!name) return reply.code(400).send({ error: 'Name required' })
|
||||
|
||||
const { rows } = await db.query(
|
||||
`INSERT INTO room_service_menu_items
|
||||
(hotel_id, name, category, price, emoji, prep_time_min, is_popular, sort_order)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING *`,
|
||||
[hotel.id, name, category, Number(price).toFixed(2), emoji, prep_time_min, is_popular, sort_order],
|
||||
)
|
||||
return reply.code(201).send(rows[0])
|
||||
},
|
||||
)
|
||||
|
||||
// ── PATCH /api/hotels/:slug/room-service/menu/:itemId ────────────────────
|
||||
fastify.patch<SlugItemParam & {
|
||||
Body: {
|
||||
name?: string; category?: string; price?: number; emoji?: string
|
||||
prep_time_min?: number; is_popular?: boolean; is_stop_list?: boolean
|
||||
is_active?: boolean; sort_order?: number
|
||||
}
|
||||
}>(
|
||||
'/api/hotels/:slug/room-service/menu/:itemId',
|
||||
{ onRequest: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const { slug, itemId } = request.params
|
||||
if (!canAccess(request.user.hotelSlug, request.user.role, slug) || !isManager(request.user.role))
|
||||
return reply.code(403).send({ error: 'Forbidden' })
|
||||
const hotel = await getHotelRow(slug)
|
||||
if (!hotel) return reply.code(404).send({ error: 'Hotel not found' })
|
||||
|
||||
const allowed = ['name','category','price','emoji','prep_time_min','is_popular','is_stop_list','is_active','sort_order']
|
||||
const updates: string[] = []
|
||||
const values: unknown[] = []
|
||||
let idx = 1
|
||||
const body = request.body as Record<string, unknown>
|
||||
for (const key of allowed) {
|
||||
if (body[key] !== undefined) {
|
||||
updates.push(`${key} = $${idx}`)
|
||||
values.push(key === 'price' ? Number(body[key]).toFixed(2) : body[key])
|
||||
idx++
|
||||
}
|
||||
}
|
||||
if (updates.length === 0) return reply.code(400).send({ error: 'Nothing to update' })
|
||||
values.push(itemId, hotel.id)
|
||||
|
||||
const { rows } = await db.query(
|
||||
`UPDATE room_service_menu_items SET ${updates.join(', ')}
|
||||
WHERE id = $${idx} AND hotel_id = $${idx + 1} RETURNING *`,
|
||||
values,
|
||||
)
|
||||
if (!rows[0]) return reply.code(404).send({ error: 'Item not found' })
|
||||
return rows[0]
|
||||
},
|
||||
)
|
||||
|
||||
// ── DELETE /api/hotels/:slug/room-service/menu/:itemId (soft) ────────────
|
||||
fastify.delete<SlugItemParam>(
|
||||
'/api/hotels/:slug/room-service/menu/:itemId',
|
||||
{ onRequest: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const { slug, itemId } = request.params
|
||||
if (!canAccess(request.user.hotelSlug, request.user.role, slug) || !isManager(request.user.role))
|
||||
return reply.code(403).send({ error: 'Forbidden' })
|
||||
const hotel = await getHotelRow(slug)
|
||||
if (!hotel) return reply.code(404).send({ error: 'Hotel not found' })
|
||||
|
||||
const { rowCount } = await db.query(
|
||||
'UPDATE room_service_menu_items SET is_active = false WHERE id = $1 AND hotel_id = $2',
|
||||
[itemId, hotel.id],
|
||||
)
|
||||
if (!rowCount) return reply.code(404).send({ error: 'Item not found' })
|
||||
return reply.code(204).send()
|
||||
},
|
||||
)
|
||||
|
||||
// ── GET /api/hotels/:slug/room-service/settings ───────────────────────────
|
||||
fastify.get<SlugParam>(
|
||||
'/api/hotels/:slug/room-service/settings',
|
||||
{ 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 hotel = await getHotelRow(slug)
|
||||
if (!hotel) return reply.code(404).send({ error: 'Hotel not found' })
|
||||
|
||||
const { rows } = await db.query(
|
||||
'SELECT * FROM room_service_settings WHERE hotel_id = $1',
|
||||
[hotel.id],
|
||||
)
|
||||
// Auto-create if missing
|
||||
if (!rows[0]) {
|
||||
const { rows: ins } = await db.query(
|
||||
'INSERT INTO room_service_settings (hotel_id) VALUES ($1) RETURNING *',
|
||||
[hotel.id],
|
||||
)
|
||||
return ins[0]
|
||||
}
|
||||
return rows[0]
|
||||
},
|
||||
)
|
||||
|
||||
// ── PATCH /api/hotels/:slug/room-service/settings ─────────────────────────
|
||||
fastify.patch<SlugParam & { Body: { is_active?: boolean; service_charge_pct?: number } }>(
|
||||
'/api/hotels/:slug/room-service/settings',
|
||||
{ onRequest: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const { slug } = request.params
|
||||
if (!canAccess(request.user.hotelSlug, request.user.role, slug) || !isManager(request.user.role))
|
||||
return reply.code(403).send({ error: 'Forbidden' })
|
||||
const hotel = await getHotelRow(slug)
|
||||
if (!hotel) return reply.code(404).send({ error: 'Hotel not found' })
|
||||
|
||||
const { is_active, service_charge_pct } = request.body
|
||||
const { rows } = await db.query(
|
||||
`INSERT INTO room_service_settings (hotel_id, is_active, service_charge_pct)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (hotel_id) DO UPDATE
|
||||
SET is_active = COALESCE($2, room_service_settings.is_active),
|
||||
service_charge_pct = COALESCE($3, room_service_settings.service_charge_pct),
|
||||
updated_at = NOW()
|
||||
RETURNING *`,
|
||||
[hotel.id, is_active ?? null, service_charge_pct ?? null],
|
||||
)
|
||||
return rows[0]
|
||||
},
|
||||
)
|
||||
|
||||
// ── GET /api/hotels/:slug/room-service/orders ─────────────────────────────
|
||||
fastify.get<SlugParam & { Querystring: { status?: string; date?: string } }>(
|
||||
'/api/hotels/:slug/room-service/orders',
|
||||
{ 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 hotel = await getHotelRow(slug)
|
||||
if (!hotel) return reply.code(404).send({ error: 'Hotel not found' })
|
||||
|
||||
const { status, date } = request.query as { status?: string; date?: string }
|
||||
const conditions = ['o.hotel_id = $1']
|
||||
const values: unknown[] = [hotel.id]
|
||||
let idx = 2
|
||||
if (status) { conditions.push(`o.status = $${idx}`); values.push(status); idx++ }
|
||||
if (date) { conditions.push(`o.created_at::date = $${idx}`); values.push(date); idx++ }
|
||||
|
||||
const { rows: orders } = await db.query(
|
||||
`SELECT o.*,
|
||||
COALESCE(
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'id', oi.id,
|
||||
'menu_item_id', oi.menu_item_id,
|
||||
'name', oi.name,
|
||||
'emoji', oi.emoji,
|
||||
'price', oi.price,
|
||||
'quantity', oi.quantity
|
||||
) ORDER BY oi.id
|
||||
) FILTER (WHERE oi.id IS NOT NULL), '[]'
|
||||
) AS items
|
||||
FROM room_service_orders o
|
||||
LEFT JOIN room_service_order_items oi ON oi.order_id = o.id
|
||||
WHERE ${conditions.join(' AND ')}
|
||||
GROUP BY o.id
|
||||
ORDER BY o.created_at DESC
|
||||
LIMIT 200`,
|
||||
values,
|
||||
)
|
||||
return orders
|
||||
},
|
||||
)
|
||||
|
||||
// ── POST /api/hotels/:slug/room-service/orders (public — guest submits) ───
|
||||
fastify.post<SlugParam & {
|
||||
Body: {
|
||||
room_number: string
|
||||
guest_name?: string
|
||||
order_type?: 'room' | 'restaurant'
|
||||
scheduled_time?: string
|
||||
payment_method?: string
|
||||
notes?: string
|
||||
items: Array<{ menu_item_id: string; quantity: number }>
|
||||
}
|
||||
}>(
|
||||
'/api/hotels/:slug/room-service/orders',
|
||||
async (request, reply) => {
|
||||
const hotel = await getHotelRow(request.params.slug)
|
||||
if (!hotel) return reply.code(404).send({ error: 'Hotel not found' })
|
||||
|
||||
const { room_number, guest_name = '', order_type = 'room', scheduled_time, payment_method, notes, items } = request.body
|
||||
|
||||
if (!room_number) return reply.code(400).send({ error: 'room_number required' })
|
||||
if (!items || items.length === 0) return reply.code(400).send({ error: 'items required' })
|
||||
|
||||
// Get service charge setting
|
||||
const { rows: settRows } = await db.query(
|
||||
'SELECT service_charge_pct FROM room_service_settings WHERE hotel_id = $1',
|
||||
[hotel.id],
|
||||
)
|
||||
const serviceChargePct = settRows[0]?.service_charge_pct ?? 0
|
||||
|
||||
// Fetch menu items to compute prices
|
||||
const itemIds = items.map(i => i.menu_item_id)
|
||||
const { rows: menuRows } = await db.query(
|
||||
`SELECT id, name, emoji, price, is_stop_list FROM room_service_menu_items
|
||||
WHERE hotel_id = $1 AND id = ANY($2) AND is_active = true`,
|
||||
[hotel.id, itemIds],
|
||||
)
|
||||
const menuMap = new Map(menuRows.map((r: { id: string; name: string; emoji: string; price: string; is_stop_list: boolean }) => [r.id, r]))
|
||||
|
||||
// Validate items exist and not in stop list
|
||||
for (const it of items) {
|
||||
const m = menuMap.get(it.menu_item_id)
|
||||
if (!m) return reply.code(400).send({ error: `Menu item not found: ${it.menu_item_id}` })
|
||||
if (m.is_stop_list) return reply.code(400).send({ error: `Item is in stop list: ${m.name}` })
|
||||
}
|
||||
|
||||
// Calculate totals
|
||||
const subtotal = items.reduce((s, it) => {
|
||||
const m = menuMap.get(it.menu_item_id)!
|
||||
return s + Number(m.price) * it.quantity
|
||||
}, 0)
|
||||
const serviceCharge = order_type === 'room' ? Math.round(subtotal * serviceChargePct / 100) : 0
|
||||
const total = subtotal + serviceCharge
|
||||
|
||||
// Create order
|
||||
const { rows: orderRows } = await db.query(
|
||||
`INSERT INTO room_service_orders
|
||||
(hotel_id, room_number, guest_name, order_type, scheduled_time, payment_method, subtotal, service_charge, total, notes)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) RETURNING *`,
|
||||
[hotel.id, room_number, guest_name, order_type,
|
||||
scheduled_time ?? null, payment_method ?? null,
|
||||
subtotal.toFixed(2), serviceCharge.toFixed(2), total.toFixed(2),
|
||||
notes ?? null],
|
||||
)
|
||||
const order = orderRows[0]
|
||||
|
||||
// Create order items
|
||||
for (const it of items) {
|
||||
const m = menuMap.get(it.menu_item_id)!
|
||||
await db.query(
|
||||
`INSERT INTO room_service_order_items (order_id, menu_item_id, name, emoji, price, quantity)
|
||||
VALUES ($1,$2,$3,$4,$5,$6)`,
|
||||
[order.id, it.menu_item_id, m.name, m.emoji, Number(m.price).toFixed(2), it.quantity],
|
||||
)
|
||||
}
|
||||
|
||||
// In-app notification
|
||||
const itemsSummary = items
|
||||
.map(it => { const m = menuMap.get(it.menu_item_id)!; return `${m.emoji} ${m.name} ×${it.quantity}` })
|
||||
.join(', ')
|
||||
void createNotification(hotel.id, hotel.slug, {
|
||||
type: 'room_service',
|
||||
title: `Новый заказ Room Service — №${room_number}`,
|
||||
body: itemsSummary,
|
||||
link: '/room-service',
|
||||
})
|
||||
|
||||
// Push notification to all hotel staff
|
||||
void sendPushToHotel(hotel.id, {
|
||||
title: `Новый заказ Room Service — №${room_number}`,
|
||||
body: itemsSummary,
|
||||
url: '/room-service',
|
||||
tag: `room-service-order-${order.id}`,
|
||||
})
|
||||
|
||||
return reply.code(201).send(order)
|
||||
},
|
||||
)
|
||||
|
||||
// ── GET /api/hotels/:slug/room-service/orders/:orderId/status (public) ──────
|
||||
fastify.get<SlugOrderParam>(
|
||||
'/api/hotels/:slug/room-service/orders/:orderId/status',
|
||||
async (request, reply) => {
|
||||
const hotel = await getHotelRow(request.params.slug)
|
||||
if (!hotel) return reply.code(404).send({ error: 'Hotel not found' })
|
||||
|
||||
const { rows } = await db.query(
|
||||
'SELECT status, updated_at FROM room_service_orders WHERE id = $1 AND hotel_id = $2',
|
||||
[request.params.orderId, hotel.id],
|
||||
)
|
||||
if (!rows[0]) return reply.code(404).send({ error: 'Order not found' })
|
||||
return rows[0]
|
||||
},
|
||||
)
|
||||
|
||||
// ── PATCH /api/hotels/:slug/room-service/orders/:orderId ──────────────────
|
||||
fastify.patch<SlugOrderParam & {
|
||||
Body: { status?: string; payment_method?: string }
|
||||
}>(
|
||||
'/api/hotels/:slug/room-service/orders/:orderId',
|
||||
{ onRequest: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const { slug, orderId } = request.params
|
||||
if (!canAccess(request.user.hotelSlug, request.user.role, slug))
|
||||
return reply.code(403).send({ error: 'Forbidden' })
|
||||
const hotel = await getHotelRow(slug)
|
||||
if (!hotel) return reply.code(404).send({ error: 'Hotel not found' })
|
||||
|
||||
const allowed = ['status', 'payment_method']
|
||||
const updates: string[] = []
|
||||
const values: unknown[] = []
|
||||
let idx = 1
|
||||
const body = request.body as Record<string, unknown>
|
||||
for (const key of allowed) {
|
||||
if (body[key] !== undefined) {
|
||||
updates.push(`${key} = $${idx}`)
|
||||
values.push(body[key])
|
||||
idx++
|
||||
}
|
||||
}
|
||||
if (updates.length === 0) return reply.code(400).send({ error: 'Nothing to update' })
|
||||
updates.push(`updated_at = NOW()`)
|
||||
values.push(orderId, hotel.id)
|
||||
|
||||
const { rows } = await db.query(
|
||||
`UPDATE room_service_orders SET ${updates.join(', ')}
|
||||
WHERE id = $${idx} AND hotel_id = $${idx + 1} RETURNING *`,
|
||||
values,
|
||||
)
|
||||
if (!rows[0]) return reply.code(404).send({ error: 'Order not found' })
|
||||
return rows[0]
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export default roomServiceRoutes
|
||||
Reference in New Issue
Block a user