first commit

This commit is contained in:
2026-05-23 14:14:50 +03:30
commit 2a22bab127
48 changed files with 7554 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules
.env
seeders

11
.sequelizerc Normal file
View File

@@ -0,0 +1,11 @@
const path = require("path");
require("ts-node/register");
require("tsconfig-paths/register");
module.exports = {
config: path.resolve("src/config/config.ts"),
"models-path": path.resolve("src/models"),
"migrations-path": path.resolve("src/migrations"),
"seeders-path": path.resolve("src/seeders")
};

3
index.ts Normal file
View File

@@ -0,0 +1,3 @@
import ServerApplication from "./src/server";
new ServerApplication();

4784
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

57
package.json Normal file
View File

@@ -0,0 +1,57 @@
{
"name": "ticketing-server",
"version": "1.0.0",
"description": "Ticketing System API (Express + Sequelize + TypeScript)",
"main": "index.ts",
"type": "commonjs",
"scripts": {
"dev": "nodemon index.ts",
"build": "tsc",
"start": "node index.ts",
"type-check": "tsc --noEmit",
"clean": "rm -rf dist"
},
"keywords": [
"express",
"sequelize",
"typescript",
"ticketing"
],
"author": "",
"license": "ISC",
"dependencies": {
"auto-bind": "^4.0.0",
"cookie-parser": "^1.4.7",
"cors": "^2.8.6",
"csurf": "^1.11.0",
"dayjs": "^1.11.20",
"dotenv": "^17.4.2",
"exceljs": "^4.4.0",
"express": "^5.2.1",
"express-rate-limit": "^8.5.2",
"helmet": "^8.1.0",
"hpp": "^0.2.3",
"http-errors": "^2.0.1",
"joi": "^18.2.1",
"jsonwebtoken": "^9.0.3",
"multer": "^2.1.1",
"pg": "^8.20.0",
"pg-hstore": "^2.3.4",
"sequelize": "^6.37.8",
"stimulsoft-reports-js": "^2026.2.3"
},
"devDependencies": {
"@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.19",
"@types/csurf": "^1.11.5",
"@types/exceljs": "^0.5.3",
"@types/express": "^5.0.6",
"@types/hpp": "^0.2.7",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^25.8.0",
"nodemon": "^3.1.14",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^6.0.3"
}
}

0
reports/tickets.mrt Normal file
View File

17
src/config/config.ts Normal file
View File

@@ -0,0 +1,17 @@
// sequelize.config.js
module.exports = {
development: {
username: "postgres",
password: "root",
database: "ticketing-shomal",
host: "127.0.0.1",
dialect: "postgres",
},
production: {
username: "postgres",
password: "root",
database: "ticketing-shomal-test",
host: "127.0.0.1",
dialect: "postgres",
},
};

15
src/config/database.ts Normal file
View File

@@ -0,0 +1,15 @@
import { sequelize } from "../models";
async function initDB(): Promise<void> {
try {
await sequelize.authenticate();
await sequelize.sync({ alter: true });
console.log("✅ Database synced successfully");
} catch (error) {
console.error("❌ Database sync failed:", error);
process.exit(1);
}
}
export default initDB;

29
src/config/secure-app.ts Normal file
View File

@@ -0,0 +1,29 @@
import helmet from "helmet";
import rateLimit from "express-rate-limit";
import cors from "cors";
import cookieParser from "cookie-parser";
import csurf from "csurf";
import hpp from "hpp";
import { cors_option, helmet_option, limiter_option } from "../core/constants/server-configuration";
export const rateLimiter = rateLimit(limiter_option);
export const corsOptions = cors(cors_option);
export const securityHeaders = helmet(helmet_option);
export const csrfProtection = [
cookieParser(),
csurf({cookie: {httpOnly: true, secure: true, sameSite: "strict"}}),
];
export const sanitizeData = [
hpp(),
];
export const secureApp = [
corsOptions,
rateLimiter,
securityHeaders,
...sanitizeData,
];

View File

@@ -0,0 +1,4 @@
export const JWT_SECRET = process.env.JWT_SECRET || "secret";
export const TOKEN_NAME = 'userToken'
export const requestType = ['software','hardware','his','rahkaran','taradod','general']

View File

@@ -0,0 +1,90 @@
import { CorsOptions } from "cors";
import dotenv from "dotenv";
import { NextFunction } from "express";
import { Options } from "express-rate-limit";
import { HelmetOptions } from "helmet";
import createHttpError from "http-errors";
import { Request } from "express";
dotenv.config();
const multer = require("multer");
const path = require("path");
const fs = require("fs");
export const config = {
port: process.env.PORT || 3500,
db: {
host: process.env.DB_HOST,
port: Number(process.env.DB_PORT),
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
},
jwtSecret: process.env.JWT_SECRET || "secret",
};
export const limiter_option: Partial<Options> = {
windowMs: 15 * 60 * 1000,
max: 4000,
standardHeaders: true,
legacyHeaders: false,
handler: (req: any, res: any, next: NextFunction) => {
next(
new createHttpError.TooManyRequests(
"تعداد درخواست شما بیشتر از حد مجاز است ، در زمان دیگری مجدد درخواست دهید",
),
);
},
// message: {error: "Too many requests, please try again later."},
};
export const helmet_option: HelmetOptions = {
contentSecurityPolicy: {
useDefaults: true,
directives: {
defaultSrc: ["'self'"],
// scriptSrc: ["'self'", "'unsafe-inline'", "https://trusted.cdn.com"],
// styleSrc: ["'self'", "'unsafe-inline'"],
// imgSrc: ["'self'", "data:", "https:"],
connectSrc: ["'self'"],
// fontSrc: ["'self'", "https://fonts.gstatic.com"],
objectSrc: ["'none'"],
upgradeInsecureRequests: [], // تبدیل اتومات http به https
},
},
crossOriginEmbedderPolicy: true,
crossOriginResourcePolicy: { policy: "same-origin" },
frameguard: { action: "deny" }, // جلوگیری از Clickjacking
referrerPolicy: { policy: "no-referrer" }, // جلوگیری از لو رفتن referrer
xssFilter: true, // فعال کردن فیلتر XSS
hsts: { maxAge: 63072000, includeSubDomains: true, preload: true }, // HSTS
};
export const cors_option: CorsOptions = {
origin: true,
credentials: true,
allowedHeaders: [
"Content-Type",
"Authorization",
"x-upload-token", // 👈 اینو اضافه کن
],
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"],
maxAge: 600,
// origin:["http://localhost:3000"]
};
async function createDirectoryRoute(req: Request<any>) {
const date = new Date();
const directory = path.join(__dirname, "..", "..", "..", "public", "images");
req.body.fileUploadPath = path.join(directory, "original");
fs.mkdirSync(directory, { recursive: true });
return directory;
}
const storage = multer.memoryStorage(); // Keep files in memory (instead of disk)
const uploadFile = multer({ storage: storage });
export { uploadFile };

View File

@@ -0,0 +1,8 @@
import autoBind from "auto-bind";
export class Controller {
constructor() {
autoBind(this);
}
}

View File

@@ -0,0 +1,6 @@
export const GlobalMessages = {
success: {},
errors: {
server: "خطايي رخ داده است",
},
};

View File

@@ -0,0 +1,29 @@
import { NextFunction } from "express";
import { ServerResponse } from "../types";
import { JWT_SECRET, TOKEN_NAME } from "../constants";
import createHttpError from "http-errors";
const jwt = require("jsonwebtoken");
const authMiddleware = (req:any, res:ServerResponse, next:NextFunction) => {
// ۱. خواندن توکن از کوکی‌ها
const token = req.cookies[TOKEN_NAME]; // TOKEN_NAME همان متغیری است که در زمان ست کردن کوکی داشتید
if (!token) {
throw new createHttpError.Unauthorized("لطفا وارد حساب كاربري خود شويد")
}
try {
// ۲. اعتبارسنجی توکن با استفاده از همان SECRET
const decoded = jwt.verify(token, JWT_SECRET);
// ۳. قرار دادن اطلاعات کاربر در آبجکت درخواست برای استفاده در مسیرهای بعدی
req.user = decoded;
next(); // ادامه به مسیر اصلی
} catch (error) {
throw new createHttpError.Forbidden("حساب كاربري شما نامعتبر است")
}
};
export default authMiddleware

