Files
imen_pishghaman/packages/shared/src/admin.ts
T

171 lines
4.5 KiB
TypeScript
Raw Normal View History

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);
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}$/, "شماره موبایل معتبر نیست (مثلاً ۰۹۱۲۱۲۳۴۵۶۷)"));
export const loginSchema = z.object({
phone: irMobileSchema,
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({
phone: irMobileSchema,
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;
phone: 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 DashboardCategoryStat {
id: number;
name: string;
count: number;
}
export interface DashboardTrendDay {
date: string;
dayName: string;
messages: 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 }[];
categoryStats: DashboardCategoryStat[];
messageBreakdown: { new: number; read: number; archived: number; total: number };
sectionsSummary: { total: number; visible: number; hidden: number };
trend: DashboardTrendDay[];
}