Major UX improvements across multiple pages

Шахматка:
- Date/period picker dropdown on navigation button (choose start date + days window)
- Cancelled bookings fade out with animation after 1 second

Бронирования:
- Clickable column headers with sort asc/desc (Гость, Заезд, Выезд, Статус, Источник, Сумма)

Страница входа:
- Removed role-based account selector — just email + password
- System auto-detects role/hotel from credentials

Настройки:
- New "Бронирование" section with room assignment strategy (spread/together/sequential/manual)
- Notifications: SMTP email config + SMS provider config (SMSC, SMS.ru, МТС, etc.)

Модули:
- Added Housekeeping and Channel Manager as proper modules
- Channel Manager lists: Booking.com, Airbnb, Яндекс Путешествия, Островок, Суточно.ру, OneTwoTrip
- Housekeeping visible to all roles (including housekeeper) via module status
- Sidebar now uses module status to show/hide Уборка and Каналы

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 15:20:05 +03:00
parent 4e615359eb
commit c233590c4b
29 changed files with 2171 additions and 149 deletions

74
backend/src/app.ts Normal file
View File

@@ -0,0 +1,74 @@
import Fastify, { FastifyRequest, FastifyReply } from 'fastify'
import jwt from '@fastify/jwt'
import cookie from '@fastify/cookie'
import cors from '@fastify/cors'
import helmet from '@fastify/helmet'
import rateLimit from '@fastify/rate-limit'
import { config } from './config'
import './types' // side-effect: augments fastify types
import authRoutes from './routes/auth'
import hotelsRoutes from './routes/hotels'
import roomsRoutes from './routes/rooms'
import bookingsRoutes from './routes/bookings'
import housekeepingRoutes from './routes/housekeeping'
import channelsRoutes from './routes/channels'
import usersRoutes from './routes/users'
export async function buildApp() {
const fastify = Fastify({
logger: {
level: config.nodeEnv === 'production' ? 'warn' : 'info',
...(config.nodeEnv !== 'production' && {
transport: { target: 'pino-pretty', options: { colorize: true } },
}),
},
})
// ── Security ───────────────────────────────────────────────────────────────
await fastify.register(helmet, { contentSecurityPolicy: false })
await fastify.register(cors, {
origin: config.cors.origins,
credentials: true,
methods: ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS'],
})
await fastify.register(rateLimit, {
max: 200,
timeWindow: '1 minute',
})
// ── Cookies & JWT ──────────────────────────────────────────────────────────
await fastify.register(cookie)
await fastify.register(jwt, {
secret: config.jwt.secret,
})
// Authenticate decorator used as onRequest hook in routes
fastify.decorate(
'authenticate',
async (request: FastifyRequest, reply: FastifyReply) => {
try {
await request.jwtVerify()
} catch (err) {
reply.code(401).send({ error: 'Unauthorized' })
}
},
)
// ── Health check ───────────────────────────────────────────────────────────
fastify.get('/health', async () => ({ status: 'ok', ts: new Date().toISOString() }))
// ── Routes ─────────────────────────────────────────────────────────────────
await fastify.register(authRoutes)
await fastify.register(hotelsRoutes)
await fastify.register(roomsRoutes)
await fastify.register(bookingsRoutes)
await fastify.register(housekeepingRoutes)
await fastify.register(channelsRoutes)
await fastify.register(usersRoutes)
return fastify
}

22
backend/src/config.ts Normal file
View File

@@ -0,0 +1,22 @@
export const config = {
port: parseInt(process.env.PORT ?? '3000', 10),
nodeEnv: process.env.NODE_ENV ?? 'development',
database: {
url: process.env.DATABASE_URL ?? 'postgresql://hotelsync:hotelsync@localhost:5432/hotelsync',
},
redis: {
url: process.env.REDIS_URL ?? 'redis://localhost:6379',
},
jwt: {
secret: process.env.JWT_SECRET ?? 'dev-secret-change-in-production',
accessExpiry: '1h',
refreshExpiry: 30 * 24 * 60 * 60, // 30 days in seconds
},
cors: {
origins: (process.env.CORS_ORIGINS ?? 'http://localhost:5173').split(',').map(s => s.trim()),
},
}

8
backend/src/db.ts Normal file
View File

@@ -0,0 +1,8 @@
import { Pool } from 'pg'
import { config } from './config'
export const db = new Pool({ connectionString: config.database.url })
db.on('error', (err) => {
console.error('[DB] Unexpected client error:', err.message)
})

48
backend/src/migrate.ts Normal file
View File

@@ -0,0 +1,48 @@
import fs from 'fs'
import path from 'path'
import { db } from './db'
export async function runMigrations() {
const migrationsDir = path.join(process.cwd(), 'migrations')
// Create migrations tracking table
await db.query(`
CREATE TABLE IF NOT EXISTS _migrations (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL UNIQUE,
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`)
const files = fs.readdirSync(migrationsDir)
.filter(f => f.endsWith('.sql'))
.sort()
for (const file of files) {
const { rows } = await db.query(
'SELECT id FROM _migrations WHERE name = $1',
[file],
)
if (rows.length > 0) continue // already applied
const sql = fs.readFileSync(path.join(migrationsDir, file), 'utf8')
console.log(`[migrate] Applying ${file}...`)
const client = await db.connect()
try {
await client.query('BEGIN')
await client.query(sql)
await client.query('INSERT INTO _migrations (name) VALUES ($1)', [file])
await client.query('COMMIT')
console.log(`[migrate] ✅ ${file} applied`)
} catch (err) {
await client.query('ROLLBACK')
console.error(`[migrate] ❌ ${file} failed:`, err)
throw err
} finally {
client.release()
}
}
console.log('[migrate] All migrations up to date')
}

