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
+129
View File
@@ -0,0 +1,129 @@
import { z } from "zod";
import { req, text } from "./common.js";
export const ROLES = ["ADMIN", "EDITOR"] as const;
export type Role = (typeof ROLES)[number];
export const ROLE_LABELS: Record<Role, string> = {
ADMIN: "مدیر کل",
EDITOR: "ویرایشگر",
};
const password = z.string().min(8, "رمز عبور باید حداقل ۸ کاراکتر باشد").max(128);
export const loginSchema = z.object({
email: z.email("ایمیل معتبر نیست").trim().toLowerCase(),
password: z.string().min(1, "رمز عبور را وارد کنید").max(128),
});
export type LoginInput = z.infer<typeof loginSchema>;
export const changePasswordSchema = z.object({
currentPassword: z.string().min(1, "رمز فعلی را وارد کنید").max(128),
newPassword: password,
});
export type ChangePasswordInput = z.infer<typeof changePasswordSchema>;
export const userCreateSchema = z.object({
email: z.email("ایمیل معتبر نیست").trim().toLowerCase(),
name: req(80),
role: z.enum(ROLES),
password,
});
export type UserCreateInput = z.infer<typeof userCreateSchema>;
export const userUpdateSchema = z.object({
name: req(80),
role: z.enum(ROLES),
isActive: z.boolean(),
/** set only when resetting the password */
password: password.optional(),
});
export type UserUpdateInput = z.infer<typeof userUpdateSchema>;
export interface AdminUserDto {
id: number;
email: string;
name: string;
role: Role;
isActive: boolean;
lastLoginAt: string | null;
createdAt: string;
}
export interface AuthResponse {
accessToken: string;
user: AdminUserDto;
}
/* ---------------- contact messages ---------------- */
export const MESSAGE_STATUSES = ["NEW", "READ", "ARCHIVED"] as const;
export type MessageStatus = (typeof MESSAGE_STATUSES)[number];
export const MESSAGE_STATUS_LABELS: Record<MessageStatus, string> = {
NEW: "جدید",
READ: "خوانده‌شده",
ARCHIVED: "بایگانی",
};
export const contactInputSchema = z.object({
name: req(80),
org: req(120),
phone: z
.string()
.trim()
.min(1, "این فیلد الزامی است")
.max(30)
.regex(/^[0-9۰-۹+\-\s()]{5,30}$/, "شماره تماس معتبر نیست"),
message: text(2000),
/** honeypot — real visitors never fill it */
website: z.string().max(200).optional(),
});
export type ContactInput = z.infer<typeof contactInputSchema>;
export const messageUpdateSchema = z.object({
status: z.enum(MESSAGE_STATUSES),
});
export interface ContactMessageDto {
id: number;
name: string;
org: string;
phone: string;
message: string;
status: MessageStatus;
createdAt: string;
}
/* ---------------- media ---------------- */
export interface MediaDto {
id: number;
url: string;
alt: string;
mime: string;
size: number;
width: number | null;
height: number | null;
originalName: string;
createdAt: string;
}
export const mediaUpdateSchema = z.object({ alt: text(200) });
export interface Paginated<T> {
items: T[];
total: number;
page: number;
pageSize: number;
}
export interface DashboardDto {
categories: number;
products: number;
draftProducts: number;
newMessages: number;
media: number;
recentMessages: ContactMessageDto[];
recentActivity: { id: number; action: string; entity: string; summary: string; user: string | null; createdAt: string }[];
}
+103
View File
@@ -0,0 +1,103 @@
import { z } from "zod";
import { idSchema, req, slugSchema, text } from "./common.js";
const shortList = z.array(z.string().trim().min(1).max(120)).max(20).default([]);
export const productGroupInputSchema = z.object({
/** present when editing an existing group */
id: idSchema.optional(),
slug: slugSchema,
name: req(120),
desc: text(1000),
});
export type ProductGroupInput = z.infer<typeof productGroupInputSchema>;
export const categoryInputSchema = z.object({
slug: slugSchema,
code: req(20),
name: req(120),
nameEn: text(120),
shortDesc: text(500),
fullDesc: text(3000),
icon: text(40),
coverImageId: idSchema.nullable().default(null),
isPublished: z.boolean().default(true),
seoTitle: text(120),
seoDescription: text(300),
groups: z.array(productGroupInputSchema).max(30).default([]),
});
export type CategoryInput = z.infer<typeof categoryInputSchema>;
export const productInputSchema = z.object({
categoryId: idSchema,
groupId: idSchema.nullable().default(null),
code: req(30),
slug: slugSchema,
name: req(160),
nameEn: text(160),
desc: text(2000),
specs: shortList,
variants: shortList,
imageId: idSchema.nullable().default(null),
galleryIds: z.array(idSchema).max(12).default([]),
isPublished: z.boolean().default(true),
});
export type ProductInput = z.infer<typeof productInputSchema>;
export const reorderSchema = z.object({
ids: z.array(idSchema).min(1).max(1000),
});
export type ReorderInput = z.infer<typeof reorderSchema>;
export const productListQuerySchema = z.object({
categoryId: idSchema.optional(),
groupId: idSchema.optional(),
q: z.string().trim().max(100).optional(),
status: z.enum(["published", "draft"]).optional(),
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(200).default(50),
});
export type ProductListQuery = z.infer<typeof productListQuerySchema>;
/* ------------------------------------------------------------------ */
/* Public (website) DTOs */
/* ------------------------------------------------------------------ */
export interface PublicProductGroup {
slug: string;
name: string;
desc: string;
}
export interface PublicProduct {
code: string;
slug: string;
name: string;
nameEn: string;
desc: string;
specs: string[];
variants: string[];
/** image URL, or null when the product has no own photo */
image: string | null;
/** slug of a `PublicCategory.groups` entry */
group: string | null;
}
export interface PublicCategorySummary {
slug: string;
code: string;
name: string;
nameEn: string;
shortDesc: string;
icon: string;
coverImage: string | null;
productCount: number;
}
export interface PublicCategory extends PublicCategorySummary {
fullDesc: string;
seoTitle: string;
seoDescription: string;
groups: PublicProductGroup[];
products: PublicProduct[];
}
+43
View File
@@ -0,0 +1,43 @@
import { z } from "zod";
import { ICON_NAMES } from "./icons.js";
/** Required, trimmed text */
export const req = (max = 300) => z.string().trim().min(1, "این فیلد الزامی است").max(max);
/** Optional, trimmed text (empty string when missing) */
export const text = (max = 300) => z.string().trim().max(max).default("");
export const idSchema = z.coerce.number().int().positive();
export const slugSchema = z
.string()
.trim()
.min(1, "این فیلد الزامی است")
.max(80)
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "فقط حروف کوچک انگلیسی، عدد و خط تیره (مثلاً safety-shoes)");
export const iconNameSchema = z.enum(ICON_NAMES);
/** Image stored by URL inside section content */
export const imageRefSchema = z.object({
url: req(500),
alt: text(200),
});
export type ImageRef = z.infer<typeof imageRefSchema>;
export const linkSchema = z.object({
label: req(60),
href: req(300),
});
/** Field validation issue as returned by the API (`path` is dot-joined) */
export interface ValidationIssue {
path: string;
message: string;
}
export const toIssues = (error: z.ZodError): ValidationIssue[] =>
error.issues.map((i) => ({ path: i.path.map(String).join("."), message: i.message }));
/** Convert latin digits to Persian digits */
export const faNum = (v: number | string): string =>
String(v).replace(/\d/g, (d) => "۰۱۲۳۴۵۶۷۸۹"[Number(d)]!);
+56
View File
@@ -0,0 +1,56 @@
/**
* Lucide icon names that content editors may pick in the admin panel.
* The web app keeps a matching registry (DynamicIcon) so only these icons end up in the bundle.
*/
export const ICON_NAMES = [
"ShieldCheck",
"ShieldPlus",
"ShieldAlert",
"BadgeCheck",
"Award",
"Medal",
"PackageCheck",
"Package",
"Truck",
"Warehouse",
"Globe",
"Users",
"Handshake",
"Headset",
"ClipboardCheck",
"FileCheck",
"FileSearch",
"GraduationCap",
"Gauge",
"Microscope",
"Wrench",
"HardHat",
"Shirt",
"Footprints",
"Hand",
"Glasses",
"Ear",
"Wind",
"TriangleAlert",
"Siren",
"TrafficCone",
"Construction",
"Factory",
"Building2",
"Fuel",
"Pickaxe",
"Flame",
"Zap",
"Leaf",
"HeartPulse",
"Clock",
"Phone",
"Mail",
"MapPin",
"Star",
"ThumbsUp",
"Target",
"Lock",
] as const;
export type IconName = (typeof ICON_NAMES)[number];
+14
View File
@@ -0,0 +1,14 @@
export * from "./common.js";
export * from "./icons.js";
export * from "./catalog.js";
export * from "./sections.js";
export * from "./admin.js";
import type { PublicCategorySummary } from "./catalog.js";
import type { SiteSections } from "./sections.js";
/** Everything the home page needs, returned by `GET /api/public/site` in one request */
export interface PublicSite {
sections: SiteSections;
categories: PublicCategorySummary[];
}
+219
View File
@@ -0,0 +1,219 @@
import { z } from "zod";
import { iconNameSchema, imageRefSchema, linkSchema, req, text } from "./common.js";
/**
* Editable website sections. Each key is one row in the `SiteSection` table whose `data`
* column must match the schema below — validated by the API on write and by the admin forms.
*
* Multi-line titles use "\n" for line breaks.
*/
export const WEEK_DAYS = [
"Saturday",
"Sunday",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
] as const;
export const SOCIAL_TYPES = [
"website",
"telegram",
"instagram",
"linkedin",
"whatsapp",
"bale",
"eitaa",
"aparat",
"youtube",
"email",
"phone",
] as const;
export type SocialType = (typeof SOCIAL_TYPES)[number];
const iconLabel = z.object({ icon: iconNameSchema, label: req(80) });
const iconCard = z.object({ icon: iconNameSchema, title: req(80), desc: text(300) });
export const sectionHeadSchema = z.object({
tag: req(80),
title: req(200),
/** optional accented last line of the title */
highlight: text(120),
desc: text(500),
});
export type SectionHeadData = z.infer<typeof sectionHeadSchema>;
export const settingsSectionSchema = z.object({
name: req(80),
nameEn: req(120),
/** default <title> and og:title */
title: req(120),
/** default meta description */
description: req(400),
/** machine-readable phone for tel: links and schema.org, e.g. +98-21-91008800 */
phone: req(30),
phoneDisplay: req(40),
email: z.email("ایمیل معتبر نیست"),
address: z.object({
street: req(200),
locality: req(80),
country: z.string().trim().length(2).default("IR"),
}),
/** human-readable working hours, e.g. «شنبه تا پنجشنبه — ۸:۰۰ تا ۱۷:۰۰» */
workingHours: req(120),
openingHours: z.object({
days: z.array(z.enum(WEEK_DAYS)).max(7),
opens: z.string().regex(/^\d{2}:\d{2}$/, "قالب ساعت باید HH:MM باشد"),
closes: z.string().regex(/^\d{2}:\d{2}$/, "قالب ساعت باید HH:MM باشد"),
}),
foundingDate: z.string().regex(/^\d{4}$/, "سال میلادی چهاررقمی"),
socials: z.array(z.object({ type: z.enum(SOCIAL_TYPES), url: req(300) })).max(12),
ogImage: imageRefSchema.extend({
width: z.number().int().positive().nullable().default(null),
height: z.number().int().positive().nullable().default(null),
}),
});
export const heroSectionSchema = z.object({
badge: req(120),
title: req(80),
titleHighlight: req(80),
intro: req(600),
primaryCta: linkSchema,
secondaryCta: linkSchema,
chips: z.array(iconLabel).max(6),
miniStats: z.array(z.object({ value: req(20), label: req(80) })).max(4),
image: imageRefSchema,
/** «آخرین بازرسی» date shown on the status card */
lastInspection: text(20),
});
export const tapeSectionSchema = z.object({
items: z.array(req(80)).min(1).max(20),
});
export const statsSectionSchema = z.object({
items: z
.array(
z.object({
end: z.number().int().min(0).max(1_000_000_000),
suffix: text(5),
label: req(80),
note: text(40),
}),
)
.max(8),
});
export const productsIntroSectionSchema = z.object({
head: sectionHeadSchema,
ctaLabel: req(60),
trust: z.array(iconCard).max(6),
});
export const aboutSectionSchema = z.object({
head: sectionHeadSchema,
body: req(2000),
image: imageRefSchema,
badge: z.object({ value: req(20), label: req(80) }),
timeline: z.array(z.object({ year: req(10), text: req(200) })).max(12),
features: z.array(iconLabel).max(6),
});
export const servicesSectionSchema = z.object({
head: sectionHeadSchema,
items: z
.array(
z.object({
icon: iconNameSchema,
title: req(80),
desc: req(600),
items: z.array(req(120)).max(8),
}),
)
.max(10),
});
export const standardsSectionSchema = z.object({
head: sectionHeadSchema,
note: text(500),
ctaLabel: req(80),
items: z.array(z.object({ code: req(30), label: req(80) })).max(12),
});
export const industriesSectionSchema = z.object({
head: sectionHeadSchema,
items: z
.array(
z.object({
name: req(60),
nameEn: text(60),
count: text(20),
image: imageRefSchema,
}),
)
.max(12),
});
export const testimonialsSectionSchema = z.object({
head: sectionHeadSchema,
items: z.array(z.object({ text: req(600), name: req(80), role: text(160) })).max(12),
});
export const contactSectionSchema = z.object({
head: sectionHeadSchema,
emergencyTitle: req(80),
emergencyText: req(400),
formTag: text(80),
formTitle: req(160),
successTitle: req(80),
successText: req(300),
});
export const footerSectionSchema = z.object({
about: req(500),
copyright: req(160),
certLine: text(160),
emergencyNote: text(80),
});
export const sectionSchemas = {
settings: settingsSectionSchema,
hero: heroSectionSchema,
tape: tapeSectionSchema,
stats: statsSectionSchema,
productsIntro: productsIntroSectionSchema,
about: aboutSectionSchema,
services: servicesSectionSchema,
standards: standardsSectionSchema,
industries: industriesSectionSchema,
testimonials: testimonialsSectionSchema,
contact: contactSectionSchema,
footer: footerSectionSchema,
} as const;
export type SectionKey = keyof typeof sectionSchemas;
export const SECTION_KEYS = Object.keys(sectionSchemas) as SectionKey[];
export type SectionData<K extends SectionKey> = z.infer<(typeof sectionSchemas)[K]>;
export type SiteSections = { [K in SectionKey]: SectionData<K> };
export type SiteSettings = SectionData<"settings">;
export const isSectionKey = (key: string): key is SectionKey => key in sectionSchemas;
/** Persian labels for the admin panel */
export const SECTION_LABELS: Record<SectionKey, { title: string; desc: string }> = {
settings: { title: "تنظیمات سایت", desc: "نام، اطلاعات تماس، ساعات کاری، شبکه‌های اجتماعی و سئوی پیش‌فرض" },
hero: { title: "بخش اول (Hero)", desc: "تیتر اصلی، متن معرفی، دکمه‌ها، نشان‌ها و تصویر پس‌زمینه" },
tape: { title: "نوار متحرک", desc: "عبارت‌های نوار زرد متحرک زیر Hero" },
stats: { title: "آمار", desc: "شمارنده‌های عددی" },
productsIntro: { title: "معرفی محصولات", desc: "عنوان بخش محصولات و کارت‌های اعتماد" },
about: { title: "درباره ما", desc: "متن معرفی، تصویر، تایم‌لاین و ویژگی‌ها" },
services: { title: "خدمات", desc: "فهرست خدمات و جزئیات هر خدمت" },
standards: { title: "استانداردها", desc: "استانداردها و گواهی‌نامه‌ها" },
industries: { title: "صنایع", desc: "صنایع تحت پوشش و تصویر هرکدام" },
testimonials: { title: "نظرات مشتریان", desc: "نقل‌قول‌های مشتریان" },
contact: { title: "تماس با ما", desc: "عنوان‌ها و متن‌های فرم تماس" },
footer: { title: "فوتر", desc: "متن معرفی، کپی‌رایت و توضیحات پایین صفحه" },
};