View File

@@ -0,0 +1,17 @@
import router from "express";
import authRouter from "../../modules/auth/routes/auth.routes";
import departmentRouter from "../../modules/department/routes/department.routes";
import ticketRouter from "../../modules/ticket/routes/ticket.routes";
import userRouter from "../../modules/user/routes/user.router";
import reportRouter from "../../modules/report/routes/report.router";
import authMiddleware from "../middleware/auth.middleware";
const mainRouter = router.Router();
mainRouter.use("/auth", authRouter);
mainRouter.use("/department", authMiddleware, departmentRouter);
mainRouter.use("/ticket", authMiddleware, ticketRouter);
mainRouter.use("/user", authMiddleware, userRouter);
mainRouter.use("/report", authMiddleware, reportRouter);
export default mainRouter;

98
src/core/types/index.ts Normal file
View File

@@ -0,0 +1,98 @@
import { Response } from "express";
export interface ServerResponseObject {
status: number;
data: any;
message?: string;
}
export interface ServerErrorsObject {
status: number;
data?: any;
error: {
message: string;
description?: string;
};
}
export type ServerResponse = Response<
ServerResponseObject | ServerErrorsObject
>;
export enum TicketPriority {
LOW = "low",
MEDIUM = "medium",
HIGH = "high",
CRITICAL = "critical",
}
export enum TicketStatus {
OPEN = "open",
PENDING = "pending",
IN_PROGRESS = "in_progress",
RESOLVED = "resolved",
CLOSED = "closed",
}
export interface ticketRequestTypeDataType {
id: string;
ticketNumber: string;
createdBy: string;
departmentId: string;
internalPhone?: string;
requestType: string;
priority: TicketPriority;
description: string;
relatedSystem?: string;
location?: string;
helpdeskAction?: string;
assignedTo?: string;
status: TicketStatus;
resolvedAt?: Date;
finalNotes?: string;
createdAt?: Date;
updatedAt?: Date;
}
// reports
export interface TicketStats {
total: number
open: number
inProgress: number
resolved: number
closed: number
}
export interface DepartmentReport {
departmentId: string
departmentName: string
totalTickets: number
openTickets: number
resolvedTickets: number
}
export interface AgentReport {
userId: string
fullname: string
totalAssigned: number
resolved: number
open: number
}

View File

@@ -0,0 +1,33 @@
import { Op, WhereOptions } from "sequelize";
export interface ReportFilters {
startDate?: string;
endDate?: string;
priority?: string;
departmentId?: string;
agentId?: string;
}
export function buildTicketWhere(filters: ReportFilters): WhereOptions {
const where: WhereOptions = {};
if (filters.startDate && filters.endDate) {
where["createdAt"] = {
[Op.between]: [new Date(filters.startDate), new Date(filters.endDate)],
};
}
if (filters.priority) {
where["priority"] = filters.priority;
}
if (filters.departmentId) {
where["departmentId"] = filters.departmentId;
}
if (filters.agentId) {
where["assignedTo"] = filters.agentId;
}
return where;
}

View File

@@ -0,0 +1,12 @@
export const generateTicketNumber = (): string => {
const now = new Date();
const date =
now.getFullYear().toString() +
String(now.getMonth() + 1).padStart(2, "0") +
String(now.getDate()).padStart(2, "0");
const random = Math.floor(1000 + Math.random() * 9000);
return `TCK-${date}-${random}`;
};

View File

@@ -0,0 +1,22 @@
'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up (queryInterface, Sequelize) {
/**
* Add altering commands here.
*
* Example:
* await queryInterface.createTable('users', { id: Sequelize.INTEGER });
*/
},
async down (queryInterface, Sequelize) {
/**
* Add reverting commands here.
*
* Example:
* await queryInterface.dropTable('users');
*/
}
};

71
src/models/Department.ts Normal file
View File

@@ -0,0 +1,71 @@
import {
Model,
DataTypes,
Optional,
Sequelize
} from "sequelize";
interface DepartmentAttributes {
id: string;
slug: string;
displayName: string;
createdAt?: Date;
updatedAt?: Date;
}
interface DepartmentCreationAttributes
extends Optional<DepartmentAttributes, "id"> {}
class Department
extends Model<DepartmentAttributes, DepartmentCreationAttributes>
implements DepartmentAttributes
{
public id!: string;
public slug!: string;
public displayName!: string;
public readonly createdAt!: Date;
public readonly updatedAt!: Date;
static initModel(sequelize: Sequelize): typeof Department {
Department.init(
{
id: {
type: DataTypes.UUID,
primaryKey: true,
defaultValue: DataTypes.UUIDV4,
},
slug: {
type: DataTypes.STRING,
allowNull: false,
unique: true,
},
displayName: {
type: DataTypes.STRING(30),
allowNull: false,
defaultValue: "تعريف نشده",
field: "display_name",
},
},
{
sequelize,
modelName: "Department",
tableName: "departments",
timestamps: true,
}
);
return Department;
}
static associate(models: any) {
Department.hasMany(models.Ticket, {
foreignKey: "departmentId",
as: "tickets",
});
}
}
export default Department;

64
src/models/Permission.ts Normal file
View File

@@ -0,0 +1,64 @@
import {
Model,
DataTypes,
Optional,
Sequelize
} from "sequelize";
interface PermissionAttributes {
id: string;
name: string;
createdAt?: Date;
updatedAt?: Date;
}
interface PermissionCreationAttributes
extends Optional<PermissionAttributes, "id"> {}
class Permission
extends Model<PermissionAttributes, PermissionCreationAttributes>
implements PermissionAttributes
{
public id!: string;
public name!: string;
public readonly createdAt!: Date;
public readonly updatedAt!: Date;
static initModel(sequelize: Sequelize): typeof Permission {
Permission.init(
{
id: {
type: DataTypes.UUID,
primaryKey: true,
defaultValue: DataTypes.UUIDV4,
},
name: {
type: DataTypes.STRING,
allowNull: false,
unique: true,
},
},
{
sequelize,
modelName: "Permission",
tableName: "permissions",
timestamps: true,
}
);
return Permission;
}
static associate(models: any) {
Permission.belongsToMany(models.Role, {
through: models.RolePermission,
foreignKey: "permissionId",
otherKey: "roleId",
as: "roles",
});
}
}
export default Permission;

79
src/models/Role.ts Normal file
View File

@@ -0,0 +1,79 @@
import {
Model,
DataTypes,
Optional,
Sequelize
} from "sequelize";
interface RoleAttributes {
id: string;
name: string;
description?: string;
createdAt?: Date;
updatedAt?: Date;
}
interface RoleCreationAttributes
extends Optional<RoleAttributes, "id" | "description"> {}
class Role
extends Model<RoleAttributes, RoleCreationAttributes>
implements RoleAttributes
{
public id!: string;
public name!: string;
public description?: string;
public readonly createdAt!: Date;
public readonly updatedAt!: Date;
static initModel(sequelize: Sequelize): typeof Role {
Role.init(
{
id: {
type: DataTypes.UUID,
primaryKey: true,
defaultValue: DataTypes.UUIDV4,
},
name: {
type: DataTypes.STRING,
unique: true,
allowNull: false,
},
description: {
type: DataTypes.STRING,
},
},
{
sequelize,
modelName: "Role",
tableName: "roles",
timestamps: true,
}
);
return Role;
}
static associate(models: any) {
// Role -> Users
Role.hasMany(models.User, {
foreignKey: "roleId",
as: "users",
});
// Role <-> Permission
Role.belongsToMany(models.Permission, {
through: models.RolePermission,
foreignKey: "roleId",
otherKey: "permissionId",
as: "permissions",
});
}
}
export default Role;

View File

