diff --git a/prisma/migrations/20250310081357_property_table/migration.sql b/prisma/migrations/20250310081357_property_table/migration.sql new file mode 100644 index 0000000..47e145d --- /dev/null +++ b/prisma/migrations/20250310081357_property_table/migration.sql @@ -0,0 +1,16 @@ +-- CreateTable +CREATE TABLE "Property" ( + "id" TEXT NOT NULL, + "registeredBranchId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "nameEN" TEXT NOT NULL, + "type" JSONB NOT NULL, + "status" "Status" NOT NULL DEFAULT 'CREATED', + "statusOrder" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Property_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "Property" ADD CONSTRAINT "Property_registeredBranchId_fkey" FOREIGN KEY ("registeredBranchId") REFERENCES "Branch"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 71d3b60..2286ca9 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -318,6 +318,7 @@ model Branch { workflowTemplate WorkflowTemplate[] taskOrder TaskOrder[] notification Notification[] + property Property[] } model BranchBank { @@ -1004,6 +1005,23 @@ model Institution { taskOrder TaskOrder[] } +model Property { + id String @id @default(cuid()) + + registeredBranch Branch @relation(fields: [registeredBranchId], references: [id]) + registeredBranchId String + + name String + nameEN String + + type Json + + status Status @default(CREATED) + statusOrder Int @default(0) + + createdAt DateTime @default(now()) +} + model WorkflowTemplate { id String @id @default(cuid()) name String diff --git a/src/controllers/00-doc-template-controller.ts b/src/controllers/00-doc-template-controller.ts index c0e6fff..99dca2a 100644 --- a/src/controllers/00-doc-template-controller.ts +++ b/src/controllers/00-doc-template-controller.ts @@ -9,6 +9,9 @@ import HttpError from "../interfaces/http-error"; import HttpStatus from "../interfaces/http-status"; import { getFileBuffer, listFile } from "../utils/minio"; import { dateFormat } from "../utils/datetime"; +import { downloadFile as edmDownloadFile, list as edmList } from "../services/edm/edm-api"; + +const DOCUMENT_PATH = process.env.DOCUMENT_TEMPLATE_LOCATION?.split("/").filter(Boolean) || []; const quotationData = (id: string) => prisma.quotation.findFirst({ @@ -66,7 +69,14 @@ const quotationData = (id: string) => export class DocTemplateController extends Controller { @Get() async getTemplate() { - return await listFile(`doc-template/`); + if ( + process.env.DOCUMENT_TEMPLATE_PROVIDER && + process.env.DOCUMENT_TEMPLATE_PROVIDER === "edm-api" + ) { + const ret = await edmList("file", DOCUMENT_PATH); + if (ret) return ret.map((v) => v.fileName); + } + return await listFile(DOCUMENT_PATH.join("/") + "/"); } @Get("{documentTemplate}") @@ -122,7 +132,29 @@ export class DocTemplateController extends Controller { if (!data) throw notFoundError("Data"); if (dataOnly) return record; - const template = await getFileBuffer(`doc-template/${documentTemplate}`); + let template: Buffer | null = null; + + switch (process.env.DOCUMENT_TEMPLATE_PROVIDER) { + case "edm-api": + await edmDownloadFile(DOCUMENT_PATH, documentTemplate).then(async (payload) => { + if (!payload) return; + const res = await fetch(payload.downloadUrl); + if (!res.ok) return; + template = Buffer.from(await res.arrayBuffer()); + }); + break; + case "local": + default: + template = await getFileBuffer(`${DOCUMENT_PATH.join("/")}/${documentTemplate}`); + } + + if (!template) { + throw new HttpError( + HttpStatus.INTERNAL_SERVER_ERROR, + "Failed to get template file", + "templateGetFailed", + ); + } if (!data) Readable.from(template); diff --git a/src/controllers/00-stats-controller.ts b/src/controllers/00-stats-controller.ts index 8cb5d53..8de7899 100644 --- a/src/controllers/00-stats-controller.ts +++ b/src/controllers/00-stats-controller.ts @@ -12,14 +12,16 @@ import { } from "@prisma/client"; import { Controller, Get, Query, Request, Route, Security, Tags } from "tsoa"; import prisma from "../db"; -import { createPermCondition } from "../services/permission"; +import { createPermCondition, createQueryPermissionCondition } from "../services/permission"; import { RequestWithUser } from "../interfaces/user"; import { precisionRound } from "../utils/arithmetic"; import dayjs from "dayjs"; import { json2csv } from "json-2-csv"; import { isSystem } from "../utils/keycloak"; +import { jsonObjectFrom } from "kysely/helpers/postgres"; const permissionCondCompany = createPermCondition((_) => true); +const permissionQueryCondCompany = createQueryPermissionCondition((_) => true); const VAT_DEFAULT = config.vat; @@ -645,125 +647,61 @@ export class StatsController extends Controller { @Get("customer-dept") async reportCustomerDept(@Request() req: RequestWithUser) { let query = prisma.$kysely - .selectFrom("Quotation") - .leftJoin("Invoice", "Quotation.id", "Invoice.quotationId") + .selectFrom("Invoice") + .leftJoin("Quotation", "Quotation.id", "Invoice.quotationId") .leftJoin("Payment", "Invoice.id", "Payment.invoiceId") .leftJoin("CustomerBranch", "CustomerBranch.id", "Quotation.customerBranchId") .leftJoin("Customer", "Customer.id", "CustomerBranch.customerId") - .select([ - "CustomerBranch.id as customerBranchId", - "CustomerBranch.code as customerBranchCode", - "CustomerBranch.registerName as customerBranchRegisterName", - "CustomerBranch.registerNameEN as customerBranchRegisterNameEN", - "CustomerBranch.firstName as customerBranchFirstName", - "CustomerBranch.firstNameEN as customerBranchFirstNameEN", - "CustomerBranch.lastName as customerBranchFirstName", - "CustomerBranch.lastNameEN as customerBranchFirstNameEN", - "Customer.customerType", - "Quotation.id as quotationId", - "Quotation.code as quotationCode", - "Quotation.finalPrice as quotationValue", + .select((eb) => [ + jsonObjectFrom( + eb + .selectFrom("CustomerBranch") + .select((eb) => [ + jsonObjectFrom( + eb + .selectFrom("Customer") + .selectAll("Customer") + .whereRef("Customer.id", "=", "CustomerBranch.customerId"), + ).as("customer"), + ]) + .selectAll("CustomerBranch") + .whereRef("CustomerBranch.id", "=", "Quotation.customerBranchId"), + ).as("customerBranch"), ]) .select(["Payment.paymentStatus"]) .selectAll(["Invoice"]) - .distinctOn("Quotation.id"); + .distinctOn("Invoice.id"); if (!isSystem(req.user)) { - query = query.where(({ eb, exists }) => - exists( - eb - .selectFrom("Branch") - .leftJoin("BranchUser", "BranchUser.branchId", "Branch.id") - .leftJoin("Branch as SubBranch", "SubBranch.headOfficeId", "Branch.id") - .leftJoin("BranchUser as SubBranchUser", "SubBranchUser.branchId", "SubBranch.id") - .leftJoin("Branch as HeadBranch", "HeadBranch.id", "Branch.id") - .leftJoin("BranchUser as HeadBranchUser", "HeadBranchUser.branchId", "HeadBranch.id") - .leftJoin("Branch as SubHeadBranch", "SubHeadBranch.headOfficeId", "HeadBranch.id") - .leftJoin( - "BranchUser as SubHeadBranchUser", - "SubHeadBranchUser.branchId", - "SubHeadBranch.id", - ) - .where((eb) => { - const cond = [ - eb("BranchUser.userId", "=", req.user.sub), // NOTE: if user belong to current branch. - eb("SubBranchUser.userId", "=", req.user.sub), // NOTE: if user belong to branch under current branch. - eb("HeadBranchUser.userId", "=", req.user.sub), // NOTE: if the current branch is under head branch user belong to. - eb("SubHeadBranchUser.userId", "=", req.user.sub), // NOTE: if the current branch is under the same head branch user belong to. - ]; - return eb.or(cond); - }) - .select("Branch.id"), - ), - ); + query = query.where(permissionQueryCondCompany(req.user)); } const ret = await query.execute(); - const arr = ret.map((item) => { - const data: Record = {}; - for (const [key, val] of Object.entries(item)) { - if (key.startsWith("customerBranch")) { - if (!data["customerBranch"]) data["customerBranch"] = {}; - data["customerBranch"][key.slice(14).slice(0, 1).toLowerCase() + key.slice(14).slice(1)] = - val; - } else if (key.startsWith("customerType")) { - data["customerBranch"]["customer"] = { customerType: val }; - } else { - data[key as keyof typeof data] = val; - } - } - return data as Invoice & { - quotationId: string; - quotationCode: string; - quotationValue: number; - paymentStatus: PaymentStatus; - customerBranch: CustomerBranch & { customer: { customerType: CustomerType } }; - }; - }); - return arr + return ret .reduce< { paid: number; unpaid: number; - customerBranch: CustomerBranch & { customer: { customerType: CustomerType } }; - _quotation: { id: string; code: string; value: number }[]; + customerBranch: CustomerBranch & { customer: Customer }; }[] >((acc, item) => { - const exists = acc.find((v) => v.customerBranch.id === item.customerBranch.id); + const exists = acc.find((v) => v.customerBranch.id === item.customerBranch!.id); - const quotation = { - id: item.quotationId, - code: item.quotationCode, - value: - item.quotationValue - - (item.paymentStatus === "PaymentSuccess" && item.amount ? item.amount : 0), - }; + if (!item.amount) return acc; if (!exists) { return acc.concat({ - _quotation: [quotation], - customerBranch: item.customerBranch, - paid: item.paymentStatus === "PaymentSuccess" && item.amount ? item.amount : 0, - unpaid: quotation.value, + customerBranch: item.customerBranch as CustomerBranch & { customer: Customer }, + paid: item.paymentStatus === "PaymentSuccess" ? item.amount : 0, + unpaid: item.paymentStatus !== "PaymentSuccess" ? item.amount : 0, }); + } else { + exists[item.paymentStatus === "PaymentSuccess" ? "paid" : "unpaid"] += item.amount; } - const same = exists._quotation.find((v) => v.id === item.quotationId); - - if (item.paymentStatus === "PaymentSuccess" && item.amount) { - exists.paid += item.amount; - if (same) same.value -= item.amount; - } - - if (!same) exists._quotation.push(quotation); - - exists.unpaid = exists._quotation.reduce((a, c) => a + c.value, 0); - return acc; }, []) - .map((v) => { - return { ...v, _quotation: undefined }; - }); + .map((v) => ({ ...v, _quotation: undefined })); } } diff --git a/src/controllers/04-properties-controller.ts b/src/controllers/04-properties-controller.ts new file mode 100644 index 0000000..46cea13 --- /dev/null +++ b/src/controllers/04-properties-controller.ts @@ -0,0 +1,187 @@ +import { + Body, + Controller, + Delete, + Get, + Path, + Post, + Put, + Query, + Request, + Route, + Security, + Tags, +} from "tsoa"; +import { RequestWithUser } from "../interfaces/user"; +import prisma from "../db"; +import { Prisma, Status } from "@prisma/client"; +import { + branchRelationPermInclude, + createPermCheck, + createPermCondition, +} from "../services/permission"; +import HttpError from "../interfaces/http-error"; +import HttpStatus from "../interfaces/http-status"; +import { notFoundError } from "../utils/error"; +import { filterStatus } from "../services/prisma"; +import { queryOrNot } from "../utils/relation"; + +type PropertyPayload = { + name: string; + nameEN: string; + type: Record; + registeredBranchId?: string; + status?: Status; +}; + +const permissionCondCompany = createPermCondition((_) => true); +const permissionCheckCompany = createPermCheck((_) => true); + +@Route("api/v1/property") +@Tags("Property") +@Security("keycloak") +export class PropertiesController extends Controller { + @Get() + async getProperties( + @Request() req: RequestWithUser, + @Query() page: number = 1, + @Query() pageSize: number = 30, + @Query() status?: Status, + @Query() query = "", + @Query() activeOnly?: boolean, + ) { + const where = { + OR: queryOrNot(query, [{ name: { contains: query } }, { nameEN: { contains: query } }]), + AND: { + ...filterStatus(activeOnly ? Status.ACTIVE : status), + registeredBranch: { + OR: permissionCondCompany(req.user, { activeOnly: true }), + }, + }, + } satisfies Prisma.PropertyWhereInput; + const [result, total] = await prisma.$transaction([ + prisma.property.findMany({ + where, + orderBy: [{ statusOrder: "asc" }, { createdAt: "asc" }], + take: pageSize, + skip: (page - 1) * pageSize, + }), + prisma.property.count({ where }), + ]); + + return { + result, + page, + pageSize, + total, + }; + } + + @Get("{propertyId}") + async getPropertyById(@Request() _req: RequestWithUser, @Path() propertyId: string) { + const record = await prisma.property.findFirst({ + where: { id: propertyId }, + orderBy: { createdAt: "asc" }, + }); + + if (!record) throw notFoundError("Property"); + + return record; + } + + @Post() + async createProperty(@Request() req: RequestWithUser, @Body() body: PropertyPayload) { + const where = { + OR: [{ name: { contains: body.name } }, { nameEN: { contains: body.nameEN } }], + AND: { + registeredBranch: { + OR: permissionCondCompany(req.user), + }, + }, + } satisfies Prisma.PropertyWhereInput; + + const exists = await prisma.property.findFirst({ where }); + + if (exists) { + throw new HttpError( + HttpStatus.BAD_REQUEST, + "Property with this name already exists", + "samePropertyNameExists", + ); + } + + const userAffiliatedBranch = await prisma.branch.findFirst({ + include: branchRelationPermInclude(req.user), + where: body.registeredBranchId + ? { id: body.registeredBranchId } + : { + user: { some: { userId: req.user.sub } }, + }, + }); + if (!userAffiliatedBranch) { + throw new HttpError( + HttpStatus.BAD_REQUEST, + "You must be affilated with at least one branch or specify branch to be registered (System permission required).", + "reqMinAffilatedBranch", + ); + } + + await permissionCheckCompany(req.user, userAffiliatedBranch); + + return await prisma.property.create({ + data: { + ...body, + statusOrder: +(body.status === "INACTIVE"), + registeredBranchId: userAffiliatedBranch.id, + }, + }); + } + + @Put("{propertyId}") + async updatePropertyById( + @Request() req: RequestWithUser, + @Path() propertyId: string, + @Body() body: PropertyPayload, + ) { + const record = await prisma.property.findUnique({ + where: { id: propertyId }, + include: { + registeredBranch: { + include: branchRelationPermInclude(req.user), + }, + }, + }); + + if (!record) throw notFoundError("Property"); + + await permissionCheckCompany(req.user, record.registeredBranch); + + return await prisma.property.update({ + where: { id: propertyId }, + data: { + ...body, + statusOrder: +(body.status === "INACTIVE"), + }, + }); + } + + @Delete("{propertyId}") + async deletePropertyById(@Request() req: RequestWithUser, @Path() propertyId: string) { + const record = await prisma.property.findUnique({ + where: { id: propertyId }, + include: { + registeredBranch: { + include: branchRelationPermInclude(req.user), + }, + }, + }); + + if (!record) throw notFoundError("Property"); + + await permissionCheckCompany(req.user, record.registeredBranch); + + return await prisma.property.delete({ + where: { id: propertyId }, + }); + } +} diff --git a/src/interfaces/edm.ts b/src/interfaces/edm.ts new file mode 100644 index 0000000..63d0769 --- /dev/null +++ b/src/interfaces/edm.ts @@ -0,0 +1,39 @@ +export interface StorageFolder { + /** + * @prop Full path to this folder. It is used as key as there are no files or directories at the same location. + */ + pathname: string; + /** + * @prop Directory / Folder name. + */ + name: string; + + createdAt: string | Date; + createdBy: string | Date; +} + +export interface StorageFile { + /** + * @prop Full path to this folder. It is used as key as there are no files or directories at the same location. + */ + pathname: string; + + fileName: string; + fileSize: number; + fileType: string; + + title: string; + description: string; + author: string; + category: string[]; + keyword: string[]; + metadata: Record; + + path: string; + upload: boolean; + + updatedAt: string | Date; + updatedBy: string; + createdAt: string | Date; + createdBy: string; +} diff --git a/src/services/edm/edm-api.ts b/src/services/edm/edm-api.ts new file mode 100644 index 0000000..29d3a15 --- /dev/null +++ b/src/services/edm/edm-api.ts @@ -0,0 +1,170 @@ +import { DecodedJwt, createDecoder } from "fast-jwt"; +import HttpError from "../../interfaces/http-error"; +import HttpStatus from "../../interfaces/http-status"; +import { StorageFile, StorageFolder } from "../../interfaces/edm"; + +const jwtDecode = createDecoder({ complete: true }); + +export type FileProps = Partial< + Pick +> & { + metadata?: { [key: string]: unknown }; +}; + +const STORAGE_KEYCLOAK = process.env.EDM_KEYCLOAK!; +const STORAGE_KEYCLOAK_CLIENT = process.env.EDM_KEYCLOAK_CLIENT!; +const STORAGE_REALM = process.env.EDM_REALM!; +const STORAGE_URL = process.env.EDM_URL!; +const STORAGE_USER = process.env.EDM_ADMIN_USER!; +const STORAGE_PASSWORD = process.env.EDM_ADMIN_PASSWORD!; + +let token: string | null = null; +let decoded: DecodedJwt | null = null; + +/** + * Check if token is expired or will expire in 30 seconds + * @returns true if expire or can't get exp, false otherwise + */ +export function expireCheck(token: string, beforeExpire: number = 30) { + decoded = jwtDecode(token); + + if (decoded && decoded.payload.exp) { + return Date.now() / 1000 >= decoded.payload.exp - beforeExpire; + } + return true; +} + +/** + * Get token from id service if needed + */ +export async function getToken() { + if (!token || expireCheck(token)) { + const body = new URLSearchParams(); + + body.append("scope", "openid"); + body.append("grant_type", "password"); + body.append("client_id", STORAGE_KEYCLOAK_CLIENT || "edm"); + body.append("username", STORAGE_USER); + body.append("password", STORAGE_PASSWORD); + + const res = await fetch( + `${STORAGE_KEYCLOAK}/realms/${STORAGE_REALM}/protocol/openid-connect/token`, + { + method: "POST", + body: body, + }, + ).catch((e) => console.error(e)); + + if (!res) return; + + const data = await res.json(); + + if (data && data.access_token) { + token = data.access_token; + } + } + return token; +} + +/** + * @param path - Path that new folder will live + * @param name - Name of the folder to create + * @param recursive - Will create parent automatically + */ +export async function createFolder(path: string[], name: string, recursive: boolean = false) { + if (recursive && path.length > 0) { + await createFolder(path.slice(0, -1), path[path.length - 1], true); + } + + const res = await fetch(`${STORAGE_URL}/storage/folder`, { + method: "POST", + headers: { + Authorization: `Bearer ${await getToken()}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ path, name }), + }).catch((e) => console.error(e)); + + if (!res || !res.ok) { + return Boolean(console.error(res ? await res.json() : res)); + } + return true; +} + +/** + * @param path - Path that new file will live + * @param file - Name of the file to create + */ +export async function createFile(path: string[], file: string, props?: FileProps) { + const res = await fetch(`${STORAGE_URL}/storage/file`, { + method: "POST", + headers: { + Authorization: `Bearer ${await getToken()}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + ...props, + path, + file, + hidden: path.some((v) => v.startsWith(".")), + }), + }).catch((e) => console.error(e)); + + if (!res || !res.ok) { + return Boolean(console.error(res ? await res.json() : res)); + } + return (await res.json()) as StorageFile & { uploadUrl: string }; +} + +export async function list( + operation: "file" | "folder", + path: string[], +): Promise { + const res = await fetch(`${STORAGE_URL}/storage/list`, { + method: "POST", + headers: { + Authorization: `Bearer ${await getToken()}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ operation, path, hidden: true }), + }).catch((e) => console.error(e)); + + if (!res || !res.ok) { + if (res && res.status === HttpStatus.NOT_FOUND) { + return []; + } + return Boolean(console.error(res ? await res.json() : res)) as false; + } + return await res.json(); +} + +export async function listFolder(path: string[]) { + return (await list("folder", path)) as StorageFolder[] | boolean; +} + +export async function listFile(path: string[]) { + return (await list("file", path)) as StorageFile[] | boolean; +} + +export async function downloadFile( + path: string[], + file: string, +): Promise { + const res = await fetch(`${STORAGE_URL}/storage/file/download`, { + method: "POST", + headers: { + Authorization: `Bearer ${await getToken()}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ path, file }), + }).catch((e) => console.error(e)); + + if (!res || !res.ok) { + if (res && res.status === HttpStatus.NOT_FOUND) { + throw new HttpError(HttpStatus.NOT_FOUND, "ไม่พบไฟล์ในระบบ"); + } + console.error(res ? await res.json() : res); + return false; + } + return await res.json(); +} diff --git a/src/services/permission.ts b/src/services/permission.ts index 7ac7f66..88f4f6e 100644 --- a/src/services/permission.ts +++ b/src/services/permission.ts @@ -4,6 +4,8 @@ import HttpError from "../interfaces/http-error"; import HttpStatus from "../interfaces/http-status"; import { RequestWithUser } from "../interfaces/user"; import { isSystem } from "../utils/keycloak"; +import { ExpressionBuilder } from "kysely"; +import { DB } from "../generated/kysely/types"; export function branchRelationPermInclude(user: RequestWithUser["user"]) { return { @@ -133,3 +135,47 @@ export function createPermCheck(globalAllow: (user: RequestWithUser["user"]) => return branch; }; } + +export function createQueryPermissionCondition( + globalAllow: (user: RequestWithUser["user"]) => boolean, + opts?: { alwaysIncludeHead?: boolean }, +) { + return (user: RequestWithUser["user"]) => + ({ eb, exists }: ExpressionBuilder) => + exists( + eb + .selectFrom("Branch") + .leftJoin("BranchUser", "BranchUser.branchId", "Branch.id") + .leftJoin("Branch as SubBranch", "SubBranch.headOfficeId", "Branch.id") + .leftJoin("BranchUser as SubBranchUser", "SubBranchUser.branchId", "SubBranch.id") + .leftJoin("Branch as HeadBranch", "HeadBranch.id", "Branch.id") + .leftJoin("BranchUser as HeadBranchUser", "HeadBranchUser.branchId", "HeadBranch.id") + .leftJoin("Branch as SubHeadBranch", "SubHeadBranch.headOfficeId", "HeadBranch.id") + .leftJoin( + "BranchUser as SubHeadBranchUser", + "SubHeadBranchUser.branchId", + "SubHeadBranch.id", + ) + .where((eb) => { + const cond = [ + eb("BranchUser.userId", "=", user.sub), // NOTE: if user belong to current branch. + ]; + + if (globalAllow?.(user) || opts?.alwaysIncludeHead) { + cond.push( + eb("SubBranchUser.userId", "=", user.sub), // NOTE: if user belong to branch under current branch. + ); + } + + if (globalAllow(user)) { + cond.push( + eb("HeadBranchUser.userId", "=", user.sub), // NOTE: if the current branch is under head branch user belong to. + eb("SubHeadBranchUser.userId", "=", user.sub), // NOTE: if the current branch is under the same head branch user belong to. + ); + } + + return eb.or(cond); + }) + .select("Branch.id"), + ); +} diff --git a/tsoa.json b/tsoa.json index f9a463c..dec28e5 100644 --- a/tsoa.json +++ b/tsoa.json @@ -41,6 +41,7 @@ { "name": "Employee Other Info" }, { "name": "Institution" }, { "name": "Workflow" }, + { "name": "Property" }, { "name": "Product Group" }, { "name": "Product" }, { "name": "Work" },