base commit

This commit is contained in:
@kirill.komarov
2026-04-29 17:32:21 +05:00
parent 3f7fdb1e15
commit f6b6959268
16 changed files with 1251 additions and 48 deletions
@@ -0,0 +1,27 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Product" (
"id" TEXT NOT NULL PRIMARY KEY,
"title" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"shortDescription" TEXT,
"description" TEXT,
"quantity" INTEGER,
"materials" TEXT NOT NULL DEFAULT '[]',
"priceCents" INTEGER NOT NULL,
"imageUrl" TEXT,
"published" BOOLEAN NOT NULL DEFAULT false,
"inStock" BOOLEAN NOT NULL DEFAULT true,
"leadTimeDays" INTEGER,
"categoryId" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Product_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "new_Product" ("categoryId", "createdAt", "description", "id", "imageUrl", "inStock", "leadTimeDays", "priceCents", "published", "shortDescription", "slug", "title", "updatedAt") SELECT "categoryId", "createdAt", "description", "id", "imageUrl", "inStock", "leadTimeDays", "priceCents", "published", "shortDescription", "slug", "title", "updatedAt" FROM "Product";
DROP TABLE "Product";
ALTER TABLE "new_Product" RENAME TO "Product";
CREATE UNIQUE INDEX "Product_slug_key" ON "Product"("slug");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
+4
View File
@@ -22,6 +22,10 @@ model Product {
slug String @unique
shortDescription String?
description String?
/// Количество на складе (если null — не ведём учёт)
quantity Int?
/// Материалы (список, например: ["хлопок","дерево"])
materials String @default("[]")
/// Цена в копейках (целое число, без дробной части)
priceCents Int
imageUrl String?
+152
View File
@@ -20,7 +20,10 @@ async function main() {
create: {
title: 'Мягкая сова',
slug: 'myagkaya-sova',
shortDescription: 'Мягкая игрушка ручной работы.',
description: 'Ручная работа, хлопок и синтепон.',
materials: JSON.stringify(['хлопок', 'синтепон']),
quantity: 3,
priceCents: 189000,
imageUrl:
'https://images.unsplash.com/photo-1558618666-fcd25c85cd64?w=600&q=80',
@@ -35,7 +38,10 @@ async function main() {
create: {
title: 'Колокольчик керамический',
slug: 'suvenir-kolokolchik',
shortDescription: 'Керамика с ручной росписью.',
description: 'Глазурь, ручная роспись.',
materials: JSON.stringify(['керамика', 'глазурь']),
quantity: 5,
priceCents: 45000,
imageUrl:
'https://images.unsplash.com/photo-1513519245088-0e12902e5a38?w=600&q=80',
@@ -44,6 +50,152 @@ async function main() {
},
})
const more = [
{
title: 'Зайчик в свитере',
slug: 'zaychik-v-svitere',
shortDescription: 'Тёплый подарок — мягкий зайчик.',
description: 'Мягкая игрушка. Свитер связан вручную.',
materials: ['акрил', 'хлопок', 'синтепон'],
quantity: 2,
priceCents: 219000,
imageUrl: 'https://images.unsplash.com/photo-1543852786-1cf6624b9987?w=600&q=80',
published: true,
categoryId: toys.id,
},
{
title: 'Подвеска “Лес”',
slug: 'podveska-les',
shortDescription: 'Лёгкая подвеска из дерева и смолы.',
description: 'Фактура дерева + прозрачная смола.',
materials: ['дерево', 'эпоксидная смола'],
quantity: 8,
priceCents: 69000,
imageUrl: 'https://images.unsplash.com/photo-1522312346375-d1a52e2b99b3?w=600&q=80',
published: true,
categoryId: gifts.id,
},
{
title: 'Набор открыток (3 шт.)',
slug: 'nabor-otkrytok-3',
shortDescription: 'Мини-коллекция с акварелью.',
description: 'Три открытки с авторской акварелью.',
materials: ['бумага', 'акварель'],
quantity: 12,
priceCents: 39000,
imageUrl: 'https://images.unsplash.com/photo-1524995997946-a1c2e315a42f?w=600&q=80',
published: true,
categoryId: gifts.id,
},
{
title: 'Свеча “Ягоды”',
slug: 'svecha-yagody',
shortDescription: 'Ароматная свеча с ягодной нотой.',
description: 'Соевый воск, хлопковый фитиль.',
materials: ['соевый воск', 'ароматизатор', 'хлопковый фитиль'],
quantity: 10,
priceCents: 55000,
imageUrl: 'https://images.unsplash.com/photo-1542315192-1f61a2b1a3c0?w=600&q=80',
published: true,
categoryId: gifts.id,
},
{
title: 'Мишка “Карамель”',
slug: 'mishka-karamel',
shortDescription: 'Плюшевый мишка с вышивкой.',
description: 'Плюш, вышивка вручную.',
materials: ['плюш', 'нитки'],
quantity: 4,
priceCents: 199000,
imageUrl: 'https://images.unsplash.com/photo-1545315003-c5ad6226c272?w=600&q=80',
published: true,
categoryId: toys.id,
},
{
title: 'Брелок макраме',
slug: 'brelok-makrame',
shortDescription: 'Мини-брелок, плетение макраме.',
description: 'Подходит на ключи или рюкзак.',
materials: ['хлопковый шнур', 'кольцо'],
quantity: 15,
priceCents: 25000,
imageUrl: 'https://images.unsplash.com/photo-1520975958225-2f3ab6f4c1c1?w=600&q=80',
published: true,
categoryId: gifts.id,
},
{
title: 'Кукла “Тильда”',
slug: 'kukla-tilda',
shortDescription: 'Классическая кукла в стиле тильда.',
description: 'Платье шьётся вручную, можно выбрать цвет.',
materials: ['хлопок', 'лен', 'синтепух'],
quantity: 1,
priceCents: 349000,
imageUrl: 'https://images.unsplash.com/photo-1563906267088-b029e7101114?w=600&q=80',
published: true,
categoryId: toys.id,
},
{
title: 'Ёлочная игрушка “Звезда”',
slug: 'elochnaya-igrushka-zvezda',
shortDescription: 'Фетр, вышивка, ленточка.',
description: 'Лёгкая игрушка для ёлки или декора.',
materials: ['фетр', 'нитки'],
quantity: 20,
priceCents: 29000,
imageUrl: 'https://images.unsplash.com/photo-1543589077-47d81606c1bf?w=600&q=80',
published: true,
categoryId: gifts.id,
},
{
title: 'Панно “Горы”',
slug: 'panno-gory',
shortDescription: 'Минималистичное панно на стену.',
description: 'Деревянная основа, роспись акрилом.',
materials: ['дерево', 'акрил'],
quantity: 2,
priceCents: 129000,
imageUrl: 'https://images.unsplash.com/photo-1452860606245-08befc0ff44b?w=600&q=80',
published: true,
categoryId: gifts.id,
},
{
title: 'Мягкий котик (под заказ)',
slug: 'myagkiy-kotik-pod-zakaz',
shortDescription: 'Можно выбрать цвет и имя.',
description: 'Делаем под заказ: выберите цвет и вышивку имени.',
materials: ['хлопок', 'синтепон'],
quantity: 0,
inStock: false,
leadTimeDays: 7,
priceCents: 229000,
imageUrl: 'https://images.unsplash.com/photo-1518791841217-8f162f1e1131?w=600&q=80',
published: true,
categoryId: toys.id,
},
]
for (const p of more) {
await prisma.product.upsert({
where: { slug: p.slug },
update: {},
create: {
title: p.title,
slug: p.slug,
shortDescription: p.shortDescription,
description: p.description,
materials: JSON.stringify(p.materials),
quantity: p.quantity,
priceCents: p.priceCents,
imageUrl: p.imageUrl,
published: p.published,
inStock: p.inStock ?? true,
leadTimeDays: p.leadTimeDays ?? null,
categoryId: p.categoryId,
},
})
}
console.log('Seed готов:', { toys: toys.slug, gifts: gifts.slug })
}
+320 -6
View File
@@ -2,6 +2,7 @@ import { prisma } from '../lib/prisma.js'
import crypto from 'node:crypto'
import fs from 'node:fs'
import path from 'node:path'
import { hashPassword, normalizeEmail } from '../lib/auth.js'
function slugify(input) {
return input
@@ -17,6 +18,43 @@ function safeExtFromFilename(filename) {
return allowed.has(ext) ? ext : null
}
function parseMaterialsInput(input) {
if (Array.isArray(input)) {
return input
.map((x) => String(x || '').trim())
.filter(Boolean)
.slice(0, 30)
}
if (typeof input === 'string') {
const s = input.trim()
if (!s) return []
// поддержка: "хлопок, дерево"
return s
.split(',')
.map((x) => x.trim())
.filter(Boolean)
.slice(0, 30)
}
return []
}
function materialsFromDb(materials) {
if (Array.isArray(materials)) return materials
try {
const v = JSON.parse(String(materials || '[]'))
return Array.isArray(v) ? v.map((x) => String(x || '').trim()).filter(Boolean) : []
} catch {
return []
}
}
function mapProductForApi(p) {
return {
...p,
materials: materialsFromDb(p.materials),
}
}
export async function registerApiRoutes(fastify) {
fastify.get('/api/categories', async () => {
return prisma.category.findMany({ orderBy: { sort: 'asc' } })
@@ -24,15 +62,70 @@ export async function registerApiRoutes(fastify) {
fastify.get('/api/products', async (request) => {
const { categorySlug } = request.query
const qRaw = request.query?.q
const q = typeof qRaw === 'string' ? qRaw.trim() : ''
const sortRaw = request.query?.sort
const sort = typeof sortRaw === 'string' ? sortRaw : ''
const pageRaw = request.query?.page
const pageParsed = typeof pageRaw === 'string' ? Number(pageRaw) : Number(pageRaw)
const page = Number.isFinite(pageParsed) && pageParsed > 0 ? Math.floor(pageParsed) : 1
const pageSizeRaw = request.query?.pageSize
const pageSizeParsed = typeof pageSizeRaw === 'string' ? Number(pageSizeRaw) : Number(pageSizeRaw)
const pageSize = Number.isFinite(pageSizeParsed) && pageSizeParsed > 0 ? Math.floor(pageSizeParsed) : 12
const priceMinRaw = request.query?.priceMin
const priceMinParsed = typeof priceMinRaw === 'string' ? Number(priceMinRaw) : Number(priceMinRaw)
const priceMin = Number.isFinite(priceMinParsed) && priceMinParsed >= 0 ? Math.floor(priceMinParsed) : null
const priceMaxRaw = request.query?.priceMax
const priceMaxParsed = typeof priceMaxRaw === 'string' ? Number(priceMaxRaw) : Number(priceMaxRaw)
const priceMax = Number.isFinite(priceMaxParsed) && priceMaxParsed >= 0 ? Math.floor(priceMaxParsed) : null
const where = { published: true }
if (typeof categorySlug === 'string' && categorySlug.length > 0) {
where.category = { slug: categorySlug }
}
return prisma.product.findMany({
if (q) {
where.OR = [{ title: { contains: q } }, { shortDescription: { contains: q } }]
}
const applyPriceFilter = !(
priceMin !== null &&
priceMax !== null &&
priceMin === 0 &&
priceMax === 0
)
if (applyPriceFilter && (priceMin !== null || priceMax !== null)) {
if (priceMin !== null && priceMax !== null && priceMax < priceMin) {
// не молчим: пользователю проще понять, чем получить пустой список
return reply.code(400).send({ error: 'priceMax должен быть ≥ priceMin' })
}
where.priceCents = {
...(priceMin !== null ? { gte: priceMin } : {}),
...(priceMax !== null ? { lte: priceMax } : {}),
}
}
const orderBy =
sort === 'price_asc'
? { priceCents: 'asc' }
: sort === 'price_desc'
? { priceCents: 'desc' }
: { createdAt: 'desc' }
const total = await prisma.product.count({ where })
const items = await prisma.product.findMany({
where,
include: { category: true, images: { orderBy: { sort: 'asc' } } },
orderBy: { createdAt: 'desc' },
orderBy,
skip: (page - 1) * pageSize,
take: pageSize,
})
return { items: items.map(mapProductForApi), total, page, pageSize }
})
fastify.get('/api/products/:id', async (request, reply) => {
@@ -45,7 +138,7 @@ export async function registerApiRoutes(fastify) {
reply.code(404).send({ error: 'Товар не найден' })
return
}
return product
return mapProductForApi(product)
})
// ---- Админ (тот же фронт, другой раздел) ----
@@ -54,10 +147,11 @@ export async function registerApiRoutes(fastify) {
'/api/admin/products',
{ preHandler: [fastify.verifyAdmin] },
async () => {
return prisma.product.findMany({
const items = await prisma.product.findMany({
include: { category: true, images: { orderBy: { sort: 'asc' } } },
orderBy: { updatedAt: 'desc' },
})
return items.map(mapProductForApi)
},
)
@@ -134,12 +228,25 @@ export async function registerApiRoutes(fastify) {
reply.code(409).send({ error: 'Такой slug уже занят' })
return
}
let quantity = null
if (!(body.quantity === undefined || body.quantity === null || body.quantity === '')) {
const n = Number(body.quantity)
if (!Number.isFinite(n) || n < 0) {
reply.code(400).send({ error: 'Некорректное количество (quantity ≥ 0)' })
return
}
quantity = Math.floor(n)
}
const product = await prisma.product.create({
data: {
title,
slug,
shortDescription: body.shortDescription ? String(body.shortDescription) : null,
description: body.description ? String(body.description) : null,
quantity,
materials: JSON.stringify(parseMaterialsInput(body.materials)),
priceCents: Math.round(priceCents),
imageUrl: body.imageUrl ? String(body.imageUrl) : null,
published: Boolean(body.published),
@@ -158,7 +265,7 @@ export async function registerApiRoutes(fastify) {
},
include: { category: true, images: { orderBy: { sort: 'asc' } } },
})
reply.code(201).send(product)
reply.code(201).send(mapProductForApi(product))
},
)
@@ -194,6 +301,22 @@ export async function registerApiRoutes(fastify) {
if (body.description !== undefined) {
data.description = body.description ? String(body.description) : null
}
if (body.quantity !== undefined) {
const v = body.quantity
if (v === null || v === '') {
data.quantity = null
} else {
const n = Number(v)
if (!Number.isFinite(n) || n < 0) {
reply.code(400).send({ error: 'Некорректное количество (quantity ≥ 0)' })
return
}
data.quantity = Math.floor(n)
}
}
if (body.materials !== undefined) {
data.materials = JSON.stringify(parseMaterialsInput(body.materials))
}
if (body.priceCents !== undefined) {
const p = Number(body.priceCents)
if (!Number.isFinite(p) || p < 0) {
@@ -251,7 +374,7 @@ export async function registerApiRoutes(fastify) {
},
include: { category: true, images: { orderBy: { sort: 'asc' } } },
})
return product
return mapProductForApi(product)
},
)
@@ -299,4 +422,195 @@ export async function registerApiRoutes(fastify) {
reply.code(201).send(category)
},
)
// ---- Админ: пользователи ----
fastify.get(
'/api/admin/users',
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
const qRaw = request.query?.q
const q = typeof qRaw === 'string' ? qRaw.trim() : ''
const pageRaw = request.query?.page
const pageParsed = typeof pageRaw === 'string' ? Number(pageRaw) : Number(pageRaw)
const page = Number.isFinite(pageParsed) && pageParsed > 0 ? Math.floor(pageParsed) : 1
const pageSizeRaw = request.query?.pageSize
const pageSizeParsed =
typeof pageSizeRaw === 'string' ? Number(pageSizeRaw) : Number(pageSizeRaw)
const pageSize =
Number.isFinite(pageSizeParsed) && pageSizeParsed > 0 ? Math.floor(pageSizeParsed) : 20
if (pageSize > 100) {
reply.code(400).send({ error: 'pageSize должен быть ≤ 100' })
return
}
const where = q
? {
OR: [
{ email: { contains: q } },
{ name: { contains: q } },
],
}
: undefined
const total = await prisma.user.count({ where })
const users = await prisma.user.findMany({
where,
select: {
id: true,
email: true,
name: true,
passwordHash: true,
createdAt: true,
updatedAt: true,
},
orderBy: { updatedAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
})
const items = users.map((u) => ({
id: u.id,
email: u.email,
name: u.name,
hasPassword: Boolean(u.passwordHash),
createdAt: u.createdAt,
updatedAt: u.updatedAt,
}))
return { items, total, page, pageSize }
},
)
fastify.post(
'/api/admin/users',
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
const body = request.body ?? {}
const email = normalizeEmail(body.email)
if (!email || !email.includes('@')) {
reply.code(400).send({ error: 'Некорректная почта' })
return
}
const nameRaw = body.name
const name = nameRaw === null || nameRaw === undefined ? null : String(nameRaw).trim()
if (name !== null && name.length > 40) {
reply.code(400).send({ error: 'Имя/ник максимум 40 символов' })
return
}
const password = body.password ? String(body.password) : ''
if (password && password.length < 8) {
reply.code(400).send({ error: 'Пароль минимум 8 символов' })
return
}
const exists = await prisma.user.findUnique({ where: { email } })
if (exists) {
reply.code(409).send({ error: 'Почта уже занята' })
return
}
const passwordHash = password ? await hashPassword(password) : null
const user = await prisma.user.create({
data: {
email,
name: name && name.length ? name : null,
passwordHash: passwordHash ?? undefined,
},
})
reply.code(201).send({
id: user.id,
email: user.email,
name: user.name,
hasPassword: Boolean(user.passwordHash),
createdAt: user.createdAt,
updatedAt: user.updatedAt,
})
},
)
fastify.patch(
'/api/admin/users/:id',
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
const { id } = request.params
const body = request.body ?? {}
const existing = await prisma.user.findUnique({ where: { id } })
if (!existing) {
reply.code(404).send({ error: 'Пользователь не найден' })
return
}
const data = {}
if (body.email !== undefined) {
const email = normalizeEmail(body.email)
if (!email || !email.includes('@')) {
reply.code(400).send({ error: 'Некорректная почта' })
return
}
if (email !== existing.email) {
const clash = await prisma.user.findUnique({ where: { email } })
if (clash) {
reply.code(409).send({ error: 'Почта уже занята' })
return
}
data.email = email
}
}
if (body.name !== undefined) {
const nameRaw = body.name
const name = nameRaw === null || nameRaw === undefined ? null : String(nameRaw).trim()
if (name !== null && name.length > 40) {
reply.code(400).send({ error: 'Имя/ник максимум 40 символов' })
return
}
data.name = name && name.length ? name : null
}
if (body.password !== undefined) {
const password = body.password ? String(body.password) : ''
if (password) {
if (password.length < 8) {
reply.code(400).send({ error: 'Пароль минимум 8 символов' })
return
}
data.passwordHash = await hashPassword(password)
}
}
const user = await prisma.user.update({ where: { id }, data })
return {
id: user.id,
email: user.email,
name: user.name,
hasPassword: Boolean(user.passwordHash),
createdAt: user.createdAt,
updatedAt: user.updatedAt,
}
},
)
fastify.delete(
'/api/admin/users/:id',
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
const { id } = request.params
try {
await prisma.user.delete({ where: { id } })
reply.code(204).send()
} catch {
reply.code(404).send({ error: 'Пользователь не найден' })
}
},
)
}