feat: monorepo with NestJS API, admin panel and DB-driven website
Restructure the repo into a pnpm + Turborepo monorepo: - apps/web: Nuxt 4 site, now server-rendered and reading all content from the API; pages cached with SWR and purged on content changes - apps/api: NestJS 12 + Prisma 7 + PostgreSQL. Auth with short-lived access tokens and rotating httpOnly refresh cookies, roles (ADMIN, EDITOR), catalog, editable site sections, media upload to S3-compatible storage (converted to WebP), contact messages, audit log - apps/admin: Vue 3 admin panel (Ant Design Vue, Pinia, Tailwind) for products, categories, site sections, messages, media and users - packages/shared: Zod schemas and types shared by API and admin Also: - seed script that loads the original 8 categories / 89 products and section content - Docker images for api (with a one-off migrate service), web, admin, and an nginx gateway for both domains - dev and production compose files, daily database backup service - API e2e tests Removes the committed static out/ build and the old root Dockerfile. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
31076174e2
commit
789d224ea0
@@ -0,0 +1,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 }[];
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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)]!);
|
||||
@@ -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];
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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: "متن معرفی، کپیرایت و توضیحات پایین صفحه" },
|
||||
};
|
||||
Reference in New Issue
Block a user