Files
shop-server/server/src/routes/auth.js
T
@kirill.komarov 123d86091d base commit
2026-04-29 20:23:30 +05:00

632 lines
26 KiB
JavaScript

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