diff --git a/src/command/queryStoreCommand.ts b/src/command/queryStoreCommand.ts new file mode 100644 index 0000000..7043159 --- /dev/null +++ b/src/command/queryStoreCommand.ts @@ -0,0 +1,15 @@ +import { DynamicQueryStructure } from "../type/dynamicQueries"; + +export interface CreateQueryStoreCommand { + title: string; + query: string | DynamicQueryStructure; +} + +export interface UpdateQueryStoreCommand { + id: number; + query: string | DynamicQueryStructure; +} + +export interface DeleteQueryStoreCommand { + id: number; +} diff --git a/src/command/queryStoreCommandHandler.ts b/src/command/queryStoreCommandHandler.ts new file mode 100644 index 0000000..7eeb80c --- /dev/null +++ b/src/command/queryStoreCommandHandler.ts @@ -0,0 +1,69 @@ +import { dataSource } from "../data-source"; +import { query } from "../entity/query"; +import InternalException from "../exceptions/internalException"; +import { CreateQueryStoreCommand, DeleteQueryStoreCommand, UpdateQueryStoreCommand } from "./queryStoreCommand"; + +export default abstract class QueryStoreCommandHandler { + /** + * @description create queryStore + * @param CreateQueryStoreCommand + * @returns {Promise} + */ + static async create(createQueryStore: CreateQueryStoreCommand): Promise { + return await dataSource + .createQueryBuilder() + .insert() + .into(query) + .values({ + title: createQueryStore.title, + query: + typeof createQueryStore.query == "string" ? createQueryStore.query : JSON.stringify(createQueryStore.query), + }) + .execute() + .then((result) => { + return result.identifiers[0].id; + }) + .catch((err) => { + throw new InternalException("Failed creating queryStore", err); + }); + } + + /** + * @description update queryStore + * @param UpdateQueryStoreCommand + * @returns {Promise} + */ + static async update(updateQueryStore: UpdateQueryStoreCommand): Promise { + return await dataSource + .createQueryBuilder() + .update(query) + .set({ + query: + typeof updateQueryStore.query == "string" ? updateQueryStore.query : JSON.stringify(updateQueryStore.query), + }) + .where("id = :id", { id: updateQueryStore.id }) + .execute() + .then(() => {}) + .catch((err) => { + throw new InternalException("Failed updating queryStore", err); + }); + } + + /** + * @description delete queryStore + * @param DeleteQueryStoreCommand + * @returns {Promise} + */ + static async delete(deletQueryStore: DeleteQueryStoreCommand): Promise { + return await dataSource + .createQueryBuilder() + .delete() + .from(query) + .where("id = :id", { id: deletQueryStore.id }) + .execute() + .then(() => {}) + .catch((err) => { + throw new InternalException("Failed deleting queryStore", err); + }); + } +} diff --git a/src/controller/admin/queryBuilderController.ts b/src/controller/admin/queryBuilderController.ts new file mode 100644 index 0000000..be4a68e --- /dev/null +++ b/src/controller/admin/queryBuilderController.ts @@ -0,0 +1,108 @@ +import { Request, Response } from "express"; +import DynamicQueryBuilder from "../../helpers/dynamicQueryBuilder"; +import { dataSource } from "../../data-source"; + +/** + * @description get all table metas + * @param req {Request} Express req object + * @param res {Response} Express res object + * @returns {Promise<*>} + */ +export async function getAllTableMeta(req: Request, res: Response): Promise { + let tableMetas = DynamicQueryBuilder.getAllTableMeta(); + + res.json(tableMetas); +} + +/** + * @description get meta by tablename + * @param req {Request} Express req object + * @param res {Response} Express res object + * @returns {Promise<*>} + */ +export async function getTableMetaByTablename(req: Request, res: Response): Promise { + const tablename = req.params.tablename; + let tableMeta = DynamicQueryBuilder.getTableMeta(tablename); + + res.json(tableMeta); +} + +/** + * @description execute Query + * @param req {Request} Express req object + * @param res {Response} Express res object + * @returns {Promise<*>} + */ +export async function executeQuery(req: Request, res: Response): Promise { + let offset = parseInt((req.query.offset as string) ?? "0"); + let count = parseInt((req.query.count as string) ?? "25"); + const query = req.body.query; + + if (typeof query == "string") { + const upperQuery = query.trim().toUpperCase(); + if (!upperQuery.startsWith("SELECT") || /INSERT|UPDATE|DELETE|ALTER|DROP|CREATE|TRUNCATE/.test(upperQuery)) { + return res.json({ + stats: "error", + sql: query, + code: "UNALLOWED", + msg: "Not allowed to change rows", + }); + } + + try { + let data: Array = []; + + const result = await dataSource + .transaction(async (manager) => { + data = await manager.query(query); + + throw new Error("AllwaysRollbackQuery"); + }) + .catch((error) => { + if (error.message === "AllwaysRollbackQuery") { + return { + stats: "success", + rows: data, + total: data.length, + offset: offset, + count: count, + }; + } else { + return { + stats: "error", + sql: error.sql, + code: error.code, + msg: error.sqlMessage, + }; + } + }); + res.send(result); + } catch (error) { + res.json({ + stats: "error", + sql: error.sql, + code: error.code, + msg: error.sqlMessage, + }); + } + } else { + try { + let [rows, total] = await DynamicQueryBuilder.buildQuery(query, offset, count).getManyAndCount(); + + res.json({ + stats: "success", + rows: rows, + total: total, + offset: offset, + count: count, + }); + } catch (error) { + res.json({ + stats: "error", + sql: error.sql, + code: error.code, + msg: error.sqlMessage, + }); + } + } +} diff --git a/src/controller/admin/queryStoreController.ts b/src/controller/admin/queryStoreController.ts new file mode 100644 index 0000000..6c6e9c6 --- /dev/null +++ b/src/controller/admin/queryStoreController.ts @@ -0,0 +1,93 @@ +import { Request, Response } from "express"; +import QueryStoreFactory from "../../factory/admin/queryStore"; +import QueryStoreService from "../../service/queryStoreService"; +import { + CreateQueryStoreCommand, + DeleteQueryStoreCommand, + UpdateQueryStoreCommand, +} from "../../command/queryStoreCommand"; +import QueryStoreCommandHandler from "../../command/queryStoreCommandHandler"; + +/** + * @description get all queryStores + * @param req {Request} Express req object + * @param res {Response} Express res object + * @returns {Promise<*>} + */ +export async function getAllQueryStores(req: Request, res: Response): Promise { + let queryStores = await QueryStoreService.getAll(); + + res.json(QueryStoreFactory.mapToBase(queryStores)); +} + +/** + * @description get queryStore by id + * @param req {Request} Express req object + * @param res {Response} Express res object + * @returns {Promise<*>} + */ +export async function getQueryStoreById(req: Request, res: Response): Promise { + const id = parseInt(req.params.id); + + let queryStore = await QueryStoreService.getById(id); + + res.json(QueryStoreFactory.mapToSingle(queryStore)); +} + +/** + * @description create new queryStore + * @param req {Request} Express req object + * @param res {Response} Express res object + * @returns {Promise<*>} + */ +export async function createQueryStore(req: Request, res: Response): Promise { + const query = req.body.query; + const title = req.body.title; + + let createQueryStore: CreateQueryStoreCommand = { + title: title, + query: query, + }; + + let id = await QueryStoreCommandHandler.create(createQueryStore); + + res.status(200).send(id); +} + +/** + * @description update queryStore + * @param req {Request} Express req object + * @param res {Response} Express res object + * @returns {Promise<*>} + */ +export async function updateQueryStore(req: Request, res: Response): Promise { + const id = parseInt(req.params.id); + const query = req.body.query; + + let updateQueryStore: UpdateQueryStoreCommand = { + id: id, + query: query, + }; + + await QueryStoreCommandHandler.update(updateQueryStore); + + res.sendStatus(204); +} + +/** + * @description delete queryStore + * @param req {Request} Express req object + * @param res {Response} Express res object + * @returns {Promise<*>} + */ +export async function deleteQueryStore(req: Request, res: Response): Promise { + const id = parseInt(req.params.id); + + let deleteQueryStore: DeleteQueryStoreCommand = { + id: id, + }; + + await QueryStoreCommandHandler.delete(deleteQueryStore); + + res.sendStatus(204); +} diff --git a/src/data-source.ts b/src/data-source.ts index 5271c7c..685d05c 100644 --- a/src/data-source.ts +++ b/src/data-source.ts @@ -44,6 +44,13 @@ import { reset } from "./entity/reset"; import { ResetToken1732358596823 } from "./migrations/1732358596823-resetToken"; import { SMSAlarming1732696919191 } from "./migrations/1732696919191-SMSAlarming"; import { SecuringCalendarType1733249553766 } from "./migrations/1733249553766-securingCalendarType"; +import { query } from "./entity/query"; +import { QueryStore1734187754677 } from "./migrations/1734187754677-queryStore"; +import { memberView } from "./views/memberView"; +import { memberExecutivePositionsView } from "./views/memberExecutivePositionView"; +import { memberQualificationsView } from "./views/memberQualificationsView"; +import { membershipView } from "./views/membershipsView"; +import { MemberDataViews1734520998539 } from "./migrations/1734520998539-memberDataViews"; const dataSource = new DataSource({ type: DB_TYPE as any, @@ -82,6 +89,11 @@ const dataSource = new DataSource({ protocolPrintout, calendar, calendarType, + query, + memberView, + memberExecutivePositionsView, + memberQualificationsView, + membershipView, ], migrations: [ Initial1724317398939, @@ -98,6 +110,8 @@ const dataSource = new DataSource({ ResetToken1732358596823, SMSAlarming1732696919191, SecuringCalendarType1733249553766, + QueryStore1734187754677, + MemberDataViews1734520998539, ], migrationsRun: true, migrationsTransactionMode: "each", diff --git a/src/entity/query.ts b/src/entity/query.ts new file mode 100644 index 0000000..eb1bfe7 --- /dev/null +++ b/src/entity/query.ts @@ -0,0 +1,13 @@ +import { Column, Entity, PrimaryColumn } from "typeorm"; + +@Entity() +export class query { + @PrimaryColumn({ generated: "increment", type: "int" }) + id: number; + + @Column({ type: "varchar", length: 255, unique: true }) + title: string; + + @Column({ type: "text", default: "" }) + query: string; +} diff --git a/src/factory/admin/queryStore.ts b/src/factory/admin/queryStore.ts new file mode 100644 index 0000000..7574ab8 --- /dev/null +++ b/src/factory/admin/queryStore.ts @@ -0,0 +1,26 @@ +import { query } from "../../entity/query"; +import { QueryStoreViewModel } from "../../viewmodel/admin/queryStore.models"; + +export default abstract class QueryStoreFactory { + /** + * @description map record to queryStore + * @param {queryStore} record + * @returns {QueryStoreViewModel} + */ + public static mapToSingle(record: query): QueryStoreViewModel { + return { + id: record.id, + title: record.title, + query: record.query.startsWith("{") ? JSON.parse(record.query) : record.query, + }; + } + + /** + * @description map records to queryStore + * @param {Array} records + * @returns {Array} + */ + public static mapToBase(records: Array): Array { + return records.map((r) => this.mapToSingle(r)); + } +} diff --git a/src/helpers/dynamicQueryBuilder.ts b/src/helpers/dynamicQueryBuilder.ts new file mode 100644 index 0000000..437fd9c --- /dev/null +++ b/src/helpers/dynamicQueryBuilder.ts @@ -0,0 +1,232 @@ +import { Brackets, DataSource, NotBrackets, ObjectLiteral, SelectQueryBuilder, WhereExpressionBuilder } from "typeorm"; +import { dataSource } from "../data-source"; +import { ConditionStructure, DynamicQueryStructure } from "../type/dynamicQueries"; +import { TableMeta } from "../type/tableMeta"; + +export default abstract class DynamicQueryBuilder { + public static allowedTables: Array = [ + "award", + "communication", + "communicationType", + "executivePosition", + "membershipStatus", + "qualification", + "member", + "memberAwards", + "memberExecutivePositions", + "memberQualifications", + "membership", + "memberView", + "memberExecutivePositionsView", + "memberQualificationsView", + "membershipView", + ]; + + public static getTableMeta(tableName: string): TableMeta { + let { name, columns, relations } = dataSource.getMetadata(tableName); + + const uniqueColumns = columns.map((c) => ({ column: c.propertyName, type: c.type })); + + return { + tableName: name, + columns: [ + ...uniqueColumns, + ...relations + .filter((r) => !uniqueColumns.some((c) => r.propertyName == c.column)) + .map((r) => ({ + column: r.propertyName, + type: r.inverseEntityMetadata?.columns.find((col) => col.propertyName === r.inverseSidePropertyPath)?.type, + })), + ], + relations: relations.map((r) => ({ + column: r.propertyName, + relationType: r.relationType, + referencedTableName: r.inverseEntityMetadata?.tableName, + })), + }; + } + + public static getAllTableMeta(): Array { + return this.allowedTables.map((table) => this.getTableMeta(table)); + } + + public static buildQuery( + queryObj: DynamicQueryStructure, + offset: number = 0, + count: number = 25 + ): SelectQueryBuilder { + let affix = Math.random().toString(36).substring(2); + let query = dataSource.getRepository(queryObj.table).createQueryBuilder(`${queryObj.table}_${affix}`); + + this.buildDynamicQuery(query, queryObj, affix); + + query.offset(offset); + query.limit(count); + + return query; + } + + private static buildDynamicQuery( + query: SelectQueryBuilder, + queryObject: DynamicQueryStructure, + affix: string = "", + depth: number = 0 + ): void { + const alias = queryObject.table + "_" + affix; + let firstSelect = true; + let selects: Array = []; + + if (queryObject.select == "*") { + let meta = this.getTableMeta(queryObject.table); + let relCols = meta.relations.map((r) => r.column); + selects = meta.columns.map((c) => c.column).filter((c) => !relCols.includes(c)); + } else { + selects = queryObject.select; + } + + for (const select of selects) { + if (firstSelect && depth == 0) { + query.select(`${alias}.${select}`); + firstSelect = false; + } else { + query.addSelect(`${alias}.${select}`); + } + } + + if (queryObject.where) { + this.applyWhere(query, queryObject.where, alias); + } + + if (queryObject.join) { + for (const join of queryObject.join) { + let subaffix = Math.random().toString(36).substring(2); + query.leftJoinAndSelect(`${alias}.${join.foreignColumn}`, join.table + "_" + subaffix); + + this.buildDynamicQuery(query, join, subaffix, depth + 1); + } + } + + if (queryObject.orderBy) { + queryObject.orderBy.forEach((order) => { + query.addOrderBy(`${alias}.${order.column}`, order.order); + }); + } + } + + public static applyWhere( + query: SelectQueryBuilder | WhereExpressionBuilder, + conditions: Array, + alias: string + ): void { + for (const condition of conditions) { + if (condition.structureType == "condition") { + const whereClause = this.buildConditionClause(condition, alias); + + if (condition.concat == "_" || condition.concat == "AND") { + query.andWhere(whereClause.query, whereClause.parameters); + } else { + query.orWhere(whereClause.query, whereClause.parameters); + } + } else { + if (condition.concat == "_" || condition.concat == "AND") { + query.andWhere( + condition.invert == undefined || condition.invert == true + ? new Brackets((qb) => { + this.applyWhere(qb, condition.conditions, alias); + }) + : new NotBrackets((qb) => { + this.applyWhere(qb, condition.conditions, alias); + }) + ); + } else { + query.orWhere( + condition.invert == undefined || condition.invert == true + ? new Brackets((qb) => { + this.applyWhere(qb, condition.conditions, alias); + }) + : new NotBrackets((qb) => { + this.applyWhere(qb, condition.conditions, alias); + }) + ); + } + } + } + } + + private static buildConditionClause( + condition: ConditionStructure & { structureType: "condition" }, + alias: string + ): { query: string; parameters: Record } { + const parameterKey = `${alias}_${condition.column}_${Math.random().toString(36).substring(2)}`; + let query = `${alias}.${condition.column}`; + let parameters: Record = {}; + + switch (condition.operation) { + case "eq": + query += ` = :${parameterKey}`; + parameters[parameterKey] = condition.value; + break; + case "neq": + query += ` != :${parameterKey}`; + parameters[parameterKey] = condition.value; + break; + case "lt": + query += ` < :${parameterKey}`; + parameters[parameterKey] = condition.value; + break; + case "lte": + query += ` <= :${parameterKey}`; + parameters[parameterKey] = condition.value; + break; + case "gt": + query += ` > :${parameterKey}`; + parameters[parameterKey] = condition.value; + break; + case "gte": + query += ` >= :${parameterKey}`; + parameters[parameterKey] = condition.value; + break; + case "in": + query += ` IN (:...${parameterKey})`; + parameters[parameterKey] = condition.value; + break; + case "notIn": + query += ` NOT IN (:...${parameterKey})`; + parameters[parameterKey] = condition.value; + break; + case "between": + query += ` BETWEEN :${parameterKey}_start AND :${parameterKey}_end`; + parameters[`${parameterKey}_start`] = (condition.value as { start: any }).start; + parameters[`${parameterKey}_end`] = (condition.value as { end: any }).end; + break; + case "null": + query += ` IS NULL`; + break; + case "notNull": + query += ` IS NOT NULL`; + break; + case "contains": + query += ` LIKE :${parameterKey}`; + parameters[parameterKey] = `%${condition.value}%`; + break; + case "notContains": + query += ` NOT LIKE :${parameterKey}`; + parameters[parameterKey] = `%${condition.value}%`; + break; + case "startsWith": + query += ` LIKE :${parameterKey}`; + parameters[parameterKey] = `${condition.value}%`; + break; + case "endsWith": + query += ` LIKE :${parameterKey}`; + parameters[parameterKey] = `%${condition.value}`; + break; + case "timespanEq": + query += ` BETWEEN :${parameterKey}_start AND :${parameterKey}_end`; + parameters[`${parameterKey}_start`] = new Date(new Date().getFullYear() - (condition.value as number), 0, 1); + parameters[`${parameterKey}_end`] = new Date(new Date().getFullYear() - (condition.value as number), 11, 31); + } + + return { query, parameters }; + } +} diff --git a/src/index.ts b/src/index.ts index 38374a4..b28e07a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ import express from "express"; import { configCheck, SERVER_PORT } from "./env.defaults"; configCheck(); +import { PermissionObject } from "./type/permissionTypes"; declare global { namespace Express { export interface Request { @@ -16,12 +17,10 @@ declare global { } import { dataSource } from "./data-source"; - dataSource.initialize(); const app = express(); import router from "./routes/index"; -import { PermissionObject } from "./type/permissionTypes"; router(app); app.listen(process.env.NODE_ENV ? SERVER_PORT : 5000, () => { console.log(`listening on *:${SERVER_PORT}`); diff --git a/src/migrations/1734187754677-queryStore.ts b/src/migrations/1734187754677-queryStore.ts new file mode 100644 index 0000000..6912110 --- /dev/null +++ b/src/migrations/1734187754677-queryStore.ts @@ -0,0 +1,26 @@ +import { MigrationInterface, QueryRunner, Table } from "typeorm"; +import { DB_TYPE } from "../env.defaults"; + +export class QueryStore1734187754677 implements MigrationInterface { + name = "QueryStore1734187754677"; + + public async up(queryRunner: QueryRunner): Promise { + const variableType_int = DB_TYPE == "mysql" ? "int" : "integer"; + + await queryRunner.createTable( + new Table({ + name: "query", + columns: [ + { name: "id", type: variableType_int, isPrimary: true, isGenerated: true, generationStrategy: "increment" }, + { name: "title", type: "varchar", length: "255", isNullable: false, isUnique: true }, + { name: "query", type: "text", isNullable: false, default: "''" }, + ], + }), + true + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable("query"); + } +} diff --git a/src/migrations/1734520998539-memberDataViews.ts b/src/migrations/1734520998539-memberDataViews.ts new file mode 100644 index 0000000..d03fd94 --- /dev/null +++ b/src/migrations/1734520998539-memberDataViews.ts @@ -0,0 +1,112 @@ +import { DataSource, MigrationInterface, QueryRunner, View } from "typeorm"; +import { member } from "../entity/member"; +import { memberExecutivePositions } from "../entity/memberExecutivePositions"; +import { memberQualifications } from "../entity/memberQualifications"; +import { membership } from "../entity/membership"; + +export class MemberDataViews1734520998539 implements MigrationInterface { + name = "MemberDataViews1734520998539"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createView( + new View({ + name: "member_view", + expression: (datasource: DataSource) => + datasource + .getRepository(member) + .createQueryBuilder("member") + .select("member.id", "id") + .addSelect("member.salutation", "salutation") + .addSelect("member.firstname", "firstname") + .addSelect("member.lastname", "lastname") + .addSelect("member.nameaffix", "nameaffix") + .addSelect("member.birthdate", "birthdate") + .addSelect("TIMESTAMPDIFF(YEAR, member.birthdate, CURDATE())", "todayAge") + .addSelect("YEAR(CURDATE()) - YEAR(member.birthdate)", "ageThisYear"), + }), + true + ); + await queryRunner.createView( + new View({ + name: "member_executive_positions_view", + expression: (datasource: DataSource) => + datasource + .getRepository(memberExecutivePositions) + .createQueryBuilder("memberExecutivePositions") + .select("executivePosition.id", "positionId") + .addSelect("executivePosition.position", "position") + .addSelect("member.id", "memberId") + .addSelect("member.salutation", "memberSalutation") + .addSelect("member.firstname", "memberFirstname") + .addSelect("member.lastname", "memberLastname") + .addSelect("member.nameaffix", "memberNameaffix") + .addSelect("member.birthdate", "memberBirthdate") + .addSelect( + "SUM(TIMESTAMPDIFF(DAY, memberExecutivePositions.start, COALESCE(memberExecutivePositions.end, CURRENT_DATE)))", + "durationInDays" + ) + .leftJoin("memberExecutivePositions.executivePosition", "executivePosition") + .leftJoin("memberExecutivePositions.member", "member") + .groupBy("executivePosition.id"), + }), + true + ); + await queryRunner.createView( + new View({ + name: "member_qualifications_view", + expression: (datasource: DataSource) => + datasource + .getRepository(memberQualifications) + .createQueryBuilder("memberQualifications") + .select("qualification.id", "qualificationId") + .addSelect("qualification.qualification", "qualification") + .addSelect("member.id", "memberId") + .addSelect("member.salutation", "memberSalutation") + .addSelect("member.firstname", "memberFirstname") + .addSelect("member.lastname", "memberLastname") + .addSelect("member.nameaffix", "memberNameaffix") + .addSelect("member.birthdate", "memberBirthdate") + .addSelect( + "SUM(TIMESTAMPDIFF(DAY, memberQualifications.start, COALESCE(memberQualifications.end, CURRENT_DATE)))", + "durationInDays" + ) + .leftJoin("memberQualifications.qualification", "qualification") + .leftJoin("memberQualifications.member", "member") + .groupBy("qualification.id"), + }), + true + ); + await queryRunner.createView( + new View({ + name: "membership_view", + expression: (datasource: DataSource) => + datasource + .getRepository(membership) + .createQueryBuilder("membership") + .select("status.id", "statusId") + .addSelect("status.status", "status") + .addSelect("member.id", "memberId") + .addSelect("member.salutation", "memberSalutation") + .addSelect("member.firstname", "memberFirstname") + .addSelect("member.lastname", "memberLastname") + .addSelect("member.nameaffix", "memberNameaffix") + .addSelect("member.birthdate", "memberBirthdate") + .addSelect( + "SUM(TIMESTAMPDIFF(DAY, membership.start, COALESCE(membership.end, CURRENT_DATE)))", + "durationInDays" + ) + .leftJoin("membership.status", "status") + .leftJoin("membership.member", "member") + .groupBy("status.id"), + }), + true + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropView("membership_view"); + await queryRunner.dropView("member_qualifications_view"); + await queryRunner.dropView("member_executive_positions_view"); + await queryRunner.dropView("member_view"); + } +} diff --git a/src/routes/admin/communicationType.ts b/src/routes/admin/communicationType.ts index 2a797f6..4fc185f 100644 --- a/src/routes/admin/communicationType.ts +++ b/src/routes/admin/communicationType.ts @@ -25,7 +25,7 @@ router.get("/:id", async (req: Request, res: Response) => { router.post( "/", - PermissionHelper.passCheckMiddleware("create", "settings", "communication"), + PermissionHelper.passCheckMiddleware("create", "settings", "communication_type"), async (req: Request, res: Response) => { await createCommunicationType(req, res); } @@ -33,7 +33,7 @@ router.post( router.patch( "/:id", - PermissionHelper.passCheckMiddleware("update", "settings", "communication"), + PermissionHelper.passCheckMiddleware("update", "settings", "communication_type"), async (req: Request, res: Response) => { await updateCommunicationType(req, res); } @@ -41,7 +41,7 @@ router.patch( router.delete( "/:id", - PermissionHelper.passCheckMiddleware("delete", "settings", "communication"), + PermissionHelper.passCheckMiddleware("delete", "settings", "communication_type"), async (req: Request, res: Response) => { await deleteCommunicationType(req, res); } diff --git a/src/routes/admin/index.ts b/src/routes/admin/index.ts index b4f5d0c..193213e 100644 --- a/src/routes/admin/index.ts +++ b/src/routes/admin/index.ts @@ -7,11 +7,12 @@ import executivePosition from "./executivePosition"; import membershipStatus from "./membershipStatus"; import qualification from "./qualification"; import calendarType from "./calendarType"; +import queryStore from "./queryStore"; import member from "./member"; import protocol from "./protocol"; - import calendar from "./calendar"; +import queryBuilder from "./queryBuilder"; import role from "./role"; import user from "./user"; @@ -22,7 +23,7 @@ var router = express.Router({ mergeParams: true }); router.use("/award", PermissionHelper.passCheckMiddleware("read", "settings", "award"), award); router.use( "/communicationtype", - PermissionHelper.passCheckMiddleware("read", "settings", "communication"), + PermissionHelper.passCheckMiddleware("read", "settings", "communication_type"), communicationType ); router.use( @@ -37,10 +38,12 @@ router.use( ); router.use("/qualification", PermissionHelper.passCheckMiddleware("read", "settings", "qualification"), qualification); router.use("/calendartype", PermissionHelper.passCheckMiddleware("read", "settings", "calendar_type"), calendarType); +router.use("/querystore", PermissionHelper.passCheckMiddleware("read", "settings", "query_store"), queryStore); router.use("/member", PermissionHelper.passCheckMiddleware("read", "club", "member"), member); router.use("/protocol", PermissionHelper.passCheckMiddleware("read", "club", "protocol"), protocol); router.use("/calendar", PermissionHelper.passCheckMiddleware("read", "club", "calendar"), calendar); +router.use("/querybuilder", PermissionHelper.passCheckMiddleware("read", "club", "query"), queryBuilder); router.use("/role", PermissionHelper.passCheckMiddleware("read", "user", "role"), role); router.use("/user", PermissionHelper.passCheckMiddleware("read", "user", "user"), user); diff --git a/src/routes/admin/queryBuilder.ts b/src/routes/admin/queryBuilder.ts new file mode 100644 index 0000000..d09b9c3 --- /dev/null +++ b/src/routes/admin/queryBuilder.ts @@ -0,0 +1,18 @@ +import express, { Request, Response } from "express"; +import { executeQuery, getAllTableMeta, getTableMetaByTablename } from "../../controller/admin/queryBuilderController"; + +var router = express.Router({ mergeParams: true }); + +router.get("/tables", async (req: Request, res: Response) => { + await getAllTableMeta(req, res); +}); + +router.get("/table/:tablename", async (req: Request, res: Response) => { + await getTableMetaByTablename(req, res); +}); + +router.post("/query", async (req: Request, res: Response) => { + await executeQuery(req, res); +}); + +export default router; diff --git a/src/routes/admin/queryStore.ts b/src/routes/admin/queryStore.ts new file mode 100644 index 0000000..e160187 --- /dev/null +++ b/src/routes/admin/queryStore.ts @@ -0,0 +1,45 @@ +import express, { Request, Response } from "express"; +import PermissionHelper from "../../helpers/permissionHelper"; +import { + createQueryStore, + deleteQueryStore, + getAllQueryStores, + getQueryStoreById, + updateQueryStore, +} from "../../controller/admin/queryStoreController"; + +var router = express.Router({ mergeParams: true }); + +router.get("/", async (req: Request, res: Response) => { + await getAllQueryStores(req, res); +}); + +router.get("/:id", async (req: Request, res: Response) => { + await getQueryStoreById(req, res); +}); + +router.post( + "/", + PermissionHelper.passCheckMiddleware("create", "settings", "query_store"), + async (req: Request, res: Response) => { + await createQueryStore(req, res); + } +); + +router.patch( + "/:id", + PermissionHelper.passCheckMiddleware("update", "settings", "query_store"), + async (req: Request, res: Response) => { + await updateQueryStore(req, res); + } +); + +router.delete( + "/:id", + PermissionHelper.passCheckMiddleware("delete", "settings", "query_store"), + async (req: Request, res: Response) => { + await deleteQueryStore(req, res); + } +); + +export default router; diff --git a/src/service/queryStoreService.ts b/src/service/queryStoreService.ts new file mode 100644 index 0000000..93145f9 --- /dev/null +++ b/src/service/queryStoreService.ts @@ -0,0 +1,40 @@ +import { dataSource } from "../data-source"; +import { query } from "../entity/query"; +import InternalException from "../exceptions/internalException"; + +export default abstract class QueryStoreService { + /** + * @description get all queryStores + * @returns {Promise>} + */ + static async getAll(): Promise> { + return await dataSource + .getRepository(query) + .createQueryBuilder("queryStore") + .getMany() + .then((res) => { + return res; + }) + .catch((err) => { + throw new InternalException("queryStores not found", err); + }); + } + + /** + * @description get queryStore by id + * @returns {Promise} + */ + static async getById(id: number): Promise { + return await dataSource + .getRepository(query) + .createQueryBuilder("queryStore") + .where("queryStore.id = :id", { id: id }) + .getOneOrFail() + .then((res) => { + return res; + }) + .catch((err) => { + throw new InternalException("queryStore not found by id", err); + }); + } +} diff --git a/src/type/dynamicQueries.ts b/src/type/dynamicQueries.ts new file mode 100644 index 0000000..9e1f046 --- /dev/null +++ b/src/type/dynamicQueries.ts @@ -0,0 +1,127 @@ +export interface DynamicQueryStructure { + select: string[] | "*"; + table: string; + where?: Array; + join?: Array; + orderBy?: Array; +} + +export type ConditionStructure = ( + | { + structureType: "condition"; + column: string; + operation: WhereOperation; + value: ConditionValue; + } + | { + structureType: "nested"; + invert?: boolean; + conditions: Array; + } +) & { + concat: WhereType; + structureType: "condition" | "nested"; +}; + +export type ConditionValue = FieldType | Array | { start: FieldType; end: FieldType }; +export type FieldType = number | string | Date | boolean; + +export type WhereType = "OR" | "AND" | "_"; // _ represents initial where in (sub-)query + +export type WhereOperation = + | "eq" // Equal + | "neq" // Not equal + | "lt" // Less than + | "lte" // Less than or equal to + | "gt" // Greater than + | "gte" // Greater than or equal to + | "in" // Included in an array + | "notIn" // Not included in an array + | "contains" // Contains + | "notContains" // Does not contain + | "null" // Is null + | "notNull" // Is not null + | "between" // Is between + | "startsWith" // Starts with + | "endsWith" // Ends with + | "timespanEq"; // Date before x years (YYYY-01-01 YYYY-12-31) +// TODO: age between | age equals | age greater | age smaller + +export type OrderByStructure = { + column: string; + order: OrderByType; +}; + +export type OrderByType = "ASC" | "DESC"; + +export const exampleQuery: DynamicQueryStructure = { + select: ["firstname", "lastname"], + table: "member", + where: [ + { + structureType: "condition", + concat: "_", + column: "mail", + operation: "endsWith", + value: "@gmail.com", + }, + { + structureType: "nested", + concat: "AND", + conditions: [ + { + structureType: "condition", + concat: "_", + column: "firstname", + operation: "startsWith", + value: "J", + }, + { + structureType: "condition", + concat: "OR", + column: "lastname", + operation: "startsWith", + value: "K", + }, + ], + }, + ], + join: [ + { + select: "*", + table: "communication", + foreignColumn: "sendNewsletter", + }, + { + select: "*", + table: "membership", + foreignColumn: "memberships", + join: [ + { + select: "*", + table: "membership_status", + foreignColumn: "status", + where: [ + { + structureType: "condition", + concat: "_", + column: "status", + operation: "eq", + value: "aktiv", + }, + ], + }, + ], + }, + ], + orderBy: [ + { + column: "firstname", + order: "ASC", + }, + { + column: "lastname", + order: "ASC", + }, + ], +}; diff --git a/src/type/permissionTypes.ts b/src/type/permissionTypes.ts index fc468a9..d5bb35e 100644 --- a/src/type/permissionTypes.ts +++ b/src/type/permissionTypes.ts @@ -8,11 +8,13 @@ export type PermissionModule = | "qualification" | "award" | "executive_position" - | "communication" + | "communication_type" | "membership_status" | "calendar_type" | "user" - | "role"; + | "role" + | "query" + | "query_store"; export type PermissionType = "read" | "create" | "update" | "delete"; @@ -44,15 +46,25 @@ export const permissionModules: Array = [ "qualification", "award", "executive_position", - "communication", + "communication_type", "membership_status", "calendar_type", "user", "role", + "query", + "query_store", ]; export const permissionTypes: Array = ["read", "create", "update", "delete"]; export const sectionsAndModules: SectionsAndModulesObject = { - club: ["member", "calendar", "newsletter", "protocol"], - settings: ["qualification", "award", "executive_position", "communication", "membership_status", "calendar_type"], + club: ["member", "calendar", "newsletter", "protocol", "query"], + settings: [ + "qualification", + "award", + "executive_position", + "communication_type", + "membership_status", + "calendar_type", + "query_store", + ], user: ["user", "role"], }; diff --git a/src/type/tableMeta.ts b/src/type/tableMeta.ts new file mode 100644 index 0000000..a50e115 --- /dev/null +++ b/src/type/tableMeta.ts @@ -0,0 +1,7 @@ +import { ColumnType } from "typeorm"; + +export interface TableMeta { + tableName: string; + columns: Array<{ column: string; type: ColumnType }>; + relations: Array<{ column: string; relationType: string; referencedTableName: string }>; +} diff --git a/src/viewmodel/admin/queryStore.models.ts b/src/viewmodel/admin/queryStore.models.ts new file mode 100644 index 0000000..ff697c6 --- /dev/null +++ b/src/viewmodel/admin/queryStore.models.ts @@ -0,0 +1,7 @@ +import { DynamicQueryStructure } from "../../type/dynamicQueries"; + +export interface QueryStoreViewModel { + id: number; + title: string; + query: string | DynamicQueryStructure; +} diff --git a/src/views/memberExecutivePositionView.ts b/src/views/memberExecutivePositionView.ts new file mode 100644 index 0000000..ffc859e --- /dev/null +++ b/src/views/memberExecutivePositionView.ts @@ -0,0 +1,53 @@ +import { DataSource, ViewColumn, ViewEntity } from "typeorm"; +import { memberExecutivePositions } from "../entity/memberExecutivePositions"; +import { Salutation } from "../enums/salutation"; + +@ViewEntity({ + expression: (datasource: DataSource) => + datasource + .getRepository(memberExecutivePositions) + .createQueryBuilder("memberExecutivePositions") + .select("executivePosition.id", "positionId") + .addSelect("executivePosition.position", "position") + .addSelect("member.id", "memberId") + .addSelect("member.salutation", "memberSalutation") + .addSelect("member.firstname", "memberFirstname") + .addSelect("member.lastname", "memberLastname") + .addSelect("member.nameaffix", "memberNameaffix") + .addSelect("member.birthdate", "memberBirthdate") + .addSelect( + "SUM(TIMESTAMPDIFF(DAY, memberExecutivePositions.start, COALESCE(memberExecutivePositions.end, CURRENT_DATE)))", + "durationInDays" + ) + .leftJoin("memberExecutivePositions.executivePosition", "executivePosition") + .leftJoin("memberExecutivePositions.member", "member") + .groupBy("executivePosition.id"), +}) +export class memberExecutivePositionsView { + @ViewColumn() + durationInDays: number; + + @ViewColumn() + position: string; + + @ViewColumn() + positionId: number; + + @ViewColumn() + memberId: number; + + @ViewColumn() + memberSalutation: Salutation; + + @ViewColumn() + memberFirstname: string; + + @ViewColumn() + memberLastname: string; + + @ViewColumn() + memberNameaffix: string; + + @ViewColumn() + memberBirthdate: Date; +} diff --git a/src/views/memberQualificationsView.ts b/src/views/memberQualificationsView.ts new file mode 100644 index 0000000..9cd8bd6 --- /dev/null +++ b/src/views/memberQualificationsView.ts @@ -0,0 +1,53 @@ +import { DataSource, ViewColumn, ViewEntity } from "typeorm"; +import { memberQualifications } from "../entity/memberQualifications"; +import { Salutation } from "../enums/salutation"; + +@ViewEntity({ + expression: (datasource: DataSource) => + datasource + .getRepository(memberQualifications) + .createQueryBuilder("memberQualifications") + .select("qualification.id", "qualificationId") + .addSelect("qualification.qualification", "qualification") + .addSelect("member.id", "memberId") + .addSelect("member.salutation", "memberSalutation") + .addSelect("member.firstname", "memberFirstname") + .addSelect("member.lastname", "memberLastname") + .addSelect("member.nameaffix", "memberNameaffix") + .addSelect("member.birthdate", "memberBirthdate") + .addSelect( + "SUM(TIMESTAMPDIFF(DAY, memberQualifications.start, COALESCE(memberQualifications.end, CURRENT_DATE)))", + "durationInDays" + ) + .leftJoin("memberQualifications.qualification", "qualification") + .leftJoin("memberQualifications.member", "member") + .groupBy("qualification.id"), +}) +export class memberQualificationsView { + @ViewColumn() + durationInDays: number; + + @ViewColumn() + qualification: string; + + @ViewColumn() + qualificationId: number; + + @ViewColumn() + memberId: number; + + @ViewColumn() + memberSalutation: Salutation; + + @ViewColumn() + memberFirstname: string; + + @ViewColumn() + memberLastname: string; + + @ViewColumn() + memberNameaffix: string; + + @ViewColumn() + memberBirthdate: Date; +} diff --git a/src/views/memberView.ts b/src/views/memberView.ts new file mode 100644 index 0000000..08f147c --- /dev/null +++ b/src/views/memberView.ts @@ -0,0 +1,43 @@ +import { DataSource, ViewColumn, ViewEntity } from "typeorm"; +import { member } from "../entity/member"; +import { Salutation } from "../enums/salutation"; + +@ViewEntity({ + expression: (datasource: DataSource) => + datasource + .getRepository(member) + .createQueryBuilder("member") + .select("member.id", "id") + .addSelect("member.salutation", "salutation") + .addSelect("member.firstname", "firstname") + .addSelect("member.lastname", "lastname") + .addSelect("member.nameaffix", "nameaffix") + .addSelect("member.birthdate", "birthdate") + .addSelect("TIMESTAMPDIFF(YEAR, member.birthdate, CURDATE())", "todayAge") + .addSelect("YEAR(CURDATE()) - YEAR(member.birthdate)", "ageThisYear"), +}) +export class memberView { + @ViewColumn() + id: number; + + @ViewColumn() + salutation: Salutation; + + @ViewColumn() + firstname: string; + + @ViewColumn() + lastname: string; + + @ViewColumn() + nameaffix: string; + + @ViewColumn() + birthdate: Date; + + @ViewColumn() + todayAge: number; + + @ViewColumn() + ageThisYear: number; +} diff --git a/src/views/membershipsView.ts b/src/views/membershipsView.ts new file mode 100644 index 0000000..6d72bcc --- /dev/null +++ b/src/views/membershipsView.ts @@ -0,0 +1,50 @@ +import { DataSource, ViewColumn, ViewEntity } from "typeorm"; +import { membership } from "../entity/membership"; +import { Salutation } from "../enums/salutation"; + +@ViewEntity({ + expression: (datasource: DataSource) => + datasource + .getRepository(membership) + .createQueryBuilder("membership") + .select("status.id", "statusId") + .addSelect("status.status", "status") + .addSelect("member.id", "memberId") + .addSelect("member.salutation", "memberSalutation") + .addSelect("member.firstname", "memberFirstname") + .addSelect("member.lastname", "memberLastname") + .addSelect("member.nameaffix", "memberNameaffix") + .addSelect("member.birthdate", "memberBirthdate") + .addSelect("SUM(TIMESTAMPDIFF(DAY, membership.start, COALESCE(membership.end, CURRENT_DATE)))", "durationInDays") + .leftJoin("membership.status", "status") + .leftJoin("membership.member", "member") + .groupBy("status.id"), +}) +export class membershipView { + @ViewColumn() + durationInDays: number; + + @ViewColumn() + status: string; + + @ViewColumn() + statusId: number; + + @ViewColumn() + memberId: number; + + @ViewColumn() + memberSalutation: Salutation; + + @ViewColumn() + memberFirstname: string; + + @ViewColumn() + memberLastname: string; + + @ViewColumn() + memberNameaffix: string; + + @ViewColumn() + memberBirthdate: Date; +}