import { prisma } from '../lib/prisma.js' import { hashPassword, issueEmailCode, normalizeEmail, verifyEmailCode, verifyPassword } from '../lib/auth.js' export async function registerAuthRoutes(fastify) { fastify.post('/api/auth/request-code', async (request, reply) => { const email = normalizeEmail(request.body?.email) if (!email || !email.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' }) // purpose: login (включает и регистрацию — пользователь создастся при verify) await issueEmailCode({ email, purpose: 'login' }) return { ok: true } }) fastify.post('/api/auth/verify-code', async (request, reply) => { const email = normalizeEmail(request.body?.email) const code = String(request.body?.code || '').trim() if (!email || !email.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' }) if (!code || code.length !== 6) return reply.code(400).send({ error: 'Код должен быть из 6 цифр' }) const ok = await verifyEmailCode({ email, purpose: 'login', code }) if (!ok) return reply.code(401).send({ error: 'Неверный или истёкший код' }) const user = await prisma.user.upsert({ where: { email }, update: {}, create: { email }, }) const token = fastify.jwt.sign({ sub: user.id, email: user.email }) return { token, user: { id: user.id, email: user.email, name: user.name, phone: user.phone } } }) fastify.post('/api/auth/register', async (request, reply) => { const email = normalizeEmail(request.body?.email) const password = String(request.body?.password || '') if (!email || !email.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' }) if (password.length < 8) return reply.code(400).send({ error: 'Пароль минимум 8 символов' }) const existing = await prisma.user.findUnique({ where: { email } }) if (existing) return reply.code(409).send({ error: 'Пользователь уже существует' }) const passwordHash = await hashPassword(password) const user = await prisma.user.create({ data: { email, passwordHash } }) const token = fastify.jwt.sign({ sub: user.id, email: user.email }) return reply.code(201).send({ token, user: { id: user.id, email: user.email, name: user.name, phone: user.phone } }) }) fastify.post('/api/auth/login', async (request, reply) => { const email = normalizeEmail(request.body?.email) const password = String(request.body?.password || '') if (!email || !email.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' }) if (!password) return reply.code(400).send({ error: 'Укажите пароль' }) const user = await prisma.user.findUnique({ where: { email } }) if (!user?.passwordHash) return reply.code(401).send({ error: 'Неверные данные' }) const ok = await verifyPassword(password, user.passwordHash) if (!ok) return reply.code(401).send({ error: 'Неверные данные' }) const token = fastify.jwt.sign({ sub: user.id, email: user.email }) return { token, user: { id: user.id, email: user.email, name: user.name, phone: user.phone } } }) fastify.get( '/api/me', { preHandler: [fastify.authenticate] }, async (request) => { const userId = request.user.sub const user = await prisma.user.findUnique({ where: { id: userId } }) if (!user) return { user: null } return { user: { id: user.id, email: user.email, name: user.name, phone: user.phone } } }, ) fastify.post( '/api/me/change-email/request-code', { preHandler: [fastify.authenticate] }, async (request, reply) => { const userId = request.user.sub const newEmail = normalizeEmail(request.body?.newEmail) if (!newEmail || !newEmail.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' }) const exists = await prisma.user.findUnique({ where: { email: newEmail } }) if (exists) return reply.code(409).send({ error: 'Эта почта уже занята' }) await issueEmailCode({ email: newEmail, purpose: 'change_email', userId }) return { ok: true } }, ) fastify.post( '/api/me/change-email/verify', { preHandler: [fastify.authenticate] }, async (request, reply) => { const userId = request.user.sub const newEmail = normalizeEmail(request.body?.newEmail) const code = String(request.body?.code || '').trim() if (!newEmail || !newEmail.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' }) if (!code || code.length !== 6) return reply.code(400).send({ error: 'Код должен быть из 6 цифр' }) const exists = await prisma.user.findUnique({ where: { email: newEmail } }) if (exists) return reply.code(409).send({ error: 'Эта почта уже занята' }) const ok = await verifyEmailCode({ email: newEmail, purpose: 'change_email', code, userId }) if (!ok) return reply.code(401).send({ error: 'Неверный или истёкший код' }) const user = await prisma.user.update({ where: { id: userId }, data: { email: newEmail }, }) return { user: { id: user.id, email: user.email, name: user.name, phone: user.phone } } }, ) fastify.patch( '/api/me/password', { preHandler: [fastify.authenticate] }, async (request, reply) => { const userId = request.user.sub const currentPassword = request.body?.currentPassword ? String(request.body.currentPassword) : '' const newPassword = String(request.body?.newPassword || '') if (newPassword.length < 8) return reply.code(400).send({ error: 'Новый пароль минимум 8 символов' }) const user = await prisma.user.findUnique({ where: { id: userId } }) if (!user) return reply.code(404).send({ error: 'Пользователь не найден' }) if (user.passwordHash) { if (!currentPassword) return reply.code(400).send({ error: 'Укажите текущий пароль' }) const ok = await verifyPassword(currentPassword, user.passwordHash) if (!ok) return reply.code(401).send({ error: 'Текущий пароль неверный' }) } const passwordHash = await hashPassword(newPassword) const updated = await prisma.user.update({ where: { id: userId }, data: { passwordHash } }) return { user: { id: updated.id, email: updated.email, name: updated.name, phone: updated.phone } } }, ) fastify.patch( '/api/me/profile', { preHandler: [fastify.authenticate] }, async (request, reply) => { const userId = request.user.sub const nameRaw = request.body?.name const name = nameRaw === null || nameRaw === undefined ? null : String(nameRaw).trim() const phoneRaw = request.body?.phone const phone = phoneRaw === null || phoneRaw === undefined ? null : String(phoneRaw).trim() if (name !== null && name.length > 40) return reply.code(400).send({ error: 'Имя/ник максимум 40 символов' }) if (phone !== null) { const compact = phone.replace(/[\s()-]/g, '') if (compact.length > 20) return reply.code(400).send({ error: 'Телефон слишком длинный' }) if (compact.length && !/^\+?\d{7,20}$/.test(compact)) { return reply.code(400).send({ error: 'Некорректный телефон' }) } } const updated = await prisma.user.update({ where: { id: userId }, data: { name: name && name.length ? name : null, phone: phone && phone.length ? phone : null }, }) return { user: { id: updated.id, email: updated.email, name: updated.name, phone: updated.phone } } }, ) // ---- Адреса доставки ---- function normalizePhoneLite(input) { const s = String(input || '').trim() if (!s) return '' return s.replace(/[\s()-]/g, '') } function validateAddressPayload(body, reply) { const labelRaw = body?.label const label = labelRaw === null || labelRaw === undefined ? null : String(labelRaw).trim() if (label !== null && label.length > 40) return reply.code(400).send({ error: 'Метка адреса максимум 40 символов' }) const recipientName = String(body?.recipientName || '').trim() if (!recipientName) return reply.code(400).send({ error: 'Укажите ФИО получателя' }) if (recipientName.length > 80) return reply.code(400).send({ error: 'ФИО получателя максимум 80 символов' }) const recipientPhone = normalizePhoneLite(body?.recipientPhone) if (!recipientPhone) return reply.code(400).send({ error: 'Укажите телефон получателя' }) if (!/^\+?\d{7,20}$/.test(recipientPhone)) return reply.code(400).send({ error: 'Некорректный телефон получателя' }) const addressLine = String(body?.addressLine || '').trim() if (!addressLine) return reply.code(400).send({ error: 'Укажите адрес' }) if (addressLine.length > 200) return reply.code(400).send({ error: 'Адрес максимум 200 символов' }) const commentRaw = body?.comment const comment = commentRaw === null || commentRaw === undefined ? null : String(commentRaw).trim() if (comment !== null && comment.length > 200) return reply.code(400).send({ error: 'Комментарий максимум 200 символов' }) const lat = Number(body?.lat) const lng = Number(body?.lng) if (!Number.isFinite(lat) || lat < -90 || lat > 90) return reply.code(400).send({ error: 'Некорректная широта' }) if (!Number.isFinite(lng) || lng < -180 || lng > 180) return reply.code(400).send({ error: 'Некорректная долгота' }) return { label, recipientName, recipientPhone, addressLine, comment, lat, lng, } } fastify.get( '/api/me/addresses', { preHandler: [fastify.authenticate] }, async (request) => { const userId = request.user.sub const items = await prisma.shippingAddress.findMany({ where: { userId }, orderBy: [{ isDefault: 'desc' }, { updatedAt: 'desc' }], }) return { items } }, ) fastify.post( '/api/me/addresses', { preHandler: [fastify.authenticate] }, async (request, reply) => { const userId = request.user.sub const validated = validateAddressPayload(request.body, reply) if (!validated) return const isDefault = Boolean(request.body?.isDefault) const created = await prisma.$transaction(async (tx) => { if (isDefault) { await tx.shippingAddress.updateMany({ where: { userId, isDefault: true }, data: { isDefault: false } }) } return tx.shippingAddress.create({ data: { userId, ...validated, isDefault, }, }) }) return reply.code(201).send({ item: created }) }, ) fastify.patch( '/api/me/addresses/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => { const userId = request.user.sub const { id } = request.params const existing = await prisma.shippingAddress.findFirst({ where: { id, userId } }) if (!existing) return reply.code(404).send({ error: 'Адрес не найден' }) const body = request.body ?? {} const data = {} if (body.label !== undefined) { const labelRaw = body.label const label = labelRaw === null || labelRaw === undefined ? null : String(labelRaw).trim() if (label !== null && label.length > 40) return reply.code(400).send({ error: 'Метка адреса максимум 40 символов' }) data.label = label && label.length ? label : null } if (body.recipientName !== undefined) { const v = String(body.recipientName || '').trim() if (!v) return reply.code(400).send({ error: 'Укажите ФИО получателя' }) if (v.length > 80) return reply.code(400).send({ error: 'ФИО получателя максимум 80 символов' }) data.recipientName = v } if (body.recipientPhone !== undefined) { const v = normalizePhoneLite(body.recipientPhone) if (!v) return reply.code(400).send({ error: 'Укажите телефон получателя' }) if (!/^\+?\d{7,20}$/.test(v)) return reply.code(400).send({ error: 'Некорректный телефон получателя' }) data.recipientPhone = v } if (body.addressLine !== undefined) { const v = String(body.addressLine || '').trim() if (!v) return reply.code(400).send({ error: 'Укажите адрес' }) if (v.length > 200) return reply.code(400).send({ error: 'Адрес максимум 200 символов' }) data.addressLine = v } if (body.comment !== undefined) { const commentRaw = body.comment const comment = commentRaw === null || commentRaw === undefined ? null : String(commentRaw).trim() if (comment !== null && comment.length > 200) return reply.code(400).send({ error: 'Комментарий максимум 200 символов' }) data.comment = comment && comment.length ? comment : null } if (body.lat !== undefined) { const lat = Number(body.lat) if (!Number.isFinite(lat) || lat < -90 || lat > 90) return reply.code(400).send({ error: 'Некорректная широта' }) data.lat = lat } if (body.lng !== undefined) { const lng = Number(body.lng) if (!Number.isFinite(lng) || lng < -180 || lng > 180) return reply.code(400).send({ error: 'Некорректная долгота' }) data.lng = lng } const setDefault = body.isDefault === true const updated = await prisma.$transaction(async (tx) => { if (setDefault) { await tx.shippingAddress.updateMany({ where: { userId, isDefault: true }, data: { isDefault: false } }) } return tx.shippingAddress.update({ where: { id }, data: { ...data, ...(setDefault ? { isDefault: true } : {}), }, }) }) return { item: updated } }, ) fastify.delete( '/api/me/addresses/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => { const userId = request.user.sub const { id } = request.params const existing = await prisma.shippingAddress.findFirst({ where: { id, userId } }) if (!existing) return reply.code(404).send({ error: 'Адрес не найден' }) await prisma.shippingAddress.delete({ where: { id } }) return reply.code(204).send() }, ) fastify.post( '/api/me/addresses/:id/default', { preHandler: [fastify.authenticate] }, async (request, reply) => { const userId = request.user.sub const { id } = request.params const existing = await prisma.shippingAddress.findFirst({ where: { id, userId } }) if (!existing) return reply.code(404).send({ error: 'Адрес не найден' }) const updated = await prisma.$transaction(async (tx) => { await tx.shippingAddress.updateMany({ where: { userId, isDefault: true }, data: { isDefault: false } }) return tx.shippingAddress.update({ where: { id }, data: { isDefault: true } }) }) return { item: updated } }, ) // ---- Корзина ---- fastify.get( '/api/me/cart', { preHandler: [fastify.authenticate] }, async (request) => { const userId = request.user.sub const items = await prisma.cartItem.findMany({ where: { userId }, include: { product: { include: { category: true, images: { orderBy: { sort: 'asc' } } } } }, orderBy: { createdAt: 'asc' }, }) return { items: items.map((x) => ({ id: x.id, qty: x.qty, product: x.product, })), } }, ) fastify.post( '/api/me/cart/items', { preHandler: [fastify.authenticate] }, async (request, reply) => { const userId = request.user.sub const productId = String(request.body?.productId || '').trim() const qtyRaw = request.body?.qty const qty = qtyRaw === undefined || qtyRaw === null || qtyRaw === '' ? 1 : Number(qtyRaw) if (!productId) return reply.code(400).send({ error: 'productId обязателен' }) if (!Number.isFinite(qty) || qty <= 0) return reply.code(400).send({ error: 'qty должен быть > 0' }) const product = await prisma.product.findFirst({ where: { id: productId, published: true } }) if (!product) return reply.code(404).send({ error: 'Товар не найден' }) const available = product.inStock ? product.quantity : 1 const existing = await prisma.cartItem.findUnique({ where: { userId_productId: { userId, productId } } }) const nextQty = (existing?.qty ?? 0) + Math.floor(qty) if (nextQty > available) return reply.code(409).send({ error: `Доступно: ${available} шт.` }) const item = await prisma.cartItem.upsert({ where: { userId_productId: { userId, productId } }, update: { qty: nextQty }, create: { userId, productId, qty: nextQty }, }) return reply.code(201).send({ item }) }, ) fastify.patch( '/api/me/cart/items/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => { const userId = request.user.sub const { id } = request.params const qtyRaw = request.body?.qty const qty = Number(qtyRaw) if (!Number.isFinite(qty) || qty < 0) return reply.code(400).send({ error: 'qty должен быть ≥ 0' }) const existing = await prisma.cartItem.findFirst({ where: { id, userId }, include: { product: true } }) if (!existing) return reply.code(404).send({ error: 'Позиция корзины не найдена' }) if (qty === 0) { await prisma.cartItem.delete({ where: { id } }) return reply.code(204).send() } const available = existing.product.inStock ? existing.product.quantity : 1 const nextQty = Math.floor(qty) if (nextQty > available) return reply.code(409).send({ error: `Доступно: ${available} шт.` }) const updated = await prisma.cartItem.update({ where: { id }, data: { qty: nextQty } }) return { item: updated } }, ) fastify.delete( '/api/me/cart/items/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => { const userId = request.user.sub const { id } = request.params const existing = await prisma.cartItem.findFirst({ where: { id, userId } }) if (!existing) return reply.code(404).send({ error: 'Позиция корзины не найдена' }) await prisma.cartItem.delete({ where: { id } }) return reply.code(204).send() }, ) // ---- Заказы (checkout) ---- fastify.post( '/api/me/orders', { preHandler: [fastify.authenticate] }, async (request, reply) => { const userId = request.user.sub const addressId = String(request.body?.addressId || '').trim() const commentRaw = request.body?.comment const comment = commentRaw === null || commentRaw === undefined ? null : String(commentRaw).trim() if (!addressId) return reply.code(400).send({ error: 'Выберите адрес доставки' }) const address = await prisma.shippingAddress.findFirst({ where: { id: addressId, userId } }) if (!address) return reply.code(404).send({ error: 'Адрес не найден' }) const cartItems = await prisma.cartItem.findMany({ where: { userId }, include: { product: true }, }) if (cartItems.length === 0) return reply.code(400).send({ error: 'Корзина пуста' }) for (const ci of cartItems) { const available = ci.product.inStock ? ci.product.quantity : 1 if (ci.qty > available) { return reply.code(409).send({ error: `Недостаточно товара: "${ci.product.title}". Доступно: ${available} шт.` }) } } const itemsPayload = cartItems.map((ci) => ({ productId: ci.productId, qty: ci.qty, titleSnapshot: ci.product.title, priceCentsSnapshot: ci.product.priceCents, })) const totalCents = itemsPayload.reduce((sum, i) => sum + i.priceCentsSnapshot * i.qty, 0) const addressSnapshotJson = JSON.stringify({ id: address.id, label: address.label, recipientName: address.recipientName, recipientPhone: address.recipientPhone, addressLine: address.addressLine, comment: address.comment, lat: address.lat, lng: address.lng, }) let created try { created = await prisma.$transaction(async (tx) => { for (const ci of cartItems) { if (!ci.product.inStock) continue const res = await tx.product.updateMany({ where: { id: ci.productId, quantity: { gte: ci.qty } }, data: { quantity: { decrement: ci.qty } }, }) if (res.count !== 1) { throw new Error(`Недостаточно товара: "${ci.product.title}"`) } const p = await tx.product.findUnique({ where: { id: ci.productId }, select: { quantity: true } }) if (p && p.quantity === 0) { await tx.product.update({ where: { id: ci.productId }, data: { published: false } }) } } const order = await tx.order.create({ data: { userId, status: 'PENDING_PAYMENT', totalCents, currency: 'RUB', addressSnapshotJson, comment: comment && comment.length ? comment : null, items: { create: itemsPayload.map((i) => ({ productId: i.productId, qty: i.qty, titleSnapshot: i.titleSnapshot, priceCentsSnapshot: i.priceCentsSnapshot, })), }, }, }) await tx.cartItem.deleteMany({ where: { userId } }) return order }) } catch (e) { return reply.code(409).send({ error: (e instanceof Error && e.message) || 'Недостаточно товара' }) } return reply.code(201).send({ orderId: created.id }) }, ) fastify.get( '/api/me/orders', { preHandler: [fastify.authenticate] }, async (request) => { const userId = request.user.sub const orders = await prisma.order.findMany({ where: { userId }, include: { items: true }, orderBy: { createdAt: 'desc' }, }) return { items: orders.map((o) => ({ id: o.id, status: o.status, totalCents: o.totalCents, currency: o.currency, createdAt: o.createdAt, updatedAt: o.updatedAt, itemsCount: o.items.reduce((s, i) => s + i.qty, 0), })), } }, ) fastify.get( '/api/me/orders/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => { const userId = request.user.sub const { id } = request.params const order = await prisma.order.findFirst({ where: { id, userId }, include: { items: true, messages: { orderBy: { createdAt: 'asc' } } }, }) if (!order) return reply.code(404).send({ error: 'Заказ не найден' }) return { item: order } }, ) fastify.get( '/api/me/orders/:id/messages', { preHandler: [fastify.authenticate] }, async (request, reply) => { const userId = request.user.sub const { id } = request.params const order = await prisma.order.findFirst({ where: { id, userId } }) if (!order) return reply.code(404).send({ error: 'Заказ не найден' }) const items = await prisma.orderMessage.findMany({ where: { orderId: id }, orderBy: { createdAt: 'asc' } }) return { items } }, ) fastify.post( '/api/me/orders/:id/messages', { preHandler: [fastify.authenticate] }, async (request, reply) => { const userId = request.user.sub const { id } = request.params const order = await prisma.order.findFirst({ where: { id, userId } }) if (!order) return reply.code(404).send({ error: 'Заказ не найден' }) const text = String(request.body?.text || '').trim() if (!text) return reply.code(400).send({ error: 'Сообщение пустое' }) if (text.length > 2000) return reply.code(400).send({ error: 'Сообщение слишком длинное' }) const msg = await prisma.orderMessage.create({ data: { orderId: id, authorType: 'user', text } }) return reply.code(201).send({ item: msg }) }, ) fastify.post( '/api/me/orders/:id/pay', { preHandler: [fastify.authenticate] }, async (request, reply) => { const userId = request.user.sub const { id } = request.params const order = await prisma.order.findFirst({ where: { id, userId } }) if (!order) return reply.code(404).send({ error: 'Заказ не найден' }) // Заглушка: пока ничего не оплачиваем, просто подтверждаем намерение оплатить if (order.status === 'DRAFT') { await prisma.order.update({ where: { id }, data: { status: 'PENDING_PAYMENT' } }) } return { ok: true, status: order.status === 'DRAFT' ? 'PENDING_PAYMENT' : order.status } }, ) }