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 }[];
|
||||
}
|
||||
Reference in New Issue
Block a user