base commit

This commit is contained in:
@kirill.komarov
2026-04-30 22:34:55 +05:00
parent 123d86091d
commit 9139a24093
46 changed files with 2023 additions and 153 deletions
+16 -1
View File
@@ -1,4 +1,19 @@
DATABASE_URL="file:./dev.db"
PORT=3333
ADMIN_API_TOKEN=замените-на-секрет
# CORS_ORIGIN=http://localhost:5173
JWT_SECRET=замените-на-секрет-jwt
# Разрешённый Origin фронта (через запятую при нескольких)
# CORS_ORIGIN=http://127.0.0.1:5173
# Публичные URL для OAuth redirect (локально обычно так):
SERVER_PUBLIC_URL=http://127.0.0.1:3333
CLIENT_PUBLIC_URL=http://127.0.0.1:5173
# VK OAuth: в кабинете VK задать redirect URI = SERVER_PUBLIC_URL + /api/auth/oauth/vk/callback
VK_CLIENT_ID=
VK_CLIENT_SECRET=
# Yandex OAuth: redirect URI = SERVER_PUBLIC_URL + /api/auth/oauth/yandex/callback
YANDEX_CLIENT_ID=
YANDEX_CLIENT_SECRET=
@@ -0,0 +1,25 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Order" (
"id" TEXT NOT NULL PRIMARY KEY,
"status" TEXT NOT NULL DEFAULT 'DRAFT',
"deliveryType" TEXT NOT NULL DEFAULT 'delivery',
"itemsSubtotalCents" INTEGER NOT NULL DEFAULT 0,
"deliveryFeeCents" INTEGER NOT NULL DEFAULT 0,
"totalCents" INTEGER NOT NULL DEFAULT 0,
"currency" TEXT NOT NULL DEFAULT 'RUB',
"addressSnapshotJson" TEXT,
"comment" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
"userId" TEXT NOT NULL,
CONSTRAINT "Order_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "new_Order" ("addressSnapshotJson", "comment", "createdAt", "currency", "id", "status", "totalCents", "updatedAt", "userId") SELECT "addressSnapshotJson", "comment", "createdAt", "currency", "id", "status", "totalCents", "updatedAt", "userId" FROM "Order";
DROP TABLE "Order";
ALTER TABLE "new_Order" RENAME TO "Order";
CREATE INDEX "Order_userId_createdAt_idx" ON "Order"("userId", "createdAt");
CREATE INDEX "Order_status_updatedAt_idx" ON "Order"("status", "updatedAt");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
@@ -0,0 +1,35 @@
-- CreateTable
CREATE TABLE "UserOrderMessageReadState" (
"id" TEXT NOT NULL PRIMARY KEY,
"lastReadAt" DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00 +00:00',
"updatedAt" DATETIME NOT NULL,
"userId" TEXT NOT NULL,
"orderId" TEXT NOT NULL,
CONSTRAINT "UserOrderMessageReadState_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "UserOrderMessageReadState_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "OAuthAccount" (
"id" TEXT NOT NULL PRIMARY KEY,
"provider" TEXT NOT NULL,
"providerUserId" TEXT NOT NULL,
"accessToken" TEXT,
"refreshToken" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
"userId" TEXT NOT NULL,
CONSTRAINT "OAuthAccount_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE INDEX "UserOrderMessageReadState_userId_idx" ON "UserOrderMessageReadState"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "UserOrderMessageReadState_userId_orderId_key" ON "UserOrderMessageReadState"("userId", "orderId");
-- CreateIndex
CREATE INDEX "OAuthAccount_userId_idx" ON "OAuthAccount"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "OAuthAccount_provider_providerUserId_key" ON "OAuthAccount"("provider", "providerUserId");
+40 -1
View File
@@ -69,6 +69,23 @@ model User {
cartItems CartItem[]
orders Order[]
reviews Review[]
orderMessageReadStates UserOrderMessageReadState[]
oauthAccounts OAuthAccount[]
}
/// Прочитанность чата по заказу (для сообщений от админа после lastReadAt)
model UserOrderMessageReadState {
id String @id @default(cuid())
lastReadAt DateTime @default("1970-01-01T00:00:00.000Z")
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
orderId String
@@unique([userId, orderId])
@@index([userId])
}
model CartItem {
@@ -90,9 +107,13 @@ model Order {
id String @id @default(cuid())
/// Статус заказа (валидация переходов на уровне API)
status String @default("DRAFT")
/// 'delivery' | 'pickup'
deliveryType String @default("delivery")
itemsSubtotalCents Int @default(0)
deliveryFeeCents Int @default(0)
totalCents Int @default(0)
currency String @default("RUB")
addressSnapshotJson String
addressSnapshotJson String?
comment String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -102,6 +123,7 @@ model Order {
items OrderItem[]
messages OrderMessage[]
messageReadStates UserOrderMessageReadState[]
@@index([userId, createdAt])
@@index([status, updatedAt])
@@ -175,6 +197,23 @@ model ShippingAddress {
@@index([userId, updatedAt])
}
model OAuthAccount {
id String @id @default(cuid())
/// 'vk' | 'yandex'
provider String
providerUserId String
accessToken String?
refreshToken String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String
@@unique([provider, providerUserId])
@@index([userId])
}
model AuthCode {
id String @id @default(cuid())
email String
+2
View File
@@ -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 }))
+35 -12
View File
@@ -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)
}
+13
View File
@@ -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}`
}
+6 -2
View File
@@ -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
}
+22 -2
View File
@@ -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}` })
}
+79 -4
View File
@@ -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)
})
}
+40 -2
View File
@@ -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
View File
@@ -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' }
},
)
}
+244
View File
@@ -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)
})
}