Files
hotelsync/backend/src/routes/auth.ts
HotelSync f6d3c78dd0 feat: rental drafts, room modal cleanup, tariff auto-code
- New hotels get 3 rental object templates (tennis/sauna/conference) as drafts (is_active=false)
- RentalPage: draft templates shown separately with Activate/Delete buttons
- Migration 047: adds is_active column to rental_objects
- RoomModal: hide bed type/status/housekeeping in create mode; optional price (from category); restructured Places tab with extra beds constructor
- TariffsPage: auto-generate tariff code from name initials if code field is empty
- types: ExtraPlace.beds for extra place bed configuration

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 11:19:54 +03:00

413 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, string> = {
а:'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 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 }
})
// ── 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 }
},
)
// ── 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