feat: monorepo with NestJS API, admin panel and DB-driven website
Restructure the repo into a pnpm + Turborepo monorepo: - apps/web: Nuxt 4 site, now server-rendered and reading all content from the API; pages cached with SWR and purged on content changes - apps/api: NestJS 12 + Prisma 7 + PostgreSQL. Auth with short-lived access tokens and rotating httpOnly refresh cookies, roles (ADMIN, EDITOR), catalog, editable site sections, media upload to S3-compatible storage (converted to WebP), contact messages, audit log - apps/admin: Vue 3 admin panel (Ant Design Vue, Pinia, Tailwind) for products, categories, site sections, messages, media and users - packages/shared: Zod schemas and types shared by API and admin Also: - seed script that loads the original 8 categories / 89 products and section content - Docker images for api (with a one-off migrate service), web, admin, and an nginx gateway for both domains - dev and production compose files, daily database backup service - API e2e tests Removes the committed static out/ build and the old root Dockerfile. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
31076174e2
commit
789d224ea0
@@ -0,0 +1,212 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "Role" AS ENUM ('ADMIN', 'EDITOR');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "MessageStatus" AS ENUM ('NEW', 'READ', 'ARCHIVED');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AdminUser" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"passwordHash" TEXT NOT NULL,
|
||||
"role" "Role" NOT NULL DEFAULT 'EDITOR',
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"lastLoginAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "AdminUser_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "RefreshToken" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"userId" INTEGER NOT NULL,
|
||||
"tokenHash" TEXT NOT NULL,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"revokedAt" TIMESTAMP(3),
|
||||
"userAgent" TEXT NOT NULL DEFAULT '',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "RefreshToken_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Media" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"key" TEXT NOT NULL,
|
||||
"mime" TEXT NOT NULL,
|
||||
"size" INTEGER NOT NULL,
|
||||
"width" INTEGER,
|
||||
"height" INTEGER,
|
||||
"alt" TEXT NOT NULL DEFAULT '',
|
||||
"originalName" TEXT NOT NULL DEFAULT '',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "Media_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Category" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"code" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"nameEn" TEXT NOT NULL DEFAULT '',
|
||||
"shortDesc" TEXT NOT NULL DEFAULT '',
|
||||
"fullDesc" TEXT NOT NULL DEFAULT '',
|
||||
"icon" TEXT NOT NULL DEFAULT '',
|
||||
"coverImageId" INTEGER,
|
||||
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||
"isPublished" BOOLEAN NOT NULL DEFAULT true,
|
||||
"seoTitle" TEXT NOT NULL DEFAULT '',
|
||||
"seoDescription" TEXT NOT NULL DEFAULT '',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Category_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ProductGroup" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"categoryId" INTEGER NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"desc" TEXT NOT NULL DEFAULT '',
|
||||
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
CONSTRAINT "ProductGroup_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Product" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"categoryId" INTEGER NOT NULL,
|
||||
"groupId" INTEGER,
|
||||
"code" TEXT NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"nameEn" TEXT NOT NULL DEFAULT '',
|
||||
"desc" TEXT NOT NULL DEFAULT '',
|
||||
"specs" TEXT[],
|
||||
"variants" TEXT[],
|
||||
"imageId" INTEGER,
|
||||
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||
"isPublished" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Product_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ProductImage" (
|
||||
"productId" INTEGER NOT NULL,
|
||||
"mediaId" INTEGER NOT NULL,
|
||||
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
CONSTRAINT "ProductImage_pkey" PRIMARY KEY ("productId","mediaId")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "SiteSection" (
|
||||
"key" TEXT NOT NULL,
|
||||
"data" JSONB NOT NULL,
|
||||
"updatedById" INTEGER,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "SiteSection_pkey" PRIMARY KEY ("key")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ContactMessage" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"org" TEXT NOT NULL,
|
||||
"phone" TEXT NOT NULL,
|
||||
"message" TEXT NOT NULL DEFAULT '',
|
||||
"status" "MessageStatus" NOT NULL DEFAULT 'NEW',
|
||||
"ip" TEXT NOT NULL DEFAULT '',
|
||||
"userAgent" TEXT NOT NULL DEFAULT '',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "ContactMessage_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AuditLog" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"userId" INTEGER,
|
||||
"action" TEXT NOT NULL,
|
||||
"entity" TEXT NOT NULL,
|
||||
"entityId" TEXT NOT NULL DEFAULT '',
|
||||
"summary" TEXT NOT NULL DEFAULT '',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "AdminUser_email_key" ON "AdminUser"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "RefreshToken_tokenHash_key" ON "RefreshToken"("tokenHash");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "RefreshToken_userId_idx" ON "RefreshToken"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Media_key_key" ON "Media"("key");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Category_slug_key" ON "Category"("slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Category_sortOrder_idx" ON "Category"("sortOrder");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ProductGroup_categoryId_slug_key" ON "ProductGroup"("categoryId", "slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Product_code_key" ON "Product"("code");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Product_slug_key" ON "Product"("slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Product_categoryId_sortOrder_idx" ON "Product"("categoryId", "sortOrder");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ContactMessage_status_createdAt_idx" ON "ContactMessage"("status", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AuditLog_createdAt_idx" ON "AuditLog"("createdAt");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "RefreshToken" ADD CONSTRAINT "RefreshToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "AdminUser"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Category" ADD CONSTRAINT "Category_coverImageId_fkey" FOREIGN KEY ("coverImageId") REFERENCES "Media"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ProductGroup" ADD CONSTRAINT "ProductGroup_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Product" ADD CONSTRAINT "Product_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Product" ADD CONSTRAINT "Product_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "ProductGroup"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Product" ADD CONSTRAINT "Product_imageId_fkey" FOREIGN KEY ("imageId") REFERENCES "Media"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ProductImage" ADD CONSTRAINT "ProductImage_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ProductImage" ADD CONSTRAINT "ProductImage_mediaId_fkey" FOREIGN KEY ("mediaId") REFERENCES "Media"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "AdminUser"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "postgresql"
|
||||
@@ -0,0 +1,173 @@
|
||||
generator client {
|
||||
provider = "prisma-client"
|
||||
output = "../src/generated/prisma"
|
||||
moduleFormat = "esm"
|
||||
importFileExtension = "js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
}
|
||||
|
||||
enum Role {
|
||||
ADMIN
|
||||
EDITOR
|
||||
}
|
||||
|
||||
enum MessageStatus {
|
||||
NEW
|
||||
READ
|
||||
ARCHIVED
|
||||
}
|
||||
|
||||
model AdminUser {
|
||||
id Int @id @default(autoincrement())
|
||||
email String @unique
|
||||
name String
|
||||
passwordHash String
|
||||
role Role @default(EDITOR)
|
||||
isActive Boolean @default(true)
|
||||
lastLoginAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
refreshTokens RefreshToken[]
|
||||
auditLogs AuditLog[]
|
||||
}
|
||||
|
||||
model RefreshToken {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int
|
||||
user AdminUser @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
/// sha256 of the opaque token stored in the httpOnly cookie
|
||||
tokenHash String @unique
|
||||
expiresAt DateTime
|
||||
revokedAt DateTime?
|
||||
userAgent String @default("")
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model Media {
|
||||
id Int @id @default(autoincrement())
|
||||
/// object key in the S3 bucket; the public URL is MEDIA_PUBLIC_URL + "/" + key
|
||||
key String @unique
|
||||
mime String
|
||||
size Int
|
||||
width Int?
|
||||
height Int?
|
||||
alt String @default("")
|
||||
originalName String @default("")
|
||||
createdAt DateTime @default(now())
|
||||
categoryCovers Category[] @relation("CategoryCover")
|
||||
productImages Product[] @relation("ProductImage")
|
||||
productGallery ProductImage[]
|
||||
}
|
||||
|
||||
model Category {
|
||||
id Int @id @default(autoincrement())
|
||||
slug String @unique
|
||||
code String
|
||||
name String
|
||||
nameEn String @default("")
|
||||
shortDesc String @default("")
|
||||
fullDesc String @default("")
|
||||
/// Lucide icon name
|
||||
icon String @default("")
|
||||
coverImageId Int?
|
||||
coverImage Media? @relation("CategoryCover", fields: [coverImageId], references: [id], onDelete: SetNull)
|
||||
sortOrder Int @default(0)
|
||||
isPublished Boolean @default(true)
|
||||
seoTitle String @default("")
|
||||
seoDescription String @default("")
|
||||
groups ProductGroup[]
|
||||
products Product[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([sortOrder])
|
||||
}
|
||||
|
||||
model ProductGroup {
|
||||
id Int @id @default(autoincrement())
|
||||
categoryId Int
|
||||
category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade)
|
||||
slug String
|
||||
name String
|
||||
desc String @default("")
|
||||
sortOrder Int @default(0)
|
||||
products Product[]
|
||||
|
||||
@@unique([categoryId, slug])
|
||||
}
|
||||
|
||||
model Product {
|
||||
id Int @id @default(autoincrement())
|
||||
categoryId Int
|
||||
category Category @relation(fields: [categoryId], references: [id], onDelete: Restrict)
|
||||
groupId Int?
|
||||
group ProductGroup? @relation(fields: [groupId], references: [id], onDelete: SetNull)
|
||||
code String @unique
|
||||
slug String @unique
|
||||
name String
|
||||
nameEn String @default("")
|
||||
desc String @default("")
|
||||
specs String[]
|
||||
variants String[]
|
||||
imageId Int?
|
||||
image Media? @relation("ProductImage", fields: [imageId], references: [id], onDelete: SetNull)
|
||||
gallery ProductImage[]
|
||||
sortOrder Int @default(0)
|
||||
isPublished Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([categoryId, sortOrder])
|
||||
}
|
||||
|
||||
model ProductImage {
|
||||
productId Int
|
||||
mediaId Int
|
||||
sortOrder Int @default(0)
|
||||
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||
media Media @relation(fields: [mediaId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([productId, mediaId])
|
||||
}
|
||||
|
||||
/// One row per editable website section; `data` is validated by the zod schema for `key` (@imen/shared)
|
||||
model SiteSection {
|
||||
key String @id
|
||||
data Json
|
||||
updatedById Int?
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model ContactMessage {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
org String
|
||||
phone String
|
||||
message String @default("")
|
||||
status MessageStatus @default(NEW)
|
||||
ip String @default("")
|
||||
userAgent String @default("")
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([status, createdAt])
|
||||
}
|
||||
|
||||
model AuditLog {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int?
|
||||
user AdminUser? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
/// create | update | delete | login ...
|
||||
action String
|
||||
/// product | category | section | media | user | message
|
||||
entity String
|
||||
entityId String @default("")
|
||||
summary String @default("")
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([createdAt])
|
||||
}
|
||||
Reference in New Issue
Block a user