@@ -0,0 +1,65 @@
import {
Model,
DataTypes,
Sequelize
} from "sequelize";
interface RolePermissionAttributes {
roleId: string;
permissionId: string;
createdAt?: Date;
updatedAt?: Date;
}
class RolePermission
extends Model<RolePermissionAttributes>
implements RolePermissionAttributes
{
public roleId!: string;
public permissionId!: string;
public readonly createdAt!: Date;
public readonly updatedAt!: Date;
static initModel(sequelize: Sequelize): typeof RolePermission {
RolePermission.init(
{
roleId: {
type: DataTypes.UUID,
allowNull: false,
field: "role_id",
},
permissionId: {
type: DataTypes.UUID,
allowNull: false,
field: "permission_id",
},
},
{
sequelize,
modelName: "RolePermission",
tableName: "role_permissions",
timestamps: true,
}
);
return RolePermission;
}
static associate(models: any) {
RolePermission.belongsTo(models.Role, {
foreignKey: "roleId",
as: "role",
});
RolePermission.belongsTo(models.Permission, {
foreignKey: "permissionId",
as: "permission",
});
}
}
export default RolePermission;

167
src/models/Ticket.ts Normal file
View File

@@ -0,0 +1,167 @@
import { Model, DataTypes, Sequelize, Optional } from "sequelize";
import { TicketPriority } from "../core/types";
interface TicketAttributes {
id: string;
ticketNumber: string;
createdBy: string;
departmentId: string;
internalPhone?: string;
requestType: string;
priority: "low" | "medium" | "high" | "critical";
description: string;
relatedSystem?: string;
location?: string;
helpdeskAction?: string;
assignedTo?: string;
status: "open" | "pending" | "in_progress" | "resolved" | "closed";
resolvedAt?: Date;
finalNotes?: string;
createdAt?: Date;
updatedAt?: Date;
}
type TicketCreationAttributes = Optional<
TicketAttributes,
"id" | "priority" | "status" | "resolvedAt" | "finalNotes"
>;
export class Ticket
extends Model<TicketAttributes, TicketCreationAttributes>
implements TicketAttributes
{
public id!: string;
public ticketNumber!: string;
public createdBy!: string;
public departmentId!: string;
public internalPhone?: string;
public requestType!: string;
public priority!: "low" | "medium" | "high" | "critical";
public description!: string;
public relatedSystem?: string;
public location?: string;
public helpdeskAction?: string;
public assignedTo?: string;
public status!: "open" | "pending" | "in_progress" | "resolved" | "closed";
public resolvedAt?: Date;
public finalNotes?: string;
public readonly createdAt!: Date;
public readonly updatedAt!: Date;
static initModel(sequelize: Sequelize): typeof Ticket {
Ticket.init(
{
id: {
type: DataTypes.UUID,
primaryKey: true,
defaultValue: DataTypes.UUIDV4,
},
ticketNumber: {
type: DataTypes.STRING,
allowNull: false,
unique: true,
field: "ticket_number",
},
createdBy: {
type: DataTypes.STRING,
allowNull: false,
field: "created_by",
},
departmentId: {
type: DataTypes.UUID,
allowNull: false,
field: "department_id",
},
internalPhone: {
type: DataTypes.STRING(10),
field: "internal_phone",
},
requestType: {
type: DataTypes.STRING,
allowNull: false,
field: "request_type",
},
priority: {
type: DataTypes.ENUM(...Object.values(TicketPriority)),
defaultValue: "medium",
},
description: {
type: DataTypes.TEXT,
allowNull: false,
},
relatedSystem: {
type: DataTypes.STRING,
field: "related_system",
},
location: {
type: DataTypes.STRING,
},
helpdeskAction: {
type: DataTypes.TEXT,
field: "helpdesk_action",
},
assignedTo: {
type: DataTypes.UUID,
field: "assigned_to",
},
status: {
type: DataTypes.ENUM(
"open",
"pending",
"in_progress",
"resolved",
"closed",
),
defaultValue: "open",
},
resolvedAt: {
type: DataTypes.DATE,
field: "resolved_at",
},
finalNotes: {
type: DataTypes.TEXT,
field: "final_notes",
},
},
{
sequelize,
tableName: "tickets",
modelName: "Ticket",
timestamps: true,
},
);
return Ticket;
}
static associate(models: any) {
Ticket.belongsTo(models.User, {
foreignKey: "assignedTo",
as: "assignee",
});
Ticket.belongsTo(models.Department, {
foreignKey: "departmentId",
as: "department",
});
}
}
export default Ticket;

95
src/models/User.ts Normal file
View File

@@ -0,0 +1,95 @@
import {
Model,
DataTypes,
Optional,
Sequelize
} from "sequelize";
interface UserAttributes {
id: string;
fullname: string;
nationalCode: string;
mobile: string;
roleId: string;
createdAt?: Date;
updatedAt?: Date;
}
interface UserCreationAttributes
extends Optional<UserAttributes, "id"> {}
class User
extends Model<UserAttributes, UserCreationAttributes>
implements UserAttributes
{
public id!: string;
public fullname!: string;
public nationalCode!: string;
public mobile!: string;
public roleId!: string;
public readonly createdAt!: Date;
public readonly updatedAt!: Date;
static initModel(sequelize: Sequelize): typeof User {
User.init(
{
id: {
type: DataTypes.UUID,
primaryKey: true,
defaultValue: DataTypes.UUIDV4,
},
fullname: {
type: DataTypes.STRING,
allowNull: false,
},
nationalCode: {
type: DataTypes.STRING(30),
allowNull: false,
unique: true,
field: "national_code",
},
mobile: {
type: DataTypes.STRING(11),
allowNull: false,
unique: true,
},
roleId: {
type: DataTypes.UUID,
allowNull: false,
field: "role_id",
},
},
{
sequelize,
modelName: "User",
tableName: "users",
timestamps: true,
}
);
return User;
}
static associate(models: any) {
User.belongsTo(models.Role, {
foreignKey: "roleId",
as: "role",
});
User.hasMany(models.Ticket, {
foreignKey: "assignedTo",
as: "assignedTickets",
});
}
}
export default User;

31
src/models/index.ts Normal file
View File

@@ -0,0 +1,31 @@
import { Sequelize } from "sequelize";
import Department from "./Department";
import User from "./User";
import Permission from "./Permission";
import Role from "./Role";
import RolePermission from "./RolePermission";
import Ticket from "./Ticket";
const sequelize = new Sequelize("ticketing-shomal", "postgres", "root", {
host: "127.0.0.1",
dialect: "postgres",
logging: false,
});
const models = {
Department: Department.initModel(sequelize),
User: User.initModel(sequelize),
Role: Role.initModel(sequelize),
Ticket: Ticket.initModel(sequelize),
Permission: Permission.initModel(sequelize),
RolePermission: RolePermission.initModel(sequelize),
};
Object.values(models).forEach((model: any) => {
if (typeof model.associate === "function") {
model.associate(models);
}
});
export { sequelize };
export default models;

View File

@@ -0,0 +1,57 @@
import AuthService from "../services/auth.service";
import { NextFunction } from "express";
import { AuthValidationSchema } from "../validation/auth.validation";
import { AuthMessages } from "../messages";
import { ServerResponse } from "../../../core/types";
import { TOKEN_NAME } from "../../../core/constants";
import { GlobalMessages } from "../../../core/messages";
import { Controller } from "../../../core/controller/main.controller";
class AuthControllerClass extends Controller {
#service;
constructor() {
super();
this.#service = AuthService;
}
async login(req: any, res: ServerResponse, next: NextFunction) {
try {
await AuthValidationSchema.validateAsync(req.body || {});
const token = await this.#service.login(
req.body?.username,
req?.body?.password,
);
res.cookie(TOKEN_NAME, token, {
httpOnly: true,
secure: false,
sameSite: "lax",
maxAge: 24 * 60 * 60 * 1000,
});
return res.status(200).json({
status: 200,
data: {},
message: AuthMessages.sucess.login,
});
} catch (error) {
next(error);
}
}
async logout(req: any, res: ServerResponse, next: NextFunction) {
try {
res.clearCookie(TOKEN_NAME);
return res.status(200).json({
status: 200,
data: {},
message: AuthMessages.sucess.logout,
});
} catch (error) {
next(GlobalMessages.errors.server);
}
}
}
const AuthController = new AuthControllerClass();
export default AuthController;

View File

