diff --git a/context.js b/context.js index fe44f85..e50229a 100644 --- a/context.js +++ b/context.js @@ -1,3 +1,3 @@ import { AsyncLocalStorage } from "node:async_hooks"; -export const httpStorage = new AsyncLocalStorage(); +export const ctxStorage = new AsyncLocalStorage(); diff --git a/db.js b/db.js index fbd907f..a3ba73d 100644 --- a/db.js +++ b/db.js @@ -5,12 +5,12 @@ const connectionConfig = { user: process.env.MSSQL_USER, password: process.env.MSSQL_PASSWORD, database: process.env.MSSQL_DB, - server: process.env.MSSQL_HOST, - port: Number.parseInt(process.env.MSSQL_PORT, 10) || 1433, + server: process.env.MSSQL_HOST || "localhost", + port: Number.parseInt(process.env.MSSQL_PORT || "1433", 10), pool: { max: 10, min: 0, - idleTimeoutMillis: 5000 + idleTimeoutMillis: 30000 }, options: { trustServerCertificate: true diff --git a/errors/index.js b/errors/index.js new file mode 100644 index 0000000..0325fd1 --- /dev/null +++ b/errors/index.js @@ -0,0 +1,37 @@ +export class HttpError extends Error { + /** + * @param {{ name?: string, message: string, statusCode: number }} msg + */ + constructor({ name, message, statusCode = 500 }) { + super(message); + this.name = name || this.constructor.name; + this.statusCode = statusCode; + Error.captureStackTrace(this); + } +} + +export class BadRequestError extends HttpError { + /** + * @param {string} [message] + */ + constructor(message) { + super({ + name: "E_BAD_REQUEST", + message: message || "Bad Request", + statusCode: 400 + }); + } +} + +export class NotFoundError extends HttpError { + /** + * @param {string} [message] + */ + constructor(message) { + super({ + name: "E_NOT_FOUND", + message: message || "Not found", + statusCode: 404 + }); + } +} diff --git a/index.d.ts b/index.d.ts index b41b4e7..0d86061 100644 --- a/index.d.ts +++ b/index.d.ts @@ -19,6 +19,7 @@ declare global { type HTTPResponse = { sendJson: (data: Record, statusCode?: number = 200) => http.ServerResponse; + sendSSEJson: (data: Record, eventName: string, statusCode?: number = 200) => void; sendText: (text: string, statusCode?: number = 200) => http.ServerResponse; res: http.ServerResponse; }; diff --git a/index.js b/index.js index ca5bd00..1271599 100644 --- a/index.js +++ b/index.js @@ -1,31 +1,26 @@ import http from "node:http"; import { routeHandler } from "./router/index.js"; import packageJson from "./package.json" with { type: "json" }; -import { httpStorage } from "./context.js"; -// import db from "./db.js"; -import response from "./response.js"; -import request from "./request.js"; +import { ctxStorage } from "./context.js"; +import db from "./db.js"; +import { createResponse } from "./response.js"; +import { createRequest } from "./request.js"; const SERVER_PORT = 3000; -// await db.connect().then(() => console.log("Database connected!")); - -http - .createServer((req, res) => { +function application() { + const server = http.createServer((req, res) => { try { const url = new URL(`http://localhost:${SERVER_PORT}${req.url}`); /** @type {HTTPContext} */ const ctx = { data: null, - req: Object.create(request), - res: Object.create(response) + req: createRequest(req), + res: createResponse(res) }; - ctx.req.req = req; - ctx.res.res = res; - - httpStorage.run(ctx, () => + ctxStorage.run(ctx, () => routeHandler(url).then(() => { console.log(`${new Date().toISOString()} Request: ${url.pathname}`); }) @@ -33,7 +28,19 @@ http } catch (error) { console.error(error); } - }) - .listen(SERVER_PORT, () => { - console.log(`Service ${packageJson.name} is running at port ${SERVER_PORT}.`); }); + + return { + /** + * @param {number} port + */ + async run(port = 3000) { + await db.connect().then(() => console.log("Database connected!")); + server.listen(port, () => { + console.log(`Service ${packageJson.name} is running at port ${SERVER_PORT}.`); + }); + } + }; +} + +application().run(SERVER_PORT); diff --git a/package-lock.json b/package-lock.json index 9ed64d6..c9f0ace 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "formidable": "^3.5.4", "mssql": "^12.2.0", + "serialize-error": "^13.0.1", "xlsx": "^0.18.5", "yup": "^1.7.1" }, @@ -1612,6 +1613,18 @@ "dev": true, "license": "MIT" }, + "node_modules/non-error": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/non-error/-/non-error-0.1.0.tgz", + "integrity": "sha512-TMB1uHiGsHRGv1uYclfhivcnf0/PdFp2pNqRxXjncaAsjYMoisaQJI+SSZCqRq+VliwRTC8tsMQfmrWjDMhkPQ==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -1839,6 +1852,37 @@ "node": ">=10" } }, + "node_modules/serialize-error": { + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-13.0.1.tgz", + "integrity": "sha512-bBZaRwLH9PN5HbLCjPId4dP5bNGEtumcErgOX952IsvOhVPrm3/AeK1y0UHA/QaPG701eg0yEnOKsCOC6X/kaA==", + "license": "MIT", + "dependencies": { + "non-error": "^0.1.0", + "type-fest": "^5.4.1" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/serialize-error/node_modules/type-fest": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.4.4.tgz", + "integrity": "sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw==", + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -1905,6 +1949,18 @@ "url": "https://opencollective.com/synckit" } }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tarn": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/tarn/-/tarn-3.0.2.tgz", diff --git a/package.json b/package.json index 6195e2e..6cfda82 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "dependencies": { "formidable": "^3.5.4", "mssql": "^12.2.0", + "serialize-error": "^13.0.1", "xlsx": "^0.18.5", "yup": "^1.7.1" }, diff --git a/request.js b/request.js index b82f654..3f90a9b 100644 --- a/request.js +++ b/request.js @@ -1,19 +1,29 @@ -/** @type {HTTPRequest} */ -export default { - req: null, - async parseJson() { - return new Promise((resolve, reject) => { - let body = []; - this.req.on("data", (chunk) => body.push(chunk)); - this.req.on("end", () => { - try { - const parsedJson = JSON.parse(Buffer.concat(body).toString()); - resolve(parsedJson); - } catch (error) { - reject(error); - } +/** + * @param {import('node:http').IncomingMessage} req + * @returns {HTTPRequest} + */ +export function createRequest(req) { + return { + get req() { + return req; + }, + + async parseJson() { + return new Promise((resolve, reject) => { + /** @type {Buffer[]} */ + const body = []; + + this.req.on("data", (chunk) => body.push(chunk)); + this.req.on("end", () => { + try { + const parsedJson = JSON.parse(Buffer.concat(body).toString()); + resolve(parsedJson); + } catch (error) { + reject(error); + } + }); + this.req.on("error", (err) => reject(err)); }); - this.req.on("error", (err) => reject(err)); - }); - } -}; + } + }; +} diff --git a/response.js b/response.js index ab044d0..8aec98b 100644 --- a/response.js +++ b/response.js @@ -1,14 +1,29 @@ -/** @type {HTTPResponse} */ -export default { - res: null, - sendJson(data, statusCode = 200) { - this.res.writeHead(statusCode, { "content-type": "application/json" }); - return this.res.end(JSON.stringify(data, null, 2)); - }, - sendText(text, statusCode = 200) { - this.res.writeHead(statusCode, { - "content-type": "text/plain; charset=utf-8" - }); - return this.res.end(text); - } -}; +/** + * @param {import('node:http').ServerResponse} res + * @returns {HTTPResponse} + */ +export function createResponse(res) { + return { + get res() { + return res; + }, + + sendSSEJson(data, eventName) { + this.res.write(`event: ${eventName}\n`); + this.res.write(`data: ${JSON.stringify(data)}`); + this.res.write("\n\n"); + }, + + sendJson(data, statusCode = 200) { + this.res.writeHead(statusCode, { "content-type": "application/json" }); + return this.res.end(JSON.stringify(data, null, 2)); + }, + + sendText(text, statusCode = 200) { + this.res.writeHead(statusCode, { + "content-type": "text/plain; charset=utf-8" + }); + return this.res.end(text); + } + }; +} diff --git a/router/index.js b/router/index.js index 29590aa..fc1e6a5 100644 --- a/router/index.js +++ b/router/index.js @@ -1,7 +1,9 @@ import path from "node:path"; import formidable from "formidable"; -import { httpStorage } from "#root/context.js"; +import { ctxStorage } from "#root/context.js"; import { handlers as reportsHandlers } from "./reports/index.js"; +import { BadRequestError, HttpError, NotFoundError } from "#root/errors/index.js"; +import { serializeError } from "serialize-error"; /** * @type {HTTPRouteMap} @@ -16,7 +18,7 @@ export const ROUTE_MAP = { filename: (_, ext) => `gpp-reports${ext}`, keepExtensions: true, uploadDir: path.join(process.cwd(), "./uploads"), - filter: ({ mimetype }) => mimetype && mimetype.includes("application/vnd.ms-excel") + filter: ({ mimetype }) => !!mimetype && mimetype.includes("application/vnd.ms-excel") }) }, "/api/reports_sync": { @@ -30,7 +32,7 @@ export const ROUTE_MAP = { */ export async function routeHandler(url) { /** @type {HTTPContext} */ - const ctx = httpStorage.getStore(); + const ctx = ctxStorage.getStore(); const { req, res } = ctx; try { @@ -42,28 +44,47 @@ export async function routeHandler(url) { } if (req.req.headers["content-type"]?.includes("multipart/form-data")) { - if (!route.incomingForm) throw new Error("Request cannot be processed without defined incoming form."); + if (!route.incomingForm) { + throw new BadRequestError("Request cannot be processed without defined incoming form."); + } + ctx.data = await route.incomingForm.parse(req.req); } if (route.method === req.req.method) { await route.handle(ctx); + return; } + } + throw new NotFoundError("Route not found"); + } catch (error) { + console.error(error); + + if (error instanceof HttpError) { + res.sendJson( + { + error: { + name: error.name, + message: error.message || "Internal Server Error", + stack: error.stack || null + } + }, + error.statusCode || 500 + ); return; } - res.sendJson({ error: { message: "Route not found." } }, 404); - } catch (error) { - console.error(error); + const serializedError = serializeError(error); + res.sendJson( { error: { - message: error?.message || "Internal Server Error", - stack: error?.stack || null + ...serializedError, + stack: process.env.NODE_ENV !== "production" ? serializedError.stack : undefined } }, - error?.cause?.statusCode || 500 + 500 ); } } diff --git a/router/reports/parseExcel.handler.js b/router/reports/parseExcel.handler.js index 797e26d..e4ae2bf 100644 --- a/router/reports/parseExcel.handler.js +++ b/router/reports/parseExcel.handler.js @@ -1,3 +1,5 @@ +import { BadRequestError } from "#root/errors/index.js"; +import db from "#root/db.js"; import xlsx from "xlsx"; const sheetName = "Отчеты"; @@ -12,18 +14,34 @@ const xlsxParseOptions = { }; /** - * Читает Excel файл отчетов и возвращает его в формате JSON + * URL: /api/reports_excel_file_parse + * Method: POST + * Description: Читает Excel файл отчетов и возвращает его в формате JSON. Принимает на вход файл + * отчетов в формате Excel. * * @type {HTTPRouteHandler} */ export default async (ctx) => { const { res } = ctx; + + if (!ctx.data) throw new BadRequestError("Cannot proceed uploaded file"); + const [, files] = ctx.data; const excelFilePath = files?.report?.at(0)?.filepath; /** @type {{ markedReports: ReportModel[], reports: ReportModel[] }} */ const ret = { reports: [], markedReports: [] }; + let foundEqupments = 0; + + ctx.res.res.setHeaders( + new Headers({ + Connection: "keep-alive", + "Cache-Control": "no-cache", + "Content-Type": "text/event-stream" + }) + ); + if (excelFilePath) { const workbook = xlsx.readFile(excelFilePath, xlsxParseOptions); const sheet = workbook.Sheets[sheetName]; @@ -63,11 +81,29 @@ export default async (ctx) => { executorNames: parseNames(data[i][cols[4]]) }); } + + /** @param {ReportModel} report */ + const checkReportAssigments = async (report) => { + const equipment = await db + .request() + .query(`select id, name from dbo.asu_system_api_subsystemlist where name like N'%${report.equipment}%';`); + + if (equipment.recordset.length !== 0) foundEqupments++; + + res.sendSSEJson(report, "reportParseProgress"); + + return equipment; + }; + + console.log("Total reports:", ret.reports.concat(ret.markedReports).length); + await Promise.allSettled(ret.reports.concat(ret.markedReports).map((r) => checkReportAssigments(r))); + ctx.res.sendSSEJson({ foundEqupments }, "reportParseDone"); + console.log("Parse done"); } else { - throw new Error("Bad request", { cause: { statusCode: 400 } }); + throw new BadRequestError(); } - res.sendJson({ data: ret }); + res.res.end(); }; function parseReasonCallAndJobDesc(str = "") {