Add hotel registration with email confirmation (nodemailer, confirm-email endpoint)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-17 17:30:07 +03:00
parent c06f4bf553
commit 152a8c0463
6 changed files with 259 additions and 5 deletions

View File

@@ -4,6 +4,7 @@ import crypto from 'crypto'
import { db } from '../db'
import { redis } from '../redis'
import { config } from '../config'
import { sendConfirmationEmail } from '../email'
import type { JwtPayload } from '../types'
const auth: FastifyPluginAsync = async (fastify) => {
@@ -38,6 +39,10 @@ const auth: FastifyPluginAsync = async (fastify) => {
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,
@@ -51,7 +56,6 @@ const auth: FastifyPluginAsync = async (fastify) => {
expiresIn: config.jwt.accessExpiry,
})
// Refresh token — opaque, stored in Redis
const refreshToken = crypto.randomBytes(40).toString('hex')
await redis.set(
`refresh:${refreshToken}`,
@@ -82,6 +86,145 @@ const auth: FastifyPluginAsync = async (fastify) => {
},
)
// ── POST /api/auth/register ────────────────────────────────────────────────
fastify.post<{
Body: {
hotelName: string
address?: string
contact: string
email: string
phone?: string
password: string
}
}>(
'/api/auth/register',
{
schema: {
body: {
type: 'object',
required: ['hotelName', 'contact', 'email', 'password'],
properties: {
hotelName: { type: 'string', minLength: 2 },
address: { type: 'string' },
contact: { type: 'string', minLength: 2 },
email: { type: 'string' },
phone: { type: 'string' },
password: { type: 'string', minLength: 8 },
},
},
},
},
async (request, reply) => {
const { hotelName, address, contact, email, phone, password } = request.body
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, 'manager', $4, $5, false, $6, NOW())`,
[contact, email.toLowerCase().trim(), passwordHash, hotel.id, phone ?? null, confirmToken],
)
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/refresh ─────────────────────────────────────────────────
fastify.post('/api/auth/refresh', async (request, reply) => {
const refreshToken = request.cookies?.refresh_token