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

16
backend/Dockerfile Normal file
View File

@@ -0,0 +1,16 @@
# ── Stage 1: Build ──────────────────────────────────────────────────────────
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
# ── Stage 2: Run ────────────────────────────────────────────────────────────
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY migrations ./migrations
EXPOSE 3000
CMD ["node", "dist/server.js"]

View File

@@ -0,0 +1,109 @@
-- HotelSync Database Schema
-- Migration 001 — Initial Schema
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- ── Hotels ─────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS hotels (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
slug VARCHAR(100) NOT NULL UNIQUE,
plan VARCHAR(20) NOT NULL DEFAULT 'starter'
CHECK (plan IN ('starter', 'pro', 'enterprise')),
timezone VARCHAR(50) NOT NULL DEFAULT 'Europe/Moscow',
currency VARCHAR(3) NOT NULL DEFAULT 'RUB',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- ── Users ──────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
hotel_id UUID REFERENCES hotels(id) ON DELETE SET NULL,
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
role VARCHAR(20) NOT NULL DEFAULT 'manager'
CHECK (role IN ('manager', 'housekeeper', 'super_admin')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- ── Rooms ──────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS rooms (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
hotel_id UUID NOT NULL REFERENCES hotels(id) ON DELETE CASCADE,
number VARCHAR(20) NOT NULL,
type VARCHAR(50) NOT NULL,
floor INTEGER NOT NULL DEFAULT 1,
capacity INTEGER NOT NULL DEFAULT 2,
price_per_night DECIMAL(10,2) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'clean'
CHECK (status IN ('clean', 'dirty', 'maintenance', 'out_of_order')),
amenities TEXT[] NOT NULL DEFAULT '{}',
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(hotel_id, number)
);
-- ── Bookings ───────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS bookings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
hotel_id UUID NOT NULL REFERENCES hotels(id) ON DELETE CASCADE,
room_id UUID NOT NULL REFERENCES rooms(id),
guest_name VARCHAR(255) NOT NULL,
guest_email VARCHAR(255),
guest_phone VARCHAR(50),
check_in DATE NOT NULL,
check_out DATE NOT NULL,
adults INTEGER NOT NULL DEFAULT 1,
children INTEGER NOT NULL DEFAULT 0,
status VARCHAR(20) NOT NULL DEFAULT 'confirmed'
CHECK (status IN ('confirmed', 'checked_in', 'checked_out', 'cancelled', 'no_show')),
source VARCHAR(20) NOT NULL DEFAULT 'direct'
CHECK (source IN ('direct', 'booking_com', 'airbnb', 'expedia', 'vrbo')),
total_amount DECIMAL(10,2),
notes TEXT,
external_id VARCHAR(255),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- ── Housekeeping Tasks ─────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS housekeeping_tasks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
hotel_id UUID NOT NULL REFERENCES hotels(id) ON DELETE CASCADE,
room_id UUID REFERENCES rooms(id) ON DELETE SET NULL,
type VARCHAR(30) NOT NULL DEFAULT 'cleaning'
CHECK (type IN ('cleaning', 'turnover', 'inspection', 'maintenance', 'amenities')),
status VARCHAR(20) NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'in_progress', 'done', 'skipped')),
priority VARCHAR(10) NOT NULL DEFAULT 'medium'
CHECK (priority IN ('low', 'medium', 'high', 'urgent')),
assignee_id UUID REFERENCES users(id) ON DELETE SET NULL,
notes TEXT,
due_date DATE,
completed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- ── Channels ───────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS channels (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
hotel_id UUID NOT NULL REFERENCES hotels(id) ON DELETE CASCADE,
name VARCHAR(50) NOT NULL
CHECK (name IN ('booking_com', 'airbnb', 'expedia', 'vrbo')),
enabled BOOLEAN NOT NULL DEFAULT false,
api_key_encrypted TEXT,
hotel_external_id VARCHAR(255),
last_synced_at TIMESTAMPTZ,
UNIQUE(hotel_id, name)
);
-- ── Indexes ────────────────────────────────────────────────────────────────
CREATE INDEX IF NOT EXISTS idx_bookings_hotel_dates ON bookings(hotel_id, check_in, check_out);
CREATE INDEX IF NOT EXISTS idx_bookings_room ON bookings(room_id);
CREATE INDEX IF NOT EXISTS idx_rooms_hotel ON rooms(hotel_id);
CREATE INDEX IF NOT EXISTS idx_tasks_hotel ON housekeeping_tasks(hotel_id);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE INDEX IF NOT EXISTS idx_users_hotel ON users(hotel_id);

29
backend/package.json Normal file
View File

@@ -0,0 +1,29 @@
{
"name": "hotelsync-api",
"version": "1.0.0",
"description": "HotelSync PMS — REST API",
"main": "dist/server.js",
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc",
"start": "node dist/server.js"
},
"dependencies": {
"@fastify/cookie": "^9.4.0",
"@fastify/cors": "^9.0.1",
"@fastify/helmet": "^11.1.1",
"@fastify/jwt": "^8.0.1",
"@fastify/rate-limit": "^9.1.0",
"bcryptjs": "^2.4.3",
"fastify": "^4.28.1",
"ioredis": "^5.3.2",
"pg": "^8.12.0"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/node": "^20.14.0",
"@types/pg": "^8.11.6",
"tsx": "^4.15.7",
"typescript": "^5.4.5"
}
}

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>
}
}

17
backend/tsconfig.json Normal file
View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"lib": ["ES2022"],
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"noUnusedLocals": false,
"noUnusedParameters": false
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}