base commit
This commit is contained in:
@@ -8,6 +8,7 @@ import path from 'node:path'
|
||||
import { registerAuth } from './plugins/auth.js'
|
||||
import { registerApiRoutes } from './routes/api.js'
|
||||
import { registerAuthRoutes } from './routes/auth.js'
|
||||
import { registerOAuthSocialRoutes } from './routes/oauth-social.js'
|
||||
|
||||
const port = Number(process.env.PORT) || 3333
|
||||
const origin = (process.env.CORS_ORIGIN ?? '')
|
||||
@@ -49,6 +50,7 @@ fastify.decorate('authenticate', async function authenticate(request, reply) {
|
||||
|
||||
registerAuth(fastify)
|
||||
await registerAuthRoutes(fastify)
|
||||
await registerOAuthSocialRoutes(fastify)
|
||||
await registerApiRoutes(fastify)
|
||||
|
||||
fastify.get('/health', async () => ({ ok: true }))
|
||||
|
||||
@@ -1,26 +1,49 @@
|
||||
export const ORDER_STATUSES = [
|
||||
'DRAFT',
|
||||
'PENDING_PAYMENT',
|
||||
'PAYMENT_VERIFICATION',
|
||||
'PAID',
|
||||
'IN_PROGRESS',
|
||||
'SHIPPED',
|
||||
'READY_FOR_PICKUP',
|
||||
'DONE',
|
||||
'CANCELLED',
|
||||
]
|
||||
|
||||
export const ORDER_STATUS_TRANSITIONS = {
|
||||
DRAFT: new Set(['PENDING_PAYMENT', 'CANCELLED']),
|
||||
PENDING_PAYMENT: new Set(['PAID', 'CANCELLED']),
|
||||
PAID: new Set(['IN_PROGRESS', 'CANCELLED']),
|
||||
IN_PROGRESS: new Set(['SHIPPED', 'CANCELLED']),
|
||||
SHIPPED: new Set(['DONE']),
|
||||
DONE: new Set([]),
|
||||
CANCELLED: new Set([]),
|
||||
/**
|
||||
* Переходы, которые делает админ через PATCH /api/admin/orders/:id/status
|
||||
* (подтверждение получения пользователем — отдельный эндпоинт).
|
||||
*/
|
||||
export function canTransitionAdminOrderStatus(order, next) {
|
||||
const from = order.status
|
||||
const dt = order.deliveryType
|
||||
if (from === next) return true
|
||||
|
||||
switch (from) {
|
||||
case 'DRAFT':
|
||||
return next === 'PENDING_PAYMENT' || next === 'CANCELLED'
|
||||
case 'PENDING_PAYMENT':
|
||||
return next === 'CANCELLED'
|
||||
case 'PAYMENT_VERIFICATION':
|
||||
return next === 'PAID' || next === 'CANCELLED'
|
||||
case 'PAID':
|
||||
return next === 'IN_PROGRESS' || next === 'CANCELLED'
|
||||
case 'IN_PROGRESS':
|
||||
if (next === 'CANCELLED') return true
|
||||
if (dt === 'delivery') return next === 'SHIPPED'
|
||||
if (dt === 'pickup') return next === 'READY_FOR_PICKUP'
|
||||
return false
|
||||
case 'SHIPPED':
|
||||
case 'READY_FOR_PICKUP':
|
||||
case 'DONE':
|
||||
case 'CANCELLED':
|
||||
return false
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** @deprecated используйте canTransitionAdminOrderStatus */
|
||||
export function canTransitionOrderStatus(from, to) {
|
||||
if (from === to) return true
|
||||
const allowed = ORDER_STATUS_TRANSITIONS[from]
|
||||
return Boolean(allowed?.has(to))
|
||||
return canTransitionAdminOrderStatus({ status: from, deliveryType: 'delivery' }, to)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
/** Публичное отображение автора отзыва (без «голого» email). */
|
||||
export function publicReviewAuthorDisplay(user) {
|
||||
if (!user || typeof user !== 'object') return 'Покупатель'
|
||||
const name = typeof user.name === 'string' ? user.name.trim() : ''
|
||||
if (name) return name
|
||||
const email = typeof user.email === 'string' ? user.email.trim() : ''
|
||||
const at = email.indexOf('@')
|
||||
if (at <= 0) return 'Покупатель'
|
||||
const local = email.slice(0, at)
|
||||
const domain = email.slice(at + 1)
|
||||
const masked = local.length <= 1 ? '*' : `${local.slice(0, 1)}***`
|
||||
return `${masked}@${domain}`
|
||||
}
|
||||
@@ -43,10 +43,14 @@ export function materialsFromDb(materials) {
|
||||
}
|
||||
}
|
||||
|
||||
export function mapProductForApi(p) {
|
||||
return {
|
||||
export function mapProductForApi(p, reviewsSummary = null) {
|
||||
const base = {
|
||||
...p,
|
||||
materials: materialsFromDb(p.materials),
|
||||
}
|
||||
if (reviewsSummary && typeof reviewsSummary === 'object') {
|
||||
base.reviewsSummary = reviewsSummary
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,26 @@
|
||||
import { prisma } from '../../lib/prisma.js'
|
||||
import { canTransitionOrderStatus } from '../../lib/order-status.js'
|
||||
import { canTransitionAdminOrderStatus } from '../../lib/order-status.js'
|
||||
|
||||
export async function registerAdminOrderRoutes(fastify) {
|
||||
fastify.get(
|
||||
'/api/admin/orders/summary',
|
||||
{ preHandler: [fastify.verifyAdmin] },
|
||||
async () => {
|
||||
const attentionCount = await prisma.order.count({
|
||||
where: { status: { in: ['PENDING_PAYMENT', 'PAYMENT_VERIFICATION'] } },
|
||||
})
|
||||
return { attentionCount }
|
||||
},
|
||||
)
|
||||
|
||||
fastify.get(
|
||||
'/api/admin/orders',
|
||||
{ preHandler: [fastify.verifyAdmin] },
|
||||
async (request, reply) => {
|
||||
const status = typeof request.query?.status === 'string' ? request.query.status.trim() : ''
|
||||
const q = typeof request.query?.q === 'string' ? request.query.q.trim() : ''
|
||||
const deliveryTypeRaw = request.query?.deliveryType
|
||||
const deliveryType = typeof deliveryTypeRaw === 'string' ? deliveryTypeRaw.trim() : ''
|
||||
|
||||
const pageRaw = request.query?.page
|
||||
const pageParsed = typeof pageRaw === 'string' ? Number(pageRaw) : Number(pageRaw)
|
||||
@@ -20,6 +33,12 @@ export async function registerAdminOrderRoutes(fastify) {
|
||||
|
||||
const where = {}
|
||||
if (status) where.status = status
|
||||
if (deliveryType) {
|
||||
if (deliveryType !== 'delivery' && deliveryType !== 'pickup') {
|
||||
return reply.code(400).send({ error: 'deliveryType должен быть delivery | pickup' })
|
||||
}
|
||||
where.deliveryType = deliveryType
|
||||
}
|
||||
if (q) {
|
||||
where.OR = [{ id: { contains: q } }, { user: { email: { contains: q } } }]
|
||||
}
|
||||
@@ -37,6 +56,7 @@ export async function registerAdminOrderRoutes(fastify) {
|
||||
items: items.map((o) => ({
|
||||
id: o.id,
|
||||
status: o.status,
|
||||
deliveryType: o.deliveryType,
|
||||
totalCents: o.totalCents,
|
||||
currency: o.currency,
|
||||
createdAt: o.createdAt,
|
||||
@@ -79,7 +99,7 @@ export async function registerAdminOrderRoutes(fastify) {
|
||||
|
||||
const existing = await prisma.order.findUnique({ where: { id } })
|
||||
if (!existing) return reply.code(404).send({ error: 'Заказ не найден' })
|
||||
if (!canTransitionOrderStatus(existing.status, next)) {
|
||||
if (!canTransitionAdminOrderStatus(existing, next)) {
|
||||
return reply.code(409).send({ error: `Нельзя сменить статус ${existing.status} → ${next}` })
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,63 @@
|
||||
import { prisma } from '../../lib/prisma.js'
|
||||
|
||||
const EMPTY_REVIEWS_SUMMARY = Object.freeze({
|
||||
approvedReviewCount: 0,
|
||||
avgRating: null,
|
||||
latestApprovedText: null,
|
||||
})
|
||||
|
||||
/** Сводка по одобренным отзывам для списка id товаров (для каталога и карточки товара). */
|
||||
export async function approvedReviewSummariesForProducts(productIds) {
|
||||
const map = new Map()
|
||||
if (!productIds.length) return map
|
||||
|
||||
const uniqueIds = [...new Set(productIds)]
|
||||
for (const id of uniqueIds) {
|
||||
map.set(id, { ...EMPTY_REVIEWS_SUMMARY })
|
||||
}
|
||||
|
||||
const grouped = await prisma.review.groupBy({
|
||||
by: ['productId'],
|
||||
where: { productId: { in: uniqueIds }, status: 'approved' },
|
||||
_count: { _all: true },
|
||||
_avg: { rating: true },
|
||||
})
|
||||
|
||||
for (const g of grouped) {
|
||||
const avg = g._avg.rating
|
||||
const prev = map.get(g.productId)
|
||||
if (!prev) continue
|
||||
map.set(g.productId, {
|
||||
...prev,
|
||||
approvedReviewCount: g._count._all,
|
||||
avgRating: avg != null ? Number(avg) : null,
|
||||
})
|
||||
}
|
||||
|
||||
const withReviews = [...map.entries()].filter(([, v]) => v.approvedReviewCount > 0).map(([k]) => k)
|
||||
if (!withReviews.length) return map
|
||||
|
||||
const previewRows = await prisma.review.findMany({
|
||||
where: { productId: { in: withReviews }, status: 'approved' },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: { productId: true, text: true },
|
||||
take: 450,
|
||||
})
|
||||
const hasPreviewFor = new Set()
|
||||
for (const r of previewRows) {
|
||||
if (hasPreviewFor.has(r.productId)) continue
|
||||
const t = typeof r.text === 'string' ? r.text.trim() : ''
|
||||
if (!t) continue
|
||||
hasPreviewFor.add(r.productId)
|
||||
const prev = map.get(r.productId)
|
||||
if (!prev) continue
|
||||
prev.latestApprovedText = t.length > 160 ? `${t.slice(0, 160)}…` : t
|
||||
if (hasPreviewFor.size === withReviews.length) break
|
||||
}
|
||||
|
||||
return map
|
||||
}
|
||||
|
||||
export async function registerPublicCatalogRoutes(fastify, { mapProductForApi } = {}) {
|
||||
fastify.get('/api/categories', async () => {
|
||||
return prisma.category.findMany({ orderBy: { sort: 'asc' } })
|
||||
@@ -9,6 +67,8 @@ export async function registerPublicCatalogRoutes(fastify, { mapProductForApi }
|
||||
const { categorySlug } = request.query
|
||||
const qRaw = request.query?.q
|
||||
const q = typeof qRaw === 'string' ? qRaw.trim() : ''
|
||||
const availabilityRaw = request.query?.availability
|
||||
const availability = typeof availabilityRaw === 'string' ? availabilityRaw.trim() : ''
|
||||
|
||||
const sortRaw = request.query?.sort
|
||||
const sort = typeof sortRaw === 'string' ? sortRaw : ''
|
||||
@@ -29,13 +89,21 @@ export async function registerPublicCatalogRoutes(fastify, { mapProductForApi }
|
||||
const priceMaxParsed = typeof priceMaxRaw === 'string' ? Number(priceMaxRaw) : Number(priceMaxRaw)
|
||||
const priceMax = Number.isFinite(priceMaxParsed) && priceMaxParsed >= 0 ? Math.floor(priceMaxParsed) : null
|
||||
|
||||
const where = { published: true, quantity: { gt: 0 } }
|
||||
const where = { published: true }
|
||||
if (typeof categorySlug === 'string' && categorySlug.length > 0) {
|
||||
where.category = { slug: categorySlug }
|
||||
}
|
||||
if (q) {
|
||||
where.OR = [{ title: { contains: q } }, { shortDescription: { contains: q } }]
|
||||
}
|
||||
if (availability === 'in_stock') {
|
||||
where.inStock = true
|
||||
where.quantity = { gt: 0 }
|
||||
} else if (availability === 'made_to_order') {
|
||||
where.inStock = false
|
||||
} else if (availability && availability !== 'all') {
|
||||
return reply.code(400).send({ error: 'availability должен быть all | in_stock | made_to_order' })
|
||||
}
|
||||
const applyPriceFilter = !(priceMin !== null && priceMax !== null && priceMin === 0 && priceMax === 0)
|
||||
|
||||
if (applyPriceFilter && (priceMin !== null || priceMax !== null)) {
|
||||
@@ -64,20 +132,27 @@ export async function registerPublicCatalogRoutes(fastify, { mapProductForApi }
|
||||
take: pageSize,
|
||||
})
|
||||
|
||||
return { items: items.map(mapProductForApi), total, page, pageSize }
|
||||
const summaries = await approvedReviewSummariesForProducts(items.map((it) => it.id))
|
||||
return {
|
||||
items: items.map((p) => mapProductForApi(p, summaries.get(p.id) ?? EMPTY_REVIEWS_SUMMARY)),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
}
|
||||
})
|
||||
|
||||
fastify.get('/api/products/:id', async (request, reply) => {
|
||||
const { id } = request.params
|
||||
const product = await prisma.product.findFirst({
|
||||
where: { id, published: true, quantity: { gt: 0 } },
|
||||
where: { id, published: true },
|
||||
include: { category: true, images: { orderBy: { sort: 'asc' } } },
|
||||
})
|
||||
if (!product) {
|
||||
reply.code(404).send({ error: 'Товар не найден' })
|
||||
return
|
||||
}
|
||||
return mapProductForApi(product)
|
||||
const summaries = await approvedReviewSummariesForProducts([product.id])
|
||||
return mapProductForApi(product, summaries.get(product.id) ?? EMPTY_REVIEWS_SUMMARY)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,36 @@
|
||||
import { publicReviewAuthorDisplay } from '../../lib/review-display.js'
|
||||
import { prisma } from '../../lib/prisma.js'
|
||||
|
||||
export async function registerPublicReviewRoutes(fastify) {
|
||||
fastify.get('/api/reviews/latest', async (request, reply) => {
|
||||
const limitRaw = request.query?.limit
|
||||
const limitParsed = typeof limitRaw === 'string' ? Number(limitRaw) : Number(limitRaw)
|
||||
const parsed = Number.isFinite(limitParsed) && limitParsed > 0 ? Math.floor(limitParsed) : 5
|
||||
const take = Math.min(parsed, 5)
|
||||
|
||||
const rows = await prisma.review.findMany({
|
||||
where: { status: 'approved', product: { published: true } },
|
||||
include: {
|
||||
user: { select: { email: true, name: true } },
|
||||
product: { select: { id: true, title: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take,
|
||||
})
|
||||
|
||||
const items = rows.map((r) => ({
|
||||
id: r.id,
|
||||
rating: r.rating,
|
||||
text: r.text,
|
||||
createdAt: r.createdAt,
|
||||
authorDisplay: publicReviewAuthorDisplay(r.user),
|
||||
productId: r.productId,
|
||||
productTitle: r.product?.title ?? '',
|
||||
}))
|
||||
|
||||
return { items }
|
||||
})
|
||||
|
||||
fastify.get('/api/products/:id/reviews', async (request, reply) => {
|
||||
const { id } = request.params
|
||||
|
||||
@@ -18,14 +48,22 @@ export async function registerPublicReviewRoutes(fastify) {
|
||||
|
||||
const where = { productId: id, status: 'approved' }
|
||||
const total = await prisma.review.count({ where })
|
||||
const items = await prisma.review.findMany({
|
||||
const rawItems = await prisma.review.findMany({
|
||||
where,
|
||||
include: { user: { select: { id: true, name: true, email: true } } },
|
||||
include: { user: { select: { email: true, name: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
})
|
||||
|
||||
const items = rawItems.map((r) => ({
|
||||
id: r.id,
|
||||
rating: r.rating,
|
||||
text: r.text,
|
||||
createdAt: r.createdAt,
|
||||
authorDisplay: publicReviewAuthorDisplay(r.user),
|
||||
}))
|
||||
|
||||
return { items, total, page, pageSize }
|
||||
})
|
||||
|
||||
|
||||
+180
-20
@@ -454,14 +454,26 @@ export async function registerAuthRoutes(fastify) {
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const userId = request.user.sub
|
||||
const deliveryTypeRaw = request.body?.deliveryType
|
||||
const deliveryType =
|
||||
deliveryTypeRaw === undefined || deliveryTypeRaw === null || deliveryTypeRaw === ''
|
||||
? 'delivery'
|
||||
: String(deliveryTypeRaw).trim()
|
||||
|
||||
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: 'Выберите адрес доставки' })
|
||||
if (deliveryType !== 'delivery' && deliveryType !== 'pickup') {
|
||||
return reply.code(400).send({ error: 'deliveryType должен быть delivery | pickup' })
|
||||
}
|
||||
|
||||
const address = await prisma.shippingAddress.findFirst({ where: { id: addressId, userId } })
|
||||
if (!address) return reply.code(404).send({ error: 'Адрес не найден' })
|
||||
let address = null
|
||||
if (deliveryType === 'delivery') {
|
||||
if (!addressId) return reply.code(400).send({ error: 'Выберите адрес доставки' })
|
||||
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 },
|
||||
@@ -483,17 +495,26 @@ export async function registerAuthRoutes(fastify) {
|
||||
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,
|
||||
})
|
||||
const itemsSubtotalCents = itemsPayload.reduce((sum, i) => sum + i.priceCentsSnapshot * i.qty, 0)
|
||||
const totalQty = itemsPayload.reduce((sum, i) => sum + i.qty, 0)
|
||||
const deliveryFeeCents =
|
||||
deliveryType === 'delivery' ? 50000 * Math.max(1, Math.ceil(totalQty / 2)) : 0
|
||||
const totalCents = itemsSubtotalCents + deliveryFeeCents
|
||||
|
||||
const addressSnapshotJson =
|
||||
deliveryType === 'pickup'
|
||||
? JSON.stringify({ deliveryType: 'pickup' })
|
||||
: JSON.stringify({
|
||||
deliveryType: 'delivery',
|
||||
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 {
|
||||
@@ -509,16 +530,15 @@ export async function registerAuthRoutes(fastify) {
|
||||
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',
|
||||
deliveryType,
|
||||
itemsSubtotalCents,
|
||||
deliveryFeeCents,
|
||||
totalCents,
|
||||
currency: 'RUB',
|
||||
addressSnapshotJson,
|
||||
@@ -612,6 +632,88 @@ export async function registerAuthRoutes(fastify) {
|
||||
},
|
||||
)
|
||||
|
||||
fastify.get(
|
||||
'/api/me/messages/unread-count',
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
async (request) => {
|
||||
const userId = request.user.sub
|
||||
const orders = await prisma.order.findMany({ where: { userId }, select: { id: true } })
|
||||
if (orders.length === 0) return { count: 0 }
|
||||
|
||||
const readStates = await prisma.userOrderMessageReadState.findMany({ where: { userId } })
|
||||
const lastReadByOrder = new Map(readStates.map((r) => [r.orderId, r.lastReadAt]))
|
||||
|
||||
let count = 0
|
||||
for (const o of orders) {
|
||||
const lastRead = lastReadByOrder.get(o.id) ?? new Date(0)
|
||||
const n = await prisma.orderMessage.count({
|
||||
where: { orderId: o.id, authorType: 'admin', createdAt: { gt: lastRead } },
|
||||
})
|
||||
count += n
|
||||
}
|
||||
return { count }
|
||||
},
|
||||
)
|
||||
|
||||
fastify.get(
|
||||
'/api/me/conversations',
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
async (request) => {
|
||||
const userId = request.user.sub
|
||||
const orders = await prisma.order.findMany({
|
||||
where: { userId, messages: { some: {} } },
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
deliveryType: true,
|
||||
messages: { orderBy: { createdAt: 'desc' }, take: 1, select: { text: true, createdAt: true } },
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
})
|
||||
|
||||
const readStates = await prisma.userOrderMessageReadState.findMany({ where: { userId } })
|
||||
const lastReadByOrder = new Map(readStates.map((r) => [r.orderId, r.lastReadAt]))
|
||||
|
||||
const items = []
|
||||
for (const o of orders) {
|
||||
const lastMsg = o.messages[0]
|
||||
if (!lastMsg) continue
|
||||
const lastRead = lastReadByOrder.get(o.id) ?? new Date(0)
|
||||
const unreadCount = await prisma.orderMessage.count({
|
||||
where: { orderId: o.id, authorType: 'admin', createdAt: { gt: lastRead } },
|
||||
})
|
||||
items.push({
|
||||
orderId: o.id,
|
||||
status: o.status,
|
||||
deliveryType: o.deliveryType,
|
||||
lastMessageAt: lastMsg.createdAt,
|
||||
preview: lastMsg.text.length > 280 ? `${lastMsg.text.slice(0, 277)}…` : lastMsg.text,
|
||||
unreadCount,
|
||||
})
|
||||
}
|
||||
return { items }
|
||||
},
|
||||
)
|
||||
|
||||
fastify.post(
|
||||
'/api/me/orders/:id/messages/read',
|
||||
{ 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 now = new Date()
|
||||
await prisma.userOrderMessageReadState.upsert({
|
||||
where: { userId_orderId: { userId, orderId: id } },
|
||||
create: { userId, orderId: id, lastReadAt: now },
|
||||
update: { lastReadAt: now },
|
||||
})
|
||||
return { ok: true }
|
||||
},
|
||||
)
|
||||
|
||||
fastify.post(
|
||||
'/api/me/orders/:id/pay',
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
@@ -620,11 +722,69 @@ export async function registerAuthRoutes(fastify) {
|
||||
const { id } = request.params
|
||||
const order = await prisma.order.findFirst({ where: { id, userId } })
|
||||
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
|
||||
// Заглушка: пока ничего не оплачиваем, просто подтверждаем намерение оплатить
|
||||
let nextStatus = order.status
|
||||
if (order.status === 'DRAFT') {
|
||||
await prisma.order.update({ where: { id }, data: { status: 'PENDING_PAYMENT' } })
|
||||
nextStatus = 'PENDING_PAYMENT'
|
||||
} else if (order.status === 'PENDING_PAYMENT') {
|
||||
await prisma.order.update({ where: { id }, data: { status: 'PAYMENT_VERIFICATION' } })
|
||||
nextStatus = 'PAYMENT_VERIFICATION'
|
||||
}
|
||||
return { ok: true, status: order.status === 'DRAFT' ? 'PENDING_PAYMENT' : order.status }
|
||||
return { ok: true, status: nextStatus }
|
||||
},
|
||||
)
|
||||
|
||||
fastify.get(
|
||||
'/api/me/orders/:id/review-eligibility',
|
||||
{ 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 } })
|
||||
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
|
||||
if (order.status !== 'DONE') {
|
||||
return { canReview: false, items: [] }
|
||||
}
|
||||
|
||||
const uniq = new Map()
|
||||
for (const it of order.items) {
|
||||
if (!uniq.has(it.productId)) {
|
||||
uniq.set(it.productId, { productId: it.productId, title: it.titleSnapshot })
|
||||
}
|
||||
}
|
||||
const productIds = [...uniq.keys()]
|
||||
const existing = await prisma.review.findMany({
|
||||
where: { userId, productId: { in: productIds } },
|
||||
select: { productId: true },
|
||||
})
|
||||
const reviewed = new Set(existing.map((r) => r.productId))
|
||||
return {
|
||||
canReview: true,
|
||||
items: [...uniq.values()].map((x) => ({
|
||||
...x,
|
||||
hasReview: reviewed.has(x.productId),
|
||||
})),
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
fastify.post(
|
||||
'/api/me/orders/:id/confirm-received',
|
||||
{ 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 okDelivery = order.deliveryType === 'delivery' && order.status === 'SHIPPED'
|
||||
const okPickup = order.deliveryType === 'pickup' && order.status === 'READY_FOR_PICKUP'
|
||||
if (!okDelivery && !okPickup) {
|
||||
return reply.code(409).send({ error: 'Сейчас нельзя подтвердить получение заказа' })
|
||||
}
|
||||
|
||||
await prisma.order.update({ where: { id }, data: { status: 'DONE' } })
|
||||
return { ok: true, status: 'DONE' }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
import { normalizeEmail } from '../lib/auth.js'
|
||||
import { prisma } from '../lib/prisma.js'
|
||||
|
||||
function clientRedirect(fastify, reply, token) {
|
||||
const base = process.env.CLIENT_PUBLIC_URL || 'http://127.0.0.1:5173'
|
||||
const url = `${base.replace(/\/$/, '')}/auth/callback?token=${encodeURIComponent(token)}`
|
||||
return reply.redirect(url)
|
||||
}
|
||||
|
||||
function oauthErrorRedirect(reply, msg) {
|
||||
const base = process.env.CLIENT_PUBLIC_URL || 'http://127.0.0.1:5173'
|
||||
const url = `${base.replace(/\/$/, '')}/auth?oauthError=${encodeURIComponent(msg)}`
|
||||
return reply.redirect(url)
|
||||
}
|
||||
|
||||
async function issueUserJwt(fastify, userId, email) {
|
||||
return fastify.jwt.sign({ sub: userId, email })
|
||||
}
|
||||
|
||||
async function findOrCreateUserFromOAuth({ provider, providerUserId, accessToken, suggestedEmail }) {
|
||||
const existingLink = await prisma.oauthAccount.findUnique({
|
||||
where: { provider_providerUserId: { provider, providerUserId } },
|
||||
include: { user: true },
|
||||
})
|
||||
if (existingLink?.user) {
|
||||
if (accessToken !== undefined) {
|
||||
await prisma.oauthAccount.update({
|
||||
where: { provider_providerUserId: { provider, providerUserId } },
|
||||
data: { accessToken },
|
||||
})
|
||||
}
|
||||
return existingLink.user
|
||||
}
|
||||
|
||||
const trimmed = typeof suggestedEmail === 'string' ? suggestedEmail.trim() : ''
|
||||
const norm = trimmed ? normalizeEmail(trimmed) : null
|
||||
let user = norm ? await prisma.user.findUnique({ where: { email: norm } }) : null
|
||||
if (user) {
|
||||
await prisma.oauthAccount.create({
|
||||
data: { provider, providerUserId: String(providerUserId), userId: user.id, accessToken },
|
||||
})
|
||||
return user
|
||||
}
|
||||
|
||||
let email = norm || `${provider}_${providerUserId}@oauth.craftshop.local`
|
||||
let n = 0
|
||||
while (await prisma.user.findUnique({ where: { email } })) {
|
||||
n += 1
|
||||
email = `${provider}_${providerUserId}_${n}@oauth.craftshop.local`
|
||||
}
|
||||
user = await prisma.user.create({ data: { email } })
|
||||
await prisma.oauthAccount.create({
|
||||
data: { provider, providerUserId: String(providerUserId), userId: user.id, accessToken },
|
||||
})
|
||||
return user
|
||||
}
|
||||
|
||||
export async function registerOAuthSocialRoutes(fastify) {
|
||||
const serverPublic = process.env.SERVER_PUBLIC_URL || 'http://127.0.0.1:3333'
|
||||
|
||||
/** --- VK --- */
|
||||
fastify.get('/api/auth/oauth/vk', async (_request, reply) => {
|
||||
const clientId = process.env.VK_CLIENT_ID
|
||||
const clientSecret = process.env.VK_CLIENT_SECRET
|
||||
if (!clientId || !clientSecret) return reply.code(503).send({ error: 'VK OAuth не настроен (нет VK_* в env)' })
|
||||
|
||||
const redirectUri = `${serverPublic}/api/auth/oauth/vk/callback`
|
||||
const state = fastify.jwt.sign({ oauth: 'vk' }, { expiresIn: '15m' })
|
||||
|
||||
const url = new URL('https://oauth.vk.com/authorize')
|
||||
url.searchParams.set('client_id', clientId)
|
||||
url.searchParams.set('display', 'page')
|
||||
url.searchParams.set('redirect_uri', redirectUri)
|
||||
url.searchParams.set('scope', 'email')
|
||||
url.searchParams.set('response_type', 'code')
|
||||
url.searchParams.set('v', '5.199')
|
||||
url.searchParams.set('state', state)
|
||||
|
||||
return reply.redirect(url.toString())
|
||||
})
|
||||
|
||||
fastify.get('/api/auth/oauth/vk/callback', async (request, reply) => {
|
||||
const query = request.query ?? {}
|
||||
if (query.error || query.error_description) {
|
||||
return oauthErrorRedirect(reply, String(query.error_description || query.error || 'ошибка VK'))
|
||||
}
|
||||
|
||||
try {
|
||||
const state = typeof query.state === 'string' ? query.state : ''
|
||||
fastify.jwt.verify(state || '')
|
||||
} catch {
|
||||
return oauthErrorRedirect(reply, 'Недействительный state OAuth')
|
||||
}
|
||||
|
||||
const code = typeof query.code === 'string' ? query.code.trim() : ''
|
||||
if (!code) return oauthErrorRedirect(reply, 'Не получен код от VK')
|
||||
|
||||
const clientId = process.env.VK_CLIENT_ID
|
||||
const clientSecret = process.env.VK_CLIENT_SECRET
|
||||
const redirectUri = `${serverPublic}/api/auth/oauth/vk/callback`
|
||||
|
||||
const tokenUrl = new URL('https://oauth.vk.com/access_token')
|
||||
tokenUrl.searchParams.set('client_id', clientId)
|
||||
tokenUrl.searchParams.set('client_secret', clientSecret)
|
||||
tokenUrl.searchParams.set('redirect_uri', redirectUri)
|
||||
tokenUrl.searchParams.set('code', code)
|
||||
|
||||
const tokenRes = await fetch(tokenUrl.toString())
|
||||
const tokenBody = await tokenRes.json()
|
||||
|
||||
if (tokenBody?.error_description || tokenBody?.error || !tokenRes.ok) {
|
||||
return oauthErrorRedirect(reply, tokenBody?.error_description || tokenBody?.error || 'Не удалось обменять код VK')
|
||||
}
|
||||
|
||||
const vkUserId = tokenBody?.user_id
|
||||
const accessTokenVk = tokenBody?.access_token
|
||||
let emailSuggestion = typeof tokenBody?.email === 'string' ? tokenBody.email : null
|
||||
|
||||
let firstName = null
|
||||
let lastName = null
|
||||
try {
|
||||
if (accessTokenVk && vkUserId) {
|
||||
const u = new URL('https://api.vk.com/method/users.get')
|
||||
u.searchParams.set('access_token', accessTokenVk)
|
||||
u.searchParams.set('users_ids', String(vkUserId))
|
||||
u.searchParams.set('fields', 'photo_50')
|
||||
u.searchParams.set('v', '5.199')
|
||||
const profRes = await fetch(u.toString())
|
||||
const prof = await profRes.json()
|
||||
const u0 = prof?.response?.[0]
|
||||
if (u0) {
|
||||
firstName = u0.first_name ?? null
|
||||
lastName = u0.last_name ?? null
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore profile extras
|
||||
}
|
||||
|
||||
const user = await findOrCreateUserFromOAuth({
|
||||
provider: 'vk',
|
||||
providerUserId: String(vkUserId),
|
||||
accessToken: accessTokenVk ?? null,
|
||||
suggestedEmail: emailSuggestion,
|
||||
})
|
||||
|
||||
if (firstName || lastName) {
|
||||
const name = [firstName, lastName].filter(Boolean).join(' ').trim()
|
||||
if (name && !user.name) {
|
||||
await prisma.user.update({ where: { id: user.id }, data: { name } })
|
||||
}
|
||||
}
|
||||
|
||||
const token = await issueUserJwt(fastify, user.id, user.email)
|
||||
return clientRedirect(fastify, reply, token)
|
||||
})
|
||||
|
||||
/** --- Yandex --- */
|
||||
fastify.get('/api/auth/oauth/yandex', async (_request, reply) => {
|
||||
const clientId = process.env.YANDEX_CLIENT_ID
|
||||
if (!clientId) return reply.code(503).send({ error: 'Yandex OAuth не настроен (нет YANDEX_* в env)' })
|
||||
|
||||
const redirectUri = `${serverPublic}/api/auth/oauth/yandex/callback`
|
||||
const state = fastify.jwt.sign({ oauth: 'yandex' }, { expiresIn: '15m' })
|
||||
|
||||
const url = new URL('https://oauth.yandex.ru/authorize')
|
||||
url.searchParams.set('response_type', 'code')
|
||||
url.searchParams.set('client_id', clientId)
|
||||
url.searchParams.set('redirect_uri', redirectUri)
|
||||
url.searchParams.set('scope', 'login:email login:info')
|
||||
url.searchParams.set('state', state)
|
||||
|
||||
return reply.redirect(url.toString())
|
||||
})
|
||||
|
||||
fastify.get('/api/auth/oauth/yandex/callback', async (request, reply) => {
|
||||
const query = request.query ?? {}
|
||||
if (query.error) return oauthErrorRedirect(reply, String(query.error))
|
||||
|
||||
try {
|
||||
const state = typeof query.state === 'string' ? query.state : ''
|
||||
fastify.jwt.verify(state || '')
|
||||
} catch {
|
||||
return oauthErrorRedirect(reply, 'Недействительный state OAuth')
|
||||
}
|
||||
|
||||
const code = typeof query.code === 'string' ? query.code.trim() : ''
|
||||
if (!code) return oauthErrorRedirect(reply, 'Не получен код от Яндекс')
|
||||
|
||||
const clientId = process.env.YANDEX_CLIENT_ID
|
||||
const clientSecret = process.env.YANDEX_CLIENT_SECRET
|
||||
const redirectUri = `${serverPublic}/api/auth/oauth/yandex/callback`
|
||||
|
||||
const body = new URLSearchParams()
|
||||
body.set('grant_type', 'authorization_code')
|
||||
body.set('code', code)
|
||||
body.set('client_id', clientId)
|
||||
body.set('client_secret', clientSecret)
|
||||
if (redirectUri) body.set('redirect_uri', redirectUri)
|
||||
|
||||
const tokenRes = await fetch('https://oauth.yandex.ru/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: body.toString(),
|
||||
})
|
||||
const tokenBody = await tokenRes.json()
|
||||
|
||||
if (!tokenRes.ok || !tokenBody.access_token) {
|
||||
return oauthErrorRedirect(
|
||||
reply,
|
||||
tokenBody.error_description || tokenBody.error || 'Не удалось обменять код Yandex',
|
||||
)
|
||||
}
|
||||
|
||||
const yaToken = tokenBody.access_token
|
||||
|
||||
const infoRes = await fetch('https://login.yandex.ru/info', {
|
||||
headers: { Authorization: `OAuth ${yaToken}` },
|
||||
})
|
||||
const info = await infoRes.json()
|
||||
const yaUserId = String(info?.id || '')
|
||||
if (!yaUserId) return oauthErrorRedirect(reply, 'Не удалось получить профиль Yandex')
|
||||
|
||||
const emailGuess =
|
||||
(Array.isArray(info?.emails) && info.emails[0]) ||
|
||||
info?.default_email ||
|
||||
(info?.login ? `${info.login}@yandex.ru` : null)
|
||||
|
||||
const user = await findOrCreateUserFromOAuth({
|
||||
provider: 'yandex',
|
||||
providerUserId: yaUserId,
|
||||
accessToken: yaToken,
|
||||
suggestedEmail: emailGuess || null,
|
||||
})
|
||||
|
||||
const dn = `${info.first_name ?? ''} ${info.last_name ?? ''}`.trim()
|
||||
if (dn && !user.name) {
|
||||
await prisma.user.update({ where: { id: user.id }, data: { name: dn } })
|
||||
}
|
||||
|
||||
const token = await issueUserJwt(fastify, user.id, user.email)
|
||||
return clientRedirect(fastify, reply, token)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user