@@ -0,0 +1,14 @@
export const AuthMessages ={
sucess:{
login:'با موفقيت وارد شديد',
logout:"با موفقيت خارج شديد",
edited:'با موفقيت ويرايش شد',
done:'انجام شد'
},
error:{
login:{
incorrectData:'نام كاربري و يا رمز عبور اشتباه است',
loginFailed:'خطا در ورود كاربر'
}
}
}

View File

@@ -0,0 +1,10 @@
import express from 'express'
import AuthController from '../controller/auth.controller';
const authRouter = express.Router();
authRouter.post('/login',AuthController.login)
authRouter.post('/logout',AuthController.logout)
export default authRouter;

View File

@@ -0,0 +1,46 @@
import createHttpError from "http-errors";
import { AuthMessages } from "../messages";
import jwt from "jsonwebtoken";
import dotenv from "dotenv";
import User from "../../../models/User";
import { JWT_SECRET } from "../../../core/constants";
dotenv.config();
class AuthServiceClass {
async login(username: string, password: string) {
try {
const user = await User.findOne({
where: {
nationalCode: username,
mobile: password,
},
});
if (!user) {
throw createHttpError.Unauthorized(
AuthMessages.error.login.incorrectData,
);
}
const token = jwt.sign(
{
id: user.id,
roleId: user.roleId,
},
JWT_SECRET,
{ expiresIn: "1d" },
);
return token;
} catch (error) {
console.log(error);
throw createHttpError.InternalServerError(
AuthMessages.error.login.loginFailed,
);
}
}
}
const AuthService = new AuthServiceClass();
export default AuthService;

View File

@@ -0,0 +1,26 @@
import Joi from "joi";
export const AuthValidationSchema = Joi.object({
username: Joi.string()
.length(10)
.pattern(/^[0-9]+$/)
.required()
.messages({
"string.base": "نام کاربری باید رشته باشد",
"string.empty": "نام کاربری الزامی است",
"string.length": "نام کاربری باید ۱۰ رقم باشد",
"string.pattern.base": "نام کاربری باید کد ملی معتبر باشد",
"any.required": "نام کاربری الزامی است",
}),
password: Joi.string()
.pattern(/^09[0-9]{9}$/)
.required()
.messages({
"string.base": "رمز عبور باید رشته باشد",
"string.empty": "رمز عبور الزامی است",
"string.pattern.base": "رمز عبور باید شماره موبایل معتبر باشد",
"any.required": "رمز عبور الزامی است",
}),
});

View File

@@ -0,0 +1,88 @@
import DepartmentService from "../services/department.service";
import { NextFunction } from "express";
import { DepartmentValidationSchema } from "../validation/department.validation";
import { DepartmentMessages } from "../messages";
import { ServerResponse } from "../../../core/types";
import { Controller } from "../../../core/controller/main.controller";
class DepartmentControllerClass extends Controller {
#service;
constructor() {
super();
this.#service = DepartmentService;
}
async getAll(req: any, res: ServerResponse, next: NextFunction) {
try {
const data = await this.#service.getAll();
return res.status(200).json({
status: 200,
data,
message: "Ok",
});
} catch (error) {
console.log(error)
next(error);
}
}
async getById(req: any, res: ServerResponse, next: NextFunction) {
try {
const id = req?.params?.id;
const data = await this.#service.getById(id);
return res.status(200).json({
status: 200,
data,
message: "Ok",
});
} catch (error) {
next(error);
}
}
async create(req: any, res: ServerResponse, next: NextFunction) {
try {
await DepartmentValidationSchema.validateAsync(req.body || {});
await this.#service.create(req.body);
return res.status(200).json({
status: 200,
data: {},
message: DepartmentMessages.sucess.create,
});
} catch (error) {
next(error);
}
}
async update(req: any, res: ServerResponse, next: NextFunction) {
try {
const id = req?.params?.id;
await this.#service.update(id, req?.body);
return res.status(200).json({
status: 200,
data: {},
message: "",
});
} catch (error) {
next(error);
}
}
async delete(req: any, res: ServerResponse, next: NextFunction) {
try {
const id = req?.params?.id;
await this.#service.delete(id);
return res.status(200).json({
status: 200,
data: {},
message: DepartmentMessages.sucess.delete,
});
} catch (error) {
next(error);
}
}
}
const DepartmentController = new DepartmentControllerClass();
export default DepartmentController;

View File

@@ -0,0 +1,16 @@
export const DepartmentMessages = {
sucess: {
create: "با موفقيت ساخته شد",
delete:"با موفقيت حذف گرديد",
update:"با موفقيت به روزرساني گرديد"
},
error: {
create: {
unique: "اين واحد قبلا ثبت شده است",
error:"خطا در انجام عمليات ثبت واحد"
},
delete:"خطا در حذف واحد",
update:"خطا در به روزرساني واحد",
notFound:"واحد يافت نشد"
},
};

View File

@@ -0,0 +1,12 @@
import router from "express";
import DepartmentController from "../controller/department.controller";
const departmentRouter = router.Router();
departmentRouter.get("/all", DepartmentController.getAll);
departmentRouter.get("/get/:id", DepartmentController.getById);
departmentRouter.post("/create", DepartmentController.create);
departmentRouter.delete("/remove/:id", DepartmentController.delete);
departmentRouter.put("/update/:id", DepartmentController.update);
export default departmentRouter;

View File

@@ -0,0 +1,105 @@
import createHttpError from "http-errors";
import { UniqueConstraintError } from "sequelize";
import { DepartmentMessages } from "../messages";
import { GlobalMessages } from "../../../core/messages";
import { Controller } from "../../../core/controller/main.controller";
import Department from "../../../models/Department";
interface CreateDepartmentDataBody {
slug: string;
displayName: string;
}
class DepartmentServiceClass extends Controller {
async getAll() {
try {
const data = await Department.findAll({ raw: true });
return data;
} catch (error) {
throw new createHttpError.InternalServerError(
GlobalMessages.errors.server,
);
}
}
async getById(id: string) {
try {
const data = await Department.findOne({ where: { id } });
return data;
} catch (error) {
throw new createHttpError.InternalServerError(
GlobalMessages.errors.server,
);
}
}
async create(data: CreateDepartmentDataBody) {
try {
const { slug, displayName } = data;
await Department.create({
slug,
displayName,
});
return true;
} catch (error: any) {
if (error instanceof UniqueConstraintError) {
throw new createHttpError.Conflict(
DepartmentMessages.error.create.unique,
);
}
throw new createHttpError.InternalServerError(
DepartmentMessages.error.create.error,
);
}
}
async update(id: string, data: CreateDepartmentDataBody) {
try {
const { slug, displayName } = data;
const department = await Department.findByPk(id);
if (!department) {
throw new createHttpError.NotFound(DepartmentMessages.error.notFound);
}
if (slug && slug !== department.slug) {
const exists = await Department.findOne({ where: { slug } });
if (exists) {
throw new createHttpError.Conflict(
DepartmentMessages.error.create.unique,
);
}
}
await department.update({
slug,
displayName,
});
return true;
} catch (error) {
throw new createHttpError.Conflict(DepartmentMessages.error.update);
}
}
async delete(id: string) {
try {
const deleted = await Department.destroy({ where: { id } });
if (!deleted) {
throw new createHttpError.NotFound(DepartmentMessages.error.notFound);
}
} catch (error) {
throw new createHttpError.InternalServerError(
DepartmentMessages.error.delete,
);
}
}
}
const DepartmentService = new DepartmentServiceClass();
export default DepartmentService;

View File

@@ -0,0 +1,25 @@
import Joi from "joi";
export const DepartmentValidationSchema = Joi.object({
slug: Joi.string()
.pattern(/^[a-z0-9-]+$/)
.required()
.messages({
"string.base": "اسلاگ باید رشته باشد",
"string.empty": "اسلاگ الزامی است",
"string.pattern.base": "اسلاگ فقط می‌تواند شامل حروف انگلیسی کوچک، عدد و - باشد",
"any.required": "اسلاگ الزامی است"
}),
displayName: Joi.string()
.min(2)
.max(100)
.required()
.messages({
"string.base": "نام نمایشی باید رشته باشد",
"string.empty": "نام نمایشی الزامی است",
"string.min": "نام نمایشی حداقل باید ۲ کاراکتر باشد",
"string.max": "نام نمایشی حداکثر ۱۰۰ کاراکتر است",
"any.required": "نام نمایشی الزامی است"
})
});