11
backend/src/redis.ts Normal file
View File

@@ -0,0 +1,11 @@
import Redis from 'ioredis'
import { config } from './config'
export const redis = new Redis(config.redis.url, {
lazyConnect: true,
maxRetriesPerRequest: 3,
})
redis.on('error', (err) => {
console.error('[Redis] Connection error:', err.message)
})

128
backend/src/routes/auth.ts Normal file
View File

@@ -0,0 +1,128 @@
import { FastifyPluginAsync } from 'fastify'
import bcrypt from 'bcryptjs'
import crypto from 'crypto'
import { db } from '../db'
import { redis } from '../redis'
import { config } from '../config'
import type { JwtPayload } from '../types'
const auth: FastifyPluginAsync = async (fastify) => {
// ── POST /api/auth/login ───────────────────────────────────────────────────
fastify.post<{ Body: { email: string; password: string } }>(
'/api/auth/login',
{
schema: {
body: {
type: 'object',
required: ['email', 'password'],
properties: {
email: { type: 'string' },
password: { type: 'string' },
},
},
},
},
async (request, reply) => {
const { email, password } = request.body
const { rows } = await db.query(
`SELECT u.*, h.slug AS hotel_slug
FROM users u
LEFT JOIN hotels h ON h.id = u.hotel_id
WHERE u.email = $1`,
[email.toLowerCase().trim()],
)
const user = rows[0]
if (!user || !(await bcrypt.compare(password, user.password_hash))) {
return reply.code(401).send({ error: 'Неверный email или пароль' })
}
const payload: JwtPayload = {
sub: user.id,
email: user.email,
name: user.name,
role: user.role,
hotelId: user.hotel_id ?? null,
hotelSlug: user.hotel_slug ?? null,
}
const accessToken = fastify.jwt.sign(payload, {
expiresIn: config.jwt.accessExpiry,
})
// Refresh token — opaque, stored in Redis
const refreshToken = crypto.randomBytes(40).toString('hex')
await redis.set(
`refresh:${refreshToken}`,
JSON.stringify(payload),
'EX',
config.jwt.refreshExpiry,
)
reply.setCookie('refresh_token', refreshToken, {
httpOnly: true,
secure: config.nodeEnv === 'production',
sameSite: 'strict',
path: '/api/auth',
maxAge: config.jwt.refreshExpiry,
})
return {
access_token: accessToken,
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
hotelId: user.hotel_id ?? null,
hotelSlug: user.hotel_slug ?? null,
},
}
},
)
// ── POST /api/auth/refresh ─────────────────────────────────────────────────
fastify.post('/api/auth/refresh', async (request, reply) => {
const refreshToken = request.cookies?.refresh_token
if (!refreshToken) return reply.code(401).send({ error: 'No refresh token' })
const stored = await redis.get(`refresh:${refreshToken}`)
if (!stored) return reply.code(401).send({ error: 'Invalid or expired refresh token' })
const payload = JSON.parse(stored) as JwtPayload
const accessToken = fastify.jwt.sign(payload, {
expiresIn: config.jwt.accessExpiry,
})
return { access_token: accessToken }
})
// ── POST /api/auth/logout ──────────────────────────────────────────────────
fastify.post('/api/auth/logout', async (request, reply) => {
const refreshToken = request.cookies?.refresh_token
if (refreshToken) {
await redis.del(`refresh:${refreshToken}`)
}
reply.clearCookie('refresh_token', { path: '/api/auth' })
return { ok: true }
})
// ── GET /api/auth/me ───────────────────────────────────────────────────────
fastify.get(
'/api/auth/me',
{ onRequest: [fastify.authenticate] },
async (request) => {
const { rows } = await db.query(
`SELECT u.id, u.email, u.name, u.role, u.hotel_id, u.created_at, h.slug AS hotel_slug
FROM users u
LEFT JOIN hotels h ON h.id = u.hotel_id
WHERE u.id = $1`,
[request.user.sub],
)
return rows[0] ?? null
},
)
}
export default auth

View File

