first commit
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
.env
|
||||||
|
seeders
|
||||||
11
.sequelizerc
Normal file
11
.sequelizerc
Normal 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
3
index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import ServerApplication from "./src/server";
|
||||||
|
|
||||||
|
new ServerApplication();
|
||||||
4784
package-lock.json
generated
Normal file
4784
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
57
package.json
Normal file
57
package.json
Normal 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
0
reports/tickets.mrt
Normal file
17
src/config/config.ts
Normal file
17
src/config/config.ts
Normal 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
15
src/config/database.ts
Normal 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
29
src/config/secure-app.ts
Normal 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,
|
||||||
|
];
|
||||||
4
src/core/constants/index.ts
Normal file
4
src/core/constants/index.ts
Normal 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']
|
||||||
90
src/core/constants/server-configuration.ts
Normal file
90
src/core/constants/server-configuration.ts
Normal 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 };
|
||||||
8
src/core/controller/main.controller.ts
Normal file
8
src/core/controller/main.controller.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import autoBind from "auto-bind";
|
||||||
|
|
||||||
|
export class Controller {
|
||||||
|
constructor() {
|
||||||
|
autoBind(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
6
src/core/messages/index.ts
Normal file
6
src/core/messages/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export const GlobalMessages = {
|
||||||
|
success: {},
|
||||||
|
errors: {
|
||||||
|
server: "خطايي رخ داده است",
|
||||||
|
},
|
||||||
|
};
|
||||||
29
src/core/middleware/auth.middleware.ts
Normal file
29
src/core/middleware/auth.middleware.ts
Normal 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
|
||||||
17
src/core/router/main.router.ts
Normal file
17
src/core/router/main.router.ts
Normal 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
98
src/core/types/index.ts
Normal 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
|
||||||
|
}
|
||||||
33
src/core/utils/functions.ts
Normal file
33
src/core/utils/functions.ts
Normal 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;
|
||||||
|
}
|
||||||
12
src/core/utils/generators.ts
Normal file
12
src/core/utils/generators.ts
Normal 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}`;
|
||||||
|
};
|
||||||
22
src/migrations/20260518111632-create-tables.js
Normal file
22
src/migrations/20260518111632-create-tables.js
Normal 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
71
src/models/Department.ts
Normal 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
64
src/models/Permission.ts
Normal 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
79
src/models/Role.ts
Normal 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;
|
||||||
65
src/models/RolePermission.ts
Normal file
65
src/models/RolePermission.ts
Normal 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
167
src/models/Ticket.ts
Normal 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
95
src/models/User.ts
Normal 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
31
src/models/index.ts
Normal 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;
|
||||||
57
src/modules/auth/controller/auth.controller.ts
Normal file
57
src/modules/auth/controller/auth.controller.ts
Normal 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;
|
||||||
14
src/modules/auth/messages/index.ts
Normal file
14
src/modules/auth/messages/index.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
export const AuthMessages ={
|
||||||
|
sucess:{
|
||||||
|
login:'با موفقيت وارد شديد',
|
||||||
|
logout:"با موفقيت خارج شديد",
|
||||||
|
edited:'با موفقيت ويرايش شد',
|
||||||
|
done:'انجام شد'
|
||||||
|
},
|
||||||
|
error:{
|
||||||
|
login:{
|
||||||
|
incorrectData:'نام كاربري و يا رمز عبور اشتباه است',
|
||||||
|
loginFailed:'خطا در ورود كاربر'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
src/modules/auth/routes/auth.routes.ts
Normal file
10
src/modules/auth/routes/auth.routes.ts
Normal 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;
|
||||||
46
src/modules/auth/services/auth.service.ts
Normal file
46
src/modules/auth/services/auth.service.ts
Normal 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;
|
||||||
26
src/modules/auth/validation/auth.validation.ts
Normal file
26
src/modules/auth/validation/auth.validation.ts
Normal 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": "رمز عبور الزامی است",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
88
src/modules/department/controller/department.controller.ts
Normal file
88
src/modules/department/controller/department.controller.ts
Normal 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;
|
||||||
16
src/modules/department/messages/index.ts
Normal file
16
src/modules/department/messages/index.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
export const DepartmentMessages = {
|
||||||
|
sucess: {
|
||||||
|
create: "با موفقيت ساخته شد",
|
||||||
|
delete:"با موفقيت حذف گرديد",
|
||||||
|
update:"با موفقيت به روزرساني گرديد"
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
create: {
|
||||||
|
unique: "اين واحد قبلا ثبت شده است",
|
||||||
|
error:"خطا در انجام عمليات ثبت واحد"
|
||||||
|
},
|
||||||
|
delete:"خطا در حذف واحد",
|
||||||
|
update:"خطا در به روزرساني واحد",
|
||||||
|
notFound:"واحد يافت نشد"
|
||||||
|
},
|
||||||
|
};
|
||||||
12
src/modules/department/routes/department.routes.ts
Normal file
12
src/modules/department/routes/department.routes.ts
Normal 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;
|
||||||
105
src/modules/department/services/department.service.ts
Normal file
105
src/modules/department/services/department.service.ts
Normal 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;
|
||||||
25
src/modules/department/validation/department.validation.ts
Normal file
25
src/modules/department/validation/department.validation.ts
Normal 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": "نام نمایشی الزامی است"
|
||||||
|
})
|
||||||
|
});
|
||||||
213
src/modules/report/controller/report.controller.ts
Normal file
213
src/modules/report/controller/report.controller.ts
Normal 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;
|
||||||
61
src/modules/report/routes/report.router.ts
Normal file
61
src/modules/report/routes/report.router.ts
Normal 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;
|
||||||
399
src/modules/report/service/report.service.ts
Normal file
399
src/modules/report/service/report.service.ts
Normal 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;
|
||||||
113
src/modules/ticket/controller/ticket.controller.ts
Normal file
113
src/modules/ticket/controller/ticket.controller.ts
Normal 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;
|
||||||
14
src/modules/ticket/routes/ticket.routes.ts
Normal file
14
src/modules/ticket/routes/ticket.routes.ts
Normal 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;
|
||||||
311
src/modules/ticket/services/ticket.service.ts
Normal file
311
src/modules/ticket/services/ticket.service.ts
Normal 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;
|
||||||
71
src/modules/ticket/validation/ticket.validation.ts
Normal file
71
src/modules/ticket/validation/ticket.validation.ts
Normal 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": "یادداشت نهایی نامعتبر است",
|
||||||
|
}),
|
||||||
|
});
|
||||||
30
src/modules/user/controller/user.controller.ts
Normal file
30
src/modules/user/controller/user.controller.ts
Normal 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;
|
||||||
8
src/modules/user/routes/user.router.ts
Normal file
8
src/modules/user/routes/user.router.ts
Normal 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;
|
||||||
20
src/modules/user/services/user.service.ts
Normal file
20
src/modules/user/services/user.service.ts
Normal 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
91
src/server.ts
Normal 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
14
tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es2016",
|
||||||
|
"module": "commonjs",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
"outDir": "dist"
|
||||||
|
},
|
||||||
|
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user