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 { sendConfirmationEmail, sendPasswordResetEmail } from '../email' 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 или пароль' }) } if (!user.email_confirmed) { return reply.code(403).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, }) 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/register ──────────────────────────────────────────────── fastify.post<{ Body: { hotel_name: string address: string contact: string email: string phone: string password: string } }>( '/api/auth/register', { schema: { body: { type: 'object', required: ['hotel_name', 'address', 'contact', 'email', 'phone', 'password'], properties: { hotel_name: { type: 'string', minLength: 2 }, address: { type: 'string', minLength: 2 }, contact: { type: 'string', minLength: 2 }, email: { type: 'string' }, phone: { type: 'string', minLength: 5 }, password: { type: 'string', minLength: 8 }, }, }, }, }, async (request, reply) => { const { hotel_name: hotelName, address, contact, email, phone, password } = request.body if (!/[a-zA-Zа-яА-ЯёЁ]/.test(password)) { return reply.code(400).send({ error: 'Пароль должен содержать хотя бы одну букву' }) } const { rows: existing } = await db.query( 'SELECT id FROM users WHERE email = $1', [email.toLowerCase().trim()], ) if (existing.length > 0) { return reply.code(409).send({ error: 'Пользователь с таким email уже существует' }) } // Generate slug from hotel name (transliterate RU→EN) const ru: Record = { а:'a',б:'b',в:'v',г:'g',д:'d',е:'e',ё:'yo',ж:'zh',з:'z',и:'i',й:'y', к:'k',л:'l',м:'m',н:'n',о:'o',п:'p',р:'r',с:'s',т:'t',у:'u',ф:'f', х:'h',ц:'ts',ч:'ch',ш:'sh',щ:'sch',ъ:'',ы:'y',ь:'',э:'e',ю:'yu',я:'ya', } const baseSlug = hotelName .toLowerCase() .split('') .map(c => ru[c] ?? c) .join('') .replace(/[^a-z0-9\s-]/g, '') .trim() .replace(/\s+/g, '-') .replace(/-+/g, '-') .substring(0, 50) || 'hotel' let slug = baseSlug let suffix = 2 for (;;) { const { rows } = await db.query('SELECT id FROM hotels WHERE slug = $1', [slug]) if (rows.length === 0) break slug = `${baseSlug}-${suffix++}` } const passwordHash = await bcrypt.hash(password, 12) const confirmToken = crypto.randomBytes(32).toString('hex') const client = await db.connect() try { await client.query('BEGIN') const { rows: [hotel] } = await client.query( `INSERT INTO hotels (name, slug, address, timezone, currency, plan, is_active) VALUES ($1, $2, $3, 'Europe/Moscow', 'RUB', 'starter', true) RETURNING id`, [hotelName, slug, address ?? null], ) await client.query( `INSERT INTO users (name, email, password_hash, role, hotel_id, phone, email_confirmed, confirmation_token, confirmation_sent_at) VALUES ($1, $2, $3, 'hotel_admin', $4, $5, false, $6, NOW())`, [contact, email.toLowerCase().trim(), passwordHash, hotel.id, phone ?? null, confirmToken], ) // Template rental objects (inactive drafts — user activates as needed) const templates = [ { name: 'Теннисный корт', icon: '🎾', color: 'bg-green-500', text_color: 'text-green-700 dark:text-green-400', price_h: 1500, price_d: 8000, open: 8, close: 22, sort: 1 }, { name: 'Баня / сауна', icon: '🛁', color: 'bg-orange-500', text_color: 'text-orange-700 dark:text-orange-400', price_h: 2500, price_d: 12000, open: 10, close: 23, sort: 2 }, { name: 'Конференц-зал', icon: '🏛️', color: 'bg-blue-500', text_color: 'text-blue-700 dark:text-blue-400', price_h: 3000, price_d: 15000, open: 9, close: 20, sort: 3 }, ] for (const t of templates) { await client.query( `INSERT INTO rental_objects (hotel_id, name, icon, color, text_color, price_per_hour, price_per_day, open_hour, close_hour, sort_order, is_active) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10, false)`, [hotel.id, t.name, t.icon, t.color, t.text_color, t.price_h, t.price_d, t.open, t.close, t.sort], ) } await client.query('COMMIT') } catch (err) { await client.query('ROLLBACK') throw err } finally { client.release() } try { await sendConfirmationEmail(email, contact, confirmToken) } catch (emailErr) { console.error('[email] Confirmation email failed:', emailErr) // Don't fail the registration — user can request resend later } return reply.code(201).send({ ok: true, message: `Письмо с подтверждением отправлено на ${email}`, }) }, ) // ── GET /api/auth/confirm-email ──────────────────────────────────────────── fastify.get<{ Querystring: { token?: string } }>( '/api/auth/confirm-email', async (request, reply) => { const { token } = request.query const appUrl = process.env.APP_URL ?? 'https://app.hotelsync.ru' if (!token) { return reply.redirect(`${appUrl}/login?error=invalid_token`) } const { rows } = await db.query( `SELECT id, confirmation_sent_at FROM users WHERE confirmation_token = $1 AND email_confirmed = false`, [token], ) if (rows.length === 0) { return reply.redirect(`${appUrl}/login?error=invalid_token`) } const sentAt = new Date(rows[0].confirmation_sent_at as string) const hoursElapsed = (Date.now() - sentAt.getTime()) / 1000 / 3600 if (hoursElapsed > 24) { return reply.redirect(`${appUrl}/login?error=token_expired`) } await db.query( `UPDATE users SET email_confirmed = true, confirmation_token = NULL WHERE id = $1`, [rows[0].id], ) return reply.redirect(`${appUrl}/login?confirmed=1`) }, ) // ── POST /api/auth/resend-confirmation ──────────────────────────────────── fastify.post<{ Body: { email: string } }>( '/api/auth/resend-confirmation', { schema: { body: { type: 'object', required: ['email'], properties: { email: { type: 'string' } }, }, }, }, async (request, reply) => { const { email } = request.body const { rows } = await db.query( `SELECT id, name FROM users WHERE email = $1 AND email_confirmed = false`, [email.toLowerCase().trim()], ) // Always return 200 to prevent enumeration if (rows.length === 0) return reply.code(200).send({ ok: true }) const user = rows[0] const confirmToken = crypto.randomBytes(32).toString('hex') await db.query( `UPDATE users SET confirmation_token = $1, confirmation_sent_at = NOW() WHERE id = $2`, [confirmToken, user.id], ) try { await sendConfirmationEmail(email, user.name as string, confirmToken) } catch (err) { console.error('[email] Resend confirmation failed:', err) } return reply.code(200).send({ ok: true }) }, ) // ── 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 cached = JSON.parse(stored) as JwtPayload // Re-read role and active status from DB so role changes take effect immediately const { rows } = await db.query( `SELECT u.role, u.active, h.slug AS hotel_slug FROM users u LEFT JOIN hotels h ON h.id = u.hotel_id WHERE u.id = $1`, [cached.sub], ) if (!rows[0] || !rows[0].active) { await redis.del(`refresh:${refreshToken}`) reply.clearCookie('refresh_token', { path: '/api/auth' }) return reply.code(401).send({ error: 'Account inactive or not found' }) } const payload: JwtPayload = { ...cached, role: rows[0].role, hotelSlug: rows[0].hotel_slug ?? cached.hotelSlug, } // Update Redis with fresh payload const ttl = await redis.ttl(`refresh:${refreshToken}`) if (ttl > 0) await redis.set(`refresh:${refreshToken}`, JSON.stringify(payload), 'EX', ttl) 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 } }) // ── POST /api/auth/forgot-password ──────────────────────────────────────── fastify.post<{ Body: { email: string } }>( '/api/auth/forgot-password', { schema: { body: { type: 'object', required: ['email'], properties: { email: { type: 'string' } }, }, }, }, async (request, reply) => { const { email } = request.body const { rows } = await db.query( `SELECT id, name FROM users WHERE email = $1 AND email_confirmed = true`, [email.toLowerCase().trim()], ) // Always return 200 to prevent email enumeration if (rows.length === 0) return { ok: true } const user = rows[0] const resetToken = crypto.randomBytes(32).toString('hex') const expires = new Date(Date.now() + 3600 * 1000) // 1 hour await db.query( `UPDATE users SET reset_token = $1, reset_token_expires = $2 WHERE id = $3`, [resetToken, expires, user.id], ) try { await sendPasswordResetEmail(email, user.name as string, resetToken) } catch (err) { console.error('[email] Password reset email failed:', err) } return reply.code(200).send({ ok: true }) }, ) // ── POST /api/auth/reset-password ───────────────────────────────────────── fastify.post<{ Body: { token: string; password: string } }>( '/api/auth/reset-password', { schema: { body: { type: 'object', required: ['token', 'password'], properties: { token: { type: 'string' }, password: { type: 'string', minLength: 8 }, }, }, }, }, async (request, reply) => { const { token, password } = request.body if (!/[a-zA-Zа-яА-ЯёЁ]/.test(password)) { return reply.code(400).send({ error: 'Пароль должен содержать хотя бы одну букву' }) } const { rows } = await db.query( `SELECT id FROM users WHERE reset_token = $1 AND reset_token_expires > NOW()`, [token], ) if (rows.length === 0) { return reply.code(400).send({ error: 'Ссылка недействительна или истекла' }) } const passwordHash = await bcrypt.hash(password, 12) await db.query( `UPDATE users SET password_hash = $1, reset_token = NULL, reset_token_expires = NULL WHERE id = $2`, [passwordHash, rows[0].id], ) return { ok: true } }, ) // ── POST /api/auth/accept-invite ────────────────────────────────────────── fastify.post<{ Body: { token: string; password: string } }>( '/api/auth/accept-invite', { schema: { body: { type: 'object', required: ['token', 'password'], properties: { token: { type: 'string' }, password: { type: 'string', minLength: 6 }, }, }, }, }, async (request, reply) => { const { token, 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.invite_token = $1 AND u.invite_expires > NOW()`, [token], ) if (rows.length === 0) { return reply.code(400).send({ error: 'Ссылка недействительна или истекла' }) } const user = rows[0] const passwordHash = await bcrypt.hash(password, 12) await db.query( `UPDATE users SET password_hash = $1, email_confirmed = true, active = true, invite_token = NULL, invite_expires = NULL, updated_at = NOW() WHERE id = $2`, [passwordHash, user.id], ) // Auto-login 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 }) 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, }, } }, ) // ── 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