@@ -0,0 +1,199 @@
import { FastifyPluginAsync } from 'fastify'
import { db } from '../db'
type SlugParam = { Params: { slug: string } }
type SlugIdParam = { Params: { slug: string; id: string } }
const bookings: FastifyPluginAsync = async (fastify) => {
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 canAccess = (userSlug: string | null, role: string, slug: string) =>
role === 'super_admin' || userSlug === slug
// ── GET /api/hotels/:slug/bookings ─────────────────────────────────────────
// Query params: ?start=YYYY-MM-DD&end=YYYY-MM-DD&room_id=&status=&source=
fastify.get<SlugParam & { Querystring: {
start?: string; end?: string; room_id?: string; status?: string; source?: string
} }>(
'/api/hotels/:slug/bookings',
{ 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 { start, end, room_id, status, source } = request.query
const conditions: string[] = ['b.hotel_id = $1']
const values: unknown[] = [hotelId]
let idx = 2
if (start && end) {
conditions.push(`b.check_out > $${idx} AND b.check_in < $${idx + 1}`)
values.push(start, end)
idx += 2
}
if (room_id) { conditions.push(`b.room_id = $${idx}`); values.push(room_id); idx++ }
if (status) { conditions.push(`b.status = $${idx}`); values.push(status); idx++ }
if (source) { conditions.push(`b.source = $${idx}`); values.push(source); idx++ }
const { rows } = await db.query(
`SELECT b.*, r.number AS room_number, r.type AS room_type
FROM bookings b
JOIN rooms r ON r.id = b.room_id
WHERE ${conditions.join(' AND ')}
ORDER BY b.check_in`,
values,
)
return rows
},
)
// ── POST /api/hotels/:slug/bookings ────────────────────────────────────────
fastify.post<SlugParam & { Body: {
room_id: string; guest_name: string; guest_email?: string; guest_phone?: string
check_in: string; check_out: string; adults?: number; children?: number
status?: string; source?: string; total_amount?: number; notes?: string
} }>(
'/api/hotels/:slug/bookings',
{ onRequest: [fastify.authenticate] },
async (request, reply) => {
if (request.user.role === 'housekeeper') {
return reply.code(403).send({ error: 'Forbidden' })
}
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 {
room_id, guest_name, guest_email, guest_phone,
check_in, check_out, adults = 1, children = 0,
status = 'confirmed', source = 'direct', total_amount, notes,
} = request.body
// Check for conflicts
const { rows: conflicts } = await db.query(
`SELECT id FROM bookings
WHERE room_id = $1
AND status NOT IN ('cancelled','no_show')
AND check_in < $2 AND check_out > $3`,
[room_id, check_out, check_in],
)
if (conflicts.length > 0) {
return reply.code(409).send({ error: 'Room already booked for these dates' })
}
const { rows } = await db.query(
`INSERT INTO bookings
(hotel_id, room_id, guest_name, guest_email, guest_phone, check_in, check_out,
adults, children, status, source, total_amount, notes)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) RETURNING *`,
[hotelId, room_id, guest_name, guest_email ?? null, guest_phone ?? null,
check_in, check_out, adults, children, status, source,
total_amount ?? null, notes ?? null],
)
return reply.code(201).send(rows[0])
},
)
// ── GET /api/hotels/:slug/bookings/:id ─────────────────────────────────────
fastify.get<SlugIdParam>(
'/api/hotels/:slug/bookings/: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 { rows } = await db.query(
`SELECT b.*, r.number AS room_number, r.type AS room_type, r.price_per_night
FROM bookings b
JOIN rooms r ON r.id = b.room_id
WHERE b.id = $1 AND b.hotel_id = $2`,
[id, hotelId],
)
if (!rows[0]) return reply.code(404).send({ error: 'Booking not found' })
return rows[0]
},
)
// ── PATCH /api/hotels/:slug/bookings/:id ───────────────────────────────────
fastify.patch<SlugIdParam & { Body: Record<string, unknown> }>(
'/api/hotels/:slug/bookings/:id',
{ onRequest: [fastify.authenticate] },
async (request, reply) => {
if (request.user.role === 'housekeeper') {
return reply.code(403).send({ error: 'Forbidden' })
}
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 allowed = ['guest_name','guest_email','guest_phone','check_in','check_out',
'adults','children','status','source','total_amount','notes']
const updates: string[] = []
const values: unknown[] = []
let idx = 1
for (const key of allowed) {
if (request.body[key] !== undefined) {
updates.push(`${key} = $${idx}`)
values.push(request.body[key])
idx++
}
}
if (updates.length === 0) return reply.code(400).send({ error: 'Nothing to update' })
updates.push(`updated_at = NOW()`)
values.push(id, hotelId)
const { rows } = await db.query(
`UPDATE bookings SET ${updates.join(', ')}
WHERE id = $${idx} AND hotel_id = $${idx + 1} RETURNING *`,
values,
)
if (!rows[0]) return reply.code(404).send({ error: 'Booking not found' })
return rows[0]
},
)
// ── DELETE /api/hotels/:slug/bookings/:id ──────────────────────────────────
fastify.delete<SlugIdParam>(
'/api/hotels/:slug/bookings/:id',
{ onRequest: [fastify.authenticate] },
async (request, reply) => {
if (request.user.role === 'housekeeper') {
return reply.code(403).send({ error: 'Forbidden' })
}
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 { rowCount } = await db.query(
'DELETE FROM bookings WHERE id = $1 AND hotel_id = $2',
[id, hotelId],
)
if (!rowCount) return reply.code(404).send({ error: 'Booking not found' })
return reply.code(204).send()
},
)
}
export default bookings

View File

