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>
174 lines
5.1 KiB
Plaintext
174 lines
5.1 KiB
Plaintext
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])
|
|
}
|