View File

@@ -0,0 +1,213 @@
import { NextFunction } from "express";
import { Controller } from "../../../core/controller/main.controller";
import ReportService from "../service/report.service";
import { ServerResponse } from "../../../core/types";
class ReportControllerClass extends Controller {
#service;
constructor() {
super();
this.#service = ReportService;
}
async ticketStats(req: any, res: ServerResponse, next: NextFunction) {
const { start, end } = req.query;
try {
const data = await this.#service.ticketStats(
start ? new Date(start as string) : undefined,
end ? new Date(end as string) : undefined,
);
return res.status(200).json({
status: 200,
data,
message: "OK",
});
} catch (error) {
next(error);
}
}
async departmentReport(req: any, res: ServerResponse, next: NextFunction) {
try {
const data = await this.#service.departmentReport();
return res.status(200).json({
status: 200,
data,
message: "OK",
});
} catch (error) {
next(error);
}
}
async agentPerformance(req: any, res: ServerResponse, next: NextFunction) {
try {
const data = await this.#service.agentPerformance();
return res.status(200).json({
status: 200,
data,
message: "OK",
});
} catch (error) {
next(error);
}
}
async avgResolution(req: any, res: ServerResponse, next: NextFunction) {
try {
const data = await this.#service.avgResolutionTime();
return res.status(200).json({
status: 200,
data,
message: "OK",
});
} catch (error) {
next(error);
}
}
async criticalTickets(req: any, res: ServerResponse, next: NextFunction) {
try {
const data = await this.#service.criticalTickets();
return res.status(200).json({
status: 200,
data,
message: "OK",
});
} catch (error) {
next(error);
}
}
async kpi(req: any, res: ServerResponse, next: NextFunction) {
try {
const system = await this.#service.systemScore();
const operators = await this.#service.operatorScore();
const departments = await this.#service.departmentScore();
res.status(200).json({
status: 200,
data: { system, operators, departments },
});
} catch (error) {
next(error);
}
}
async prediction(req: any, res: ServerResponse, next: NextFunction) {
try {
const data = await this.#service.monthlyTrend();
console.log(data);
return res.status(200).json({
status: 200,
data,
});
} catch (error) {
next(error);
}
}
async exportExcel(req: any, res: any, next: NextFunction) {
const file = await this.#service.exportTickets();
res.setHeader(
"Content-Type",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
);
res.setHeader(
"Content-Disposition",
"attachment; filename=ticket-report.xlsx",
);
try {
return res.status(200).send(file);
} catch (error) {
next(error);
}
}
async ticketsPerDay(req: any, res: ServerResponse, next: NextFunction) {
try {
const data = await this.#service.ticketsPerDay();
return res.status(200).json({
status: 200,
data,
message: "OK",
});
} catch (error) {
next(error);
}
}
async closureRate(req: any, res: ServerResponse, next: NextFunction) {
try {
const data = await this.#service.closureRate();
return res.status(200).json({
status: 200,
data,
message: "OK",
});
} catch (error) {
next(error);
}
}
async slaBreach(req: any, res: ServerResponse, next: NextFunction) {
try {
const data = await this.#service.slaBreachReport();
return res.status(200).json({
status: 200,
data,
message: "OK",
});
} catch (error) {
next(error);
}
}
async agentEfficiency(req: any, res: ServerResponse, next: NextFunction) {
try {
const data = await this.#service.agentEfficiency();
return res.status(200).json({
status: 200,
data,
message: "OK",
});
} catch (error) {
next(error);
}
}
async departmentLoad(req: any, res: ServerResponse, next: NextFunction) {
try {
const data = await this.#service.departmentLoad();
return res.status(200).json({
status: 200,
data,
message: "OK",
});
} catch (error) {
next(error);
}
}
async aging(req: any, res: ServerResponse, next: NextFunction) {
try {
const data = await this.#service.agingReport();
return res.status(200).json({
status: 200,
data,
message: "OK",
});
} catch (error) {
next(error);
}
}
}
const ReportController = new ReportControllerClass();
export default ReportController;

View File

@@ -0,0 +1,61 @@
import { Router } from "express";
import ReportController from "../controller/report.controller";
const reportRouter = Router();
reportRouter.get(
"/stats",
// requireAuth,
// requirePermission("VIEW_REPORTS"),
ReportController.ticketStats,
);
reportRouter.get(
"/departments",
// requireAuth,
// requirePermission("VIEW_REPORTS"),
ReportController.departmentReport,
);
reportRouter.get(
"/agents",
// requireAuth,
// requirePermission("VIEW_REPORTS"),
ReportController.agentPerformance,
);
reportRouter.get(
"/avg-resolution",
// requireAuth,
// requirePermission("VIEW_REPORTS"),
ReportController.avgResolution,
);
reportRouter.get(
"/critical",
// requireAuth,
// requirePermission("VIEW_REPORTS"),
ReportController.criticalTickets,
);
reportRouter.get("/trend", ReportController.ticketsPerDay)
reportRouter.get("/closure-rate", ReportController.closureRate)
reportRouter.get("/sla", ReportController.slaBreach)
reportRouter.get("/aging", ReportController.aging)
reportRouter.get("/agent-efficiency", ReportController.agentEfficiency)
reportRouter.get("/department-load", ReportController.departmentLoad)
reportRouter.get("/kpi", ReportController.kpi)
reportRouter.get("/prediction", ReportController.prediction)
reportRouter.get("/export-excel", ReportController.exportExcel)
export default reportRouter;

View File