@@ -0,0 +1,113 @@
import { FastifyPluginAsync } from 'fastify'
import { db } from '../db'
type SlugParam = { Params: { slug: string } }
type SlugIdParam = { Params: { slug: string; id: string } }
const channels: FastifyPluginAsync = async (fastify) => {
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 canAccess = (userSlug: string | null, role: string, slug: string) =>
role === 'super_admin' || userSlug === slug
// ── GET /api/hotels/:slug/channels ─────────────────────────────────────────
fastify.get<SlugParam>(
'/api/hotels/:slug/channels',
{ 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 id, hotel_id, name, enabled, hotel_external_id, last_synced_at
FROM channels WHERE hotel_id = $1 ORDER BY name`,
[hotelId],
)
// api_key_encrypted is never returned to client
return rows
},
)
// ── PATCH /api/hotels/:slug/channels/:id ──────────────────────────────────
fastify.patch<SlugIdParam & { Body: {
enabled?: boolean; api_key?: string; hotel_external_id?: string
} }>(
'/api/hotels/:slug/channels/:id',
{ onRequest: [fastify.authenticate] },
async (request, reply) => {
if (!['manager', 'super_admin'].includes(request.user.role)) {
return reply.code(403).send({ error: 'Forbidden' })
}
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 updates: string[] = []
const values: unknown[] = []
let idx = 1
if (request.body.enabled !== undefined) {
updates.push(`enabled = $${idx}`); values.push(request.body.enabled); idx++
}
if (request.body.api_key) {
// In production you'd encrypt this; for now store as-is
updates.push(`api_key_encrypted = $${idx}`); values.push(request.body.api_key); idx++
}
if (request.body.hotel_external_id !== undefined) {
updates.push(`hotel_external_id = $${idx}`); values.push(request.body.hotel_external_id); idx++
}
if (updates.length === 0) return reply.code(400).send({ error: 'Nothing to update' })
values.push(id, hotelId)
const { rows } = await db.query(
`UPDATE channels SET ${updates.join(', ')}
WHERE id = $${idx} AND hotel_id = $${idx + 1}
RETURNING id, hotel_id, name, enabled, hotel_external_id, last_synced_at`,
values,
)
if (!rows[0]) return reply.code(404).send({ error: 'Channel not found' })
return rows[0]
},
)
// ── POST /api/hotels/:slug/channels/:id/sync ───────────────────────────────
fastify.post<SlugIdParam>(
'/api/hotels/:slug/channels/:id/sync',
{ onRequest: [fastify.authenticate] },
async (request, reply) => {
if (!['manager', 'super_admin'].includes(request.user.role)) {
return reply.code(403).send({ error: 'Forbidden' })
}
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' })
// In production this would call the channel's API.
// For now, just update last_synced_at
const { rows } = await db.query(
`UPDATE channels SET last_synced_at = NOW()
WHERE id = $1 AND hotel_id = $2
RETURNING id, name, enabled, last_synced_at`,
[id, hotelId],
)
if (!rows[0]) return reply.code(404).send({ error: 'Channel not found' })
return { ...rows[0], synced_bookings: 0 }
},
)
}
export default channels

View File

@@ -0,0 +1,106 @@
import { FastifyPluginAsync } from 'fastify'
import { db } from '../db'
const hotels: FastifyPluginAsync = async (fastify) => {
// ── GET /api/hotels ────────────────────────────────────────────────────────
fastify.get(
'/api/hotels',
{ onRequest: [fastify.authenticate] },
async (request, reply) => {
if (request.user.role !== 'super_admin') {
return reply.code(403).send({ error: 'Forbidden' })
}
const { rows } = await db.query(
`SELECT h.*, count(u.id)::int AS user_count, count(r.id)::int AS room_count
FROM hotels h
LEFT JOIN users u ON u.hotel_id = h.id
LEFT JOIN rooms r ON r.hotel_id = h.id
GROUP BY h.id
ORDER BY h.created_at`,
)
return rows
},
)
// ── POST /api/hotels ───────────────────────────────────────────────────────
fastify.post<{ Body: { name: string; slug: string; plan?: string; timezone?: string; currency?: string } }>(
'/api/hotels',
{ onRequest: [fastify.authenticate] },
async (request, reply) => {
if (request.user.role !== 'super_admin') {
return reply.code(403).send({ error: 'Forbidden' })
}
const { name, slug, plan = 'starter', timezone = 'Europe/Moscow', currency = 'RUB' } = request.body
const { rows } = await db.query(
`INSERT INTO hotels (name, slug, plan, timezone, currency)
VALUES ($1, $2, $3, $4, $5) RETURNING *`,
[name, slug, plan, timezone, currency],
)
return reply.code(201).send(rows[0])
},
)
// ── GET /api/hotels/:slug ──────────────────────────────────────────────────
fastify.get<{ Params: { slug: string } }>(
'/api/hotels/:slug',
{ onRequest: [fastify.authenticate] },
async (request, reply) => {
const { slug } = request.params
if (request.user.role !== 'super_admin' && request.user.hotelSlug !== slug) {
return reply.code(403).send({ error: 'Forbidden' })
}
const { rows } = await db.query(
`SELECT h.*,
(SELECT count(*)::int FROM rooms r WHERE r.hotel_id = h.id) AS room_count,
(SELECT count(*)::int FROM users u WHERE u.hotel_id = h.id) AS user_count,
(SELECT count(*)::int FROM bookings b WHERE b.hotel_id = h.id
AND b.status IN ('confirmed','checked_in')) AS active_bookings
FROM hotels h WHERE h.slug = $1`,
[slug],
)
if (!rows[0]) return reply.code(404).send({ error: 'Hotel not found' })
return rows[0]
},
)
// ── PATCH /api/hotels/:slug ────────────────────────────────────────────────
fastify.patch<{ Params: { slug: string }; Body: Record<string, unknown> }>(
'/api/hotels/:slug',
{ onRequest: [fastify.authenticate] },
async (request, reply) => {
const { slug } = request.params
if (request.user.role !== 'super_admin' && request.user.hotelSlug !== slug) {
return reply.code(403).send({ error: 'Forbidden' })
}
if (request.user.role === 'housekeeper') {
return reply.code(403).send({ error: 'Forbidden' })
}
const allowed = ['name', 'plan', 'timezone', 'currency']
const updates: string[] = []
const values: unknown[] = []
let idx = 1
for (const key of allowed) {
if (request.body[key] !== undefined) {
updates.push(`${key} = $${idx}`)
values.push(request.body[key])
idx++
}
}
if (updates.length === 0) return reply.code(400).send({ error: 'Nothing to update' })
updates.push(`updated_at = NOW()`)
values.push(slug)
const { rows } = await db.query(
`UPDATE hotels SET ${updates.join(', ')} WHERE slug = $${idx} RETURNING *`,
values,
)
if (!rows[0]) return reply.code(404).send({ error: 'Hotel not found' })
return rows[0]
},
)
}
export default hotels

View File

@@ -0,0 +1,150 @@
import { FastifyPluginAsync } from 'fastify'
import { db } from '../db'
type SlugParam = { Params: { slug: string } }
type SlugIdParam = { Params: { slug: string; id: string } }
const housekeeping: FastifyPluginAsync = async (fastify) => {
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 canAccess = (userSlug: string | null, role: string, slug: string) =>
role === 'super_admin' || userSlug === slug
// ── GET /api/hotels/:slug/housekeeping ─────────────────────────────────────
fastify.get<SlugParam & { Querystring: { status?: string; date?: string } }>(
'/api/hotels/:slug/housekeeping',
{ 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 conditions: string[] = ['t.hotel_id = $1']
const values: unknown[] = [hotelId]
let idx = 2
const { status, date } = request.query
if (status) { conditions.push(`t.status = $${idx}`); values.push(status); idx++ }
if (date) { conditions.push(`t.due_date = $${idx}`); values.push(date); idx++ }
const { rows } = await db.query(
`SELECT t.*,
r.number AS room_number, r.type AS room_type,
u.name AS assignee_name
FROM housekeeping_tasks t
LEFT JOIN rooms r ON r.id = t.room_id
LEFT JOIN users u ON u.id = t.assignee_id
WHERE ${conditions.join(' AND ')}
ORDER BY
CASE t.priority WHEN 'urgent' THEN 1 WHEN 'high' THEN 2 WHEN 'medium' THEN 3 ELSE 4 END,
t.created_at`,
values,
)
return rows
},
)
// ── POST /api/hotels/:slug/housekeeping ────────────────────────────────────
fastify.post<SlugParam & { Body: {
room_id?: string; type: string; priority?: string
assignee_id?: string; notes?: string; due_date?: string
} }>(
'/api/hotels/:slug/housekeeping',
{ 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 { room_id, type, priority = 'medium', assignee_id, notes, due_date } = request.body
const { rows } = await db.query(
`INSERT INTO housekeeping_tasks
(hotel_id, room_id, type, priority, assignee_id, notes, due_date)
VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`,
[hotelId, room_id ?? null, type, priority,
assignee_id ?? null, notes ?? null, due_date ?? null],
)
return reply.code(201).send(rows[0])
},
)
// ── PATCH /api/hotels/:slug/housekeeping/:id ───────────────────────────────
fastify.patch<SlugIdParam & { Body: Record<string, unknown> }>(
'/api/hotels/:slug/housekeeping/: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 allowed = ['room_id','type','status','priority','assignee_id','notes','due_date']
const updates: string[] = []
const values: unknown[] = []
let idx = 1
for (const key of allowed) {
if (request.body[key] !== undefined) {
updates.push(`${key} = $${idx}`)
values.push(request.body[key])
idx++
}
}
// Auto-set completed_at when marking done
if (request.body.status === 'done') {
updates.push(`completed_at = NOW()`)
} else if (request.body.status && request.body.status !== 'done') {
updates.push(`completed_at = NULL`)
}
if (updates.length === 0) return reply.code(400).send({ error: 'Nothing to update' })
values.push(id, hotelId)
const { rows } = await db.query(
`UPDATE housekeeping_tasks SET ${updates.join(', ')}
WHERE id = $${idx} AND hotel_id = $${idx + 1} RETURNING *`,
values,
)
if (!rows[0]) return reply.code(404).send({ error: 'Task not found' })
return rows[0]
},
)
// ── DELETE /api/hotels/:slug/housekeeping/:id ──────────────────────────────
fastify.delete<SlugIdParam>(
'/api/hotels/:slug/housekeeping/:id',
{ onRequest: [fastify.authenticate] },
async (request, reply) => {
if (request.user.role === 'housekeeper') {
return reply.code(403).send({ error: 'Forbidden' })
}
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 { rowCount } = await db.query(
'DELETE FROM housekeeping_tasks WHERE id = $1 AND hotel_id = $2',
[id, hotelId],
)
if (!rowCount) return reply.code(404).send({ error: 'Task not found' })
return reply.code(204).send()
},
)
}
export default housekeeping

163
backend/src/routes/rooms.ts Normal file
View File

@@ -0,0 +1,163 @@
import { FastifyPluginAsync } from 'fastify'
import { db } from '../db'
type SlugParam = { Params: { slug: string } }
type SlugIdParam = { Params: { slug: string; id: string } }
const rooms: FastifyPluginAsync = async (fastify) => {
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 canAccess = (userSlug: string | null, role: string, slug: string) =>
role === 'super_admin' || userSlug === slug
// ── GET /api/hotels/:slug/rooms ────────────────────────────────────────────
fastify.get<SlugParam>(
'/api/hotels/:slug/rooms',
{ 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.*,
(SELECT json_build_object(
'guest_name', b.guest_name,
'check_in', b.check_in,
'check_out', b.check_out,
'status', b.status
)
FROM bookings b
WHERE b.room_id = r.id
AND b.status = 'checked_in'
LIMIT 1
) AS current_booking
FROM rooms r
WHERE r.hotel_id = $1
ORDER BY r.floor, r.number`,
[hotelId],
)
return rows
},
)
// ── POST /api/hotels/:slug/rooms ───────────────────────────────────────────
fastify.post<SlugParam & { Body: {
number: string; type: string; floor?: number; capacity?: number
price_per_night: number; amenities?: string[]; notes?: string
} }>(
'/api/hotels/:slug/rooms',
{ onRequest: [fastify.authenticate] },
async (request, reply) => {
if (!['manager', 'super_admin'].includes(request.user.role)) {
return reply.code(403).send({ error: 'Forbidden' })
}
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 { number, type, floor = 1, capacity = 2, price_per_night, amenities = [], notes } = request.body
const { rows } = await db.query(
`INSERT INTO rooms (hotel_id, number, type, floor, capacity, price_per_night, amenities, notes)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`,
[hotelId, number, type, floor, capacity, price_per_night, amenities, notes ?? null],
)
return reply.code(201).send(rows[0])
},
)
// ── GET /api/hotels/:slug/rooms/:id ───────────────────────────────────────
fastify.get<SlugIdParam>(
'/api/hotels/:slug/rooms/: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 { rows } = await db.query(
'SELECT * FROM rooms WHERE id = $1 AND hotel_id = $2',
[id, hotelId],
)
if (!rows[0]) return reply.code(404).send({ error: 'Room not found' })
return rows[0]
},
)
// ── PATCH /api/hotels/:slug/rooms/:id ─────────────────────────────────────
fastify.patch<SlugIdParam & { Body: Record<string, unknown> }>(
'/api/hotels/:slug/rooms/:id',
{ onRequest: [fastify.authenticate] },
async (request, reply) => {
if (!['manager', 'super_admin'].includes(request.user.role)) {
return reply.code(403).send({ error: 'Forbidden' })
}
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 allowed = ['number', 'type', 'floor', 'capacity', 'price_per_night', 'status', 'amenities', 'notes']
const updates: string[] = []
const values: unknown[] = []
let idx = 1
for (const key of allowed) {
if (request.body[key] !== undefined) {
updates.push(`${key} = $${idx}`)
values.push(request.body[key])
idx++
}
}
if (updates.length === 0) return reply.code(400).send({ error: 'Nothing to update' })
values.push(id, hotelId)
const { rows } = await db.query(
`UPDATE rooms SET ${updates.join(', ')} WHERE id = $${idx} AND hotel_id = $${idx + 1} RETURNING *`,
values,
)
if (!rows[0]) return reply.code(404).send({ error: 'Room not found' })
return rows[0]
},
)
// ── DELETE /api/hotels/:slug/rooms/:id ────────────────────────────────────
fastify.delete<SlugIdParam>(
'/api/hotels/:slug/rooms/:id',
{ onRequest: [fastify.authenticate] },
async (request, reply) => {
if (!['manager', 'super_admin'].includes(request.user.role)) {
return reply.code(403).send({ error: 'Forbidden' })
}
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 { rowCount } = await db.query(
'DELETE FROM rooms WHERE id = $1 AND hotel_id = $2',
[id, hotelId],
)
if (!rowCount) return reply.code(404).send({ error: 'Room not found' })
return reply.code(204).send()
},
)
}
export default rooms

