From 8df10aa2e373bb541ae3fa2b2db5a53308495b69 Mon Sep 17 00:00:00 2001 From: Rayhan Ardiansyah Date: Mon, 2 Mar 2026 12:31:03 +0700 Subject: [PATCH] add pharmacy info --- src/controllers/pharmacy/pharmacy_info.ts | 631 ++++++++++++++++++++++ src/helpers/orm.ts | 8 +- src/model/pharmacy_info.ts | 23 + src/routes/private.ts | 11 + src/swagger/builder.js | 11 + 5 files changed, 682 insertions(+), 2 deletions(-) create mode 100644 src/controllers/pharmacy/pharmacy_info.ts create mode 100644 src/model/pharmacy_info.ts diff --git a/src/controllers/pharmacy/pharmacy_info.ts b/src/controllers/pharmacy/pharmacy_info.ts new file mode 100644 index 0000000..3bf272e --- /dev/null +++ b/src/controllers/pharmacy/pharmacy_info.ts @@ -0,0 +1,631 @@ +import { Response, NextFunction, Application } from "express"; +import { Request } from "express-jwt"; +import { ReturnHelper } from "../../helpers/express/return"; +import { OrmHelper } from "../../helpers/orm"; +import Joi from "joi"; +import { ILogObj, Logger } from "tslog"; +import { Language } from "../../langs/lang"; +import { PharmacyInfo, Paging } from "entity"; +import CommonHelper from "../../helpers/common"; +import * as fastcsv from 'fast-csv'; +import dayjs from "dayjs"; +import fileUpload from "express-fileupload"; +import fs from "fs"; +import path from "path"; +import { v4 as uuidv4 } from 'uuid'; + +import { PharmacyInfoModel } from "../../model/pharmacy_info"; + +interface FileUploadRequest extends Request { + files?: fileUpload.FileArray | null; + body: any; +} + +const log: Logger = new Logger({ name: '[PharmacyInfoController]', type: 'pretty' }); + +export class PharmacyInfoController { + static async list(req: Request, res: Response, next: NextFunction): Promise { + /* + #swagger.tags = ['Pharmacy - PharmacyInfo'] + #swagger.security = [{ "bearerAuth": [] }] + #swagger.parameters['filter'] = { description: '', in: 'query', type: 'string' } + #swagger.parameters['limit'] = { in: 'query', required: true, type: 'number' } + #swagger.parameters['page'] = { in: 'query', required: true, type: 'number' } + #swagger.parameters['with_deleted'] = { in: 'query', required: true, type: 'boolean' } + #swagger.parameters['order_field'] = { in: 'query', required: true, type: 'string' } + #swagger.parameters['order_direction'] = { in: 'query', required: true, schema: { '@enum': ['ASC', 'DESC'] } } + */ + try { + const schema = Joi.object().keys({ + filter: Joi.string().allow("").optional().label("Filter"), + page: Joi.number().required().min(1).label("Page"), + limit: Joi.number().required().min(1).label("Limit"), + with_deleted: Joi.bool().required().label("With Deleted"), + order_field: Joi.string().required().label("Order Field"), + order_direction: Joi.string().allow("asc", "desc").required().label("Order Direction"), + }); + + let param: Paging = await schema.validateAsync(req.query); + + let filter = param.filter && param.filter !== "" ? JSON.parse(param.filter) : {}; + + var query = await PharmacyInfoModel.list(filter); + + const offset = (param.page - 1) * param.limit; + let limit = param.limit; + + const res_count = query; + const res_list = query + .orderBy("pharmacy_info." + param.order_field, param.order_direction) + .offset(offset) + .limit(limit); + + if (param.with_deleted) { + res_count.withDeleted(); + res_list.withDeleted(); + } + + const current_page = param.page; + const total_count_data = await res_count.getCount(); + const list_data = await res_list.getMany(); + const count_data = CommonHelper.countObject(list_data); + + return ReturnHelper.successResponselist(res, 200, Language.lang.success_view, count_data, current_page, total_count_data, list_data); + } catch (e: unknown) { + log.error(e); + const err = e as Error; + return ReturnHelper.errorResponse(res, 400, 401, Language.lang.failed_view, err.message); + } + } + + static async export(req: Request, res: Response, next: NextFunction): Promise { + /* + #swagger.tags = ['Pharmacy - PharmacyInfo'] + #swagger.security = [{ + "bearerAuth": [] + }] + #swagger.parameters['filter'] = { + description: 'Filter with 2 format :
  • Simple format use text plaint will filter eq:code or like %name%
  • Advance format using field existing {status:\'Y\', any:\'SAME AS PLAINT LOGIC\', etc...}
', + in: 'query', + type: 'string' + } + #swagger.parameters['filter'] = { + in: 'query', + type: 'string' + } + #swagger.parameters['order_field'] = { + in: 'query', + required: true, + type: 'string' + } + #swagger.parameters['order_direction'] = { + in: 'query', + required: true, + schema: { + '@enum': ['ASC', 'DESC'] + } + } + */ + + try { + const schema = Joi.object().keys({ + filter: Joi.string().allow('').optional().label('Filter'), + order_field: Joi.string().required().label('Order Field'), + order_direction: Joi.string().allow('asc', 'desc').required().label('Order Direction'), + }); + + const param: Paging = await schema.validateAsync(req.query); + + let filter = param.filter && param.filter !== "" ? JSON.parse(param.filter) : {}; + + var query = await PharmacyInfoModel.list(filter); + const data = await query.getMany(); + + const filename = "pharmacy_info.csv"; + + res.setHeader('Content-Type', 'text/csv'); + res.setHeader('Content-Disposition', 'attachment; filename=' + filename); + + const csvStream = fastcsv.format({ + headers: true, + writeHeaders: true, + transform: (row: PharmacyInfo): any => ({ + ...row, + created_at: dayjs(row.created_at).format('DD-MM-YYYY HH:MM:ss'), + }) + }); + csvStream.pipe(res); + + const limit = 50; + + const fetchAndWrite = async (page: any) => { + + if (CommonHelper.countObject(data) === 0) { + csvStream.end(); + } else { + data.forEach((item: any) => csvStream.write(item)); + + if (CommonHelper.countObject(data) == limit) { + fetchAndWrite(page + 1); + } else { + csvStream.end(); + } + } + }; + + fetchAndWrite(1); + } catch (e: unknown) { + log.error(e); + const err = e as Error; + + return ReturnHelper.errorResponse(res, 400, 401, Language.lang.failed_view, err.message); + } + } + + static async create(req: Request, res: Response, next: NextFunction): Promise { + /* + #swagger.tags = ['Pharmacy - PharmacyInfo'] + #swagger.security = [{ + "bearerAuth": [] + }] + + #swagger.requestBody = { + required: true, + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/pharmacy_info" + } + } + } + } + */ + try { + const schema = Joi.object().keys({ + logo: Joi.string() + .max(256) + .required() + .label('Logo'), + + name: Joi.string() + .max(256) + .required() + .label('Name'), + + address: Joi.string() + .max(500) + .required() + .label('Address'), + + munisipiu: Joi.string() + .uuid() + .required() + .label('Munisipiu'), + + postu_admin: Joi.string() + .uuid() + .required() + .label('Postu Admin'), + + suco: Joi.string() + .uuid() + .required() + .label('Suco'), + + aldeia: Joi.string() + .uuid() + .required() + .label('Aldeia'), + + phone: Joi.string() + .max(30) + .optional() + .label('Phone'), + + default_room_id: Joi.string() + .uuid() + .required() + .label('Default Room'), + }); + + const param: any = await schema.validateAsync(req.body); + + const data = new PharmacyInfo() + data.name = param.name, + data.address = param.address + data.munisipiu = param.munisipiu, + data.postu_admin = param.postu_admin + data.suco = param.suco + data.aldeia = param.aldeia + data.phone = param.phone + data.default_room = param.default_room_id + data.created_by = req.auth.data.name; + + await OrmHelper.DB.manager.save(data); + + return ReturnHelper.successResponseAny(res, 200, Language.lang.success_insert, data); + } catch (e: unknown) { + log.error(e); + const err = e as Error; + + return ReturnHelper.errorResponse(res, 500, 401, Language.lang.failed_insert, err.message); + } + } + + static async update(req: Request, res: Response, next: NextFunction): Promise { + /* + #swagger.tags = ['Pharmacy - PharmacyInfo'] + #swagger.security = [{ + "bearerAuth": [] + }] + #swagger.parameters['id'] = { + in: 'path', + description: 'PharmacyInfo ID.', + required: true, + type: 'string' + } + + #swagger.requestBody = { + required: true, + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/pharmacy_info" + } + } + } + } + */ + + try { + const schema = Joi.object().keys({ + id: Joi.string().uuid().required().label('ID'), + logo: Joi.string() + .max(256) + .required() + .label('Logo'), + + name: Joi.string() + .max(256) + .required() + .label('Name'), + + address: Joi.string() + .max(500) + .required() + .label('Address'), + + munisipiu: Joi.string() + .uuid() + .required() + .label('Munisipiu'), + + postu_admin: Joi.string() + .uuid() + .required() + .label('Postu Admin'), + + suco: Joi.string() + .uuid() + .required() + .label('Suco'), + + aldeia: Joi.string() + .uuid() + .required() + .label('Aldeia'), + + phone: Joi.string() + .max(30) + .optional() + .label('Phone'), + + default_room_id: Joi.string() + .uuid() + .required() + .label('Default Room'), + }); + + req.body.id = req.params['id']; + + const param: any = await schema.validateAsync(req.body); + + const repo = OrmHelper.DB.getRepository(PharmacyInfo); + + const data = await repo.findOneBy({ id: param.id }); + + if (data != null) { + data.name = param.name, + data.address = param.address + data.munisipiu = param.munisipiu, + data.postu_admin = param.postu_admin + data.suco = param.suco + data.aldeia = param.aldeia + data.phone = param.phone + data.default_room = param.default_room_id + data.updated_by = req.auth.data.name; + + await repo.save(data); + + return ReturnHelper.successResponseAny(res, 200, Language.lang.success_update, data); + } else { + return ReturnHelper.errorResponse(res, 404, 401, Language.lang.failed_not_found, ""); + } + } catch (e: unknown) { + log.error(e); + const err = e as Error; + + return ReturnHelper.errorResponse(res, 500, 402, Language.lang.failed_update, err.message); + } + } + + static async delete(req: Request, res: Response, next: NextFunction): Promise { + /* + #swagger.tags = ['Pharmacy - PharmacyInfo'] + #swagger.security = [{ + "bearerAuth": [] + }] + #swagger.parameters['id'] = { + in: 'path', + description: 'Pharmacy Info ID.', + required: true, + type: 'string' + } + #swagger.parameters['hard'] = { + in: 'path', + description: 'Is Hard Delete', + required: false, + type: 'boolean' + } + */ + try { + const schema = Joi.object().keys({ + id: Joi.string().uuid().required().label('ID'), + hard: Joi.bool().optional().allow('').label('Is hard delete?') + }); + + const param: { id: string, hard: boolean } = await schema.validateAsync(req.params); + + const repo = OrmHelper.DB.getRepository(PharmacyInfo); + + const existData = await repo.findOne({ where: { id: param.id } }); + if (existData && !param.hard) { + existData.deleted_by = req.auth.data.name; + await OrmHelper.DB.manager.save(existData); + } + + const affected = (!param.hard ? await repo.softDelete({ id: param.id }) : await repo.delete({ id: param.id })).affected; + + if (affected > 0) { + return ReturnHelper.successResponseAny(res, 200, Language.lang.success_delete, {}); + } else { + return ReturnHelper.errorResponse(res, 404, 401, Language.lang.failed_not_found, ""); + } + } catch (e: unknown) { + log.error(e); + const err = e as Error; + + return ReturnHelper.errorResponse(res, 500, 402, Language.lang.failed_delete, err.message); + } + } + + static async restore(req: Request, res: Response, next: NextFunction): Promise { + /* + #swagger.tags = ['Pharmacy - PharmacyInfo'] + #swagger.security = [{ + "bearerAuth": [] + }] + + #swagger.parameters['id'] = { + in: 'path', + description: 'PharmacyInfo ID.', + required: true, + type: 'string' + } + */ + try { + const schema = Joi.object().keys({ + id: Joi.string().uuid().required().label('ID') + }); + + const param: PharmacyInfo = await schema.validateAsync(req.params); + + const repo = OrmHelper.DB.getRepository(PharmacyInfo); + + const affected = (await repo.restore({ id: param.id })).affected; + + if (affected > 0) { + return ReturnHelper.successResponseAny(res, 200, Language.lang.success_restore, {}); + } else { + return ReturnHelper.errorResponse(res, 404, 401, Language.lang.failed_not_found, ""); + } + } catch (e: unknown) { + log.error(e); + const err = e as Error; + + return ReturnHelper.errorResponse(res, 500, 402, Language.lang.failed_restore, err.message); + } + } + + static async uploadlogo(req: FileUploadRequest, res: Response, next: NextFunction): Promise { + /* #swagger.tags = ['Pharmacy - PharmacyInfo'] + #swagger.security = [{ "bearerAuth": [] }] + #swagger.consumes = ['multipart/form-data'] + #swagger.requestBody = { + required: true, + content: { + "multipart/form-data": { + schema: { + type: "object", + properties: { + file: { + type: "string", + format: "binary", + description: "Image file (max 1MB, jpg/png/webp)" + }, + path: { + type: "string", + description: 'File path', + default: 'uploads/hospital-logo' + }, + application: { + type: "string", + description: 'Application name', + default: 'saude' + } + }, + required: ["file", "path", "application"] + } + } + } + } + */ + try { + // Validation schema + const schema = Joi.object().keys({ + path: Joi.string().label('File Path'), + application: Joi.string().label('Application'), + }); + + log.info("body", req.body); + + const param: { path: string, application: string } = await schema.validateAsync(req.body); + + // Check if file exists + if (!req.files || !req.files.file) { + return ReturnHelper.errorResponse(res, 400, 404, Language.lang.failed_insert, "File not found"); + } + + const file = req.files.file as fileUpload.UploadedFile; + + // Validate file size (max 1MB) + const maxSize = 1024 * 1024; // 1MB + if (file.size > maxSize) { + return ReturnHelper.errorResponse(res, 400, 400, Language.lang.failed_insert, "File size exceeds 1MB limit"); + } + + // Validate file type + const allowedMimes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp']; + if (!allowedMimes.includes(file.mimetype)) { + return ReturnHelper.errorResponse(res, 400, 400, Language.lang.failed_insert, "Invalid file type. Only JPG, PNG, and WEBP are allowed"); + } + + // Normalize path + param.path = param.path[param.path.length - 1] !== '/' ? param.path + '/' : param.path; + + if (param.path == undefined || param.path == null) param.path = 'uploads/pharmacy-logo/'; + + // Generate unique filename + const ext = file.name.split('.'); + const name = uuidv4() + '.' + ext[ext.length - 1]; + + const result: { file: string } = { + // url: "", + // path: "", + file: "", + // mime: file.mimetype, + // size: file.size, + // md5: file.md5 + }; + + const folder_path = param.path; + const file_path = path.join(folder_path, name); + + // Create directory if it doesn't exist + if (!fs.existsSync(folder_path)) { + fs.mkdirSync(folder_path, { recursive: true }); + } + + // Move file to destination + await file.mv(file_path); + + // result.path = param.path[param.path.length - 1] === '/' ? param.path.substring(0, param.path.length - 1) : param.path; + result.file = name; + // result.url = `${result.path}/${name}`; // Generate URL + + log.info(`File uploaded successfully: ${file_path}`); + + return ReturnHelper.successResponseAny(res, 200, Language.lang.success_insert, result); + + } catch (e: unknown) { + // Proper error logging to avoid tslog serialization issues + if (e instanceof Error) { + log.error("File upload failed:", { + message: e.message, + stack: e.stack, + name: e.name + }); + return ReturnHelper.errorResponse(res, 500, 500, Language.lang.failed_insert, e.message); + } else { + log.error("File upload failed with unknown error:", String(e)); + return ReturnHelper.errorResponse(res, 500, 500, Language.lang.failed_insert, "Unknown error occurred"); + } + } + } + + static async deletelogo(req: Request, res: Response, next: NextFunction): Promise { + /* + #swagger.tags = ['Pharmacy - PharmacyInfo'] + #swagger.security = [{ "bearerAuth": [] }] + #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/files" } } } } + */ + + try { + const schema = Joi.object().keys({ + file_name: Joi.string().required().label('File Name'), + }); + + const param: { file_name: string } = await schema.validateAsync(req.body); + + // const env = process.env.NODE_ENV + '/' + const fixedPath = 'uploads/pharmacy-logo/'; + const file_path = fixedPath + param.file_name; + + // Check if file exists + if (!fs.existsSync(file_path)) { + return ReturnHelper.errorResponse(res, 404, 404, Language.lang.failed_not_found, "File not found"); + } + + // Delete file + fs.unlinkSync(file_path); + + return ReturnHelper.successResponseAny(res, 200, Language.lang.success_delete, {}); + } catch (e: unknown) { + log.error(e); + const err = e as Error; + + return ReturnHelper.errorResponse(res, 500, 402, Language.lang.failed_delete, err.message); + } + } + + static async view(req: Request, res: Response, next: NextFunction): Promise { + /* + #swagger.tags = ['Pharmacy - PharmacyInfo'] + #swagger.security = [{ "bearerAuth": [] }] + */ + try { + const data = await OrmHelper.DB + .getRepository(PharmacyInfo) + .findOne({ + where: {} + }); + + if (!data) return ReturnHelper.errorResponse(res, 404, 404, Language.lang.failed_view, "data not found"); + + const result = { + id: data.id, + name: data.name, + address: data.address, + phone: data.phone, + logo: 'http://his.shiblysolution.id:3011/service-master-data/uploads/hospital-logo/' + data.logo + }; + + return ReturnHelper.successResponseAny(res, 200, Language.lang.success_view, result); + + } catch (e: unknown) { + log.error(e); + const err = e as Error; + return ReturnHelper.errorResponse(res,400,401,Language.lang.failed_view,err.message); + } + } +} \ No newline at end of file diff --git a/src/helpers/orm.ts b/src/helpers/orm.ts index 88979f9..e119087 100644 --- a/src/helpers/orm.ts +++ b/src/helpers/orm.ts @@ -1,7 +1,7 @@ import config from 'config'; import { DataSource } from "typeorm"; import { ILogObj, Logger } from 'tslog'; -import { Administrativu, Aldeia, District, Menu, Munisipo, Suco, Application, Nationality, Diagnosis, DiagnosticProcedure, Room, ServiceType, ServiceClass, Service, ServiceFare, ServicePackage, Department, DoctorSchedule, MeasurementUnit, ReferenceRange, QueueMonitoring, ExaminationDetail, ExaminationType, User, UserRole, HrmsEmployee, HrmsDepartment, HrmsPosition, HrmsShift,HospitalInformation, ItemOrigin, ItemType, ItemTypeDetail, Unit, ItemCategory, ItemStatus, UsageInstructions, UsageTime, ItemClass, ItemClassDetail, GenericName, Factory, Supplier, FactoryToSupplier, ItemMaster, ItemGroup } from 'entity' +import { Administrativu, Aldeia, District, Menu, Munisipo, Suco, Application, Nationality, Diagnosis, DiagnosticProcedure, Room, ServiceType, ServiceClass, Service, ServiceFare, ServicePackage, Department, DoctorSchedule, MeasurementUnit, ReferenceRange, QueueMonitoring, ExaminationDetail, ExaminationType, User, UserRole, HrmsEmployee, HrmsDepartment, HrmsPosition, HrmsShift,HospitalInformation, ItemOrigin, ItemType, ItemTypeDetail, Unit, ItemCategory, ItemStatus, UsageInstructions, UsageTime, ItemClass, ItemClassDetail, GenericName, Factory, Supplier, FactoryToSupplier, ItemMaster, ItemGroup, PharmacyInfo, ItemPrice, SellingPricePercentage } from 'entity' export class OrmHelper { static DB: DataSource = null @@ -35,7 +35,11 @@ export class OrmHelper { Factory, Supplier, FactoryToSupplier, - ItemMaster], + ItemMaster, + PharmacyInfo, + ItemPrice, + SellingPricePercentage + ], subscribers: [], migrations: [], }) diff --git a/src/model/pharmacy_info.ts b/src/model/pharmacy_info.ts new file mode 100644 index 0000000..ac8bfc3 --- /dev/null +++ b/src/model/pharmacy_info.ts @@ -0,0 +1,23 @@ +import { SelectQueryBuilder } from "typeorm"; +import CommonHelper from "../helpers/common"; +import { OrmHelper } from "../helpers/orm"; +import { PharmacyInfo } from "entity"; + +export class PharmacyInfoModel { + static async list(filter = {}): Promise> { + const repo = OrmHelper.DB.getRepository(PharmacyInfo); + let whereAttr: string[] = []; + let whereVal: any = {}; + if (filter && Object.keys(filter).length > 0) { + whereAttr = [...whereAttr, ...CommonHelper.handleQueryFilter(filter, "pharmacy_info").whereAttr]; + whereVal = { ...whereVal, ...CommonHelper.handleQueryFilter(filter, "pharmacy_info").whereVal }; + } + + var query = repo.createQueryBuilder("pharmacy_info").leftJoinAndSelect("pharmacy_info.default_room", "default_room"); + if (whereAttr.length != 0) { + query = query.where(whereAttr.join(" and "), whereVal); + } + + return query; + } +} \ No newline at end of file diff --git a/src/routes/private.ts b/src/routes/private.ts index 8b48158..f57f14b 100644 --- a/src/routes/private.ts +++ b/src/routes/private.ts @@ -42,6 +42,7 @@ import { ItemClassDetailController } from '../controllers/pharmacy/item_class_de import { FactoryController } from '../controllers/pharmacy/factory'; import { SupplierController } from '../controllers/pharmacy/supplier'; import { ItemMasterController } from '../controllers/pharmacy/item_master'; +import { PharmacyInfoController } from '../controllers/pharmacy/pharmacy_info'; export class RoutePrivate { static setup(app: express.Application) { @@ -332,5 +333,15 @@ export class RoutePrivate { app.put('/api/pharmacy/item-master/update/:id', ItemMasterController.update) app.delete('/api/pharmacy/item-master/delete/:id/:hard', ItemMasterController.delete) app.put('/api/pharmacy/item-master/restore/:id', ItemMasterController.restore) + + app.get('/api/pharmacy/pharmacy-info/list', PharmacyInfoController.list) + app.get('/api/pharmacy/pharmacy-info/export', PharmacyInfoController.export) + app.post('/api/pharmacy/pharmacy-info/create', PharmacyInfoController.create) + app.put('/api/pharmacy/pharmacy-info/update/:id', PharmacyInfoController.update) + app.delete('/api/pharmacy/pharmacy-info/delete/:id/:hard', PharmacyInfoController.delete) + app.put('/api/pharmacy/pharmacy-info/restore/:id', PharmacyInfoController.restore) + app.post('/api/pharmacy/pharmacy-info/upload-logo', PharmacyInfoController.uploadlogo) + app.delete('/api/pharmacy/pharmacy-info/delete-logo', PharmacyInfoController.deletelogo) + app.get('/api/pharmacy/pharmacy-info/view', PharmacyInfoController.view) } } \ No newline at end of file diff --git a/src/swagger/builder.js b/src/swagger/builder.js index d0a193d..26b15d4 100644 --- a/src/swagger/builder.js +++ b/src/swagger/builder.js @@ -272,6 +272,17 @@ const doc = { $strength: 'Strength value (number, minimum 0)', $active: 'Active status (boolean)' }, + pharmacy_info: { + $logo: 'Logo (string, max 256)', + $name: 'Name (string, max 256)', + $address: 'Address (string, max 500)', + $munisipiu: 'Munisipiu ID (UUID)', + $postu_admin: 'Postu Admin ID (UUID)', + $suco: 'Suco ID (UUID)', + $aldeia: 'Aldeia ID (UUID)', + $phone: 'Phone (string, max 30, optional)', + $default_room_id: 'Default Room ID (UUID)' + } }, parameters: {