2026-10-21 20:31:05 +03:30
|
|
|
|
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);
|
|
|
|
|
|
|
2026-10-22 17:18:17 +03:30
|
|
|
|
const FA_DIGITS = "۰۱۲۳۴۵۶۷۸۹";
|
|
|
|
|
|
const AR_DIGITS = "٠١٢٣٤٥٦٧٨٩";
|
|
|
|
|
|
|
|
|
|
|
|
/** Normalize Iranian mobile to `09XXXXXXXXX` (Latin digits). */
|
|
|
|
|
|
export function normalizeIrMobile(raw: string): string {
|
|
|
|
|
|
let s = raw.trim().replace(/[\s\-()]/g, "");
|
|
|
|
|
|
s = s.replace(/[۰-۹٠-٩]/g, (d) => {
|
|
|
|
|
|
const fa = FA_DIGITS.indexOf(d);
|
|
|
|
|
|
if (fa >= 0) return String(fa);
|
|
|
|
|
|
const ar = AR_DIGITS.indexOf(d);
|
|
|
|
|
|
return ar >= 0 ? String(ar) : d;
|
|
|
|
|
|
});
|
|
|
|
|
|
if (s.startsWith("+98")) s = `0${s.slice(3)}`;
|
|
|
|
|
|
else if (s.startsWith("0098")) s = `0${s.slice(4)}`;
|
|
|
|
|
|
else if (s.startsWith("98") && s.length === 12) s = `0${s.slice(2)}`;
|
|
|
|
|
|
return s;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export const irMobileSchema = z
|
|
|
|
|
|
.string()
|
|
|
|
|
|
.trim()
|
|
|
|
|
|
.min(1, "شماره موبایل را وارد کنید")
|
|
|
|
|
|
.transform(normalizeIrMobile)
|
|
|
|
|
|
.pipe(z.string().regex(/^09\d{9}$/, "شماره موبایل معتبر نیست (مثلاً ۰۹۱۲۱۲۳۴۵۶۷)"));
|
|
|
|
|
|
|
2026-10-21 20:31:05 +03:30
|
|
|
|
export const loginSchema = z.object({
|
2026-10-22 17:18:17 +03:30
|
|
|
|
phone: irMobileSchema,
|
2026-10-21 20:31:05 +03:30
|
|
|
|
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({
|
2026-10-22 17:18:17 +03:30
|
|
|
|
phone: irMobileSchema,
|
2026-10-21 20:31:05 +03:30
|
|
|
|
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;
|
2026-10-22 17:18:17 +03:30
|
|
|
|
phone: string;
|
2026-10-21 20:31:05 +03:30
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-23 21:06:15 +03:30
|
|
|
|
export interface DashboardCategoryStat {
|
|
|
|
|
|
id: number;
|
|
|
|
|
|
name: string;
|
|
|
|
|
|
count: number;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export interface DashboardTrendDay {
|
|
|
|
|
|
date: string;
|
|
|
|
|
|
dayName: string;
|
|
|
|
|
|
messages: number;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-21 20:31:05 +03:30
|
|
|
|
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 }[];
|
2026-10-23 21:06:15 +03:30
|
|
|
|
categoryStats: DashboardCategoryStat[];
|
|
|
|
|
|
messageBreakdown: { new: number; read: number; archived: number; total: number };
|
|
|
|
|
|
sectionsSummary: { total: number; visible: number; hidden: number };
|
|
|
|
|
|
trend: DashboardTrendDay[];
|
2026-10-21 20:31:05 +03:30
|
|
|
|
}
|