163
backend/src/routes/users.ts Normal file
View File

@@ -0,0 +1,163 @@
import { FastifyPluginAsync } from 'fastify'
import bcrypt from 'bcryptjs'
import { db } from '../db'
type SlugParam = { Params: { slug: string } }
type SlugIdParam = { Params: { slug: string; id: string } }
const users: FastifyPluginAsync = async (fastify) => {
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 canAccess = (userSlug: string | null, role: string, slug: string) =>
role === 'super_admin' || userSlug === slug
const USER_FIELDS = 'id, hotel_id, email, name, role, created_at, updated_at'
// ── GET /api/hotels/:slug/users ────────────────────────────────────────────
fastify.get<SlugParam>(
'/api/hotels/:slug/users',
{ onRequest: [fastify.authenticate] },
async (request, reply) => {
if (!['manager', 'super_admin'].includes(request.user.role)) {
return reply.code(403).send({ error: 'Forbidden' })
}
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 ${USER_FIELDS} FROM users WHERE hotel_id = $1 ORDER BY name`,
[hotelId],
)
return rows
},
)
// ── POST /api/hotels/:slug/users ───────────────────────────────────────────
fastify.post<SlugParam & { Body: {
email: string; password: string; name: string; role?: string
} }>(
'/api/hotels/:slug/users',
{ onRequest: [fastify.authenticate] },
async (request, reply) => {
if (!['manager', 'super_admin'].includes(request.user.role)) {
return reply.code(403).send({ error: 'Forbidden' })
}
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 { email, password, name, role = 'housekeeper' } = request.body
// Managers cannot create other managers or super_admins
if (request.user.role === 'manager' && role !== 'housekeeper') {
return reply.code(403).send({ error: 'Managers can only create housekeepers' })
}
const passwordHash = await bcrypt.hash(password, 12)
try {
const { rows } = await db.query(
`INSERT INTO users (hotel_id, email, password_hash, name, role)
VALUES ($1, $2, $3, $4, $5)
RETURNING ${USER_FIELDS}`,
[hotelId, email.toLowerCase(), passwordHash, name, role],
)
return reply.code(201).send(rows[0])
} catch (err: unknown) {
if ((err as { code?: string }).code === '23505') {
return reply.code(409).send({ error: 'Email already in use' })
}
throw err
}
},
)
// ── PATCH /api/hotels/:slug/users/:id ─────────────────────────────────────
fastify.patch<SlugIdParam & { Body: {
name?: string; email?: string; role?: string; password?: string
} }>(
'/api/hotels/:slug/users/:id',
{ onRequest: [fastify.authenticate] },
async (request, reply) => {
if (!['manager', 'super_admin'].includes(request.user.role)) {
return reply.code(403).send({ error: 'Forbidden' })
}
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 updates: string[] = []
const values: unknown[] = []
let idx = 1
if (request.body.name) {
updates.push(`name = $${idx}`); values.push(request.body.name); idx++
}
if (request.body.email) {
updates.push(`email = $${idx}`); values.push(request.body.email.toLowerCase()); idx++
}
if (request.body.role && request.user.role === 'super_admin') {
updates.push(`role = $${idx}`); values.push(request.body.role); idx++
}
if (request.body.password) {
const hash = await bcrypt.hash(request.body.password, 12)
updates.push(`password_hash = $${idx}`); values.push(hash); idx++
}
if (updates.length === 0) return reply.code(400).send({ error: 'Nothing to update' })
updates.push(`updated_at = NOW()`)
values.push(id, hotelId)
const { rows } = await db.query(
`UPDATE users SET ${updates.join(', ')}
WHERE id = $${idx} AND hotel_id = $${idx + 1}
RETURNING ${USER_FIELDS}`,
values,
)
if (!rows[0]) return reply.code(404).send({ error: 'User not found' })
return rows[0]
},
)
// ── DELETE /api/hotels/:slug/users/:id ────────────────────────────────────
fastify.delete<SlugIdParam>(
'/api/hotels/:slug/users/:id',
{ onRequest: [fastify.authenticate] },
async (request, reply) => {
if (!['manager', 'super_admin'].includes(request.user.role)) {
return reply.code(403).send({ error: 'Forbidden' })
}
const { slug, id } = request.params
if (!canAccess(request.user.hotelSlug, request.user.role, slug)) {
return reply.code(403).send({ error: 'Forbidden' })
}
// Prevent self-deletion
if (request.user.sub === id) {
return reply.code(400).send({ error: 'Cannot delete yourself' })
}
const hotelId = await getHotelId(slug)
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
const { rowCount } = await db.query(
'DELETE FROM users WHERE id = $1 AND hotel_id = $2',
[id, hotelId],
)
if (!rowCount) return reply.code(404).send({ error: 'User not found' })
return reply.code(204).send()
},
)
}
export default users

131
backend/src/seed.ts Normal file
View File

@@ -0,0 +1,131 @@
import bcrypt from 'bcryptjs'
import { db } from './db'
const HOTELS = [
{ name: 'Grand Palace Hotel', slug: 'grand-palace', plan: 'pro' },
{ name: 'Marina Bay Resort', slug: 'marina-bay', plan: 'enterprise' },
{ name: 'City Center Inn', slug: 'city-inn', plan: 'starter' },
]
const ROOMS_TEMPLATE = [
{ number: '101', type: 'Standard', floor: 1, capacity: 2, price: 3500 },
{ number: '102', type: 'Standard', floor: 1, capacity: 2, price: 3500 },
{ number: '103', type: 'Deluxe', floor: 1, capacity: 3, price: 5200 },
{ number: '201', type: 'Deluxe', floor: 2, capacity: 3, price: 5200 },
{ number: '202', type: 'Suite', floor: 2, capacity: 4, price: 8900 },
{ number: '301', type: 'Suite', floor: 3, capacity: 4, price: 8900 },
{ number: '302', type: 'Junior Suite', floor: 3, capacity: 4, price: 6800 },
{ number: '401', type: 'Penthouse', floor: 4, capacity: 6, price: 18000 },
]
export async function seedIfEmpty() {
const { rows } = await db.query('SELECT count(*)::int AS c FROM hotels')
if (rows[0].c > 0) {
console.log('[seed] Data already exists, skipping')
return
}
console.log('[seed] Seeding demo data...')
const passwordHash = await bcrypt.hash('demo', 12)
for (const hotel of HOTELS) {
// Insert hotel
const { rows: [h] } = await db.query(
`INSERT INTO hotels (name, slug, plan) VALUES ($1, $2, $3) RETURNING id`,
[hotel.name, hotel.slug, hotel.plan],
)
const hotelId = h.id
// Insert rooms
for (const r of ROOMS_TEMPLATE) {
await db.query(
`INSERT INTO rooms (hotel_id, number, type, floor, capacity, price_per_night, amenities)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[hotelId, r.number, r.type, r.floor, r.capacity, r.price,
['Wi-Fi', 'TV', 'Mini-bar']],
)
}
// Insert channels (disabled by default)
for (const name of ['booking_com', 'airbnb', 'expedia', 'vrbo']) {
await db.query(
`INSERT INTO channels (hotel_id, name) VALUES ($1, $2)`,
[hotelId, name],
)
}
}
// Super admin (no hotel)
await db.query(
`INSERT INTO users (email, password_hash, name, role, hotel_id)
VALUES ($1, $2, 'Super Admin', 'super_admin', NULL)`,
['admin@hotelsync.io', passwordHash],
)
// Grand Palace users
const { rows: [gp] } = await db.query(
`SELECT id FROM hotels WHERE slug = 'grand-palace'`,
)
await db.query(
`INSERT INTO users (email, password_hash, name, role, hotel_id) VALUES
($1, $2, 'Артём Голомазов', 'manager', $3),
($4, $2, 'Клавдия Иванова', 'housekeeper', $3)`,
['manager@grand-palace.ru', passwordHash, gp.id,
'cleaner@grand-palace.ru'],
)
// Seed a few bookings for grand-palace
const { rows: rooms } = await db.query(
`SELECT id FROM rooms WHERE hotel_id = $1 LIMIT 4`,
[gp.id],
)
const today = new Date()
const d = (offset: number) => {
const dt = new Date(today)
dt.setDate(dt.getDate() + offset)
return dt.toISOString().slice(0, 10)
}
const bookingData = [
{ room: 0, guest: 'Иван Петров', email: 'ivan@example.com', ci: d(-2), co: d(1), status: 'checked_in', src: 'direct' },
{ room: 1, guest: 'Maria Schmidt', email: 'maria@example.com', ci: d(1), co: d(4), status: 'confirmed', src: 'booking_com' },
{ room: 2, guest: 'John Smith', email: 'john@example.com', ci: d(3), co: d(7), status: 'confirmed', src: 'airbnb' },
{ room: 3, guest: 'Анна Сидорова', email: 'anna@example.com', ci: d(-5), co: d(-1), status: 'checked_out', src: 'direct' },
]
for (const b of bookingData) {
if (!rooms[b.room]) continue
const nights = (new Date(b.co).getTime() - new Date(b.ci).getTime()) / 86400000
await db.query(
`INSERT INTO bookings
(hotel_id, room_id, guest_name, guest_email, check_in, check_out, status, source, total_amount)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
[gp.id, rooms[b.room].id, b.guest, b.email, b.ci, b.co, b.status, b.src, nights * 3500],
)
}
// Seed housekeeping tasks
const taskRooms = rooms.slice(0, 3)
const { rows: [cleaner] } = await db.query(
`SELECT id FROM users WHERE email = 'cleaner@grand-palace.ru'`,
)
const taskData = [
{ r: 0, type: 'cleaning', status: 'pending', priority: 'high', notes: 'Стандартная уборка' },
{ r: 1, type: 'turnover', status: 'in_progress', priority: 'urgent', notes: 'Заезд через 2 часа' },
{ r: 2, type: 'inspection', status: 'pending', priority: 'medium', notes: 'Плановая проверка' },
]
for (const t of taskData) {
if (!taskRooms[t.r]) continue
await db.query(
`INSERT INTO housekeeping_tasks
(hotel_id, room_id, type, status, priority, assignee_id, notes, due_date)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
[gp.id, taskRooms[t.r].id, t.type, t.status, t.priority, cleaner.id, t.notes, d(0)],
)
}
console.log('[seed] ✅ Demo data seeded')
console.log('[seed] admin@hotelsync.io / demo (super_admin)')
console.log('[seed] manager@grand-palace.ru / demo (manager)')
console.log('[seed] cleaner@grand-palace.ru / demo (housekeeper)')
}

