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:
S. Esmaeili
2026-10-21 20:31:05 +03:30
co-authored by Claude Opus 5.5
parent 31076174e2
commit 789d224ea0
395 changed files with 19070 additions and 11592 deletions
+38
View File
@@ -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
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
+53
View File
@@ -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"
}
}
+17
View File
@@ -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"
+173
View File
@@ -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])
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 763 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 709 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 516 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

+48
View File
@@ -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 {}
+101
View File
@@ -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 {}
+139
View File
@@ -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) };
}
}
+139
View File
@@ -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 {}

Some files were not shown because too many files have changed in this diff Show More