@@ -0,0 +1,399 @@
import { col, fn, literal, Op } from "sequelize";
import { Controller } from "../../../core/controller/main.controller";
import Ticket from "../../../models/Ticket";
import Department from "../../../models/Department";
import User from "../../../models/User";
import ExcelJS from "exceljs";
class ReportServiceClass extends Controller {
// آمار کلی تیکت ها
async ticketStats(start?: Date, end?: Date) {
const where: any = {};
if (start && end) {
where.createdAt = {
[Op.between]: [start, end],
};
}
const total = await Ticket.count({ where });
const open = await Ticket.count({
where: { ...where, status: "open" },
});
const inProgress = await Ticket.count({
where: { ...where, status: "in_progress" },
});
const resolved = await Ticket.count({
where: { ...where, status: "resolved" },
});
const closed = await Ticket.count({
where: { ...where, status: "closed" },
});
return {
total,
open,
inProgress,
resolved,
closed,
};
}
// گزارش بر اساس دپارتمان
async departmentReport() {
const data = await Ticket.findAll({
attributes: [
"departmentId",
[fn("COUNT", col("Ticket.id")), "totalTickets"],
[
fn("SUM", literal(`CASE WHEN status='open' THEN 1 ELSE 0 END`)),
"openTickets",
],
[
fn("SUM", literal(`CASE WHEN status='resolved' THEN 1 ELSE 0 END`)),
"resolvedTickets",
],
],
include: [
{
model: Department,
as: "department",
attributes: ["displayName"],
},
],
group: ["departmentId", "department.id"],
});
console.log(data)
return data;
}
// گزارش عملکرد اپراتورها
async agentPerformance() {
const data = await Ticket.findAll({
attributes: [
"assignedTo",
[fn("COUNT", col("Ticket.id")), "totalAssigned"],
[
fn("SUM", literal(`CASE WHEN status='resolved' THEN 1 ELSE 0 END`)),
"resolved",
],
[
fn("SUM", literal(`CASE WHEN status='open' THEN 1 ELSE 0 END`)),
"open",
],
],
include: [
{
model: User,
as: "assignee",
attributes: ["id", "fullname"],
},
],
group: ["assignedTo", "assignee.id"],
});
return data;
}
// میانگین زمان حل تیکت
async avgResolutionTime() {
const data = await Ticket.findAll({
attributes: [
[
fn(
"AVG",
literal(`EXTRACT(EPOCH FROM ("resolved_at" - "createdAt"))`),
),
"avgResolutionSeconds",
],
],
// استفاده از literal برای رفع خطا و بهبود خوانایی
where: literal(`"resolved_at" IS NOT NULL`) as any,
});
return data[0];
}
// تیکت های بحرانی
async criticalTickets() {
return Ticket.findAll({
where: {
priority: "critical",
status: {
[Op.not]: "resolved",
},
},
include: [
{
model: Department,
as: "department",
attributes: ["displayName"],
},
{
model: User,
as: "assignee",
attributes: ["fullname"],
},
],
order: [["createdAt", "DESC"]],
});
}
async ticketsPerDay(days: number = 30) {
return Ticket.findAll({
attributes: [
[fn("DATE", col("createdAt")), "date"],
[fn("COUNT", col("id")), "count"],
],
where: {
createdAt: {
[Op.gte]: literal(`NOW() - INTERVAL '${days} days'`),
},
},
group: [fn("DATE", col("createdAt"))],
order: [[fn("DATE", col("createdAt")), "ASC"]],
});
}
async closureRate() {
const total = await Ticket.count();
const resolved = await Ticket.count({
where: { status: "resolved" },
});
const rate = total === 0 ? 0 : (resolved / total) * 100;
return {
total,
resolved,
closureRate: rate.toFixed(2),
};
}
async slaBreachReport(slaHours: number = 48) {
const breached = await Ticket.count({
where: literal(`
"resolved_at" IS NOT NULL AND
EXTRACT(EPOCH FROM ("resolved_at" - "createdAt")) / 3600 > ${slaHours}
`),
});
const totalResolved = await Ticket.count({
where: {
resolvedAt: {
[Op.ne]: null as any,
},
},
});
return {
totalResolved,
breached,
breachRate:
totalResolved === 0 ? 0 : ((breached / totalResolved) * 100).toFixed(2),
};
}
async agingReport() {
return Ticket.findAll({
attributes: [
"id",
"ticketNumber",
[literal(`EXTRACT(DAY FROM (NOW() - "createdAt"))`), "ageInDays"],
],
where: {
status: "open",
},
order: [[literal(`EXTRACT(DAY FROM (NOW() - "createdAt"))`), "DESC"]],
});
}
async agentEfficiency() {
return Ticket.findAll({
attributes: [
"assignedTo",
[fn("COUNT", col("Ticket.id")), "total"],
[
fn(
"SUM",
literal(`CASE WHEN "Ticket"."status"='resolved' THEN 1 ELSE 0 END`),
),
"resolved",
],
[
fn(
"AVG",
literal(
`EXTRACT(EPOCH FROM ("Ticket"."resolved_at" - "Ticket"."createdAt"))`,
),
),
"avgResolutionSeconds",
],
],
include: [
{
model: User,
as: "assignee",
attributes: ["fullname"],
},
],
group: ["Ticket.assigned_to", "assignee.id"],
});
}
async departmentLoad() {
return Ticket.findAll({
attributes: [
"departmentId",
[fn("COUNT", col("Ticket.id")), "totalTickets"],
[
fn("SUM", literal(`CASE WHEN status='open' THEN 1 ELSE 0 END`)),
"openTickets",
],
],
include: [
{
model: Department,
as: "department",
attributes: ["displayName"],
},
],
group: ["departmentId", "department.id"],
order: [[fn("COUNT", col("Ticket.id")), "DESC"]],
});
}
async systemScore() {
const total = await Ticket.count();
const resolved = await Ticket.count({
where: { status: "resolved" },
});
const open = await Ticket.count({
where: { status: "open" },
});
const resolutionRate = (resolved / total) * 100;
const score = resolutionRate * 0.7 + ((total - open) / total) * 30;
return {
totalTickets: total,
resolved,
open,
resolutionRate,
score: Math.round(score),
};
}
async operatorScore() {
return Ticket.findAll({
attributes: [
"assignedTo",
[fn("COUNT", col("Ticket.id")), "total"],
[fn("SUM", col("status = 'resolved'")), "resolved"],
],
include: [
{
model: User,
as: "assignee",
attributes: ["fullname"],
},
],
group: ["assignedTo", "assignee.id"],
});
}
async departmentScore() {
return Ticket.findAll({
attributes: ["departmentId", [fn("COUNT", col("Ticket.id")), "total"]],
include: [
{
model: Department,
as: "department",
attributes: ["displayName"],
},
],
group: ["departmentId", "department.id"],
});
}
async exportTickets() {
const workbook = new ExcelJS.Workbook();
const ticketsSheet = workbook.addWorksheet("Tickets");
const agentsSheet = workbook.addWorksheet("Agents");
const deptSheet = workbook.addWorksheet("Departments");
const tickets = await Ticket.findAll({
include: [
{ model: Department, as: "department" },
{ model: User, as: "assignee" },
],
});
ticketsSheet.columns = [
{ header: "Ticket", key: "ticketNumber", width: 20 },
{ header: "Priority", key: "priority", width: 15 },
{ header: "Status", key: "status", width: 15 },
{ header: "Department", key: "department", width: 20 },
{ header: "Agent", key: "agent", width: 20 },
{ header: "Created", key: "createdAt", width: 20 },
];
tickets.forEach((t: any) => {
ticketsSheet.addRow({
ticketNumber: t.ticketNumber,
priority: t.priority,
status: t.status,
department: t.department?.displayName,
agent: t.assignee?.fullname,
createdAt: t.createdAt,
});
});
const buffer = await workbook.xlsx.writeBuffer();
return buffer;
}
async monthlyTrend() {
const data = await Ticket.findAll({
attributes: [
[fn("DATE_TRUNC", "month", col("createdAt")), "month"],
[fn("COUNT", col("id")), "total"],
],
group: ["month"],
order: [[col("month"), "ASC"]],
});
const values = data.map((r: any) => parseInt(r.get("total")));
if (values.length < 2) {
return { prediction: values[0] || 0 };
}
const last = values[values.length - 1];
const prev = values[values.length - 2];
const trend = last - prev;
const prediction = last + trend;
return {
lastMonth: last,
trend,
predictedNextMonth: Math.max(prediction, 0),
};
}
}
const ReportService = new ReportServiceClass();
export default ReportService;

View File

@@ -0,0 +1,113 @@
import { NextFunction } from "express";
import { ServerResponse } from "../../../core/types";
import TicketService from "../services/ticket.service";
import { ticketValidationSchema } from "../validation/ticket.validation";
import { Controller } from "../../../core/controller/main.controller";
class TicketControllerClass extends Controller {
#service;
constructor() {
super();
this.#service = TicketService;
}
async getAllExport(req: any, res: ServerResponse, next: NextFunction) {
try {
const data = await this.#service.getAll(req);
return res.status(200).json({
status: 201,
data,
message: "Ok",
});
} catch (error) {
next(error);
}
}
async getAllReport(req: any, res: ServerResponse, next: NextFunction) {
try {
const data = await this.#service.getAllReport(req, res);
// return res.status(200).json({
// status: 201,
// data,
// message: "Ok",
// });
} catch (error) {
next(error);
}
}
async getAll(req: any, res: ServerResponse, next: NextFunction) {
try {
const data = await this.#service.getAll(req);
return res.status(200).json({
status: 201,
data,
message: "Ok",
});
} catch (error) {
next(error);
}
}
async getById(req: any, res: ServerResponse, next: NextFunction) {
try {
const id = req?.params?.id;
const data = await this.#service.getById(id);
return res.status(200).json({
status: 200,
data,
message: "حذف شد",
});
} catch (error) {
next(error);
}
}
async create(req: any, res: ServerResponse, next: NextFunction) {
try {
await ticketValidationSchema.validateAsync(req.body || {});
await this.#service.create(req.body);
return res.status(200).json({
status: 201,
data: {},
message: "تيكت ثبت شد",
});
} catch (error) {
next(error);
}
}
async update(req: any, res: ServerResponse, next: NextFunction) {
try {
const id = req?.params?.id;
await ticketValidationSchema.validateAsync(req.body || {});
await this.#service.update(id, req.body);
return res.status(200).json({
status: 201,
data: {},
message: "تيكت ويرايش شد",
});
} catch (error) {
next(error);
}
}
async remove(req: any, res: ServerResponse, next: NextFunction) {
try {
const id = req?.params?.id;
await this.#service.remove(id);
return res.status(200).json({
status: 200,
data: {},
message: "تيكت حذف شد",
});
} catch (error) {
next(error);
}
}
}
const TicketController = new TicketControllerClass();
export default TicketController;