38
backend/src/server.ts Normal file
View File

@@ -0,0 +1,38 @@
import { db } from './db'
import { redis } from './redis'
import { runMigrations } from './migrate'
import { seedIfEmpty } from './seed'
import { buildApp } from './app'
import { config } from './config'
async function waitForDb(retries = 10, delayMs = 3000) {
for (let i = 1; i <= retries; i++) {
try {
await db.query('SELECT 1')
console.log('[db] Connected')
return
} catch {
console.log(`[db] Not ready, retry ${i}/${retries}...`)
await new Promise(r => setTimeout(r, delayMs))
}
}
throw new Error('Database not available after retries')
}
async function main() {
await waitForDb()
await runMigrations()
await seedIfEmpty()
await redis.connect()
console.log('[redis] Connected')
const app = await buildApp()
await app.listen({ port: config.port, host: '0.0.0.0' })
console.log(`[server] Listening on :${config.port}`)
}
main().catch(err => {
console.error('[server] Fatal error:', err)
process.exit(1)
})

71
backend/src/types.ts Normal file
View File

@@ -0,0 +1,71 @@
export interface JwtPayload {
sub: string // user.id (UUID)
email: string
name: string
role: 'manager' | 'housekeeper' | 'super_admin'
hotelId: string | null
hotelSlug: string | null
}
export interface Hotel {
id: string
name: string
slug: string
plan: 'starter' | 'pro' | 'enterprise'
timezone: string
currency: string
created_at: string
updated_at: string
}
export interface Room {
id: string
hotel_id: string
number: string
type: string
floor: number
capacity: number
price_per_night: number
status: 'clean' | 'dirty' | 'maintenance' | 'out_of_order'
amenities: string[]
notes: string | null
created_at: string
}
export interface Booking {
id: string
hotel_id: string
room_id: string
guest_name: string
guest_email: string | null
guest_phone: string | null
check_in: string
check_out: string
adults: number
children: number
status: 'confirmed' | 'checked_in' | 'checked_out' | 'cancelled' | 'no_show'
source: 'direct' | 'booking_com' | 'airbnb' | 'expedia' | 'vrbo'
total_amount: number | null
notes: string | null
external_id: string | null
created_at: string
updated_at: string
}
// Fastify JWT augmentation
declare module '@fastify/jwt' {
interface FastifyJWT {
payload: JwtPayload
user: JwtPayload
}
}
// Fastify instance augmentation
declare module 'fastify' {
interface FastifyInstance {
authenticate: (
request: import('fastify').FastifyRequest,
reply: import('fastify').FastifyReply,
) => Promise<void>
}
}