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>
@@ -0,0 +1,38 @@
|
||||
# --- server ---
|
||||
PORT=4000
|
||||
NODE_ENV=development
|
||||
# comma-separated origins allowed to call the API from a browser (admin dev server, site)
|
||||
CORS_ORIGINS=http://localhost:5173,http://localhost:3000
|
||||
|
||||
# --- database (docker-compose.dev.yml) ---
|
||||
DATABASE_URL=postgresql://imen:imen@localhost:5433/imen
|
||||
|
||||
# --- auth ---
|
||||
# generate with: node -e "console.log(require('crypto').randomBytes(48).toString('base64url'))"
|
||||
JWT_SECRET=change-me-dev-secret-change-me-dev-secret
|
||||
JWT_ACCESS_TTL=15m
|
||||
REFRESH_TTL_DAYS=30
|
||||
# set to false only when serving the admin over plain http in production
|
||||
COOKIE_SECURE=false
|
||||
|
||||
# first admin user, created by `pnpm db:seed`
|
||||
ADMIN_EMAIL=admin@imenpishgaman.ir
|
||||
ADMIN_PASSWORD=ChangeMe123!
|
||||
ADMIN_NAME=مدیر سایت
|
||||
|
||||
# --- object storage (MinIO in dev, any S3-compatible service in production) ---
|
||||
S3_ENDPOINT=http://localhost:9000
|
||||
S3_REGION=us-east-1
|
||||
S3_BUCKET=imen-media
|
||||
S3_ACCESS_KEY=imen
|
||||
S3_SECRET_KEY=imen-secret-key
|
||||
S3_FORCE_PATH_STYLE=true
|
||||
# create the bucket and make it publicly readable on startup (MinIO)
|
||||
S3_ENSURE_BUCKET=true
|
||||
# public base URL of uploaded files (no trailing slash). "/media" is proxied to the bucket
|
||||
# by the site/admin dev servers and by nginx in production, so URLs are the same everywhere
|
||||
MEDIA_PUBLIC_URL=/media
|
||||
|
||||
# --- website cache revalidation ---
|
||||
WEB_REVALIDATE_URL=http://localhost:3000/_internal/revalidate
|
||||
REVALIDATE_SECRET=change-me-revalidate-secret
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"name": "@imen/api",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "prisma generate && nest build",
|
||||
"dev": "nest start --watch --preserveWatchOutput",
|
||||
"start": "node dist/main.js",
|
||||
"typecheck": "prisma generate && tsc --noEmit -p tsconfig.json",
|
||||
"test": "vitest run --config ./vitest.config.e2e.ts",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
"prisma:deploy": "prisma migrate deploy",
|
||||
"prisma:studio": "prisma studio",
|
||||
"db:seed": "prisma db seed",
|
||||
"postinstall": "prisma generate"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1154.0",
|
||||
"@imen/shared": "workspace:*",
|
||||
"@nestjs/common": "^12.2.0",
|
||||
"@nestjs/core": "^12.2.0",
|
||||
"@nestjs/jwt": "^12.0.3",
|
||||
"@nestjs/platform-express": "^12.2.0",
|
||||
"@nestjs/throttler": "^6.7.1",
|
||||
"@node-rs/argon2": "^2.2.2",
|
||||
"@prisma/adapter-pg": "7.10.1",
|
||||
"@prisma/client": "7.10.1",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"helmet": "^8.3.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.2",
|
||||
"sharp": "^0.35.5",
|
||||
"zod": "^4.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^12.0.10",
|
||||
"@nestjs/schematics": "^12.0.10",
|
||||
"@nestjs/testing": "^12.2.0",
|
||||
"@types/cookie-parser": "^1.4.9",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/multer": "^2.3.0",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/supertest": "^7.0.0",
|
||||
"prisma": "7.10.1",
|
||||
"supertest": "^7.1.4",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "^6.0.2",
|
||||
"vite": "^8.0.0",
|
||||
"vitest": "^4.1.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { defineConfig } from "prisma/config";
|
||||
|
||||
// Prisma 7 no longer loads .env on its own
|
||||
if (existsSync(".env")) process.loadEnvFile(".env");
|
||||
|
||||
export default defineConfig({
|
||||
schema: "prisma/schema.prisma",
|
||||
migrations: {
|
||||
path: "prisma/migrations",
|
||||
seed: "tsx src/seed/seed.ts",
|
||||
},
|
||||
datasource: {
|
||||
// `prisma generate` (e.g. in the Docker build) does not need a real database
|
||||
url: process.env.DATABASE_URL ?? "postgresql://placeholder:placeholder@localhost:5432/placeholder",
|
||||
},
|
||||
});
|
||||
@@ -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])
|
||||
}
|
||||
|
After Width: | Height: | Size: 306 KiB |
|
After Width: | Height: | Size: 201 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 119 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 188 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 265 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 244 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 191 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 763 KiB |
|
After Width: | Height: | Size: 709 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 516 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 198 KiB |
@@ -0,0 +1,48 @@
|
||||
import { Controller, Get, Module } from "@nestjs/common";
|
||||
import { APP_FILTER, APP_GUARD } from "@nestjs/core";
|
||||
import { ThrottlerGuard, ThrottlerModule } from "@nestjs/throttler";
|
||||
import { AuthModule } from "./auth/auth.controller.js";
|
||||
import { CatalogModule } from "./catalog/catalog.module.js";
|
||||
import { CommonModule } from "./common/common.module.js";
|
||||
import { PrismaExceptionFilter } from "./common/prisma-exception.filter.js";
|
||||
import { ContactModule } from "./contact/contact.module.js";
|
||||
import { DashboardModule } from "./dashboard/dashboard.module.js";
|
||||
import { MediaModule } from "./media/media.controller.js";
|
||||
import { PrismaModule, PrismaService } from "./prisma/prisma.service.js";
|
||||
import { PublicModule } from "./public/public.module.js";
|
||||
import { SectionsModule } from "./sections/sections.module.js";
|
||||
import { UsersModule } from "./users/users.controller.js";
|
||||
|
||||
@Controller("health")
|
||||
class HealthController {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
@Get()
|
||||
async health() {
|
||||
await this.prisma.$queryRaw`SELECT 1`;
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
// generous default; sensitive routes (login, contact form) set stricter limits
|
||||
ThrottlerModule.forRoot([{ ttl: 60_000, limit: 300 }]),
|
||||
PrismaModule,
|
||||
CommonModule,
|
||||
AuthModule,
|
||||
UsersModule,
|
||||
MediaModule,
|
||||
CatalogModule,
|
||||
SectionsModule,
|
||||
ContactModule,
|
||||
PublicModule,
|
||||
DashboardModule,
|
||||
],
|
||||
controllers: [HealthController],
|
||||
providers: [
|
||||
{ provide: APP_GUARD, useClass: ThrottlerGuard },
|
||||
{ provide: APP_FILTER, useClass: PrismaExceptionFilter },
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { Body, Controller, Get, HttpCode, Module, Post, Req, Res } from "@nestjs/common";
|
||||
import { JwtModule, type JwtSignOptions } from "@nestjs/jwt";
|
||||
import { Throttle } from "@nestjs/throttler";
|
||||
import {
|
||||
type AuthResponse,
|
||||
type ChangePasswordInput,
|
||||
changePasswordSchema,
|
||||
type LoginInput,
|
||||
loginSchema,
|
||||
} from "@imen/shared";
|
||||
import type { Request, Response } from "express";
|
||||
import { env, isProd } from "../config/env.js";
|
||||
import { AdminAuth, type AuthUser, CurrentUser } from "../common/auth.js";
|
||||
import { ZodPipe } from "../common/zod.pipe.js";
|
||||
import { AuthService, type IssuedTokens } from "./auth.service.js";
|
||||
|
||||
const COOKIE = "imen_rt";
|
||||
const COOKIE_PATH = "/api/admin/auth";
|
||||
|
||||
const ua = (req: Request) => req.headers["user-agent"] ?? "";
|
||||
const readCookie = (req: Request): string | undefined =>
|
||||
(req.cookies as Record<string, string> | undefined)?.[COOKIE];
|
||||
|
||||
@Controller("admin/auth")
|
||||
export class AuthController {
|
||||
constructor(private readonly auth: AuthService) {}
|
||||
|
||||
@Post("login")
|
||||
@HttpCode(200)
|
||||
@Throttle({ default: { limit: 8, ttl: 60_000 } })
|
||||
async login(
|
||||
@Body(new ZodPipe(loginSchema)) body: LoginInput,
|
||||
@Req() req: Request,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): Promise<AuthResponse> {
|
||||
return this.respond(res, await this.auth.login(body, ua(req)));
|
||||
}
|
||||
|
||||
@Post("refresh")
|
||||
@HttpCode(200)
|
||||
@Throttle({ default: { limit: 30, ttl: 60_000 } })
|
||||
async refresh(
|
||||
@Req() req: Request,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): Promise<AuthResponse> {
|
||||
try {
|
||||
return this.respond(res, await this.auth.refresh(readCookie(req), ua(req)));
|
||||
} catch (e) {
|
||||
res.clearCookie(COOKIE, { path: COOKIE_PATH });
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@Post("logout")
|
||||
@HttpCode(204)
|
||||
async logout(@Req() req: Request, @Res({ passthrough: true }) res: Response) {
|
||||
await this.auth.logout(readCookie(req));
|
||||
res.clearCookie(COOKIE, { path: COOKIE_PATH });
|
||||
}
|
||||
|
||||
@Get("me")
|
||||
@AdminAuth()
|
||||
me(@CurrentUser() user: AuthUser) {
|
||||
return this.auth.me(user.id);
|
||||
}
|
||||
|
||||
@Post("change-password")
|
||||
@HttpCode(204)
|
||||
@AdminAuth()
|
||||
async changePassword(
|
||||
@Body(new ZodPipe(changePasswordSchema)) body: ChangePasswordInput,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
await this.auth.changePassword(user.id, body);
|
||||
}
|
||||
|
||||
private respond(res: Response, t: IssuedTokens): AuthResponse {
|
||||
res.cookie(COOKIE, t.refreshToken, {
|
||||
httpOnly: true,
|
||||
secure: isProd && env.COOKIE_SECURE,
|
||||
sameSite: "strict",
|
||||
path: COOKIE_PATH,
|
||||
expires: t.refreshExpiresAt,
|
||||
});
|
||||
return { accessToken: t.accessToken, user: t.user };
|
||||
}
|
||||
}
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
JwtModule.register({
|
||||
global: true,
|
||||
secret: env.JWT_SECRET,
|
||||
signOptions: { expiresIn: env.JWT_ACCESS_TTL as JwtSignOptions["expiresIn"] },
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { BadRequestException, Injectable, UnauthorizedException } from "@nestjs/common";
|
||||
import { JwtService } from "@nestjs/jwt";
|
||||
import { hash, verify } from "@node-rs/argon2";
|
||||
import type { AdminUserDto, AuthResponse, ChangePasswordInput, LoginInput } from "@imen/shared";
|
||||
import { env } from "../config/env.js";
|
||||
import type { JwtPayload } from "../common/auth.js";
|
||||
import { AuditService } from "../common/common.module.js";
|
||||
import type { AdminUser } from "../generated/prisma/client.js";
|
||||
import { PrismaService } from "../prisma/prisma.service.js";
|
||||
|
||||
export const hashPassword = (password: string) => hash(password);
|
||||
|
||||
const sha256 = (v: string) => createHash("sha256").update(v).digest("hex");
|
||||
|
||||
export const toUserDto = (u: AdminUser): AdminUserDto => ({
|
||||
id: u.id,
|
||||
email: u.email,
|
||||
name: u.name,
|
||||
role: u.role,
|
||||
isActive: u.isActive,
|
||||
lastLoginAt: u.lastLoginAt?.toISOString() ?? null,
|
||||
createdAt: u.createdAt.toISOString(),
|
||||
});
|
||||
|
||||
export interface IssuedTokens extends AuthResponse {
|
||||
refreshToken: string;
|
||||
refreshExpiresAt: Date;
|
||||
}
|
||||
|
||||
const INVALID_LOGIN = "ایمیل یا رمز عبور اشتباه است";
|
||||
|
||||
/** hash of a random password, verified against when the email is unknown so timing stays the same */
|
||||
const DUMMY_HASH = hashPassword(randomBytes(16).toString("hex"));
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly jwt: JwtService,
|
||||
private readonly audit: AuditService,
|
||||
) {}
|
||||
|
||||
async login(input: LoginInput, userAgent: string): Promise<IssuedTokens> {
|
||||
const user = await this.prisma.adminUser.findUnique({ where: { email: input.email } });
|
||||
// verify even when the user is missing so response time does not reveal valid emails
|
||||
const ok = await verify(user?.passwordHash ?? (await DUMMY_HASH), input.password).catch(
|
||||
() => false,
|
||||
);
|
||||
if (!user || !ok || !user.isActive) throw new UnauthorizedException(INVALID_LOGIN);
|
||||
|
||||
const updated = await this.prisma.adminUser.update({
|
||||
where: { id: user.id },
|
||||
data: { lastLoginAt: new Date() },
|
||||
});
|
||||
this.audit.log(user.id, "login", "user", user.id, user.email);
|
||||
return this.issue(updated, userAgent);
|
||||
}
|
||||
|
||||
/** Rotates the refresh token: the old one is revoked and a new pair is issued */
|
||||
async refresh(token: string | undefined, userAgent: string): Promise<IssuedTokens> {
|
||||
if (!token) throw new UnauthorizedException("نشست شما منقضی شده است");
|
||||
|
||||
const stored = await this.prisma.refreshToken.findUnique({
|
||||
where: { tokenHash: sha256(token) },
|
||||
include: { user: true },
|
||||
});
|
||||
if (!stored) throw new UnauthorizedException("نشست شما منقضی شده است");
|
||||
|
||||
if (stored.revokedAt) {
|
||||
// a revoked token was replayed: assume it leaked and end every session of this user
|
||||
await this.revokeAll(stored.userId);
|
||||
throw new UnauthorizedException("نشست شما منقضی شده است");
|
||||
}
|
||||
if (stored.expiresAt < new Date() || !stored.user.isActive) {
|
||||
throw new UnauthorizedException("نشست شما منقضی شده است");
|
||||
}
|
||||
|
||||
await this.prisma.refreshToken.update({
|
||||
where: { id: stored.id },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
return this.issue(stored.user, userAgent);
|
||||
}
|
||||
|
||||
async logout(token: string | undefined) {
|
||||
if (!token) return;
|
||||
await this.prisma.refreshToken.updateMany({
|
||||
where: { tokenHash: sha256(token), revokedAt: null },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
async changePassword(userId: number, input: ChangePasswordInput) {
|
||||
const user = await this.prisma.adminUser.findUniqueOrThrow({ where: { id: userId } });
|
||||
if (!(await verify(user.passwordHash, input.currentPassword).catch(() => false))) {
|
||||
throw new BadRequestException("رمز عبور فعلی اشتباه است");
|
||||
}
|
||||
await this.prisma.adminUser.update({
|
||||
where: { id: userId },
|
||||
data: { passwordHash: await hashPassword(input.newPassword) },
|
||||
});
|
||||
this.audit.log(userId, "change-password", "user", userId, user.email);
|
||||
}
|
||||
|
||||
async me(userId: number) {
|
||||
return toUserDto(await this.prisma.adminUser.findUniqueOrThrow({ where: { id: userId } }));
|
||||
}
|
||||
|
||||
revokeAll(userId: number) {
|
||||
return this.prisma.refreshToken.updateMany({
|
||||
where: { userId, revokedAt: null },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
private async issue(user: AdminUser, userAgent: string): Promise<IssuedTokens> {
|
||||
const payload: JwtPayload = { sub: user.id, role: user.role };
|
||||
const accessToken = await this.jwt.signAsync(payload);
|
||||
|
||||
const refreshToken = randomBytes(48).toString("base64url");
|
||||
const refreshExpiresAt = new Date(Date.now() + env.REFRESH_TTL_DAYS * 86_400_000);
|
||||
await this.prisma.refreshToken.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
tokenHash: sha256(refreshToken),
|
||||
expiresAt: refreshExpiresAt,
|
||||
userAgent: userAgent.slice(0, 300),
|
||||
},
|
||||
});
|
||||
|
||||
// keep the table small: drop tokens that expired more than a day ago
|
||||
void this.prisma.refreshToken
|
||||
.deleteMany({ where: { expiresAt: { lt: new Date(Date.now() - 86_400_000) } } })
|
||||
.catch(() => undefined);
|
||||
|
||||
return { accessToken, refreshToken, refreshExpiresAt, user: toUserDto(user) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
Module,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
} from "@nestjs/common";
|
||||
import {
|
||||
type CategoryInput,
|
||||
categoryInputSchema,
|
||||
type ProductInput,
|
||||
productInputSchema,
|
||||
type ProductListQuery,
|
||||
productListQuerySchema,
|
||||
type ReorderInput,
|
||||
reorderSchema,
|
||||
} from "@imen/shared";
|
||||
import { z } from "zod";
|
||||
import { AdminAuth, type AuthUser, CurrentUser } from "../common/auth.js";
|
||||
import { IdPipe, ZodPipe } from "../common/zod.pipe.js";
|
||||
import { MediaModule } from "../media/media.controller.js";
|
||||
import { CategoriesService } from "./categories.service.js";
|
||||
import { ProductsService } from "./products.service.js";
|
||||
|
||||
const publishSchema = z.object({ isPublished: z.boolean() });
|
||||
|
||||
@AdminAuth()
|
||||
@Controller("admin/categories")
|
||||
export class CategoriesController {
|
||||
constructor(private readonly categories: CategoriesService) {}
|
||||
|
||||
@Get()
|
||||
list() {
|
||||
return this.categories.list();
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
get(@Param("id", IdPipe) id: number) {
|
||||
return this.categories.get(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(
|
||||
@Body(new ZodPipe(categoryInputSchema)) body: CategoryInput,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.categories.create(body, user.id);
|
||||
}
|
||||
|
||||
@Post("reorder")
|
||||
@HttpCode(204)
|
||||
async reorder(@Body(new ZodPipe(reorderSchema)) body: ReorderInput, @CurrentUser() user: AuthUser) {
|
||||
await this.categories.reorder(body.ids, user.id);
|
||||
}
|
||||
|
||||
@Put(":id")
|
||||
update(
|
||||
@Param("id", IdPipe) id: number,
|
||||
@Body(new ZodPipe(categoryInputSchema)) body: CategoryInput,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.categories.update(id, body, user.id);
|
||||
}
|
||||
|
||||
@Delete(":id")
|
||||
@HttpCode(204)
|
||||
async remove(@Param("id", IdPipe) id: number, @CurrentUser() user: AuthUser) {
|
||||
await this.categories.remove(id, user.id);
|
||||
}
|
||||
}
|
||||
|
||||
@AdminAuth()
|
||||
@Controller("admin/products")
|
||||
export class ProductsController {
|
||||
constructor(private readonly products: ProductsService) {}
|
||||
|
||||
@Get()
|
||||
list(@Query(new ZodPipe(productListQuerySchema)) q: ProductListQuery) {
|
||||
return this.products.list(q);
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
get(@Param("id", IdPipe) id: number) {
|
||||
return this.products.get(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@Body(new ZodPipe(productInputSchema)) body: ProductInput, @CurrentUser() user: AuthUser) {
|
||||
return this.products.create(body, user.id);
|
||||
}
|
||||
|
||||
@Post("reorder")
|
||||
@HttpCode(204)
|
||||
async reorder(@Body(new ZodPipe(reorderSchema)) body: ReorderInput, @CurrentUser() user: AuthUser) {
|
||||
await this.products.reorder(body.ids, user.id);
|
||||
}
|
||||
|
||||
@Post(":id/duplicate")
|
||||
duplicate(@Param("id", IdPipe) id: number, @CurrentUser() user: AuthUser) {
|
||||
return this.products.duplicate(id, user.id);
|
||||
}
|
||||
|
||||
@Patch(":id/publish")
|
||||
publish(
|
||||
@Param("id", IdPipe) id: number,
|
||||
@Body(new ZodPipe(publishSchema)) body: z.output<typeof publishSchema>,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.products.setPublished(id, body.isPublished, user.id);
|
||||
}
|
||||
|
||||
@Put(":id")
|
||||
update(
|
||||
@Param("id", IdPipe) id: number,
|
||||
@Body(new ZodPipe(productInputSchema)) body: ProductInput,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.products.update(id, body, user.id);
|
||||
}
|
||||
|
||||
@Delete(":id")
|
||||
@HttpCode(204)
|
||||
async remove(@Param("id", IdPipe) id: number, @CurrentUser() user: AuthUser) {
|
||||
await this.products.remove(id, user.id);
|
||||
}
|
||||
}
|
||||
|
||||
@Module({
|
||||
imports: [MediaModule],
|
||||
controllers: [CategoriesController, ProductsController],
|
||||
providers: [CategoriesService, ProductsService],
|
||||
})
|
||||
export class CatalogModule {}
|
||||