This commit is contained in:
@kirill.komarov
2026-05-11 20:15:01 +05:00
parent 4eda6d0f81
commit 7a92991cff
19 changed files with 1010 additions and 49 deletions
@@ -0,0 +1,39 @@
-- CreateTable
CREATE TABLE "CatalogSliderSlide" (
"id" TEXT NOT NULL PRIMARY KEY,
"sortOrder" INTEGER NOT NULL,
"caption" TEXT NOT NULL DEFAULT '',
"galleryImageId" TEXT NOT NULL,
CONSTRAINT "CatalogSliderSlide_galleryImageId_fkey" FOREIGN KEY ("galleryImageId") REFERENCES "GalleryImage" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- 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 NOT NULL DEFAULT 0,
"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 RESTRICT ON UPDATE CASCADE
);
INSERT INTO "new_Product" ("categoryId", "createdAt", "description", "id", "imageUrl", "inStock", "leadTimeDays", "materials", "priceCents", "published", "quantity", "shortDescription", "slug", "title", "updatedAt") SELECT "categoryId", "createdAt", "description", "id", "imageUrl", "inStock", "leadTimeDays", "materials", "priceCents", "published", "quantity", "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;
-- CreateIndex
CREATE INDEX "CatalogSliderSlide_sortOrder_idx" ON "CatalogSliderSlide"("sortOrder");
+14 -1
View File
@@ -32,7 +32,7 @@ model Product {
published Boolean @default(false)
inStock Boolean @default(true)
leadTimeDays Int?
category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade)
category Category @relation(fields: [categoryId], references: [id], onDelete: Restrict)
categoryId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -60,6 +60,19 @@ model GalleryImage {
id String @id @default(cuid())
url String @unique
createdAt DateTime @default(now())
catalogSliderSlides CatalogSliderSlide[]
}
/// Слайды главной витрины (каталог): картинка из галереи + подпись.
model CatalogSliderSlide {
id String @id @default(cuid())
sortOrder Int
caption String @default("")
galleryImageId String
galleryImage GalleryImage @relation(fields: [galleryImageId], references: [id], onDelete: Cascade)
@@index([sortOrder])
}
model User {
+2
View File
@@ -6,6 +6,7 @@ import multipart from '@fastify/multipart'
import fastifyStatic from '@fastify/static'
import path from 'node:path'
import { ensureAdminUser } from './lib/bootstrap-admin.js'
import { getOrCreateUnspecifiedCategory } from './lib/default-category.js'
import { getMaxUploadBodyBytes, getProductImageMaxFileBytes } from './lib/upload-limits.js'
import { registerAuth } from './plugins/auth.js'
import { registerApiRoutes } from './routes/api.js'
@@ -58,6 +59,7 @@ await registerAuthRoutes(fastify)
await registerOAuthSocialRoutes(fastify)
await registerApiRoutes(fastify)
await ensureAdminUser()
await getOrCreateUnspecifiedCategory()
fastify.get('/health', async () => ({ ok: true }))
+20
View File
@@ -0,0 +1,20 @@
import { prisma } from './prisma.js'
/** Служебная категория для товаров без выбранной категории. Slug не менять. */
export const UNSPECIFIED_CATEGORY_SLUG = 'ne-ukazano'
export async function getOrCreateUnspecifiedCategory() {
return prisma.category.upsert({
where: { slug: UNSPECIFIED_CATEGORY_SLUG },
update: {},
create: {
name: 'Не указано',
slug: UNSPECIFIED_CATEGORY_SLUG,
sort: 9999,
},
})
}
export function isUnspecifiedCategorySlug(slug) {
return slug === UNSPECIFIED_CATEGORY_SLUG
}
+2
View File
@@ -5,6 +5,7 @@ import {
} from './api/_product-helpers.js'
import { registerAdminGalleryRoutes } from './api/admin-gallery.js'
import { registerAdminCategoryRoutes } from './api/admin-categories.js'
import { registerCatalogSliderRoutes } from './api/catalog-slider.js'
import { registerAdminOrderRoutes } from './api/admin-orders.js'
import { registerAdminProductRoutes } from './api/admin-products.js'
import { registerAdminReviewRoutes } from './api/admin-reviews.js'
@@ -17,6 +18,7 @@ export async function registerApiRoutes(fastify) {
await registerPublicCatalogRoutes(fastify, { mapProductForApi })
await registerPublicReviewRoutes(fastify)
await registerInfoPageRoutes(fastify)
await registerCatalogSliderRoutes(fastify)
await registerAdminProductRoutes(fastify, {
slugify,
+106 -1
View File
@@ -1,6 +1,22 @@
import {
getOrCreateUnspecifiedCategory,
isUnspecifiedCategorySlug,
UNSPECIFIED_CATEGORY_SLUG,
} from '../../lib/default-category.js'
import { prisma } from '../../lib/prisma.js'
export async function registerAdminCategoryRoutes(fastify, { slugify } = {}) {
fastify.get(
'/api/admin/categories',
{ preHandler: [fastify.verifyAdmin] },
async () => {
const items = await prisma.category.findMany({
orderBy: [{ sort: 'asc' }, { name: 'asc' }],
})
return { items }
},
)
fastify.post(
'/api/admin/categories',
{ preHandler: [fastify.verifyAdmin] },
@@ -12,6 +28,10 @@ export async function registerAdminCategoryRoutes(fastify, { slugify } = {}) {
return
}
const slug = String(body.slug ?? '').trim() || slugify(name) || `cat-${Date.now()}`
if (isUnspecifiedCategorySlug(slug)) {
reply.code(400).send({ error: `Slug «${UNSPECIFIED_CATEGORY_SLUG}» зарезервирован` })
return
}
const sort = body.sort !== undefined && body.sort !== null && body.sort !== '' ? Number(body.sort) : undefined
const exists = await prisma.category.findUnique({ where: { slug } })
if (exists) {
@@ -28,5 +48,90 @@ export async function registerAdminCategoryRoutes(fastify, { slugify } = {}) {
reply.code(201).send(category)
},
)
}
fastify.patch(
'/api/admin/categories/:id',
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
const { id } = request.params
const body = request.body ?? {}
const existing = await prisma.category.findUnique({ where: { id } })
if (!existing) {
reply.code(404).send({ error: 'Категория не найдена' })
return
}
const data = {}
if (body.name !== undefined) data.name = String(body.name ?? '').trim()
if (body.sort !== undefined) {
const s = Number(body.sort)
if (!Number.isFinite(s)) {
reply.code(400).send({ error: 'Некорректный sort' })
return
}
data.sort = Math.round(s)
}
if (body.slug !== undefined) {
const s = String(body.slug ?? '').trim()
if (isUnspecifiedCategorySlug(existing.slug) && s !== UNSPECIFIED_CATEGORY_SLUG) {
reply.code(400).send({ error: 'Нельзя сменить slug служебной категории «Не указано»' })
return
}
if (!s) {
reply.code(400).send({ error: 'Slug не может быть пустым' })
return
}
if (s !== existing.slug) {
if (isUnspecifiedCategorySlug(s)) {
reply.code(400).send({ error: `Slug «${UNSPECIFIED_CATEGORY_SLUG}» зарезервирован` })
return
}
const clash = await prisma.category.findFirst({ where: { slug: s, NOT: { id } } })
if (clash) {
reply.code(409).send({ error: 'Такой slug уже занят' })
return
}
}
data.slug = s
}
if (Object.keys(data).length === 0) {
return existing
}
if (data.name !== undefined && !data.name) {
reply.code(400).send({ error: 'Укажите название' })
return
}
const updated = await prisma.category.update({ where: { id }, data })
return updated
},
)
fastify.delete(
'/api/admin/categories/:id',
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
const { id } = request.params
const existing = await prisma.category.findUnique({ where: { id } })
if (!existing) {
reply.code(404).send({ error: 'Категория не найдена' })
return
}
if (isUnspecifiedCategorySlug(existing.slug)) {
reply.code(409).send({ error: 'Служебную категорию «Не указано» нельзя удалить' })
return
}
const fallback = await getOrCreateUnspecifiedCategory()
await prisma.$transaction([
prisma.product.updateMany({
where: { categoryId: id },
data: { categoryId: fallback.id },
}),
prisma.category.delete({ where: { id } }),
])
return reply.code(204).send()
},
)
}
+23 -4
View File
@@ -1,3 +1,4 @@
import { getOrCreateUnspecifiedCategory } from '../../lib/default-category.js'
import { upsertGalleryImagesByUrls } from '../../lib/gallery.js'
import { prisma } from '../../lib/prisma.js'
import {
@@ -60,10 +61,15 @@ export async function registerAdminProductRoutes(
return
}
const slug = String(body.slug ?? '').trim() || slugify(title) || `item-${Date.now()}`
const categoryId = String(body.categoryId ?? '').trim()
let categoryId = String(body.categoryId ?? '').trim()
if (!categoryId) {
reply.code(400).send({ error: 'Укажите категорию' })
return
categoryId = (await getOrCreateUnspecifiedCategory()).id
} else {
const cat = await prisma.category.findUnique({ where: { id: categoryId } })
if (!cat) {
reply.code(400).send({ error: 'Категория не найдена' })
return
}
}
const priceCents = Number(body.priceCents)
if (!Number.isFinite(priceCents) || priceCents < 0) {
@@ -190,7 +196,20 @@ export async function registerAdminProductRoutes(
data.imageUrl = body.imageUrl ? String(body.imageUrl) : null
}
if (body.published !== undefined) data.published = Boolean(body.published)
if (body.categoryId !== undefined) data.categoryId = String(body.categoryId)
if (body.categoryId !== undefined) {
const raw = body.categoryId
if (raw === null || raw === '') {
data.categoryId = (await getOrCreateUnspecifiedCategory()).id
} else {
const cid = String(raw).trim()
const cat = await prisma.category.findUnique({ where: { id: cid } })
if (!cat) {
reply.code(400).send({ error: 'Категория не найдена' })
return
}
data.categoryId = cid
}
}
if (body.inStock !== undefined) data.inStock = Boolean(body.inStock)
if (body.leadTimeDays !== undefined) {
+99
View File
@@ -0,0 +1,99 @@
import { prisma } from '../../lib/prisma.js'
const MAX_SLIDES = 20
export async function registerCatalogSliderRoutes(fastify) {
fastify.get('/api/catalog-slider', async () => {
const slides = await prisma.catalogSliderSlide.findMany({
orderBy: { sortOrder: 'asc' },
include: { galleryImage: true },
})
return {
slides: slides.map((s) => ({
id: s.id,
url: s.galleryImage.url,
caption: s.caption,
})),
}
})
fastify.get(
'/api/admin/catalog-slider',
{ preHandler: [fastify.verifyAdmin] },
async () => {
const slides = await prisma.catalogSliderSlide.findMany({
orderBy: { sortOrder: 'asc' },
include: { galleryImage: true },
})
return {
slides: slides.map((s) => ({
id: s.id,
galleryImageId: s.galleryImageId,
url: s.galleryImage.url,
caption: s.caption,
})),
}
},
)
fastify.put(
'/api/admin/catalog-slider',
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
const body = request.body ?? {}
const rawSlides = body.slides
if (!Array.isArray(rawSlides)) {
return reply.code(400).send({ error: 'Ожидается slides: массив' })
}
if (rawSlides.length > MAX_SLIDES) {
return reply.code(400).send({ error: `Не более ${MAX_SLIDES} слайдов` })
}
const seenGalleryIds = new Set()
const normalized = []
for (let i = 0; i < rawSlides.length; i++) {
const row = rawSlides[i]
const galleryImageId = String(row?.galleryImageId ?? '').trim()
if (!galleryImageId) {
return reply.code(400).send({ error: `Слайд ${i + 1}: укажите galleryImageId` })
}
if (seenGalleryIds.has(galleryImageId)) {
return reply.code(400).send({ error: 'Одно изображение нельзя добавить дважды' })
}
seenGalleryIds.add(galleryImageId)
const img = await prisma.galleryImage.findUnique({ where: { id: galleryImageId } })
if (!img) {
return reply.code(400).send({ error: `Изображение не найдено: ${galleryImageId}` })
}
const caption = row?.caption == null ? '' : String(row.caption).slice(0, 500)
normalized.push({ galleryImageId, caption, sortOrder: i })
}
await prisma.$transaction(async (tx) => {
await tx.catalogSliderSlide.deleteMany({})
for (const n of normalized) {
await tx.catalogSliderSlide.create({
data: {
sortOrder: n.sortOrder,
caption: n.caption,
galleryImageId: n.galleryImageId,
},
})
}
})
const slides = await prisma.catalogSliderSlide.findMany({
orderBy: { sortOrder: 'asc' },
include: { galleryImage: true },
})
return {
slides: slides.map((s) => ({
id: s.id,
galleryImageId: s.galleryImageId,
url: s.galleryImage.url,
caption: s.caption,
})),
}
},
)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 MiB