deploy
This commit is contained in:
+39
@@ -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");
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }))
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 |
Reference in New Issue
Block a user