init project

This commit is contained in:
@kirill.komarov
2026-04-28 11:02:08 +05:00
commit 55480d4aa5
50 changed files with 9241 additions and 0 deletions
+30
View File
@@ -0,0 +1,30 @@
import 'dotenv/config'
import Fastify from 'fastify'
import cors from '@fastify/cors'
import { registerAuth } from './plugins/auth.js'
import { registerApiRoutes } from './routes/api.js'
const port = Number(process.env.PORT) || 3333
const origin = (process.env.CORS_ORIGIN ?? '')
.split(',')
.map((s) => s.trim())
.filter(Boolean)
const fastify = Fastify({ logger: true })
await fastify.register(cors, {
origin: origin.length ? origin : true,
credentials: true,
})
registerAuth(fastify)
await registerApiRoutes(fastify)
fastify.get('/health', async () => ({ ok: true }))
try {
await fastify.listen({ port, host: '0.0.0.0' })
} catch (err) {
fastify.log.error(err)
process.exit(1)
}
+9
View File
@@ -0,0 +1,9 @@
import { PrismaClient } from '@prisma/client'
const globalForPrisma = globalThis
export const prisma = globalForPrisma.prisma ?? new PrismaClient()
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma
}
+16
View File
@@ -0,0 +1,16 @@
/**
* Простая защита админ-роутов: заголовок Authorization: Bearer <ADMIN_API_TOKEN>
*/
export function registerAuth(fastify) {
fastify.decorate('verifyAdmin', async function verifyAdmin(request, reply) {
const token = process.env.ADMIN_API_TOKEN
if (!token) {
return reply.code(503).send({ error: 'ADMIN_API_TOKEN не задан в .env' })
}
const auth = request.headers.authorization
const match = typeof auth === 'string' ? auth.match(/^Bearer\s+(.+)$/i) : null
if (!match?.[1] || match[1] !== token) {
return reply.code(401).send({ error: 'Неверный или отсутствующий токен' })
}
})
}
+194
View File
@@ -0,0 +1,194 @@
import { prisma } from '../lib/prisma.js'
function slugify(input) {
return input
.toLowerCase()
.trim()
.replace(/\s+/g, '-')
.replace(/[^a-z0-9-а-яё]/gi, '')
}
export async function registerApiRoutes(fastify) {
fastify.get('/api/categories', async () => {
return prisma.category.findMany({ orderBy: { sort: 'asc' } })
})
fastify.get('/api/products', async (request) => {
const { categorySlug } = request.query
const where = { published: true }
if (typeof categorySlug === 'string' && categorySlug.length > 0) {
where.category = { slug: categorySlug }
}
return prisma.product.findMany({
where,
include: { category: true },
orderBy: { createdAt: 'desc' },
})
})
fastify.get('/api/products/:id', async (request, reply) => {
const { id } = request.params
const product = await prisma.product.findFirst({
where: { id, published: true },
include: { category: true },
})
if (!product) {
reply.code(404).send({ error: 'Товар не найден' })
return
}
return product
})
// ---- Админ (тот же фронт, другой раздел) ----
fastify.get(
'/api/admin/products',
{ preHandler: [fastify.verifyAdmin] },
async () => {
return prisma.product.findMany({
include: { category: true },
orderBy: { updatedAt: 'desc' },
})
},
)
fastify.post(
'/api/admin/products',
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
const body = request.body ?? {}
const title = String(body.title ?? '').trim()
if (!title) {
reply.code(400).send({ error: 'Укажите название' })
return
}
const slug =
String(body.slug ?? '').trim() || slugify(title) || `item-${Date.now()}`
const categoryId = String(body.categoryId ?? '').trim()
if (!categoryId) {
reply.code(400).send({ error: 'Укажите категорию' })
return
}
const priceCents = Number(body.priceCents)
if (!Number.isFinite(priceCents) || priceCents < 0) {
reply.code(400).send({ error: 'Некорректная цена (priceCents ≥ 0)' })
return
}
const exists = await prisma.product.findUnique({ where: { slug } })
if (exists) {
reply.code(409).send({ error: 'Такой slug уже занят' })
return
}
const product = await prisma.product.create({
data: {
title,
slug,
description: body.description ? String(body.description) : null,
priceCents: Math.round(priceCents),
imageUrl: body.imageUrl ? String(body.imageUrl) : null,
published: Boolean(body.published),
categoryId,
},
include: { category: true },
})
reply.code(201).send(product)
},
)
fastify.patch(
'/api/admin/products/:id',
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
const { id } = request.params
const body = request.body ?? {}
const existing = await prisma.product.findUnique({ where: { id } })
if (!existing) {
reply.code(404).send({ error: 'Товар не найден' })
return
}
const data = {}
if (body.title !== undefined) data.title = String(body.title).trim()
if (body.slug !== undefined) {
const s = String(body.slug).trim()
if (s && s !== existing.slug) {
const clash = await prisma.product.findFirst({
where: { slug: s, NOT: { id } },
})
if (clash) {
reply.code(409).send({ error: 'Такой slug уже занят' })
return
}
data.slug = s
}
}
if (body.description !== undefined) {
data.description = body.description ? String(body.description) : null
}
if (body.priceCents !== undefined) {
const p = Number(body.priceCents)
if (!Number.isFinite(p) || p < 0) {
reply.code(400).send({ error: 'Некорректная цена' })
return
}
data.priceCents = Math.round(p)
}
if (body.imageUrl !== undefined) {
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)
const product = await prisma.product.update({
where: { id },
data,
include: { category: true },
})
return product
},
)
fastify.delete(
'/api/admin/products/:id',
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
const { id } = request.params
try {
await prisma.product.delete({ where: { id } })
reply.code(204).send()
} catch {
reply.code(404).send({ error: 'Товар не найден' })
}
},
)
fastify.post(
'/api/admin/categories',
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
const body = request.body ?? {}
const name = String(body.name ?? '').trim()
if (!name) {
reply.code(400).send({ error: 'Укажите название категории' })
return
}
const slug = String(body.slug ?? '').trim() || slugify(name) || `cat-${Date.now()}`
const sort =
body.sort !== undefined && body.sort !== null && body.sort !== ''
? Number(body.sort)
: undefined
const exists = await prisma.category.findUnique({ where: { slug } })
if (exists) {
reply.code(409).send({ error: 'Такой slug уже занят' })
return
}
const category = await prisma.category.create({
data: {
name,
slug,
sort: Number.isFinite(sort) ? Math.round(sort) : 0,
},
})
reply.code(201).send(category)
},
)
}