View File

@@ -0,0 +1,14 @@
import express from "express";
import TicketController from "../controller/ticket.controller";
const ticketRouter = express.Router();
ticketRouter.post("/create", TicketController.create);
ticketRouter.get("/all", TicketController.getAll);
ticketRouter.get("/all/export", TicketController.getAllExport);
ticketRouter.get("/all/report", TicketController.getAllReport);
ticketRouter.get("/get/:id", TicketController.getById);
ticketRouter.param("/update/:id", TicketController.update);
ticketRouter.delete("/remove/:id", TicketController.remove);
export default ticketRouter;

View File

@@ -0,0 +1,311 @@
import createHttpError from "http-errors";
import { ticketRequestTypeDataType } from "../../../core/types";
import { GlobalMessages } from "../../../core/messages";
import Ticket from "../../../models/Ticket";
import { generateTicketNumber } from "../../../core/utils/generators";
import { Controller } from "../../../core/controller/main.controller";
import Department from "../../../models/Department";
import User from "../../../models/User";
import { Op } from "sequelize";
import path from "node:path";
const Stimulsoft = require("stimulsoft-reports-js");
// برای اطمینان از دسترسی گلوبال
const fs = require("fs");
class TicketServiceClass extends Controller {
async getAll(req: any) {
try {
const {
page = 1,
limit = 10,
departmentId,
priority,
status,
user,
requestType,
startDate,
endDate,
} = req.query;
// ۱. محاسبه offset برای صفحه‌بندی
const offset = (page - 1) * limit;
// ۲. ساخت آبجکت فیلترها به صورت داینامیک
const whereClause: any = {};
if (departmentId) whereClause.departmentId = departmentId;
if (priority) whereClause.priority = priority;
if (status) whereClause.status = status;
if (user) whereClause.assignedTo = user;
if (requestType) whereClause.requestType = requestType;
if (startDate || endDate) {
whereClause.createdAt = {};
if (startDate) {
whereClause.createdAt[Op.gte] = new Date(startDate); // تاریخ شروع (بزرگتر مساوی)
}
if (endDate) {
// برای اینکه شامل کلِ روز آخر باشد، می‌توان یک روز به تاریخ پایان اضافه کرد
// یا ساعت را ۲۳:۵۹:۵۹ تنظیم کرد
const end = new Date(endDate);
end.setHours(23, 59, 59, 999);
whereClause.createdAt[Op.lte] = end; // تاریخ پایان (کوچکتر مساوی)
}
}
const { count, rows } = await Ticket.findAndCountAll({
where: whereClause,
include: [
{ model: Department, as: "department", attributes: ["displayName"] },
{ model: User, as: "assignee", attributes: ["fullname"] },
],
order: [["createdAt", "DESC"]],
limit: parseInt(limit),
offset: parseInt(offset.toString()),
});
return {
data: rows,
totalItems: count,
totalPages: Math.ceil(count / limit),
currentPage: parseInt(page),
};
} catch (error) {
throw new createHttpError.InternalServerError(
GlobalMessages.errors.server,
);
}
}
async getAllReport(req: any, res: any) {
try {
const {
page = 1,
limit = 10,
departmentId,
priority,
status,
user,
requestType,
startDate,
endDate,
} = req.query;
// ۱. آماده‌سازی مسیر و بارگذاری فایل گزارش
const reportPath = path.join(
__dirname,
"..",
"..",
"..",
"..",
"reports",
"tickets.mrt",
);
if (!fs.existsSync(reportPath)) {
console.error("فایل گزارش پیدا نشد در مسیر:", reportPath);
throw new createHttpError.NotFound("فایل گزارش یافت نشد.");
}
const report = new Stimulsoft.Report.StiReport();
report.loadFile(reportPath);
// ۲. فچ کردن داده‌ها از دیتابیس (مشابه قبل)
const offset = (page - 1) * limit;
const whereClause: any = {};
if (departmentId) whereClause.departmentId = departmentId;
if (priority) whereClause.priority = priority;
if (status) whereClause.status = status;
if (user) whereClause.assignedTo = user;
if (requestType) whereClause.requestType = requestType;
if (startDate || endDate) {
whereClause.createdAt = {};
if (startDate) whereClause.createdAt[Op.gte] = new Date(startDate);
if (endDate) {
const end = new Date(endDate);
end.setHours(23, 59, 59, 999);
whereClause.createdAt[Op.lte] = end;
}
}
const { count, rows } = await Ticket.findAndCountAll({
where: whereClause,
include: [
{ model: Department, as: "department", attributes: ["displayName"] },
{ model: User, as: "assignee", attributes: ["fullname"] },
],
order: [["createdAt", "DESC"]],
// برای گزارش، معمولاً کاربر می‌خواهد تمام داده‌های فیلتر شده را ببیند، نه فقط 10 تا
// اگر می‌خواهید گزارش فقط روی صفحه فعلی باشد، همین limit و offset را نگه دارید
});
// ۳. تزریق داده به گزارش
const dataSet = new Stimulsoft.System.Data.DataSet("TicketsData");
// تبدیل داده‌های Sequelize به فرمت ساده JSON
const reportData = rows.map((r) => r.toJSON());
dataSet.readJson({ tickets: reportData });
report.regData("TicketsData", "TicketsData", dataSet);
report.dictionary.synchronize(); // بسیار مهم: دیتای جدید را به ساختار گزارش متصل می‌کند
// ۴. رندر و خروجی
report.render();
const pdfData = report.exportDocument(
Stimulsoft.Report.StiExportFormat.Pdf,
);
res.setHeader("Content-Type", "application/pdf");
res.setHeader(
"Content-Disposition",
"attachment; filename=tickets-report.pdf",
);
res.send(pdfData);
} catch (error: any) {
console.error("خطا در تولید گزارش:", error);
// اگر از قبل خطا را هندل کردیم، همان را پاس می‌دهیم
if (error.status) throw error;
throw new createHttpError.InternalServerError(
GlobalMessages.errors.server,
);
}
}
async getAllExport(req: any) {
try {
const {
departmentId,
priority,
status,
user,
requestType,
startDate,
endDate,
} = req.query;
// ۲. ساخت آبجکت فیلترها به صورت داینامیک
const whereClause: any = {};
if (departmentId) whereClause.departmentId = departmentId;
if (priority) whereClause.priority = priority;
if (status) whereClause.status = status;
if (user) whereClause.assignedTo = user;
if (requestType) whereClause.requestType = requestType;
if (startDate || endDate) {
whereClause.createdAt = {};
if (startDate) {
whereClause.createdAt[Op.gte] = new Date(startDate); // تاریخ شروع (بزرگتر مساوی)
}
if (endDate) {
// برای اینکه شامل کلِ روز آخر باشد، می‌توان یک روز به تاریخ پایان اضافه کرد
// یا ساعت را ۲۳:۵۹:۵۹ تنظیم کرد
const end = new Date(endDate);
end.setHours(23, 59, 59, 999);
whereClause.createdAt[Op.lte] = end; // تاریخ پایان (کوچکتر مساوی)
}
}
const { count, rows } = await Ticket.findAndCountAll({
where: whereClause,
include: [
{ model: Department, as: "department", attributes: ["displayName"] },
{ model: User, as: "assignee", attributes: ["fullname"] },
],
order: [["createdAt", "DESC"]],
});
return {
data: rows,
};
} catch (error) {
throw new createHttpError.InternalServerError(
GlobalMessages.errors.server,
);
}
}
async getById(id: string) {
try {
const data = await Ticket.findOne({
where: { ticketNumber: id },
include: [
{ model: Department, as: "department", attributes: ["displayName"] },
{ model: User, as: "assignee", attributes: ["fullname"] },
],
});
return data;
} catch (error) {
throw new createHttpError.InternalServerError(
GlobalMessages.errors.server,
);
}
}
async create(data: ticketRequestTypeDataType) {
try {
if (data.status === "resolved") {
data.resolvedAt = new Date();
}
await Ticket.create({
ticketNumber: generateTicketNumber(),
departmentId: data.departmentId,
description: data.description,
requestType: data.requestType,
assignedTo: data.assignedTo,
finalNotes: data.finalNotes,
createdBy: data.createdBy,
internalPhone: data.internalPhone,
helpdeskAction: data.helpdeskAction,
location: data.location,
priority: data.priority,
relatedSystem: data.relatedSystem,
resolvedAt: data.resolvedAt,
status: data.status,
});
return true;
} catch (error) {
console.log(error);
throw new createHttpError.InternalServerError(
GlobalMessages.errors.server,
);
}
}
async update(id: string, data: Partial<ticketRequestTypeDataType>) {
try {
const ticket = await Ticket.findByPk(id);
if (!ticket) {
throw new createHttpError.NotFound("تيكت يافت نشد");
}
if (data.status === "resolved" && !ticket.resolvedAt) {
data.resolvedAt = new Date();
}
await ticket.update(data);
return true;
} catch (error) {
throw new createHttpError.InternalServerError(
GlobalMessages.errors.server,
);
}
}
async remove(id: string) {
try {
const ticket = await Ticket.findByPk(id);
if (!ticket) {
throw new createHttpError.NotFound("تيكت يافت نشد");
}
if (ticket.status === "open") {
throw new createHttpError.NotAcceptable(
"امكان حذف تيكت باز وجود ندارد",
);
}
await ticket.destroy();
return true;
} catch (error) {
throw new createHttpError.InternalServerError(
GlobalMessages.errors.server,
);
}
}
}
const TicketService = new TicketServiceClass();
export default TicketService;

View File

@@ -0,0 +1,71 @@
import Joi from "joi";
import { TicketPriority, TicketStatus } from "../../../core/types";
export const ticketValidationSchema = Joi.object({
// ticketNumber: Joi.string().optional().allow(null,""),
departmentId: Joi.string().uuid().required().messages({
"string.base": "شناسه دپارتمان نامعتبر است",
"string.empty": "شناسه دپارتمان الزامی است",
"string.guid": "فرمت شناسه دپارتمان صحیح نیست",
"any.required": "شناسه دپارتمان الزامی است",
}),
internalPhone: Joi.string()
.pattern(/^[0-9]{3,10}$/)
.optional()
.messages({
"string.pattern.base": "شماره داخلی باید فقط عدد و بین ۳ تا ۱۰ رقم باشد",
}),
requestType: Joi.string().required().messages({
"string.empty": "نوع درخواست الزامی است",
"any.required": "نوع درخواست الزامی است",
}),
priority: Joi.string()
.valid(...Object.values(TicketPriority))
.optional()
.messages({
"any.only": "اولویت انتخاب شده معتبر نیست",
}),
description: Joi.string().min(5).required().messages({
"string.empty": "توضیحات الزامی است",
"string.min": "توضیحات باید حداقل ۵ کاراکتر باشد",
"any.required": "توضیحات الزامی است",
}),
relatedSystem: Joi.string().optional().allow("").messages({
"string.base": "سیستم مرتبط نامعتبر است",
}),
location: Joi.string().optional().allow("").messages({
"string.base": "موقعیت وارد شده نامعتبر است",
}),
createdBy: Joi.string().required().allow("").messages({
"string.empty": "نام نام خانوادگي كاربر الزامی است",
"any.required": "نام نام خانوادگي كاربر الزامی است",
}),
helpdeskAction: Joi.string().optional().allow("").messages({
"string.base": "اقدام واحد پشتیبانی نامعتبر است",
}),
assignedTo: Joi.string().uuid().optional().messages({
"string.guid": "شناسه کاربر تخصیص داده شده نامعتبر است",
}),
status: Joi.string()
.valid(...Object.values(TicketStatus))
.optional()
.messages({
"any.only": "وضعیت تیکت نامعتبر است",
}),
resolvedAt: Joi.date().optional().messages({
"date.base": "تاریخ حل شدن نامعتبر است",
}),
finalNotes: Joi.string().optional().allow("").messages({
"string.base": "یادداشت نهایی نامعتبر است",
}),
});

View File

@@ -0,0 +1,30 @@
import { NextFunction } from "express";
import { ServerResponse } from "../../../core/types";
import { Controller } from "../../../core/controller/main.controller";
import UserService from "../services/user.service";
class UserControllerClass extends Controller {
#service;
constructor() {
super();
this.#service = UserService;
}
async getAll(req: any, res: ServerResponse, next: NextFunction) {
try {
const data = await this.#service.getAll();
return res.status(200).json({
status: 200,
data,
message: "Ok",
});
} catch (error) {
console.log(error);
next(error);
}
}
}
const UserController = new UserControllerClass();
export default UserController;

View File

@@ -0,0 +1,8 @@
import router from "express";
import UserController from "../controller/user.controller";
const userRouter = router.Router();
userRouter.get("/all", UserController.getAll);
export default userRouter;

View File

@@ -0,0 +1,20 @@
import createHttpError from "http-errors";
import { GlobalMessages } from "../../../core/messages";
import User from "../../../models/User";
class UserServiceClass {
async getAll() {
try {
const data = await User.findAll({ raw: true });
return data;
} catch (error) {
throw new createHttpError.InternalServerError(
GlobalMessages.errors.server,
);
}
}
}
const UserService = new UserServiceClass();
export default UserService;

91
src/server.ts Normal file
View File

@@ -0,0 +1,91 @@
import { NextFunction, Request, Response } from "express";
import path from "node:path";
import initDB from "./config/database";
import cookieParser from "cookie-parser";
import createHttpError from "http-errors";
import { ServerErrorsObject, ServerResponse } from "./core/types";
import { secureApp } from "./config/secure-app";
import mainRouter from "./core/router/main.router";
import dotenv from "dotenv";
import { seedDepartments } from "./seeders/department.seed";
import { seedUsers } from "./seeders/user.seed";
import { seedRoles } from "./seeders/role.seed";
const express = require("express") as typeof import("express");
dotenv.config();
export default class ServerApplication {
#PORT = process.env.PORT || 8000;
#APP = express();
constructor() {
this.serverConfiguration();
this.StartApplication();
this.InitClientSession();
this.RoutesConfiguration();
this.ErrorHandlingConfiguration();
}
async serverConfiguration() {
this.#APP.use(secureApp);
this.#APP.use(express.json());
this.#APP.use(express.urlencoded({ extended: true }));
this.#APP.set("json spaces", 2);
this.#APP.use(
"/media/images",
express.static(path.join(__dirname, "..", "media", "images")),
);
this.#APP.use(
"/media/videos",
express.static(path.join(__dirname, "..", "media", "videos")),
);
}
async StartApplication() {
await initDB();
// await seedDepartments();
// await seedRoles();
// await seedUsers();
this.#APP.listen(this.#PORT, () => {
console.log(
`Server Running on PORT ${this.#PORT} url : ${"http://localhost:"}${
this.#PORT
}`,
);
});
}
InitClientSession() {
this.#APP.use(cookieParser(process.env.COOKIE_PARSER_SECRET_KEY));
}
RoutesConfiguration() {
this.#APP.get("/", (req, res) => res.send(""));
this.#APP.use("/api/v1", mainRouter);
}
ErrorHandlingConfiguration() {
this.#APP.use((req: any, res: Response, next: NextFunction) => {
next(createHttpError.NotFound("این آدرس یافت نشد"));
});
this.#APP.use(
async (error: any, req: any, res: ServerResponse, next: NextFunction) => {
// await ErrorLog.create({
// message: error.message,
// stack: error.stack,
// severity: "HIGH",
// });
// console.log(error);
const serverError = createHttpError.InternalServerError();
const statusCode = error.status || serverError.status;
const message: string = error.message || serverError.message;
const errorObject: ServerErrorsObject = {
status: statusCode,
error: {
message,
},
};
return res.status(statusCode).json(errorObject);
},
);
}
}

14
tsconfig.json Normal file
View File

@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "es2016",
"module": "commonjs",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist"
},
}