diff --git a/package.json b/package.json index 9b6f5a4..ef1f732 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "pdfmake": "^0.2.10", "pg": "^8.11.5", "plop": "^4.0.1", + "qrcode": "^1.5.4", "reflect-metadata": "^0.2.0", "rxjs": "^7.5.0", "typeorm": "^0.3.20", @@ -71,6 +72,7 @@ "@types/express": "^4.17.13", "@types/jest": "29.5.12", "@types/node": "^20.12.13", + "@types/qrcode": "^1.5.5", "@types/supertest": "^2.0.11", "@typescript-eslint/eslint-plugin": "^5.0.0", "@typescript-eslint/parser": "^5.0.0", diff --git a/src/app.module.ts b/src/app.module.ts index 5266aa2..cef2349 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -99,6 +99,15 @@ import { QueueBucketModel } from './modules/queue/data/models/queue-bucket.model import { VerificationModel } from './modules/booking-online/authentication/data/models/verification.model'; import { BookingOnlineAuthModule } from './modules/booking-online/authentication/auth.module'; import { BookingOrderModule } from './modules/booking-online/order/order.module'; +import { TimeGroupModule } from './modules/item-related/time-group/time-group.module'; +import { TimeGroupModel } from './modules/item-related/time-group/data/models/time-group.model'; + +import { OtpVerificationModule } from './modules/configuration/otp-verification/otp-verification.module'; +import { OtpVerificationModel } from './modules/configuration/otp-verification/data/models/otp-verification.model'; +import { OtpVerifierModel } from './modules/configuration/otp-verification/data/models/otp-verifier.model'; +import { RescheduleVerificationModel } from './modules/booking-online/order/data/models/reschedule-verification.model'; +import { OtpCheckerGuard } from './core/guards/domain/otp-checker.guard'; + @Module({ imports: [ ApmModule.register(), @@ -123,6 +132,7 @@ import { BookingOrderModule } from './modules/booking-online/order/order.module' ItemCategoryModel, ItemRateModel, ItemQueueModel, + TimeGroupModel, LogModel, LogUserLoginModel, NewsModel, @@ -162,6 +172,10 @@ import { BookingOrderModule } from './modules/booking-online/order/order.module' // Booking Online VerificationModel, + RescheduleVerificationModel, + + OtpVerificationModel, + OtpVerifierModel, ], synchronize: false, }), @@ -188,6 +202,7 @@ import { BookingOrderModule } from './modules/booking-online/order/order.module' ItemModule, ItemRateModule, ItemQueueModule, + TimeGroupModule, // transaction PaymentMethodModule, @@ -226,11 +241,14 @@ import { BookingOrderModule } from './modules/booking-online/order/order.module' BookingOnlineAuthModule, BookingOrderModule, + OtpVerificationModule, ], controllers: [], providers: [ AuthService, PrivilegeService, + OtpCheckerGuard, + /** * By default all request from client will protect by JWT * if there is some endpoint/function that does'nt require authentication diff --git a/src/core/guards/domain/otp-checker.guard.ts b/src/core/guards/domain/otp-checker.guard.ts new file mode 100644 index 0000000..61e5146 --- /dev/null +++ b/src/core/guards/domain/otp-checker.guard.ts @@ -0,0 +1,57 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + UnprocessableEntityException, +} from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { CONNECTION_NAME } from 'src/core/strings/constants/base.constants'; +import { OtpVerificationModel } from 'src/modules/configuration/otp-verification/data/models/otp-verification.model'; +import { OtpVerificationEntity } from 'src/modules/configuration/otp-verification/domain/entities/otp-verification.entity'; +import { DataSource } from 'typeorm'; + +@Injectable() +export class OtpCheckerGuard implements CanActivate { + constructor( + @InjectDataSource(CONNECTION_NAME.DEFAULT) + protected readonly dataSource: DataSource, + ) {} + + get otpRepository() { + return this.dataSource.getRepository(OtpVerificationModel); + } + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const verificationCode = request.headers['x-verification-code']; + console.log({ verificationCode }); + + if (verificationCode) { + const decoded = Buffer.from(verificationCode, 'base64').toString('ascii'); + const [dataIdentity, otpCode] = decoded.split('|'); + + let otpData: OtpVerificationEntity; + + otpData = await this.otpRepository.findOne({ + where: { + otp_code: otpCode, + target_id: dataIdentity, + }, + }); + + if (!otpData) { + otpData = await this.otpRepository.findOne({ + where: { + otp_code: otpCode, + reference: dataIdentity, + }, + }); + } + + // console.log({ dataIdentity, otpCode, otpData }); + if (otpData && otpData?.verified_at) return true; + } + + throw new UnprocessableEntityException('OTP not verified.'); + } +} diff --git a/src/core/helpers/otp/otp-service.ts b/src/core/helpers/otp/otp-service.ts new file mode 100644 index 0000000..0b4e3e1 --- /dev/null +++ b/src/core/helpers/otp/otp-service.ts @@ -0,0 +1,66 @@ +interface OtpServiceEntity { + length?: number; +} + +export class OtpService { + private readonly otpLength: number; + + constructor({ length = 4 }: OtpServiceEntity) { + this.otpLength = Math.max(length, 4); // Minimum of 4 digits + } + + private hasSequentialDigits(str: string): boolean { + for (let i = 0; i < str.length - 1; i++) { + const current = parseInt(str[i], 10); + const next = parseInt(str[i + 1], 10); + if (next === current + 1 || next === current - 1) { + return true; + } + } + return false; + } + + private hasRepeatedDigits(str: string): boolean { + return str.split('').every((char) => char === str[0]); + } + + private isPalindrome(str: string): boolean { + return str === str.split('').reverse().join(''); + } + + private hasPartiallyRepeatedDigits(str: string): boolean { + const counts: Record = {}; + for (const char of str) { + counts[char] = (counts[char] || 0) + 1; + } + + // Reject if any digit appears more than twice + return Object.values(counts).some((count) => count > 2); + } + + private hasNoMatchLength(str: string) { + return str.length !== this.otpLength; + } + + private hasStartWithZero(str: string) { + return str.split('')[0] === '0'; + } + + public generateSecureOTP(): string { + let otp: string; + + do { + otp = Array.from({ length: this.otpLength }, () => + Math.floor(Math.random() * 10).toString(), + ).join(''); + } while ( + this.hasNoMatchLength(otp) || + this.hasSequentialDigits(otp) || + this.hasRepeatedDigits(otp) || + this.isPalindrome(otp) || + this.hasPartiallyRepeatedDigits(otp) || + this.hasStartWithZero(otp) + ); + return otp; + } +} diff --git a/src/core/helpers/validation/validate-relation.helper.ts b/src/core/helpers/validation/validate-relation.helper.ts index 2468595..ae5b929 100644 --- a/src/core/helpers/validation/validate-relation.helper.ts +++ b/src/core/helpers/validation/validate-relation.helper.ts @@ -55,7 +55,7 @@ export class ValidateRelationHelper { const relationColumn = data[relation.relation]?.[`${relation.singleQuery[0]}`]; if ( - !!relationColumn && + // !!relationColumn && this.mappingValidator( relationColumn, relation.singleQuery[1], diff --git a/src/core/strings/constants/module.constants.ts b/src/core/strings/constants/module.constants.ts index b05838e..8e8896e 100644 --- a/src/core/strings/constants/module.constants.ts +++ b/src/core/strings/constants/module.constants.ts @@ -28,4 +28,9 @@ export enum MODULE_NAME { REPORT_SUMMARY = 'report-summary', QUEUE = 'queue', + + TIME_GROUPS = 'time-groups', + OTP_VERIFICATIONS = 'otp-verification', + + OTP_VERIFIER = 'otp-verifier', } diff --git a/src/core/strings/constants/table.constants.ts b/src/core/strings/constants/table.constants.ts index 4725e10..2139ba6 100644 --- a/src/core/strings/constants/table.constants.ts +++ b/src/core/strings/constants/table.constants.ts @@ -43,4 +43,8 @@ export enum TABLE_NAME { QUEUE_TICKET = 'queue_tickets', QUEUE_ITEM = 'queue_items', QUEUE_BUCKET = 'queue_bucket', + + TIME_GROUPS = 'time_groups', + OTP_VERIFICATIONS = 'otp_verifications', + OTP_VERIFIER = 'otp_verifier', } diff --git a/src/database/migrations/1748409891706-create-time-group-table.ts b/src/database/migrations/1748409891706-create-time-group-table.ts new file mode 100644 index 0000000..5c83f84 --- /dev/null +++ b/src/database/migrations/1748409891706-create-time-group-table.ts @@ -0,0 +1,27 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateTimeGroupTable1748409891706 implements MigrationInterface { + name = 'CreateTimeGroupTable1748409891706'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TYPE "public"."time_groups_status_enum" AS ENUM('active', 'cancel', 'confirmed', 'draft', 'expired', 'inactive', 'partial refund', 'pending', 'proses refund', 'refunded', 'rejected', 'settled', 'waiting')`, + ); + await queryRunner.query( + `CREATE TABLE "time_groups" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "creator_id" character varying(36), "creator_name" character varying(125), "editor_id" character varying(36), "editor_name" character varying(125), "created_at" bigint NOT NULL, "updated_at" bigint NOT NULL, "status" "public"."time_groups_status_enum" NOT NULL DEFAULT 'draft', "name" character varying NOT NULL, "start_time" TIME NOT NULL, "end_time" TIME NOT NULL, "max_usage_time" TIME NOT NULL, CONSTRAINT "PK_083d02988db7bedfe3b7c869b50" PRIMARY KEY ("id"))`, + ); + await queryRunner.query(`ALTER TABLE "items" ADD "time_group_id" uuid`); + await queryRunner.query( + `ALTER TABLE "items" ADD CONSTRAINT "FK_f44f222e1808448dca1b6cc4557" FOREIGN KEY ("time_group_id") REFERENCES "time_groups"("id") ON DELETE CASCADE ON UPDATE CASCADE`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "items" DROP CONSTRAINT "FK_f44f222e1808448dca1b6cc4557"`, + ); + await queryRunner.query(`ALTER TABLE "items" DROP COLUMN "time_group_id"`); + await queryRunner.query(`DROP TABLE "time_groups"`); + await queryRunner.query(`DROP TYPE "public"."time_groups_status_enum"`); + } +} diff --git a/src/database/migrations/1748935417155-add_column_otp_code.ts b/src/database/migrations/1748935417155-add_column_otp_code.ts new file mode 100644 index 0000000..a7347fd --- /dev/null +++ b/src/database/migrations/1748935417155-add_column_otp_code.ts @@ -0,0 +1,21 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddColumnOtpCode1748935417155 implements MigrationInterface { + name = 'AddColumnOtpCode1748935417155'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "vip_codes" ADD "otp_code" character varying`, + ); + await queryRunner.query( + `ALTER TABLE "transactions" ADD "otp_code" character varying`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "transactions" DROP COLUMN "otp_code"`, + ); + await queryRunner.query(`ALTER TABLE "vip_codes" DROP COLUMN "otp_code"`); + } +} diff --git a/src/database/migrations/1749028279580-create-table-otp-cerifications.ts b/src/database/migrations/1749028279580-create-table-otp-cerifications.ts new file mode 100644 index 0000000..a1d2800 --- /dev/null +++ b/src/database/migrations/1749028279580-create-table-otp-cerifications.ts @@ -0,0 +1,29 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateTableOtpCerifications1749028279580 + implements MigrationInterface +{ + name = 'CreateTableOtpCerifications1749028279580'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TYPE "public"."otp_verifications_action_type_enum" AS ENUM('CREATE_DISCOUNT', 'CANCEL_TRANSACTION')`, + ); + await queryRunner.query( + `CREATE TYPE "public"."otp_verifications_source_enum" AS ENUM('POS', 'WEB')`, + ); + await queryRunner.query( + `CREATE TABLE "otp_verifications" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "creator_id" character varying(36), "creator_name" character varying(125), "editor_id" character varying(36), "editor_name" character varying(125), "created_at" bigint NOT NULL, "updated_at" bigint NOT NULL, "otp_code" character varying NOT NULL, "action_type" "public"."otp_verifications_action_type_enum" NOT NULL, "target_id" character varying, "reference" character varying, "source" "public"."otp_verifications_source_enum" NOT NULL, "is_used" boolean NOT NULL DEFAULT false, "expired_at" bigint NOT NULL, "verified_at" bigint, CONSTRAINT "PK_91d17e75ac3182dba6701869b39" PRIMARY KEY ("id"))`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "otp_verifications"`); + await queryRunner.query( + `DROP TYPE "public"."otp_verifications_source_enum"`, + ); + await queryRunner.query( + `DROP TYPE "public"."otp_verifications_action_type_enum"`, + ); + } +} diff --git a/src/database/migrations/1749030419440-add_column_is_replaced_otp_verification.ts b/src/database/migrations/1749030419440-add_column_is_replaced_otp_verification.ts new file mode 100644 index 0000000..befa570 --- /dev/null +++ b/src/database/migrations/1749030419440-add_column_is_replaced_otp_verification.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddColumnIsReplacedOtpVerification1749030419440 + implements MigrationInterface +{ + name = 'AddColumnIsReplacedOtpVerification1749030419440'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "otp_verifications" ADD "is_replaced" boolean NOT NULL DEFAULT false`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "otp_verifications" DROP COLUMN "is_replaced"`, + ); + } +} diff --git a/src/database/migrations/1749043616622-add_table_otp_verifier.ts b/src/database/migrations/1749043616622-add_table_otp_verifier.ts new file mode 100644 index 0000000..b2085c4 --- /dev/null +++ b/src/database/migrations/1749043616622-add_table_otp_verifier.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddTableOtpVerifier1749043616622 implements MigrationInterface { + name = 'AddTableOtpVerifier1749043616622'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "otp_verifier" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "creator_id" character varying(36), "creator_name" character varying(125), "editor_id" character varying(36), "editor_name" character varying(125), "created_at" bigint NOT NULL, "updated_at" bigint NOT NULL, "name" character varying, "phone_number" character varying NOT NULL, CONSTRAINT "PK_884e2d0873fc589a1bdc477b2ea" PRIMARY KEY ("id"))`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "otp_verifier"`); + } +} diff --git a/src/database/migrations/1749046285398-update_enum_otp_action_type.ts b/src/database/migrations/1749046285398-update_enum_otp_action_type.ts new file mode 100644 index 0000000..e107607 --- /dev/null +++ b/src/database/migrations/1749046285398-update_enum_otp_action_type.ts @@ -0,0 +1,37 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class UpdateEnumOtpActionType1749046285398 + implements MigrationInterface +{ + name = 'UpdateEnumOtpActionType1749046285398'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TYPE "public"."otp_verifications_action_type_enum" RENAME TO "otp_verifications_action_type_enum_old"`, + ); + await queryRunner.query( + `CREATE TYPE "public"."otp_verifications_action_type_enum" AS ENUM('CREATE_DISCOUNT', 'CANCEL_TRANSACTION', 'REJECT_RECONCILIATION')`, + ); + await queryRunner.query( + `ALTER TABLE "otp_verifications" ALTER COLUMN "action_type" TYPE "public"."otp_verifications_action_type_enum" USING "action_type"::"text"::"public"."otp_verifications_action_type_enum"`, + ); + await queryRunner.query( + `DROP TYPE "public"."otp_verifications_action_type_enum_old"`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TYPE "public"."otp_verifications_action_type_enum_old" AS ENUM('CREATE_DISCOUNT', 'CANCEL_TRANSACTION')`, + ); + await queryRunner.query( + `ALTER TABLE "otp_verifications" ALTER COLUMN "action_type" TYPE "public"."otp_verifications_action_type_enum_old" USING "action_type"::"text"::"public"."otp_verifications_action_type_enum_old"`, + ); + await queryRunner.query( + `DROP TYPE "public"."otp_verifications_action_type_enum"`, + ); + await queryRunner.query( + `ALTER TYPE "public"."otp_verifications_action_type_enum_old" RENAME TO "otp_verifications_action_type_enum"`, + ); + } +} diff --git a/src/database/migrations/1749524993295-reschedule-otp.ts b/src/database/migrations/1749524993295-reschedule-otp.ts new file mode 100644 index 0000000..b9051ff --- /dev/null +++ b/src/database/migrations/1749524993295-reschedule-otp.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class RescheduleOtp1749524993295 implements MigrationInterface { + name = 'RescheduleOtp1749524993295'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "reschedule_verification" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying NOT NULL, "phone_number" character varying NOT NULL, "booking_id" character varying NOT NULL, "reschedule_date" character varying NOT NULL, "code" integer NOT NULL, "tried" integer NOT NULL DEFAULT '0', "created_at" bigint NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW()) * 1000, "updated_at" bigint NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW()) * 1000, CONSTRAINT "PK_d4df453337ca12771eb223323d8" PRIMARY KEY ("id"))`, + ); + + await queryRunner.query( + `ALTER TABLE "booking_verification" ALTER COLUMN "created_at" SET DEFAULT EXTRACT(EPOCH FROM NOW()) * 1000`, + ); + await queryRunner.query( + `ALTER TABLE "booking_verification" ALTER COLUMN "updated_at" SET DEFAULT EXTRACT(EPOCH FROM NOW()) * 1000`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "booking_verification" ALTER COLUMN "updated_at" SET DEFAULT (EXTRACT(epoch FROM now()) * (1000))`, + ); + await queryRunner.query( + `ALTER TABLE "booking_verification" ALTER COLUMN "created_at" SET DEFAULT (EXTRACT(epoch FROM now()) * (1000))`, + ); + await queryRunner.query(`DROP TABLE "reschedule_verification"`); + } +} diff --git a/src/database/migrations/1749537252986-add-booking-description-to-item.ts b/src/database/migrations/1749537252986-add-booking-description-to-item.ts new file mode 100644 index 0000000..f8456a8 --- /dev/null +++ b/src/database/migrations/1749537252986-add-booking-description-to-item.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddBookingDescriptionToItem1749537252986 + implements MigrationInterface +{ + name = 'AddBookingDescriptionToItem1749537252986'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "items" ADD "booking_description" text`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "items" DROP COLUMN "booking_description"`, + ); + } +} diff --git a/src/database/migrations/1749604239749-add-booking-parent-to-transaction.ts b/src/database/migrations/1749604239749-add-booking-parent-to-transaction.ts new file mode 100644 index 0000000..b9a7012 --- /dev/null +++ b/src/database/migrations/1749604239749-add-booking-parent-to-transaction.ts @@ -0,0 +1,24 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddBookingParentToTransaction1749604239749 + implements MigrationInterface +{ + name = 'AddBookingParentToTransaction1749604239749'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "transactions" ADD "parent_id" uuid`); + + await queryRunner.query( + `ALTER TABLE "transactions" ADD CONSTRAINT "FK_413e95171729ba18cabce1c31e3" FOREIGN KEY ("parent_id") REFERENCES "transactions"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "transactions" DROP CONSTRAINT "FK_413e95171729ba18cabce1c31e3"`, + ); + await queryRunner.query( + `ALTER TABLE "transactions" DROP COLUMN "parent_id"`, + ); + } +} diff --git a/src/database/migrations/1750045520332-add_enum_otp_action_type_and_update_column_otp_verifier.ts b/src/database/migrations/1750045520332-add_enum_otp_action_type_and_update_column_otp_verifier.ts new file mode 100644 index 0000000..963d8e8 --- /dev/null +++ b/src/database/migrations/1750045520332-add_enum_otp_action_type_and_update_column_otp_verifier.ts @@ -0,0 +1,55 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddEnumOtpActionTypeAndUpdateColumnOtpVerifier1750045520332 + implements MigrationInterface +{ + name = 'AddEnumOtpActionTypeAndUpdateColumnOtpVerifier1750045520332'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "otp_verifier" ADD "is_all_action" boolean NOT NULL DEFAULT false`, + ); + await queryRunner.query( + `CREATE TYPE "public"."otp_verifier_action_types_enum" AS ENUM('CREATE_DISCOUNT', 'CANCEL_TRANSACTION', 'REJECT_RECONCILIATION', 'ACTIVATE_ITEM', 'ACTIVATE_USER', 'UPDATE_ITEM_PRICE', 'UPDATE_ITEM_DETAILS', 'CONFIRM_TRANSACTION')`, + ); + await queryRunner.query( + `ALTER TABLE "otp_verifier" ADD "action_types" "public"."otp_verifier_action_types_enum" array`, + ); + await queryRunner.query( + `ALTER TYPE "public"."otp_verifications_action_type_enum" RENAME TO "otp_verifications_action_type_enum_old"`, + ); + await queryRunner.query( + `CREATE TYPE "public"."otp_verifications_action_type_enum" AS ENUM('CREATE_DISCOUNT', 'CANCEL_TRANSACTION', 'REJECT_RECONCILIATION', 'ACTIVATE_ITEM', 'ACTIVATE_USER', 'UPDATE_ITEM_PRICE', 'UPDATE_ITEM_DETAILS', 'CONFIRM_TRANSACTION')`, + ); + await queryRunner.query( + `ALTER TABLE "otp_verifications" ALTER COLUMN "action_type" TYPE "public"."otp_verifications_action_type_enum" USING "action_type"::"text"::"public"."otp_verifications_action_type_enum"`, + ); + await queryRunner.query( + `DROP TYPE "public"."otp_verifications_action_type_enum_old"`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TYPE "public"."otp_verifications_action_type_enum_old" AS ENUM('CREATE_DISCOUNT', 'CANCEL_TRANSACTION', 'REJECT_RECONCILIATION')`, + ); + await queryRunner.query( + `ALTER TABLE "otp_verifications" ALTER COLUMN "action_type" TYPE "public"."otp_verifications_action_type_enum_old" USING "action_type"::"text"::"public"."otp_verifications_action_type_enum_old"`, + ); + await queryRunner.query( + `DROP TYPE "public"."otp_verifications_action_type_enum"`, + ); + await queryRunner.query( + `ALTER TYPE "public"."otp_verifications_action_type_enum_old" RENAME TO "otp_verifications_action_type_enum"`, + ); + await queryRunner.query( + `ALTER TABLE "otp_verifier" DROP COLUMN "action_types"`, + ); + await queryRunner.query( + `DROP TYPE "public"."otp_verifier_action_types_enum"`, + ); + await queryRunner.query( + `ALTER TABLE "otp_verifier" DROP COLUMN "is_all_action"`, + ); + } +} diff --git a/src/database/migrations/1750319148269-add-usage-type-to-item.ts b/src/database/migrations/1750319148269-add-usage-type-to-item.ts new file mode 100644 index 0000000..a9eac38 --- /dev/null +++ b/src/database/migrations/1750319148269-add-usage-type-to-item.ts @@ -0,0 +1,29 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddUsageTypeToItem1750319148269 implements MigrationInterface { + name = 'AddUsageTypeToItem1750319148269'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TYPE "public"."item_queues_usage_type_enum" AS ENUM('one_time', 'multiple')`, + ); + await queryRunner.query( + `ALTER TABLE "item_queues" ADD "usage_type" "public"."item_queues_usage_type_enum" NOT NULL DEFAULT 'one_time'`, + ); + await queryRunner.query( + `CREATE TYPE "public"."items_usage_type_enum" AS ENUM('one_time', 'multiple')`, + ); + await queryRunner.query( + `ALTER TABLE "items" ADD "usage_type" "public"."items_usage_type_enum" NOT NULL DEFAULT 'one_time'`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "items" DROP COLUMN "usage_type"`); + await queryRunner.query(`DROP TYPE "public"."items_usage_type_enum"`); + await queryRunner.query( + `ALTER TABLE "item_queues" DROP COLUMN "usage_type"`, + ); + await queryRunner.query(`DROP TYPE "public"."item_queues_usage_type_enum"`); + } +} diff --git a/src/database/migrations/1750834308368-remove-item-name-unique.ts b/src/database/migrations/1750834308368-remove-item-name-unique.ts new file mode 100644 index 0000000..1c1b7c4 --- /dev/null +++ b/src/database/migrations/1750834308368-remove-item-name-unique.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class RemoveItemNameUnique1750834308368 implements MigrationInterface { + name = 'RemoveItemNameUnique1750834308368'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "items" DROP CONSTRAINT "UQ_213736582899b3599acaade2cd1"`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "items" ADD CONSTRAINT "UQ_213736582899b3599acaade2cd1" UNIQUE ("name")`, + ); + } +} diff --git a/src/modules/booking-online/authentication/data/services/verification.service.ts b/src/modules/booking-online/authentication/data/services/verification.service.ts index 85ce140..69a5540 100644 --- a/src/modules/booking-online/authentication/data/services/verification.service.ts +++ b/src/modules/booking-online/authentication/data/services/verification.service.ts @@ -4,6 +4,7 @@ import { VerificationModel } from '../models/verification.model'; import { BookingVerification } from '../../domain/entities/booking-verification.entity'; import { UnprocessableEntityException } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; +import { WhatsappService } from 'src/services/whatsapp/whatsapp.service'; export class VerificationService { constructor( @InjectRepository(VerificationModel) @@ -24,22 +25,28 @@ export class VerificationService { } async register(data: BookingVerification) { + const isProduction = process.env.NODE_ENV === 'true'; const currentTime = Math.floor(Date.now()); // current time in seconds - if ( - data.created_at && - currentTime - data.created_at > this.expiredTimeRegister - ) { - throw new UnprocessableEntityException('Please try again in 1 minute'); - } + // Generate a 4 digit OTP code - data.code = Math.floor(1000 + Math.random() * 9000).toString(); - data.tried = 0; - data.updated_at = currentTime; + const otpCode = Math.floor(1000 + Math.random() * 9000).toString(); let verification = await this.verificationRepository.findOne({ where: { phone_number: data.phone_number }, }); + if ( + isProduction && + verification.updated_at && + currentTime - verification.updated_at < this.expiredTimeRegister + ) { + throw new UnprocessableEntityException('Please try again in 1 minute'); + } + + data.code = otpCode; + data.tried = 0; + data.updated_at = currentTime; + if (verification) { // Update existing record verification = this.verificationRepository.merge(verification, data); @@ -47,7 +54,15 @@ export class VerificationService { // Create new record verification = this.verificationRepository.create(data); } - return this.verificationRepository.save(verification); + const payload = await this.verificationRepository.save(verification); + + const notificationService = new WhatsappService(); + notificationService.sendOtpNotification({ + phone: data.phone_number, + code: otpCode, + }); + + return payload; } async findByPhoneNumber(phoneNumber: string) { diff --git a/src/modules/booking-online/helpers/generate-otp.ts b/src/modules/booking-online/helpers/generate-otp.ts new file mode 100644 index 0000000..5b737cf --- /dev/null +++ b/src/modules/booking-online/helpers/generate-otp.ts @@ -0,0 +1,9 @@ +export function generateOtp(digits = 4): number { + if (digits < 1) { + throw new Error('OTP digits must be at least 1'); + } + const min = Math.pow(10, digits - 1); + const max = Math.pow(10, digits) - 1; + const otp = Math.floor(Math.random() * (max - min + 1)) + min; + return otp; +} diff --git a/src/modules/booking-online/order/data/models/reschedule-verification.model.ts b/src/modules/booking-online/order/data/models/reschedule-verification.model.ts new file mode 100644 index 0000000..a3935fc --- /dev/null +++ b/src/modules/booking-online/order/data/models/reschedule-verification.model.ts @@ -0,0 +1,36 @@ +import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; +import { RescheduleVerification } from '../../domain/entities/reschedule-verification.entity'; + +@Entity('reschedule_verification') +export class RescheduleVerificationModel implements RescheduleVerification { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + name: string; + + @Column() + phone_number: string; + + @Column() + booking_id: string; + + @Column() + reschedule_date: string; + + @Column() + code: number; + + @Column({ default: 0 }) + tried: number; + + @Column({ type: 'bigint', default: () => 'EXTRACT(EPOCH FROM NOW()) * 1000' }) + created_at: number; + + @Column({ + type: 'bigint', + default: () => 'EXTRACT(EPOCH FROM NOW()) * 1000', + onUpdate: 'EXTRACT(EPOCH FROM NOW()) * 1000', + }) + updated_at: number; +} diff --git a/src/modules/booking-online/order/domain/entities/reschedule-verification.entity.ts b/src/modules/booking-online/order/domain/entities/reschedule-verification.entity.ts new file mode 100644 index 0000000..482d4ba --- /dev/null +++ b/src/modules/booking-online/order/domain/entities/reschedule-verification.entity.ts @@ -0,0 +1,16 @@ +export interface RescheduleVerification { + id: string; + name: string; + phone_number: string; + booking_id: string; + reschedule_date: string; + code: number; + tried?: number; + created_at?: number; + updated_at?: number; +} + +export interface RescheduleRequest { + booking_id: string; + reschedule_date: string; +} diff --git a/src/modules/booking-online/order/domain/usecases/managers/booking-item.manager.ts b/src/modules/booking-online/order/domain/usecases/managers/booking-item.manager.ts new file mode 100644 index 0000000..050458b --- /dev/null +++ b/src/modules/booking-online/order/domain/usecases/managers/booking-item.manager.ts @@ -0,0 +1,67 @@ +import { Injectable } from '@nestjs/common'; +import { RelationParam } from 'src/core/modules/domain/entities/base-filter.entity'; +import { PaginationResponse } from 'src/core/response/domain/ok-response.interface'; +import { ItemEntity } from 'src/modules/item-related/item/domain/entities/item.entity'; +import { IndexItemManager } from 'src/modules/item-related/item/domain/usecases/managers/index-item.manager'; +import { SelectQueryBuilder } from 'typeorm'; + +@Injectable() +export class BookingItemManager extends IndexItemManager { + get relations(): RelationParam { + return { + // relation only join (for query purpose) + joinRelations: [], + + // relation join and select (relasi yang ingin ditampilkan), + selectRelations: [ + 'item_category', + 'bundling_items', + 'tenant', + 'time_group', + 'item_rates', + ], + + // relation yang hanya ingin dihitung (akan return number) + countRelations: [], + }; + } + + get selects(): string[] { + const parent = super.selects; + return [ + ...parent, + `${this.tableName}.image_url`, + 'item_rates.id', + 'item_rates.price', + 'item_rates.season_period_id', + ]; + } + + getResult(): PaginationResponse { + const result = super.getResult(); + const { data, total } = result; + const hasRates = (this.filterParam.season_period_ids?.length ?? 0) > 0; + const items = data.map((item) => { + const currentRate = item.item_rates.find((rate) => + this.filterParam.season_period_ids?.includes(rate.season_period_id), + ); + const { item_rates, ...rest } = item; + const rate = currentRate?.['price'] ?? rest.base_price; + return { + ...rest, + base_price: hasRates ? rate : rest.base_price, + }; + }); + return { total, data: items }; + } + + setQueryFilter( + queryBuilder: SelectQueryBuilder, + ): SelectQueryBuilder { + const query = super.setQueryFilter(queryBuilder); + + query.andWhere(`${this.tableName}.status = 'active'`); + + return query; + } +} diff --git a/src/modules/booking-online/order/domain/usecases/managers/create-booking.manager.ts b/src/modules/booking-online/order/domain/usecases/managers/create-booking.manager.ts new file mode 100644 index 0000000..1af417e --- /dev/null +++ b/src/modules/booking-online/order/domain/usecases/managers/create-booking.manager.ts @@ -0,0 +1,64 @@ +import { HttpStatus } from '@nestjs/common'; +import { UnprocessableEntityException } from '@nestjs/common'; +import { STATUS } from 'src/core/strings/constants/base.constants'; +import { TransactionType } from 'src/modules/transaction/transaction/constants'; +import { CreateTransactionManager } from 'src/modules/transaction/transaction/domain/usecases/managers/create-transaction.manager'; +import { generateInvoiceCodeHelper } from 'src/modules/transaction/transaction/domain/usecases/managers/helpers/generate-invoice-code.helper'; +import { mappingRevertTransaction } from 'src/modules/transaction/transaction/domain/usecases/managers/helpers/mapping-transaction.helper'; +import { WhatsappService } from 'src/services/whatsapp/whatsapp.service'; +import { v4 as uuidv4 } from 'uuid'; + +export class CreateBookingManager extends CreateTransactionManager { + async beforeProcess(): Promise { + mappingRevertTransaction(this.data, TransactionType.ONLINE); + + const id = uuidv4(); + const invoiceCode = await generateInvoiceCodeHelper( + this.dataService, + 'BOOK', + ); + + try { + const { token, redirect_url } = await this.dataServiceFirstOpt.create({ + ...this.data, + id, + }); + Object.assign(this.data, { + payment_midtrans_token: token, + payment_midtrans_url: redirect_url, + }); + } catch (error) { + console.log({ error }); + throw new UnprocessableEntityException({ + statusCode: HttpStatus.UNPROCESSABLE_ENTITY, + message: `Gagal! transaksi telah terbuat, silahkan periksa email untuk melanjutkan pembayaran`, + error: 'Unprocessable Entity', + }); + } + + Object.assign(this.data, { + id, + invoice_code: invoiceCode, + status: STATUS.PENDING, + invoice_date: new Date(), + }); + return; + } + + async afterProcess(): Promise { + const whatsapp = new WhatsappService(); + console.log(`/snap/v4/redirection/${this.data.payment_midtrans_token}`); + console.log(this.data.payment_midtrans_url); + await whatsapp.bookingRegister( + { + phone: this.data.customer_phone, + code: this.data.invoice_code, + name: this.data.customer_name, + time: this.data.booking_date, + id: this.data.id, + }, + this.data.payment_total, + `snap/v4/redirection/${this.data.payment_midtrans_token}`, + ); + } +} diff --git a/src/modules/booking-online/order/domain/usecases/managers/reschedule-verification.manager.ts b/src/modules/booking-online/order/domain/usecases/managers/reschedule-verification.manager.ts new file mode 100644 index 0000000..b40d513 --- /dev/null +++ b/src/modules/booking-online/order/domain/usecases/managers/reschedule-verification.manager.ts @@ -0,0 +1,115 @@ +import { Injectable, UnprocessableEntityException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { RescheduleVerificationModel } from '../../../data/models/reschedule-verification.model'; +import { + RescheduleRequest, + RescheduleVerification, +} from '../../entities/reschedule-verification.entity'; +import { generateOtp } from 'src/modules/booking-online/helpers/generate-otp'; +import { TransactionReadService } from 'src/modules/transaction/transaction/data/services/transaction-read.service'; +import { TransactionEntity } from 'src/modules/transaction/transaction/domain/entities/transaction.entity'; +import { WhatsappService } from 'src/services/whatsapp/whatsapp.service'; + +@Injectable() +export class RescheduleVerificationManager { + constructor( + @InjectRepository(RescheduleVerificationModel) + private readonly rescheduleVerificationRepository: Repository, + private readonly transactionService: TransactionReadService, + ) {} + + async saveVerification( + request: RescheduleRequest, + ): Promise { + try { + const otp = generateOtp(); + const transaction = await this.findDetailByBookingId(request.booking_id); + + if (!transaction) { + throw new Error('Transaction not found for the provided booking id'); + } + + const data: Partial = { + code: otp, + booking_id: transaction.id, + name: transaction.customer_name, + phone_number: transaction.customer_phone, + reschedule_date: request.reschedule_date, + }; + + const existTransaction = + await this.rescheduleVerificationRepository.findOne({ + where: { + booking_id: transaction.id, + }, + }); + + const verification = + existTransaction ?? this.rescheduleVerificationRepository.create(data); + const result = await this.rescheduleVerificationRepository.save({ + ...verification, + code: otp, + }); + + const whatsapp = new WhatsappService(); + whatsapp.sendOtpNotification({ + phone: transaction.customer_phone, + code: otp.toString(), + }); + // whatsapp.bookingRescheduleOTP({ + // phone: transaction.customer_phone, + // code: otp.toString(), + // name: transaction.customer_name, + // time: new Date(request.reschedule_date).getTime(), + // id: transaction.id, + // }); + return result; + } catch (error) { + // You can customize the error handling as needed, e.g., throw HttpException for NestJS + throw new UnprocessableEntityException( + `Failed to save reschedule verification: ${error.message}`, + ); + } + } + + async verifyOtp( + booking_id: string, + code: number, + ): Promise { + const verification = await this.rescheduleVerificationRepository.findOne({ + where: { booking_id, code }, + order: { created_at: 'DESC' }, + }); + + if (!verification) { + throw new UnprocessableEntityException({ + success: false, + message: 'Verification code not match', + }); + } + + // Optionally, you can implement OTP expiration logic here + + if (verification.code !== code) { + // Increment tried count + verification.tried = (verification.tried || 0) + 1; + await this.rescheduleVerificationRepository.save(verification); + throw new UnprocessableEntityException({ + success: false, + message: 'Invalid verification code.', + }); + } + + // Optionally, you can mark the verification as used or verified here + + return verification; + } + + async findDetailByBookingId(bookingId: string): Promise { + const transaction = await this.transactionService.getOneByOptions({ + where: { id: bookingId }, + }); + return transaction; + } +} diff --git a/src/modules/booking-online/order/domain/usecases/managers/reschedule.manager.ts b/src/modules/booking-online/order/domain/usecases/managers/reschedule.manager.ts new file mode 100644 index 0000000..cb67563 --- /dev/null +++ b/src/modules/booking-online/order/domain/usecases/managers/reschedule.manager.ts @@ -0,0 +1,90 @@ +import { Injectable, UnprocessableEntityException } from '@nestjs/common'; +import { TransactionModel } from 'src/modules/transaction/transaction/data/models/transaction.model'; +import { STATUS } from 'src/core/strings/constants/base.constants'; +import { v4 as uuidv4 } from 'uuid'; +import { TransactionDataService } from 'src/modules/transaction/transaction/data/services/transaction-data.service'; +import { generateInvoiceCodeHelper } from 'src/modules/transaction/transaction/domain/usecases/managers/helpers/generate-invoice-code.helper'; +import * as moment from 'moment'; +import { TransactionItemModel } from 'src/modules/transaction/transaction/data/models/transaction-item.model'; +import { RescheduleVerificationModel } from '../../../data/models/reschedule-verification.model'; +import { WhatsappService } from 'src/services/whatsapp/whatsapp.service'; + +@Injectable() +export class RescheduleManager { + constructor(private serviceData: TransactionDataService) {} + + async reschedule(data: RescheduleVerificationModel) { + const transaction = await this.serviceData.getTransactionWithReschedule( + data.booking_id, + ); + + const rescheduleDate = moment(data.reschedule_date, 'DD-MM-YYYY'); + + const id = uuidv4(); + const invoiceCode = await generateInvoiceCodeHelper( + this.serviceData, + 'BOOK', + ); + + const items = this.makeItemZeroPrice(transaction.items); + const transactionData = this.makeTransactionZeroPrice(transaction); + + Object.assign(transactionData, { + parent_id: transaction.id, + id, + invoice_code: invoiceCode, + status: STATUS.SETTLED, + invoice_date: rescheduleDate.format('YYYY-MM-DD'), + booking_date: rescheduleDate.format('YYYY-MM-DD'), + created_at: moment().unix() * 1000, + updated_at: moment().unix() * 1000, + items, + }); + + await this.serviceData.getRepository().save(transactionData); + + const whatsapp = new WhatsappService(); + whatsapp.rescheduleCreated({ + id: transactionData.id, + name: transactionData.customer_name, + phone: transactionData.customer_phone, + time: moment(transactionData.invoice_date).unix() * 1000, + code: transactionData.invoice_code, + }); + + return transactionData; + } + + private makeItemZeroPrice(items: TransactionItemModel[]) { + return items.map((item) => { + return { + ...item, + id: uuidv4(), + item_price: 0, + total_price: 0, + total_hpp: 0, + total_profit: 0, + total_profit_share: 0, + payment_total_dpp: 0, + payment_total_tax: 0, + total_net_price: 0, + }; + }); + } + + private makeTransactionZeroPrice(transaction: TransactionModel) { + return { + ...transaction, + payment_sub_total: 0, + payment_discount_total: 0, + payment_total: 0, + payment_total_pay: 0, + payment_total_share: 0, + payment_total_tax: 0, + payment_total_profit: 0, + payment_total_net_profit: 0, + payment_total_dpp: 0, + discount_percentage: 0, + }; + } +} diff --git a/src/modules/booking-online/order/infrastructure/dto/booking-order.dto.ts b/src/modules/booking-online/order/infrastructure/dto/booking-order.dto.ts new file mode 100644 index 0000000..9c725ab --- /dev/null +++ b/src/modules/booking-online/order/infrastructure/dto/booking-order.dto.ts @@ -0,0 +1,121 @@ +import { BaseStatusDto } from 'src/core/modules/infrastructure/dto/base-status.dto'; +import { ApiProperty } from '@nestjs/swagger'; +import { + IsArray, + IsNumber, + IsObject, + IsString, + ValidateIf, +} from 'class-validator'; +import { SeasonPeriodEntity } from 'src/modules/season-related/season-period/domain/entities/season-period.entity'; +import { TransactionItemEntity } from 'src/modules/transaction/transaction/domain/entities/transaction-item.entity'; +import { + TransactionPaymentType, + TransactionUserType, +} from 'src/modules/transaction/transaction/constants'; + +export class TransactionDto extends BaseStatusDto { + @ApiProperty({ + type: Object, + required: false, + example: { + id: 'uuid', + season_type: { + id: 'uuid', + name: 'high season', + }, + }, + }) + @IsObject() + @ValidateIf((body) => body.season_period) + season_period: SeasonPeriodEntity; + + @ApiProperty({ + type: String, + required: true, + example: TransactionUserType.GROUP, + }) + @IsString() + customer_type: TransactionUserType; + + @ApiProperty({ + type: String, + required: true, + example: 'Andika', + }) + @IsString() + customer_name: string; + + @ApiProperty({ + type: String, + required: false, + example: '0823...', + }) + @ValidateIf((body) => body.customer_phone) + customer_phone: string; + + @ApiProperty({ + type: Date, + required: true, + example: '2024-01-01', + }) + booking_date: Date; + + @ApiProperty({ + type: String, + required: false, + example: TransactionPaymentType.MIDTRANS, + }) + payment_type: TransactionPaymentType; + + @ApiProperty({ + type: Number, + required: true, + example: 7000000, + }) + @IsNumber() + payment_sub_total: number; + + @ApiProperty({ + type: Number, + required: true, + example: 3500000, + }) + @IsNumber() + payment_total: number; + + @ApiProperty({ + type: [Object], + required: true, + example: [ + { + item: { + id: '68aa12f7-2cce-422b-9bae-185eb1343b94', + created_at: '1718876384378', + status: 'active', + name: 'tes', + item_type: 'bundling', + hpp: '100000', + base_price: '100000', + limit_type: 'no limit', + limit_value: 0, + item_category: { + id: 'ab15981a-a656-4efc-856c-b2abfbe30979', + name: 'Kategori Bundling 2', + }, + bundling_items: [ + { + id: 'bd5a7a38-df25-4203-a1cd-bf94867946b2', + name: 'Wahana 21 panjangggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg', + }, + ], + tenant: null, + }, + qty: 40, + total_price: 4000000, + }, + ], + }) + @IsArray() + items: TransactionItemEntity[]; +} diff --git a/src/modules/booking-online/order/infrastructure/dto/reschedule.dto.ts b/src/modules/booking-online/order/infrastructure/dto/reschedule.dto.ts new file mode 100644 index 0000000..21bb027 --- /dev/null +++ b/src/modules/booking-online/order/infrastructure/dto/reschedule.dto.ts @@ -0,0 +1,47 @@ +import { IsString, Matches } from 'class-validator'; +import { RescheduleRequest } from 'src/modules/booking-online/order/domain/entities/reschedule-verification.entity'; + +import { ApiProperty } from '@nestjs/swagger'; + +export class RescheduleRequestDTO implements RescheduleRequest { + @ApiProperty({ + type: String, + required: true, + example: '123e4567-e89b-12d3-a456-426614174000', + description: 'The unique identifier of the booking', + }) + @IsString() + booking_id: string; + + @ApiProperty({ + type: String, + required: true, + example: '25-12-2024', + description: 'The new date for rescheduling in the format DD-MM-YYYY', + }) + @IsString() + @Matches(/^(0[1-9]|[12][0-9]|3[01])-(0[1-9]|1[0-2])-\d{4}$/, { + message: 'reschedule_date must be in the format DD-MM-YYYY', + }) + reschedule_date: string; +} + +export class RescheduleVerificationOTP { + @ApiProperty({ + type: String, + required: true, + example: '123e4567-e89b-12d3-a456-426614174000', + description: 'The unique identifier of the booking', + }) + @IsString() + booking_id: string; + + @ApiProperty({ + type: String, + required: true, + example: '123456', + description: 'The OTP code sent for verification', + }) + @IsString() + code: string; +} diff --git a/src/modules/booking-online/order/infrastructure/item.controller.ts b/src/modules/booking-online/order/infrastructure/item.controller.ts index 7a70a07..bb0741c 100644 --- a/src/modules/booking-online/order/infrastructure/item.controller.ts +++ b/src/modules/booking-online/order/infrastructure/item.controller.ts @@ -1,19 +1,19 @@ import { Controller, Get, Query } from '@nestjs/common'; -import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { ApiTags } from '@nestjs/swagger'; import { Public } from 'src/core/guards'; import { PaginationResponse } from 'src/core/response/domain/ok-response.interface'; import { TABLE_NAME } from 'src/core/strings/constants/table.constants'; import { ItemReadService } from 'src/modules/item-related/item/data/services/item-read.service'; import { ItemEntity } from 'src/modules/item-related/item/domain/entities/item.entity'; -import { IndexItemManager } from 'src/modules/item-related/item/domain/usecases/managers/index-item.manager'; import { FilterItemDto } from 'src/modules/item-related/item/infrastructure/dto/filter-item.dto'; +import { BookingItemManager } from '../domain/usecases/managers/booking-item.manager'; @ApiTags('Booking Item') @Controller('v1/booking-item') @Public(true) export class ItemController { constructor( - private indexManager: IndexItemManager, + private indexManager: BookingItemManager, private serviceData: ItemReadService, ) {} @@ -21,15 +21,12 @@ export class ItemController { async index( @Query() params: FilterItemDto, ): Promise> { - try { - params.show_to_booking = true; - this.indexManager.setFilterParam(params); - this.indexManager.setService(this.serviceData, TABLE_NAME.ITEM); - await this.indexManager.execute(); - return this.indexManager.getResult(); - } catch (error) { - console.log(error); - throw error; - } + params.limit = 1000; + params.show_to_booking = true; + params.all_item = true; + this.indexManager.setFilterParam(params); + this.indexManager.setService(this.serviceData, TABLE_NAME.ITEM); + await this.indexManager.execute(); + return this.indexManager.getResult(); } } diff --git a/src/modules/booking-online/order/infrastructure/order.controller.ts b/src/modules/booking-online/order/infrastructure/order.controller.ts new file mode 100644 index 0000000..1139260 --- /dev/null +++ b/src/modules/booking-online/order/infrastructure/order.controller.ts @@ -0,0 +1,274 @@ +import { + Body, + Controller, + Get, + Param, + Post, + Res, + UnprocessableEntityException, +} from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { Public } from 'src/core/guards'; +import { TransactionDto } from './dto/booking-order.dto'; +import { TransactionEntity } from 'src/modules/transaction/transaction/domain/entities/transaction.entity'; +import { TransactionDataService } from 'src/modules/transaction/transaction/data/services/transaction-data.service'; +import { TABLE_NAME } from 'src/core/strings/constants/table.constants'; +import { MidtransService } from 'src/modules/configuration/midtrans/data/services/midtrans.service'; +import { CreateBookingManager } from '../domain/usecases/managers/create-booking.manager'; +import * as QRCode from 'qrcode'; +import { Gate } from 'src/core/response/domain/decorators/pagination.response'; +import { Response } from 'express'; +import { + RescheduleRequestDTO, + RescheduleVerificationOTP, +} from './dto/reschedule.dto'; +import { RescheduleVerificationManager } from '../domain/usecases/managers/reschedule-verification.manager'; +import { RescheduleManager } from '../domain/usecases/managers/reschedule.manager'; +import { STATUS } from 'src/core/strings/constants/base.constants'; +import * as moment from 'moment'; +import { WhatsappService } from 'src/services/whatsapp/whatsapp.service'; + +@ApiTags('Booking Order') +@Controller('v1/booking') +@Public(true) +export class BookingOrderController { + constructor( + private createBooking: CreateBookingManager, + private serviceData: TransactionDataService, + private midtransService: MidtransService, + private rescheduleVerification: RescheduleVerificationManager, + private rescheduleManager: RescheduleManager, + ) {} + + @Post() + async create(@Body() data: TransactionDto) { + const payload: Partial = data; + + this.createBooking.setData(payload as any); + this.createBooking.setService( + this.serviceData, + TABLE_NAME.TRANSACTION, + this.midtransService, + ); + await this.createBooking.execute(); + const result = await this.createBooking.getResult(); + const { + invoice_code, + status, + payment_midtrans_token, + payment_midtrans_url, + id, + } = result; + + return { + id, + invoice_code, + status, + payment_midtrans_token, + payment_midtrans_url, + }; + } + + @Post('reschedule') + async reschedule(@Body() data: RescheduleRequestDTO) { + const transaction = await this.serviceData.getTransactionWithReschedule( + data.booking_id, + ); + + const today = moment().startOf('day'); + const rescheduleDate = moment(data.reschedule_date, 'DD-MM-YYYY'); + const rescheduleDateStartOfDay = rescheduleDate.startOf('day'); + + //TODO: validate session period priority + + if (rescheduleDateStartOfDay.isSameOrBefore(today)) { + throw new UnprocessableEntityException( + 'Reschedule date must be in the future', + ); + } + + if (!transaction) { + throw new UnprocessableEntityException('Transaction not found'); + } + + if (transaction.status !== STATUS.SETTLED) { + throw new UnprocessableEntityException('Transaction is not settled'); + } + + if (transaction.children_transactions.length > 0) { + throw new UnprocessableEntityException('Transaction already rescheduled'); + } + + if (transaction.parent_id) { + throw new UnprocessableEntityException('Transaction is a reschedule'); + } + + const result = await this.rescheduleVerification.saveVerification(data); + const maskedPhoneNumber = result.phone_number.replace(/.(?=.{4})/g, '*'); + result.phone_number = maskedPhoneNumber; + + return `Verification code sent to ${maskedPhoneNumber}`; + } + + @Post('reschedule/verification') + async verificationReschedule(@Body() data: RescheduleVerificationOTP) { + const result = await this.rescheduleVerification.verifyOtp( + data.booking_id, + +data.code, + ); + + const reschedule = await this.rescheduleManager.reschedule(result); + const transaction = await this.get(reschedule.id); + + return { + id: reschedule.id, + phone_number: result.phone_number, + name: result.name, + reschedule_date: result.reschedule_date, + transaction, + }; + } + + @Get(':id') + async get(@Param('id') transactionId: string) { + const data = await this.serviceData.getOneByOptions({ + relations: [ + 'items', + 'parent_transaction', + 'items.item', + 'items.item.time_group', + ], + where: { id: transactionId }, + }); + + const { + parent_id, + customer_name, + customer_phone, + booking_date, + invoice_code, + status, + id, + items, + parent_transaction, + } = data; + + let timeGroup = null; + + const usageItems = items.map((item) => { + const itemData = item.item; + if (itemData.time_group) { + const timeGroupData = itemData.time_group; + const { + id: groupId, + name, + start_time, + end_time, + max_usage_time, + } = timeGroupData; + timeGroup = { + id: groupId, + name, + start_time, + end_time, + max_usage_time, + }; + } + const { + id, + item_id, + item_name, + item_price, + item_category_name, + total_price, + total_net_price, + qty, + qty_remaining, + } = item; + return { + id, + item_id, + item_name, + item_price, + item_category_name, + total_price, + total_net_price, + qty, + qty_remaining, + }; + }); + + // Mask customer_phone with * and keep last 4 numbers + let maskedCustomerPhone = customer_phone; + if (typeof customer_phone === 'string' && customer_phone.length > 4) { + const last4 = customer_phone.slice(-4); + maskedCustomerPhone = '*'.repeat(customer_phone.length - 4) + last4; + } + + let parentTransaction = undefined; + if (parent_transaction) { + const { + id: parentId, + invoice_code: parentInvoiceCode, + invoice_date: parentInvoiceDate, + } = parent_transaction; + parentTransaction = { + id: parentId, + invoice_code: parentInvoiceCode, + invoice_date: parentInvoiceDate, + }; + } + + return { + customer_name, + customer_phone: maskedCustomerPhone, + booking_date, + invoice_code, + status, + id, + is_reschedule: !!parent_id, + items: usageItems, + time_group: timeGroup, + parent: parentTransaction, + }; + } + + @Gate() + @Get('qrcode/:id') + async getQRcode(@Param('id') id: string, @Res() res: Response) { + console.log(QRCode); + const qrData = id; + const data = await QRCode.toDataURL(qrData); + res.setHeader('Content-Type', 'image/png'); + const base64Data = data.split(',')[1]; + const buffer = Buffer.from(base64Data, 'base64'); + res.send(buffer); + } + + @Post('resend-notification/:id') + async resendNotification(@Param('id') id: string) { + try { + const transaction = await this.serviceData.getOneByOptions({ + where: { id }, + }); + + const whatsappService = new WhatsappService(); + const formattedDate = moment(transaction.booking_date); + const payload = { + id: transaction.id, + phone: transaction.customer_phone, + code: transaction.invoice_code, + name: transaction.customer_name, + time: formattedDate.valueOf(), + }; + await whatsappService.bookingCreated(payload); + return { + message: 'Notification sent successfully', + }; + } catch (error) { + throw new UnprocessableEntityException({ + message: 'Failed to send notification', + }); + } + } +} diff --git a/src/modules/booking-online/order/order.module.ts b/src/modules/booking-online/order/order.module.ts index 7a2203b..b0aea9e 100644 --- a/src/modules/booking-online/order/order.module.ts +++ b/src/modules/booking-online/order/order.module.ts @@ -6,13 +6,33 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { ItemModel } from 'src/modules/item-related/item/data/models/item.model'; import { ItemModule } from 'src/modules/item-related/item/item.module'; import { ItemController } from './infrastructure/item.controller'; +import { TransactionModule } from 'src/modules/transaction/transaction/transaction.module'; +import { BookingOrderController } from './infrastructure/order.controller'; +import { CreateBookingManager } from './domain/usecases/managers/create-booking.manager'; +import { MidtransModule } from 'src/modules/configuration/midtrans/midtrans.module'; +import { CqrsModule } from '@nestjs/cqrs'; +import { RescheduleVerificationModel } from './data/models/reschedule-verification.model'; +import { RescheduleVerificationManager } from './domain/usecases/managers/reschedule-verification.manager'; +import { RescheduleManager } from './domain/usecases/managers/reschedule.manager'; +import { BookingItemManager } from './domain/usecases/managers/booking-item.manager'; @Module({ imports: [ ConfigModule.forRoot(), - TypeOrmModule.forFeature([ItemModel], CONNECTION_NAME.DEFAULT), + TypeOrmModule.forFeature( + [ItemModel, RescheduleVerificationModel], + CONNECTION_NAME.DEFAULT, + ), ItemModule, + TransactionModule, + MidtransModule, + CqrsModule, + ], + controllers: [ItemController, BookingOrderController], + providers: [ + CreateBookingManager, + RescheduleVerificationManager, + RescheduleManager, + BookingItemManager, ], - controllers: [ItemController], - providers: [], }) export class BookingOrderModule {} diff --git a/src/modules/configuration/couch/constants.ts b/src/modules/configuration/couch/constants.ts index 8171752..fa8be79 100644 --- a/src/modules/configuration/couch/constants.ts +++ b/src/modules/configuration/couch/constants.ts @@ -3,4 +3,5 @@ export const DatabaseListen = [ 'vip_code', 'pos_activity', 'pos_cash_activity', + 'time_groups', ]; diff --git a/src/modules/configuration/couch/couch.module.ts b/src/modules/configuration/couch/couch.module.ts index 6fd848f..060254c 100644 --- a/src/modules/configuration/couch/couch.module.ts +++ b/src/modules/configuration/couch/couch.module.ts @@ -52,6 +52,10 @@ import { SeasonPeriodDataService } from 'src/modules/season-related/season-perio import { SeasonPeriodModel } from 'src/modules/season-related/season-period/data/models/season-period.model'; import { TransactionDemographyModel } from 'src/modules/transaction/transaction/data/models/transaction-demography.model'; import { UserLoginModel } from 'src/modules/user-related/user/data/models/user-login.model'; +import { + TimeGroupDeletedHandler, + TimeGroupUpdatedHandler, +} from './domain/managers/time-group.handle'; @Module({ imports: [ @@ -83,6 +87,10 @@ import { UserLoginModel } from 'src/modules/user-related/user/data/models/user-l VipCodeCreatedHandler, VipCategoryDeletedHandler, VipCategoryUpdatedHandler, + + TimeGroupDeletedHandler, + TimeGroupUpdatedHandler, + SeasonPeriodDeletedHandler, SeasonPeriodUpdatedHandler, ItemUpdatedHandler, diff --git a/src/modules/configuration/couch/domain/managers/item.handler.ts b/src/modules/configuration/couch/domain/managers/item.handler.ts index 1c02076..75e4c78 100644 --- a/src/modules/configuration/couch/domain/managers/item.handler.ts +++ b/src/modules/configuration/couch/domain/managers/item.handler.ts @@ -44,10 +44,12 @@ export class ItemUpdatedHandler 'item_category', 'bundling_items', 'bundling_items.item_category', + 'bundling_items.time_group', 'item_rates', 'item_rates.item', 'item_rates.season_period', 'item_rates.season_period.season_type', + 'time_group', ], }); @@ -105,10 +107,12 @@ export class ItemPriceUpdatedHandler 'item_category', 'bundling_items', 'bundling_items.item_category', + 'bundling_items.time_group', 'item_rates', 'item_rates.item', 'item_rates.season_period', 'item_rates.season_period.season_type', + 'time_group', ], }); @@ -146,10 +150,12 @@ export class ItemRateUpdatedHandler 'item_category', 'bundling_items', 'bundling_items.item_category', + 'bundling_items.time_group', 'item_rates', 'item_rates.item', 'item_rates.season_period', 'item_rates.season_period.season_type', + 'time_group', ], }); diff --git a/src/modules/configuration/couch/domain/managers/time-group.handle.ts b/src/modules/configuration/couch/domain/managers/time-group.handle.ts new file mode 100644 index 0000000..b93fe04 --- /dev/null +++ b/src/modules/configuration/couch/domain/managers/time-group.handle.ts @@ -0,0 +1,65 @@ +import { EventsHandler, IEventHandler } from '@nestjs/cqrs'; +import { CouchService } from '../../data/services/couch.service'; +import { STATUS } from 'src/core/strings/constants/base.constants'; +import { TimeGroupDeletedEvent } from 'src/modules/item-related/time-group/domain/entities/event/time-group-deleted.event'; +import { TimeGroupChangeStatusEvent } from 'src/modules/item-related/time-group/domain/entities/event/time-group-change-status.event'; +import { TimeGroupUpdatedEvent } from 'src/modules/item-related/time-group/domain/entities/event/time-group-updated.event'; + +@EventsHandler(TimeGroupDeletedEvent) +export class TimeGroupDeletedHandler + implements IEventHandler +{ + constructor(private couchService: CouchService) {} + + async handle(event: TimeGroupDeletedEvent) { + const data = await this.couchService.deleteDoc( + { + _id: event.data.id, + ...event.data.data, + }, + 'time_groups', + ); + } +} + +@EventsHandler(TimeGroupChangeStatusEvent, TimeGroupUpdatedEvent) +export class TimeGroupUpdatedHandler + implements IEventHandler +{ + constructor(private couchService: CouchService) {} + + async handle(event: TimeGroupChangeStatusEvent) { + const dataOld = event.data.old; + const data = event.data.data; + + // change status to active + if (dataOld?.status != data.status && data.status == STATUS.ACTIVE) { + await this.couchService.createDoc( + { + _id: data.id, + ...data, + }, + 'time_groups', + ); + } else if (dataOld?.status != data.status) { + await this.couchService.deleteDoc( + { + _id: data.id, + ...data, + }, + 'time_groups', + ); + } + + // update + else { + await this.couchService.updateDoc( + { + _id: data.id, + ...data, + }, + 'time_groups', + ); + } + } +} diff --git a/src/modules/configuration/google-calendar/google-calendar.module.ts b/src/modules/configuration/google-calendar/google-calendar.module.ts index 24fde4b..75a10a2 100644 --- a/src/modules/configuration/google-calendar/google-calendar.module.ts +++ b/src/modules/configuration/google-calendar/google-calendar.module.ts @@ -10,7 +10,7 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { CONNECTION_NAME } from 'src/core/strings/constants/base.constants'; import { TransactionItemModel } from 'src/modules/transaction/transaction/data/models/transaction-item.model'; import { TransactionTaxModel } from 'src/modules/transaction/transaction/data/models/transaction-tax.model'; - +import { CouchModule } from 'src/modules/configuration/couch/couch.module'; @Module({ imports: [ ConfigModule.forRoot(), @@ -19,6 +19,7 @@ import { TransactionTaxModel } from 'src/modules/transaction/transaction/data/mo CONNECTION_NAME.DEFAULT, ), CqrsModule, + CouchModule, ], controllers: [GoogleCalendarController], providers: [ diff --git a/src/modules/configuration/mail/mail.module.ts b/src/modules/configuration/mail/mail.module.ts index 412b0c3..ab13a2e 100644 --- a/src/modules/configuration/mail/mail.module.ts +++ b/src/modules/configuration/mail/mail.module.ts @@ -10,7 +10,7 @@ import { TransactionDataService } from 'src/modules/transaction/transaction/data import { PaymentTransactionHandler } from './domain/handlers/payment-transaction.handler'; import { MailTemplateController } from './infrastructure/mail.controller'; import { PdfMakeManager } from '../export/domain/managers/pdf-make.manager'; - +import { CouchModule } from '../couch/couch.module'; @Module({ imports: [ ConfigModule.forRoot(), @@ -19,6 +19,7 @@ import { PdfMakeManager } from '../export/domain/managers/pdf-make.manager'; CONNECTION_NAME.DEFAULT, ), CqrsModule, + CouchModule, ], controllers: [MailTemplateController], providers: [ diff --git a/src/modules/configuration/otp-verification/data/models/otp-verification.model.ts b/src/modules/configuration/otp-verification/data/models/otp-verification.model.ts new file mode 100644 index 0000000..eec21fb --- /dev/null +++ b/src/modules/configuration/otp-verification/data/models/otp-verification.model.ts @@ -0,0 +1,41 @@ +import { TABLE_NAME } from 'src/core/strings/constants/table.constants'; +import { + OTP_ACTION_TYPE, + OTP_SOURCE, + OtpVerificationEntity, +} from '../../domain/entities/otp-verification.entity'; +import { Column, Entity } from 'typeorm'; +import { BaseModel } from 'src/core/modules/data/model/base.model'; + +@Entity(TABLE_NAME.OTP_VERIFICATIONS) +export class OtpVerificationModel + extends BaseModel + implements OtpVerificationEntity +{ + @Column({ type: 'varchar', nullable: false }) + otp_code: string; + + @Column({ type: 'enum', enum: OTP_ACTION_TYPE }) + action_type: OTP_ACTION_TYPE; + + @Column({ type: 'varchar', nullable: true }) + target_id: string; + + @Column({ type: 'varchar', nullable: true }) + reference: string; + + @Column({ type: 'enum', enum: OTP_SOURCE }) + source: OTP_SOURCE; + + @Column({ default: false }) + is_used: boolean; + + @Column({ default: false }) + is_replaced: boolean; + + @Column({ type: 'bigint', nullable: false }) + expired_at: number; // UNIX timestamp + + @Column({ type: 'bigint', nullable: true }) + verified_at: number; // UNIX timestamp or null +} diff --git a/src/modules/configuration/otp-verification/data/models/otp-verifier.model.ts b/src/modules/configuration/otp-verification/data/models/otp-verifier.model.ts new file mode 100644 index 0000000..23f9bc3 --- /dev/null +++ b/src/modules/configuration/otp-verification/data/models/otp-verifier.model.ts @@ -0,0 +1,25 @@ +import { TABLE_NAME } from 'src/core/strings/constants/table.constants'; +import { + OTP_ACTION_TYPE, + OtpVerifierEntity, +} from '../../domain/entities/otp-verification.entity'; +import { Column, Entity } from 'typeorm'; +import { BaseModel } from 'src/core/modules/data/model/base.model'; + +@Entity(TABLE_NAME.OTP_VERIFIER) +export class OtpVerifierModel + extends BaseModel + implements OtpVerifierEntity +{ + @Column({ type: 'varchar', nullable: true }) + name: string; + + @Column({ type: 'varchar', nullable: false }) + phone_number: string; + + @Column({ default: false }) + is_all_action: boolean; + + @Column({ type: 'enum', enum: OTP_ACTION_TYPE, array: true, nullable: true }) + action_types: OTP_ACTION_TYPE[] | null; +} diff --git a/src/modules/configuration/otp-verification/data/services/otp-verification.service.ts b/src/modules/configuration/otp-verification/data/services/otp-verification.service.ts new file mode 100644 index 0000000..f79856a --- /dev/null +++ b/src/modules/configuration/otp-verification/data/services/otp-verification.service.ts @@ -0,0 +1,266 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { OtpVerificationModel } from '../models/otp-verification.model'; +import { + OTP_ACTION_TYPE, + OtpRequestEntity, + OtpVerificationEntity, + OtpVerifierEntity, + // OtpVerifierEntity, + OtpVerifyEntity, +} from '../../domain/entities/otp-verification.entity'; +import * as moment from 'moment'; +import { OtpService } from 'src/core/helpers/otp/otp-service'; +import { TABLE_NAME } from 'src/core/strings/constants/table.constants'; +import { WhatsappService } from 'src/services/whatsapp/whatsapp.service'; +import { OtpVerifierModel } from '../models/otp-verifier.model'; + +@Injectable() +export class OtpVerificationService { + constructor( + @InjectRepository(OtpVerificationModel) + private readonly otpVerificationRepo: Repository, + + @InjectRepository(OtpVerifierModel) + private readonly otpVerifierRepo: Repository, + ) {} + + private generateOtpExpiration(minutes = 5): number { + return moment().add(minutes, 'minutes').valueOf(); // epoch millis expired time + } + + private generateResendAvailableAt(seconds = 60): number { + return moment().add(seconds, 'seconds').valueOf(); // epoch millis + } + + private generateTimestamp(): number { + return moment().valueOf(); // epoch millis verification time (now) + } + + private generateHeaderTemplate(payload) { + const label = payload.action_type; + + // Optional logic to override label based on action type. + // e.g., if (payload.action_type === OTP_ACTION_TYPE.CONFIRM_TRANSACTION) label = 'CONFIRM_BOOKING_TRANSACTION' + + const header = label.split('_').join(' '); + const type = label + .split('_') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(' '); + + return { header, type }; + } + + private generateOTPMsgTemplate(payload) { + const { userRequest, newOtp } = payload; + const { header, type } = this.generateHeaderTemplate(newOtp); + + const otpCode = newOtp?.otp_code; + const username = userRequest?.username; + + return { + name: 'general_flow', + language: { code: 'id' }, + components: [ + { + type: 'header', + parameters: [ + { + type: 'text', + parameter_name: 'header', + text: header, + }, + ], + }, + { + type: 'body', + parameters: [ + { + type: 'text', + parameter_name: 'name', + text: username, + }, + { + type: 'text', + parameter_name: 'code', + text: otpCode, + }, + { + type: 'text', + parameter_name: 'type', + text: type, + }, + ], + }, + { + type: 'footer', + parameters: [ + { + type: 'text', + text: 'Kode berlaku selama 5 menit.', + }, + ], + }, + ], + }; + } + + async requestOTP(payload: OtpRequestEntity, req: any) { + const otpService = new OtpService({ length: 4 }); + const otpCode = otpService.generateSecureOTP(); + const dateNow = this.generateTimestamp(); + const expiredAt = this.generateOtpExpiration(); + const userRequest = req?.user; + + const newOtp: OtpVerificationEntity = { + otp_code: otpCode, + action_type: payload.action_type, + target_id: payload.target_id, + reference: payload.reference, + source: payload.source, + is_used: false, + is_replaced: false, + expired_at: expiredAt, + + creator_id: userRequest?.id, + creator_name: userRequest?.name, + created_at: dateNow, + verified_at: null, + + editor_id: userRequest?.id, + editor_name: userRequest?.name, + updated_at: dateNow, + }; + + const activeOTP = await this.getActiveOtp( + payload.target_id ? payload.target_id : payload.reference, + ); + + if (activeOTP) { + const createdAtMoment = moment(Number(activeOTP.created_at)); + const nowMoment = moment(Number(dateNow)); + const diffSeconds = nowMoment.diff(createdAtMoment, 'seconds'); + const isProduction = process.env.NODE_ENV === 'true'; + + if (diffSeconds < 60 && isProduction) { + throw new BadRequestException( + 'An active OTP request was made recently. Please try again later.', + ); + } else { + // Update data is_replaced on database + this.otpVerificationRepo.save({ + ...activeOTP, + is_replaced: true, + }); + } + } + + // save otp to database + await this.otpVerificationRepo.save(newOtp); + const verifiers: OtpVerifierEntity[] = await this.getVerifier([ + payload.action_type, + ]); + const notificationService = new WhatsappService(); + + verifiers?.map((v) => { + notificationService.sendTemplateMessage({ + phone: v.phone_number, + templateMsg: this.generateOTPMsgTemplate({ userRequest, newOtp }), + }); + }); + + return { + message: `OTP has been sent to the admin's WhatsApp.`, + updated_at: expiredAt, + resend_available_at: this.generateResendAvailableAt(), + }; + } + + async verifyOTP(payload: OtpVerifyEntity, req: any) { + const userRequest = req?.user; + const { otp_code, action_type, target_id, reference, source } = payload; + const dateNow = this.generateTimestamp(); + + if (!target_id && !reference) { + throw new BadRequestException( + 'Either target_id or reference must be provided.', + ); + } + + let otp: any; + + // Build a where condition with OR between target_id and reference + + if (target_id) { + otp = await this.otpVerificationRepo.findOne({ + where: { + otp_code, + action_type, + target_id, + source, + is_used: false, + is_replaced: false, + }, + }); + } else if (reference) { + otp = await this.otpVerificationRepo.findOne({ + where: { + otp_code, + action_type, + reference, + source, + is_used: false, + is_replaced: false, + }, + }); + } + + if (!otp) { + throw new BadRequestException('Invalid or expired OTP.'); + } else if (otp.expired_at <= dateNow) { + throw new BadRequestException('OTP has expired.'); + } + + otp.is_used = true; + otp.verified_at = dateNow; + otp.editor_id = userRequest?.id; + otp.editor_name = userRequest?.name; + otp.updated_at = dateNow; + + // update otp to database + await this.otpVerificationRepo.save(otp); + return { message: 'OTP verified successfully.' }; + } + + async getActiveOtp(payload: string) { + const now = this.generateTimestamp(); + const tableName = TABLE_NAME.OTP_VERIFICATIONS; + + return this.otpVerificationRepo + .createQueryBuilder(tableName) + .where( + `(${tableName}.target_id = :payload OR ${tableName}.reference = :payload) + AND ${tableName}.is_used = false + AND ${tableName}.is_replaced = false + AND ${tableName}.expired_at > :now`, + { payload, now }, + ) + .orderBy( + `CASE WHEN ${tableName}.target_id = :payload THEN 0 ELSE 1 END`, + 'ASC', + ) + .getOne(); + } + + async getVerifier(actions: OTP_ACTION_TYPE[]) { + const tableALias = TABLE_NAME.OTP_VERIFIER; + const results = await this.otpVerifierRepo + .createQueryBuilder(tableALias) + .where(`${tableALias}.is_all_action = :isAll`, { isAll: true }) + .orWhere(`${tableALias}.action_types && :actions`, { actions }) + .getMany(); + return results ?? []; + } +} diff --git a/src/modules/configuration/otp-verification/data/services/otp-verifier.service.ts b/src/modules/configuration/otp-verification/data/services/otp-verifier.service.ts new file mode 100644 index 0000000..eb06bd8 --- /dev/null +++ b/src/modules/configuration/otp-verification/data/services/otp-verifier.service.ts @@ -0,0 +1,24 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { OtpVerifierModel } from '../models/otp-verifier.model'; +import { OtpVerifierCreateDto } from '../../infrastructure/dto/otp-verification.dto'; +import * as moment from 'moment'; + +@Injectable() +export class OtpVerifierService { + constructor( + @InjectRepository(OtpVerifierModel) + private readonly otpVerifierRepo: Repository, + ) {} + + async create(payload: OtpVerifierCreateDto) { + const dateNow = moment().valueOf(); + + return this.otpVerifierRepo.save({ + ...payload, + created_at: dateNow, + updated_at: dateNow, + }); + } +} diff --git a/src/modules/configuration/otp-verification/domain/entities/otp-verification.entity.ts b/src/modules/configuration/otp-verification/domain/entities/otp-verification.entity.ts new file mode 100644 index 0000000..6300834 --- /dev/null +++ b/src/modules/configuration/otp-verification/domain/entities/otp-verification.entity.ts @@ -0,0 +1,50 @@ +import { BaseEntity } from 'src/core/modules/domain/entities//base.entity'; + +export enum OTP_ACTION_TYPE { + CREATE_DISCOUNT = 'CREATE_DISCOUNT', + CANCEL_TRANSACTION = 'CANCEL_TRANSACTION', + REJECT_RECONCILIATION = 'REJECT_RECONCILIATION', + + ACTIVATE_USER = 'ACTIVATE_USER', + + ACTIVATE_ITEM = 'ACTIVATE_ITEM', + UPDATE_ITEM_PRICE = 'UPDATE_ITEM_PRICE', + UPDATE_ITEM_DETAILS = 'UPDATE_ITEM_DETAILS', + + CONFIRM_TRANSACTION = 'CONFIRM_TRANSACTION', +} + +export enum OTP_SOURCE { + POS = 'POS', + WEB = 'WEB', +} + +export interface OtpVerificationEntity extends BaseEntity { + otp_code: string; + action_type: OTP_ACTION_TYPE; + target_id: string; + reference: string; + source: OTP_SOURCE; + is_used: boolean; + is_replaced: boolean; + expired_at: number; + verified_at: number; +} + +export interface OtpRequestEntity { + action_type: OTP_ACTION_TYPE; + source: OTP_SOURCE; + target_id: string; + reference: string; +} + +export interface OtpVerifyEntity extends OtpRequestEntity { + otp_code: string; +} + +export interface OtpVerifierEntity { + name: string; + phone_number: string; + is_all_action?: boolean; + action_types?: OTP_ACTION_TYPE[] | null; +} diff --git a/src/modules/configuration/otp-verification/infrastructure/dto/otp-verification.dto.ts b/src/modules/configuration/otp-verification/infrastructure/dto/otp-verification.dto.ts new file mode 100644 index 0000000..ef721de --- /dev/null +++ b/src/modules/configuration/otp-verification/infrastructure/dto/otp-verification.dto.ts @@ -0,0 +1,112 @@ +import { + IsArray, + IsBoolean, + IsEnum, + IsNotEmpty, + IsOptional, + IsPhoneNumber, + IsString, + ValidateIf, +} from 'class-validator'; +import { + OTP_ACTION_TYPE, + OTP_SOURCE, + OtpRequestEntity, + OtpVerifyEntity, +} from '../../domain/entities/otp-verification.entity'; +import { ApiProperty } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; + +export class OtpRequestDto implements OtpRequestEntity { + @ApiProperty({ + type: String, + required: true, + example: OTP_ACTION_TYPE.CANCEL_TRANSACTION, + description: 'CANCEL_TRANSACTION || CREATE_DISCOUNT', + }) + @IsString() + @IsNotEmpty() + action_type: OTP_ACTION_TYPE; + + @ApiProperty({ + type: String, + required: true, + example: OTP_SOURCE.POS, + description: 'POS || WEB', + }) + @IsString() + @IsNotEmpty() + source: OTP_SOURCE; + + @ApiProperty({ + name: 'target_id', + example: 'bccc0c6a-51a0-437f-abc8-dc18851604ee', + }) + @IsString() + @ValidateIf((body) => body.target_id) + target_id: string; + + @ApiProperty({ name: 'reference', example: '0625N21' }) + @IsString() + @ValidateIf((body) => body.reference) + reference: string; +} + +export class OtpVerifyDto extends OtpRequestDto implements OtpVerifyEntity { + @ApiProperty({ + name: 'otp_code', + type: String, + required: true, + example: '2345', + }) + @IsString() + @IsNotEmpty() + otp_code: string; +} + +export class OtpVerifierCreateDto { + @ApiProperty({ + example: 'Item Manager', + description: 'Nama verifier, opsional.', + }) + @IsOptional() + @IsString() + name?: string; + + @ApiProperty({ + example: '6281234567890', + description: 'Nomor telepon verifier dalam format internasional (E.164).', + }) + @IsString() + @IsPhoneNumber('ID') + phone_number: string; + + @ApiProperty({ + example: false, + description: + 'True jika verifier boleh memverifikasi semua aksi tanpa batas.', + }) + @IsBoolean() + is_all_action: boolean; + + @ApiProperty({ + isArray: true, + enum: OTP_ACTION_TYPE, + example: [ + 'CREATE_DISCOUNT', + 'CANCEL_TRANSACTION', + 'REJECT_RECONCILIATION', + 'ACTIVATE_ITEM', + 'ACTIVATE_USER', + 'UPDATE_ITEM_PRICE', + 'UPDATE_ITEM_DETAILS', + 'CONFIRM_TRANSACTION', + ], + description: 'Daftar tipe aksi yang boleh diverifikasi, jika bukan semua.', + }) + @IsOptional() + @IsArray() + @IsEnum(OTP_ACTION_TYPE, { each: true }) + @Type(() => String) + action_types?: OTP_ACTION_TYPE[]; +} diff --git a/src/modules/configuration/otp-verification/infrastructure/guards/otp-auth.guard.ts b/src/modules/configuration/otp-verification/infrastructure/guards/otp-auth.guard.ts new file mode 100644 index 0000000..1af5e2a --- /dev/null +++ b/src/modules/configuration/otp-verification/infrastructure/guards/otp-auth.guard.ts @@ -0,0 +1,80 @@ +// auth/otp-auth.guard.ts +import { + CanActivate, + ExecutionContext, + Injectable, + UnprocessableEntityException, +} from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { validatePassword } from 'src/core/helpers/password/bcrypt.helpers'; +import { + CONNECTION_NAME, + STATUS, +} from 'src/core/strings/constants/base.constants'; +import { UserRole } from 'src/modules/user-related/user/constants'; +import { UserModel } from 'src/modules/user-related/user/data/models/user.model'; +import { DataSource, Not } from 'typeorm'; + +@Injectable() +export class OtpAuthGuard implements CanActivate { + constructor( + private readonly jwtService: JwtService, + + @InjectDataSource(CONNECTION_NAME.DEFAULT) + protected readonly dataSource: DataSource, + ) {} + + get userRepository() { + return this.dataSource.getRepository(UserModel); + } + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const jwtAuth = request.headers['authorization']; + const basicAuth = request.headers['x-basic-authorization']; + + // 1. Cek OTP Auth (basic_authorization header) + if (basicAuth) { + try { + const decoded = Buffer.from(basicAuth, 'base64').toString('ascii'); + const [username, password] = decoded.split('|'); + + const userLogin = await this.userRepository.findOne({ + where: { + username: username, + status: STATUS.ACTIVE, + role: Not(UserRole.QUEUE_ADMIN), + }, + }); + + const valid = await validatePassword(password, userLogin?.password); + + if (userLogin && valid) { + request.user = userLogin; + return true; + } else { + throw new UnprocessableEntityException('Invalid OTP credentials'); + } + } catch (err) { + throw new UnprocessableEntityException('Invalid OTP encoding'); + } + } + + // 2. Cek JWT (Authorization: Bearer ) + if (jwtAuth && jwtAuth.startsWith('Bearer ')) { + const token = jwtAuth.split(' ')[1]; + try { + const payload = await this.jwtService.verifyAsync(token); + request.user = payload; + return true; + } catch (err) { + throw new UnprocessableEntityException('Invalid JWT token'); + } + } + + throw new UnprocessableEntityException( + 'No valid authentication method found', + ); + } +} diff --git a/src/modules/configuration/otp-verification/infrastructure/otp-verification-data.controller.ts b/src/modules/configuration/otp-verification/infrastructure/otp-verification-data.controller.ts new file mode 100644 index 0000000..b441441 --- /dev/null +++ b/src/modules/configuration/otp-verification/infrastructure/otp-verification-data.controller.ts @@ -0,0 +1,60 @@ +import { + Body, + Controller, + Get, + Param, + Post, + Req, + UseGuards, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { ExcludePrivilege, Public } from 'src/core/guards'; +import { MODULE_NAME } from 'src/core/strings/constants/module.constants'; +import { OtpVerificationService } from '../data/services/otp-verification.service'; +import { + OtpRequestDto, + OtpVerifierCreateDto, + OtpVerifyDto, +} from './dto/otp-verification.dto'; +import { OtpAuthGuard } from './guards/otp-auth.guard'; +import { OtpVerifierService } from '../data/services/otp-verifier.service'; + +@ApiTags(`${MODULE_NAME.OTP_VERIFICATIONS.split('-').join(' ')} - data`) +@Controller(`v1/${MODULE_NAME.OTP_VERIFICATIONS}`) +@Public() +export class OtpVerificationController { + constructor( + private readonly otpVerificationService: OtpVerificationService, + ) {} + + @Post('request') + @UseGuards(OtpAuthGuard) + async request(@Body() body: OtpRequestDto, @Req() req) { + return await this.otpVerificationService.requestOTP(body, req); + } + + @Post('verify') + @UseGuards(OtpAuthGuard) + async verify(@Body() body: OtpVerifyDto, @Req() req) { + return await this.otpVerificationService.verifyOTP(body, req); + } + + @Get(':ref_or_target_id') + async getByPhoneNumber(@Param('ref_or_target_id') ref_or_target_id: string) { + return this.otpVerificationService.getActiveOtp(ref_or_target_id); + } +} + +@ApiTags(`${MODULE_NAME.OTP_VERIFIER.split('-').join(' ')} - data`) +@Controller(`v1/${MODULE_NAME.OTP_VERIFIER}`) +@ApiBearerAuth('JWT') +@Public(false) +export class OtpVerifierController { + constructor(private readonly otpVerifierService: OtpVerifierService) {} + + @Post() + @ExcludePrivilege() + async create(@Body() body: OtpVerifierCreateDto) { + return await this.otpVerifierService.create(body); + } +} diff --git a/src/modules/configuration/otp-verification/otp-verification.module.ts b/src/modules/configuration/otp-verification/otp-verification.module.ts new file mode 100644 index 0000000..d4e933d --- /dev/null +++ b/src/modules/configuration/otp-verification/otp-verification.module.ts @@ -0,0 +1,37 @@ +import { CONNECTION_NAME } from 'src/core/strings/constants/base.constants'; + +import { ConfigModule } from '@nestjs/config'; +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { OtpVerificationModel } from './data/models/otp-verification.model'; +import { + OtpVerificationController, + OtpVerifierController, +} from './infrastructure/otp-verification-data.controller'; +import { OtpVerificationService } from './data/services/otp-verification.service'; +import { OtpVerifierModel } from './data/models/otp-verifier.model'; +import { OtpAuthGuard } from './infrastructure/guards/otp-auth.guard'; + +import { JwtModule } from '@nestjs/jwt'; +import { JWT_EXPIRED } from 'src/core/sessions/constants'; +import { JWT_SECRET } from 'src/core/sessions/constants'; +import { OtpVerifierService } from './data/services/otp-verifier.service'; + +@Module({ + imports: [ + ConfigModule.forRoot(), + + TypeOrmModule.forFeature( + [OtpVerificationModel, OtpVerifierModel], + CONNECTION_NAME.DEFAULT, + ), + + JwtModule.register({ + secret: JWT_SECRET, + signOptions: { expiresIn: JWT_EXPIRED }, + }), + ], + controllers: [OtpVerificationController, OtpVerifierController], + providers: [OtpAuthGuard, OtpVerificationService, OtpVerifierService], +}) +export class OtpVerificationModule {} diff --git a/src/modules/item-related/item-queue/constants.ts b/src/modules/item-related/item-queue/constants.ts index 9b62bb1..44e6c0d 100644 --- a/src/modules/item-related/item-queue/constants.ts +++ b/src/modules/item-related/item-queue/constants.ts @@ -5,3 +5,8 @@ export enum ItemType { FREE_GIFT = 'free gift', OTHER = 'other', } + +export enum UsageType { + ONE_TIME = 'one_time', + MULTIPLE = 'multiple', +} diff --git a/src/modules/item-related/item-queue/data/models/item-queue.model.ts b/src/modules/item-related/item-queue/data/models/item-queue.model.ts index c298f6d..fe7317d 100644 --- a/src/modules/item-related/item-queue/data/models/item-queue.model.ts +++ b/src/modules/item-related/item-queue/data/models/item-queue.model.ts @@ -2,7 +2,7 @@ import { TABLE_NAME } from 'src/core/strings/constants/table.constants'; import { ItemQueueEntity } from '../../domain/entities/item-queue.entity'; import { Column, Entity, OneToMany } from 'typeorm'; import { BaseStatusModel } from 'src/core/modules/data/model/base-status.model'; -import { ItemType } from '../../constants'; +import { ItemType, UsageType } from '../../constants'; import { ItemModel } from 'src/modules/item-related/item/data/models/item.model'; @Entity(TABLE_NAME.ITEM_QUEUE) @@ -29,6 +29,13 @@ export class ItemQueueModel }) item_type: ItemType; + @Column('enum', { + name: 'usage_type', + enum: UsageType, + default: UsageType.ONE_TIME, + }) + usage_type: UsageType; + @OneToMany(() => ItemModel, (model) => model.item_queue, { onUpdate: 'CASCADE', }) diff --git a/src/modules/item-related/item-queue/domain/entities/item-queue.entity.ts b/src/modules/item-related/item-queue/domain/entities/item-queue.entity.ts index 1d7c765..c40c0b3 100644 --- a/src/modules/item-related/item-queue/domain/entities/item-queue.entity.ts +++ b/src/modules/item-related/item-queue/domain/entities/item-queue.entity.ts @@ -1,5 +1,5 @@ import { BaseStatusEntity } from 'src/core/modules/domain/entities/base-status.entity'; -import { ItemType } from '../../constants'; +import { ItemType, UsageType } from '../../constants'; import { ItemEntity } from 'src/modules/item-related/item/domain/entities/item.entity'; export interface ItemQueueEntity extends BaseStatusEntity { @@ -11,4 +11,5 @@ export interface ItemQueueEntity extends BaseStatusEntity { items: ItemEntity[]; use_notification?: boolean; requiring_notification?: boolean; + usage_type?: UsageType; } diff --git a/src/modules/item-related/item-queue/domain/usecases/managers/detail-item-queue.manager.ts b/src/modules/item-related/item-queue/domain/usecases/managers/detail-item-queue.manager.ts index 2686425..461e0b2 100644 --- a/src/modules/item-related/item-queue/domain/usecases/managers/detail-item-queue.manager.ts +++ b/src/modules/item-related/item-queue/domain/usecases/managers/detail-item-queue.manager.ts @@ -20,7 +20,7 @@ export class DetailItemQueueManager extends BaseDetailManager { get relations(): RelationParam { return { joinRelations: [], - selectRelations: ['items'], + selectRelations: ['items', 'items.time_group'], countRelations: [], }; } @@ -40,6 +40,7 @@ export class DetailItemQueueManager extends BaseDetailManager { `${this.tableName}.call_preparation`, `${this.tableName}.use_notification`, `${this.tableName}.requiring_notification`, + `${this.tableName}.usage_type`, `items.id`, `items.created_at`, @@ -53,6 +54,9 @@ export class DetailItemQueueManager extends BaseDetailManager { `items.share_profit`, `items.play_estimation`, `items.video_url`, + + 'time_group.id', + 'time_group.name', ]; } diff --git a/src/modules/item-related/item-queue/domain/usecases/managers/index-item-queue.manager.ts b/src/modules/item-related/item-queue/domain/usecases/managers/index-item-queue.manager.ts index a0c94b5..1da7e07 100644 --- a/src/modules/item-related/item-queue/domain/usecases/managers/index-item-queue.manager.ts +++ b/src/modules/item-related/item-queue/domain/usecases/managers/index-item-queue.manager.ts @@ -24,7 +24,7 @@ export class IndexItemQueueManager extends BaseIndexManager { get relations(): RelationParam { return { joinRelations: [], - selectRelations: ['items'], + selectRelations: ['items', 'items.time_group'], countRelations: [], }; } @@ -43,7 +43,7 @@ export class IndexItemQueueManager extends BaseIndexManager { `${this.tableName}.call_preparation`, `${this.tableName}.use_notification`, `${this.tableName}.requiring_notification`, - + `${this.tableName}.usage_type`, `items.id`, `items.created_at`, `items.status`, @@ -55,6 +55,9 @@ export class IndexItemQueueManager extends BaseIndexManager { `items.base_price`, `items.share_profit`, `items.play_estimation`, + + 'time_group.id', + 'time_group.name', ]; } diff --git a/src/modules/item-related/item-rate/domain/usecases/managers/index-item-rate.manager.ts b/src/modules/item-related/item-rate/domain/usecases/managers/index-item-rate.manager.ts index bb403c1..34c5314 100644 --- a/src/modules/item-related/item-rate/domain/usecases/managers/index-item-rate.manager.ts +++ b/src/modules/item-related/item-rate/domain/usecases/managers/index-item-rate.manager.ts @@ -76,6 +76,7 @@ export class IndexItemRateManager extends BaseIndexManager { 'item_rates', 'item_rates.season_period', 'season_period.season_type', + 'time_group', ], // relation yang hanya ingin dihitung (akan return number) @@ -113,6 +114,9 @@ export class IndexItemRateManager extends BaseIndexManager { 'season_type.id', 'season_type.name', + + 'time_group.id', + 'time_group.name', ]; } diff --git a/src/modules/item-related/item/data/models/item.model.ts b/src/modules/item-related/item/data/models/item.model.ts index 62e9c83..b161c50 100644 --- a/src/modules/item-related/item/data/models/item.model.ts +++ b/src/modules/item-related/item/data/models/item.model.ts @@ -17,15 +17,20 @@ import { UserModel } from 'src/modules/user-related/user/data/models/user.model' import { ItemRateModel } from 'src/modules/item-related/item-rate/data/models/item-rate.model'; import { GateModel } from 'src/modules/web-information/gate/data/models/gate.model'; import { ItemQueueModel } from 'src/modules/item-related/item-queue/data/models/item-queue.model'; +import { TimeGroupModel } from 'src/modules/item-related/time-group/data/models/time-group.model'; +import { UsageType } from 'src/modules/item-related/item-queue/constants'; @Entity(TABLE_NAME.ITEM) export class ItemModel extends BaseStatusModel implements ItemEntity { - @Column('varchar', { name: 'name', unique: true }) + @Column('varchar', { name: 'name' }) name: string; + @Column('text', { name: 'booking_description', nullable: true }) + booking_description: string; + @Column('varchar', { name: 'image_url', nullable: true }) image_url: string; @@ -39,6 +44,13 @@ export class ItemModel }) item_type: ItemType; + @Column('enum', { + name: 'usage_type', + enum: UsageType, + default: UsageType.ONE_TIME, + }) + usage_type: UsageType; + @Column('bigint', { name: 'hpp', nullable: true }) hpp: number; @@ -86,6 +98,17 @@ export class ItemModel @JoinColumn({ name: 'item_category_id' }) item_category: ItemCategoryModel; + // start relation to time group + @Column('varchar', { name: 'time_group_id', nullable: true }) + time_group_id: number; + @ManyToOne(() => TimeGroupModel, (model) => model.items, { + onUpdate: 'CASCADE', + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'time_group_id' }) + time_group: TimeGroupModel; + // end relation to time group + @ManyToOne(() => ItemQueueModel, (model) => model.items, { onUpdate: 'CASCADE', onDelete: 'SET NULL', diff --git a/src/modules/item-related/item/domain/entities/item.entity.ts b/src/modules/item-related/item/domain/entities/item.entity.ts index 995b113..b131162 100644 --- a/src/modules/item-related/item/domain/entities/item.entity.ts +++ b/src/modules/item-related/item/domain/entities/item.entity.ts @@ -1,7 +1,8 @@ import { BaseStatusEntity } from 'src/core/modules/domain/entities/base-status.entity'; import { ItemType } from 'src/modules/item-related/item-category/constants'; import { LimitType } from '../../constants'; - +import { ItemRateEntity } from 'src/modules/item-related/item-rate/domain/entities/item-rate.entity'; +import { UsageType } from 'src/modules/item-related/item-queue/constants'; export interface ItemEntity extends BaseStatusEntity { name: string; item_type: ItemType; @@ -18,4 +19,8 @@ export interface ItemEntity extends BaseStatusEntity { use_queue: boolean; show_to_booking: boolean; breakdown_bundling?: boolean; + booking_description?: string; + usage_type?: UsageType; + + item_rates?: ItemRateEntity[] | any[]; } diff --git a/src/modules/item-related/item/domain/usecases/managers/create-item.manager.ts b/src/modules/item-related/item/domain/usecases/managers/create-item.manager.ts index 2b9d9e0..db1c055 100644 --- a/src/modules/item-related/item/domain/usecases/managers/create-item.manager.ts +++ b/src/modules/item-related/item/domain/usecases/managers/create-item.manager.ts @@ -8,6 +8,7 @@ import { ItemEntity } from '../../entities/item.entity'; import { ItemModel } from '../../../data/models/item.model'; import { BaseCreateManager } from 'src/core/modules/domain/usecase/managers/base-create.manager'; import { ItemCreatedEvent } from '../../entities/event/item-created.event'; +import { STATUS } from 'src/core/strings/constants/base.constants'; @Injectable() export class CreateItemManager extends BaseCreateManager { @@ -29,11 +30,37 @@ export class CreateItemManager extends BaseCreateManager { } get validateRelations(): validateRelations[] { - return []; + const timeGroupId = this.data.time_group_id ?? this.data.time_group?.id; + const relation = + this.data.bundling_items?.length > 0 + ? 'bundling_items' + : 'bundling_parents'; + return timeGroupId != null + ? [ + { + relation: relation, + singleQuery: ['time_group_id', '!=', timeGroupId], + message: `Gagal Update! Time group item dan bundling item tidak sama`, + }, + ] + : []; } get uniqueColumns(): columnUniques[] { - return [{ column: 'name' }]; + const timeGroupId = this.data.time_group_id ?? this.data.time_group?.id; + return timeGroupId != null + ? [ + { + column: 'name', + query: `(status = '${STATUS.ACTIVE}' AND (${this.tableName}.time_group_id Is Null OR ${this.tableName}.time_group_id = '${timeGroupId}'))`, + }, + ] + : [ + { + column: 'name', + query: `(status = '${STATUS.ACTIVE}')`, + }, + ]; } get eventTopics(): EventTopics[] { diff --git a/src/modules/item-related/item/domain/usecases/managers/detail-item.manager.ts b/src/modules/item-related/item/domain/usecases/managers/detail-item.manager.ts index 3488d9d..0738850 100644 --- a/src/modules/item-related/item/domain/usecases/managers/detail-item.manager.ts +++ b/src/modules/item-related/item/domain/usecases/managers/detail-item.manager.ts @@ -23,7 +23,13 @@ export class DetailItemManager extends BaseDetailManager { joinRelations: [], // relation join and select (relasi yang ingin ditampilkan), - selectRelations: ['item_category', 'bundling_items', 'tenant'], + selectRelations: [ + 'item_category', + 'bundling_items', + 'bundling_items.time_group bundling_time_groups', + 'tenant', + 'time_group', + ], // relation yang hanya ingin dihitung (akan return number) countRelations: [], @@ -47,9 +53,11 @@ export class DetailItemManager extends BaseDetailManager { `${this.tableName}.total_price`, `${this.tableName}.base_price`, `${this.tableName}.use_queue`, + `${this.tableName}.usage_type`, `${this.tableName}.show_to_booking`, `${this.tableName}.breakdown_bundling`, `${this.tableName}.play_estimation`, + `${this.tableName}.booking_description`, `item_category.id`, `item_category.name`, @@ -59,8 +67,14 @@ export class DetailItemManager extends BaseDetailManager { 'bundling_items.hpp', 'bundling_items.base_price', + 'bundling_time_groups.id', + 'bundling_time_groups.name', + 'tenant.id', 'tenant.name', + + 'time_group.id', + 'time_group.name', ]; } diff --git a/src/modules/item-related/item/domain/usecases/managers/index-item.manager.ts b/src/modules/item-related/item/domain/usecases/managers/index-item.manager.ts index b34b1ca..884828b 100644 --- a/src/modules/item-related/item/domain/usecases/managers/index-item.manager.ts +++ b/src/modules/item-related/item/domain/usecases/managers/index-item.manager.ts @@ -27,7 +27,12 @@ export class IndexItemManager extends BaseIndexManager { joinRelations: [], // relation join and select (relasi yang ingin ditampilkan), - selectRelations: ['item_category', 'bundling_items', 'tenant'], + selectRelations: [ + 'item_category', + 'bundling_items', + 'tenant', + 'time_group', + ], // relation yang hanya ingin dihitung (akan return number) countRelations: [], @@ -48,6 +53,9 @@ export class IndexItemManager extends BaseIndexManager { `${this.tableName}.share_profit`, `${this.tableName}.breakdown_bundling`, `${this.tableName}.play_estimation`, + `${this.tableName}.show_to_booking`, + `${this.tableName}.booking_description`, + `${this.tableName}.usage_type`, `item_category.id`, `item_category.name`, @@ -57,6 +65,9 @@ export class IndexItemManager extends BaseIndexManager { 'tenant.id', 'tenant.name', + + 'time_group.id', + 'time_group.name', ]; } @@ -98,10 +109,29 @@ export class IndexItemManager extends BaseIndexManager { queryBuilder.andWhere(`${this.tableName}.tenant_id Is Null`); } + if (this.filterParam.time_group_ids?.length) { + queryBuilder.andWhere( + `(${this.tableName}.time_group_id In (:...timeGroupIds) OR ${this.tableName}.time_group_id Is Null)`, + { + timeGroupIds: this.filterParam.time_group_ids, + }, + ); + } + if (this.filterParam.show_to_booking) { queryBuilder.andWhere(`${this.tableName}.show_to_booking = true`); } + if (this.filterParam.without_time_group != null) { + const withoutTimeGroup = this.filterParam.without_time_group + ? 'Is Null' + : 'Is Not Null'; + + queryBuilder.andWhere( + `${this.tableName}.time_group_id ${withoutTimeGroup}`, + ); + } + return queryBuilder; } } diff --git a/src/modules/item-related/item/domain/usecases/managers/update-item.manager.ts b/src/modules/item-related/item/domain/usecases/managers/update-item.manager.ts index 510da69..bfc9620 100644 --- a/src/modules/item-related/item/domain/usecases/managers/update-item.manager.ts +++ b/src/modules/item-related/item/domain/usecases/managers/update-item.manager.ts @@ -8,6 +8,7 @@ import { columnUniques, validateRelations, } from 'src/core/strings/constants/interface.constants'; +import { STATUS } from 'src/core/strings/constants/base.constants'; @Injectable() export class UpdateItemManager extends BaseUpdateManager { @@ -39,11 +40,31 @@ export class UpdateItemManager extends BaseUpdateManager { } get validateRelations(): validateRelations[] { - return []; + const timeGroupId = this.data.time_group_id ?? this.data.time_group?.id; + const relation = + this.data.bundling_items?.length > 0 + ? 'bundling_items' + : 'bundling_parents'; + + return timeGroupId != null + ? [ + { + relation: relation, + singleQuery: ['time_group_id', '!=', timeGroupId], + message: `Gagal Update! Time group item dan bundling item tidak sama`, + }, + ] + : []; } get uniqueColumns(): columnUniques[] { - return []; + const timeGroupId = this.data.time_group_id ?? this.data.time_group?.id; + return [ + { + column: 'name', + query: `(status = '${STATUS.ACTIVE}' AND (${this.tableName}.time_group_id Is Null OR ${this.tableName}.time_group_id = '${timeGroupId}'))`, + }, + ]; } get entityTarget(): any { diff --git a/src/modules/item-related/item/infrastructure/dto/filter-item.dto.ts b/src/modules/item-related/item/infrastructure/dto/filter-item.dto.ts index b87dc33..be87a1b 100644 --- a/src/modules/item-related/item/infrastructure/dto/filter-item.dto.ts +++ b/src/modules/item-related/item/infrastructure/dto/filter-item.dto.ts @@ -16,6 +16,12 @@ export class FilterItemDto extends BaseFilterDto implements FilterItemEntity { }) season_period_ids: string[]; + @ApiProperty({ type: ['string'], required: false }) + @Transform((body) => { + return Array.isArray(body.value) ? body.value : [body.value]; + }) + time_group_ids: string[]; + @ApiProperty({ type: ['string'], required: false }) @Transform((body) => { return Array.isArray(body.value) ? body.value : [body.value]; diff --git a/src/modules/item-related/item/infrastructure/dto/item.dto.ts b/src/modules/item-related/item/infrastructure/dto/item.dto.ts index c1add0f..b91f3f5 100644 --- a/src/modules/item-related/item/infrastructure/dto/item.dto.ts +++ b/src/modules/item-related/item/infrastructure/dto/item.dto.ts @@ -138,6 +138,17 @@ export class ItemDto extends BaseStatusDto implements ItemEntity { @ValidateIf((body) => body.show_to_booking) show_to_booking: boolean; + @ApiProperty({ + type: String, + required: false, + example: '...', + }) + @ValidateIf((body) => body.show_to_booking) + @IsString({ + message: 'Booking description is required when show to booking is enabled.', + }) + booking_description: string; + @ApiProperty({ name: 'bundling_items', type: [Object], diff --git a/src/modules/item-related/item/infrastructure/item-data.controller.ts b/src/modules/item-related/item/infrastructure/item-data.controller.ts index f416665..76e43fb 100644 --- a/src/modules/item-related/item/infrastructure/item-data.controller.ts +++ b/src/modules/item-related/item/infrastructure/item-data.controller.ts @@ -6,6 +6,7 @@ import { Patch, Post, Put, + UseGuards, } from '@nestjs/common'; import { ItemDataOrchestrator } from '../domain/usecases/item-data.orchestrator'; import { ItemDto } from './dto/item.dto'; @@ -16,6 +17,7 @@ import { BatchResult } from 'src/core/response/domain/ok-response.interface'; import { BatchIdsDto } from 'src/core/modules/infrastructure/dto/base-batch.dto'; import { Public } from 'src/core/guards'; import { UpdateItemPriceDto } from './dto/update-item-price.dto'; +import { OtpCheckerGuard } from 'src/core/guards/domain/otp-checker.guard'; @ApiTags(`${MODULE_NAME.ITEM.split('-').join(' ')} - data`) @Controller(`v1/${MODULE_NAME.ITEM}`) @@ -29,6 +31,7 @@ export class ItemDataController { return await this.orchestrator.create(data); } + @Public(true) @Post('update-price') async updatePrice(@Body() body: UpdateItemPriceDto): Promise { return await this.orchestrator.updatePrice(body); @@ -40,6 +43,7 @@ export class ItemDataController { } @Patch(':id/active') + @UseGuards(OtpCheckerGuard) async active(@Param('id') dataId: string): Promise { return await this.orchestrator.active(dataId); } @@ -50,6 +54,7 @@ export class ItemDataController { } @Patch(':id/confirm') + @UseGuards(OtpCheckerGuard) async confirm(@Param('id') dataId: string): Promise { return await this.orchestrator.confirm(dataId); } @@ -70,6 +75,7 @@ export class ItemDataController { } @Put(':id') + @UseGuards(OtpCheckerGuard) async update( @Param('id') dataId: string, @Body() data: ItemDto, diff --git a/src/modules/item-related/time-group/data/models/time-group.model.ts b/src/modules/item-related/time-group/data/models/time-group.model.ts new file mode 100644 index 0000000..e4332e0 --- /dev/null +++ b/src/modules/item-related/time-group/data/models/time-group.model.ts @@ -0,0 +1,29 @@ +import { TABLE_NAME } from 'src/core/strings/constants/table.constants'; +import { TimeGroupEntity } from '../../domain/entities/time-group.entity'; +import { Column, Entity, OneToMany } from 'typeorm'; +import { BaseStatusModel } from 'src/core/modules/data/model/base-status.model'; +import { ItemModel } from 'src/modules/item-related/item/data/models/item.model'; + +@Entity(TABLE_NAME.TIME_GROUPS) +export class TimeGroupModel + extends BaseStatusModel + implements TimeGroupEntity +{ + @Column('varchar', { name: 'name' }) + name: string; + + @Column({ type: 'time' }) + start_time: string; + + @Column({ type: 'time' }) + end_time: string; + + @Column({ type: 'time' }) + max_usage_time: string; + + @OneToMany(() => ItemModel, (model) => model.time_group, { + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }) + items: ItemModel[]; +} diff --git a/src/modules/item-related/time-group/data/services/time-group-data.service.ts b/src/modules/item-related/time-group/data/services/time-group-data.service.ts new file mode 100644 index 0000000..823d81a --- /dev/null +++ b/src/modules/item-related/time-group/data/services/time-group-data.service.ts @@ -0,0 +1,17 @@ +import { Injectable } from '@nestjs/common'; +import { BaseDataService } from 'src/core/modules/data/service/base-data.service'; +import { TimeGroupEntity } from '../../domain/entities/time-group.entity'; +import { InjectRepository } from '@nestjs/typeorm'; +import { TimeGroupModel } from '../models/time-group.model'; +import { CONNECTION_NAME } from 'src/core/strings/constants/base.constants'; +import { Repository } from 'typeorm'; + +@Injectable() +export class TimeGroupDataService extends BaseDataService { + constructor( + @InjectRepository(TimeGroupModel, CONNECTION_NAME.DEFAULT) + private repo: Repository, + ) { + super(repo); + } +} diff --git a/src/modules/item-related/time-group/data/services/time-group-read.service.ts b/src/modules/item-related/time-group/data/services/time-group-read.service.ts new file mode 100644 index 0000000..a528bd3 --- /dev/null +++ b/src/modules/item-related/time-group/data/services/time-group-read.service.ts @@ -0,0 +1,17 @@ +import { Injectable } from '@nestjs/common'; +import { TimeGroupEntity } from '../../domain/entities/time-group.entity'; +import { InjectRepository } from '@nestjs/typeorm'; +import { TimeGroupModel } from '../models/time-group.model'; +import { CONNECTION_NAME } from 'src/core/strings/constants/base.constants'; +import { Repository } from 'typeorm'; +import { BaseReadService } from 'src/core/modules/data/service/base-read.service'; + +@Injectable() +export class TimeGroupReadService extends BaseReadService { + constructor( + @InjectRepository(TimeGroupModel, CONNECTION_NAME.DEFAULT) + private repo: Repository, + ) { + super(repo); + } +} diff --git a/src/modules/item-related/time-group/domain/entities/event/time-group-change-status.event.ts b/src/modules/item-related/time-group/domain/entities/event/time-group-change-status.event.ts new file mode 100644 index 0000000..4aa911f --- /dev/null +++ b/src/modules/item-related/time-group/domain/entities/event/time-group-change-status.event.ts @@ -0,0 +1,5 @@ +import { IEvent } from 'src/core/strings/constants/interface.constants'; + +export class TimeGroupChangeStatusEvent { + constructor(public readonly data: IEvent) {} +} diff --git a/src/modules/item-related/time-group/domain/entities/event/time-group-created.event.ts b/src/modules/item-related/time-group/domain/entities/event/time-group-created.event.ts new file mode 100644 index 0000000..27c35a0 --- /dev/null +++ b/src/modules/item-related/time-group/domain/entities/event/time-group-created.event.ts @@ -0,0 +1,5 @@ +import { IEvent } from 'src/core/strings/constants/interface.constants'; + +export class TimeGroupCreatedEvent { + constructor(public readonly data: IEvent) {} +} diff --git a/src/modules/item-related/time-group/domain/entities/event/time-group-deleted.event.ts b/src/modules/item-related/time-group/domain/entities/event/time-group-deleted.event.ts new file mode 100644 index 0000000..a1f8030 --- /dev/null +++ b/src/modules/item-related/time-group/domain/entities/event/time-group-deleted.event.ts @@ -0,0 +1,5 @@ +import { IEvent } from 'src/core/strings/constants/interface.constants'; + +export class TimeGroupDeletedEvent { + constructor(public readonly data: IEvent) {} +} diff --git a/src/modules/item-related/time-group/domain/entities/event/time-group-updated.event.ts b/src/modules/item-related/time-group/domain/entities/event/time-group-updated.event.ts new file mode 100644 index 0000000..21d9c80 --- /dev/null +++ b/src/modules/item-related/time-group/domain/entities/event/time-group-updated.event.ts @@ -0,0 +1,5 @@ +import { IEvent } from 'src/core/strings/constants/interface.constants'; + +export class TimeGroupUpdatedEvent { + constructor(public readonly data: IEvent) {} +} diff --git a/src/modules/item-related/time-group/domain/entities/filter-time-group.entity.ts b/src/modules/item-related/time-group/domain/entities/filter-time-group.entity.ts new file mode 100644 index 0000000..c286d7c --- /dev/null +++ b/src/modules/item-related/time-group/domain/entities/filter-time-group.entity.ts @@ -0,0 +1,11 @@ +import { BaseFilterEntity } from 'src/core/modules/domain/entities/base-filter.entity'; + +export interface FilterITimeGroupEntity extends BaseFilterEntity { + names: string[]; + start_time_from: string; + start_time_to: string; + end_time_from: string; + end_time_to: string; + max_usage_time_from: string; + max_usage_time_to: string; +} diff --git a/src/modules/item-related/time-group/domain/entities/time-group.entity.ts b/src/modules/item-related/time-group/domain/entities/time-group.entity.ts new file mode 100644 index 0000000..7281552 --- /dev/null +++ b/src/modules/item-related/time-group/domain/entities/time-group.entity.ts @@ -0,0 +1,8 @@ +import { BaseStatusEntity } from 'src/core/modules/domain/entities/base-status.entity'; + +export interface TimeGroupEntity extends BaseStatusEntity { + name: string; + start_time: string; + end_time: string; + max_usage_time: string; +} diff --git a/src/modules/item-related/time-group/domain/usecases/managers/active-time-group.manager.ts b/src/modules/item-related/time-group/domain/usecases/managers/active-time-group.manager.ts new file mode 100644 index 0000000..6f3e3e7 --- /dev/null +++ b/src/modules/item-related/time-group/domain/usecases/managers/active-time-group.manager.ts @@ -0,0 +1,45 @@ +import { Injectable } from '@nestjs/common'; +import { BaseUpdateStatusManager } from 'src/core/modules/domain/usecase/managers/base-update-status.manager'; +import { TimeGroupEntity } from '../../entities/time-group.entity'; +import { + EventTopics, + validateRelations, +} from 'src/core/strings/constants/interface.constants'; +import { TimeGroupModel } from '../../../data/models/time-group.model'; +import { TimeGroupChangeStatusEvent } from '../../entities/event/time-group-change-status.event'; + +@Injectable() +export class ActiveTimeGroupManager extends BaseUpdateStatusManager { + getResult(): string { + return `Success active data ${this.result.name}`; + } + + async validateProcess(): Promise { + return; + } + + async beforeProcess(): Promise { + return; + } + + async afterProcess(): Promise { + return; + } + + get validateRelations(): validateRelations[] { + return []; + } + + get entityTarget(): any { + return TimeGroupModel; + } + + get eventTopics(): EventTopics[] { + return [ + { + topic: TimeGroupChangeStatusEvent, + data: this.data, + }, + ]; + } +} diff --git a/src/modules/item-related/time-group/domain/usecases/managers/batch-active-time-group.manager.ts b/src/modules/item-related/time-group/domain/usecases/managers/batch-active-time-group.manager.ts new file mode 100644 index 0000000..464c346 --- /dev/null +++ b/src/modules/item-related/time-group/domain/usecases/managers/batch-active-time-group.manager.ts @@ -0,0 +1,45 @@ +import { BaseBatchUpdateStatusManager } from 'src/core/modules/domain/usecase/managers/base-batch-update-status.manager'; +import { TimeGroupEntity } from '../../entities/time-group.entity'; +import { + EventTopics, + validateRelations, +} from 'src/core/strings/constants/interface.constants'; +import { TimeGroupModel } from '../../../data/models/time-group.model'; +import { TimeGroupChangeStatusEvent } from '../../entities/event/time-group-change-status.event'; +import { BatchResult } from 'src/core/response/domain/ok-response.interface'; +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class BatchActiveTimeGroupManager extends BaseBatchUpdateStatusManager { + validateData(data: TimeGroupEntity): Promise { + return; + } + + beforeProcess(): Promise { + return; + } + + afterProcess(): Promise { + return; + } + + get validateRelations(): validateRelations[] { + return []; + } + + get entityTarget(): any { + return TimeGroupModel; + } + + get eventTopics(): EventTopics[] { + return [ + { + topic: TimeGroupChangeStatusEvent, + }, + ]; + } + + getResult(): BatchResult { + return this.result; + } +} diff --git a/src/modules/item-related/time-group/domain/usecases/managers/batch-confirm-time-group.manager.ts b/src/modules/item-related/time-group/domain/usecases/managers/batch-confirm-time-group.manager.ts new file mode 100644 index 0000000..2d2482c --- /dev/null +++ b/src/modules/item-related/time-group/domain/usecases/managers/batch-confirm-time-group.manager.ts @@ -0,0 +1,45 @@ +import { BaseBatchUpdateStatusManager } from 'src/core/modules/domain/usecase/managers/base-batch-update-status.manager'; +import { TimeGroupEntity } from '../../entities/time-group.entity'; +import { + EventTopics, + validateRelations, +} from 'src/core/strings/constants/interface.constants'; +import { TimeGroupModel } from '../../../data/models/time-group.model'; +import { TimeGroupChangeStatusEvent } from '../../entities/event/time-group-change-status.event'; +import { BatchResult } from 'src/core/response/domain/ok-response.interface'; +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class BatchConfirmTimeGroupManager extends BaseBatchUpdateStatusManager { + validateData(data: TimeGroupEntity): Promise { + return; + } + + beforeProcess(): Promise { + return; + } + + afterProcess(): Promise { + return; + } + + get validateRelations(): validateRelations[] { + return []; + } + + get entityTarget(): any { + return TimeGroupModel; + } + + get eventTopics(): EventTopics[] { + return [ + { + topic: TimeGroupChangeStatusEvent, + }, + ]; + } + + getResult(): BatchResult { + return this.result; + } +} diff --git a/src/modules/item-related/time-group/domain/usecases/managers/batch-delete-time-group.manager.ts b/src/modules/item-related/time-group/domain/usecases/managers/batch-delete-time-group.manager.ts new file mode 100644 index 0000000..95bb85b --- /dev/null +++ b/src/modules/item-related/time-group/domain/usecases/managers/batch-delete-time-group.manager.ts @@ -0,0 +1,51 @@ +import { BaseBatchDeleteManager } from 'src/core/modules/domain/usecase/managers/base-batch-delete.manager'; +import { TimeGroupEntity } from '../../entities/time-group.entity'; +import { + EventTopics, + validateRelations, +} from 'src/core/strings/constants/interface.constants'; +import { TimeGroupModel } from '../../../data/models/time-group.model'; +import { TimeGroupDeletedEvent } from '../../entities/event/time-group-deleted.event'; +import { BatchResult } from 'src/core/response/domain/ok-response.interface'; +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class BatchDeleteTimeGroupManager extends BaseBatchDeleteManager { + async beforeProcess(): Promise { + return; + } + + async validateData(data: TimeGroupEntity): Promise { + return; + } + + async afterProcess(): Promise { + return; + } + + get validateRelations(): validateRelations[] { + return [ + { + relation: 'items', + message: + 'Gagal! tidak dapat menghapus time group karena sudah berelasi dengan item', + }, + ]; + } + + get entityTarget(): any { + return TimeGroupModel; + } + + get eventTopics(): EventTopics[] { + return [ + { + topic: TimeGroupDeletedEvent, + }, + ]; + } + + getResult(): BatchResult { + return this.result; + } +} diff --git a/src/modules/item-related/time-group/domain/usecases/managers/batch-inactive-time-group.manager.ts b/src/modules/item-related/time-group/domain/usecases/managers/batch-inactive-time-group.manager.ts new file mode 100644 index 0000000..ecb437f --- /dev/null +++ b/src/modules/item-related/time-group/domain/usecases/managers/batch-inactive-time-group.manager.ts @@ -0,0 +1,51 @@ +import { BaseBatchUpdateStatusManager } from 'src/core/modules/domain/usecase/managers/base-batch-update-status.manager'; +import { TimeGroupEntity } from '../../entities/time-group.entity'; +import { + EventTopics, + validateRelations, +} from 'src/core/strings/constants/interface.constants'; +import { TimeGroupModel } from '../../../data/models/time-group.model'; +import { TimeGroupChangeStatusEvent } from '../../entities/event/time-group-change-status.event'; +import { BatchResult } from 'src/core/response/domain/ok-response.interface'; +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class BatchInactiveTimeGroupManager extends BaseBatchUpdateStatusManager { + validateData(data: TimeGroupEntity): Promise { + return; + } + + beforeProcess(): Promise { + return; + } + + afterProcess(): Promise { + return; + } + + get validateRelations(): validateRelations[] { + return [ + { + relation: 'items', + message: + 'Gagal! tidak dapat mengubah status time group karena sudah berelasi dengan item', + }, + ]; + } + + get entityTarget(): any { + return TimeGroupModel; + } + + get eventTopics(): EventTopics[] { + return [ + { + topic: TimeGroupChangeStatusEvent, + }, + ]; + } + + getResult(): BatchResult { + return this.result; + } +} diff --git a/src/modules/item-related/time-group/domain/usecases/managers/confirm-time-group.manager.ts b/src/modules/item-related/time-group/domain/usecases/managers/confirm-time-group.manager.ts new file mode 100644 index 0000000..76e207f --- /dev/null +++ b/src/modules/item-related/time-group/domain/usecases/managers/confirm-time-group.manager.ts @@ -0,0 +1,45 @@ +import { Injectable } from '@nestjs/common'; +import { BaseUpdateStatusManager } from 'src/core/modules/domain/usecase/managers/base-update-status.manager'; +import { TimeGroupEntity } from '../../entities/time-group.entity'; +import { + EventTopics, + validateRelations, +} from 'src/core/strings/constants/interface.constants'; +import { TimeGroupModel } from '../../../data/models/time-group.model'; +import { TimeGroupChangeStatusEvent } from '../../entities/event/time-group-change-status.event'; + +@Injectable() +export class ConfirmTimeGroupManager extends BaseUpdateStatusManager { + getResult(): string { + return `Success active data ${this.result.name}`; + } + + async validateProcess(): Promise { + return; + } + + async beforeProcess(): Promise { + return; + } + + async afterProcess(): Promise { + return; + } + + get validateRelations(): validateRelations[] { + return []; + } + + get entityTarget(): any { + return TimeGroupModel; + } + + get eventTopics(): EventTopics[] { + return [ + { + topic: TimeGroupChangeStatusEvent, + data: this.data, + }, + ]; + } +} diff --git a/src/modules/item-related/time-group/domain/usecases/managers/create-time-group.manager.ts b/src/modules/item-related/time-group/domain/usecases/managers/create-time-group.manager.ts new file mode 100644 index 0000000..ce07b64 --- /dev/null +++ b/src/modules/item-related/time-group/domain/usecases/managers/create-time-group.manager.ts @@ -0,0 +1,85 @@ +import { Injectable } from '@nestjs/common'; +import { + EventTopics, + columnUniques, + validateRelations, +} from 'src/core/strings/constants/interface.constants'; +import { TimeGroupEntity } from '../../entities/time-group.entity'; +import { TimeGroupModel } from '../../../data/models/time-group.model'; +import { BaseCreateManager } from 'src/core/modules/domain/usecase/managers/base-create.manager'; +import { TimeGroupCreatedEvent } from '../../entities/event/time-group-created.event'; +import * as moment from 'moment'; + +@Injectable() +export class CreateTimeGroupManager extends BaseCreateManager { + async beforeProcess(): Promise { + const queryBuilder = this.dataService + .getRepository() + .createQueryBuilder(this.tableName); + + const overlapping = await queryBuilder + .where(`${this.tableName}.start_time <= :end_time`, { + end_time: this.data.end_time, + }) + .andWhere(`${this.tableName}.end_time >= :start_time`, { + start_time: this.data.start_time, + }) + .getOne(); + + if (overlapping) { + throw new Error( + 'Rentang waktu yang dimasukkan beririsan dengan data lain.', + ); + } else if (this.data.max_usage_time) { + const format = 'HH:mm'; + const end_time = moment(this.data.end_time, format); + const max_usage_time = moment(this.data.max_usage_time, format); + + if (max_usage_time.isBefore(end_time)) { + throw new Error( + 'Waktu maksimum penggunaan harus lebih besar dari waktu selesai.', + ); + } + return; + } else if (this.data.start_time && this.data.end_time) { + const format = 'HH:mm'; + const start_time = moment(this.data.start_time, format); + const end_time = moment(this.data.end_time, format); + + if (end_time.isBefore(start_time)) { + throw new Error('Waktu akhir harus lebih besar dari waktu mulai.'); + } + return; + } + return; + } + + async afterProcess(): Promise { + return; + } + + async generateConfig(): Promise { + // TODO: Implement logic here + } + + get validateRelations(): validateRelations[] { + return []; + } + + get uniqueColumns(): columnUniques[] { + return [{ column: 'name' }]; + } + + get eventTopics(): EventTopics[] { + return [ + { + topic: TimeGroupCreatedEvent, + data: this.data, + }, + ]; + } + + get entityTarget(): any { + return TimeGroupModel; + } +} diff --git a/src/modules/item-related/time-group/domain/usecases/managers/delete-time-group.manager.ts b/src/modules/item-related/time-group/domain/usecases/managers/delete-time-group.manager.ts new file mode 100644 index 0000000..5195904 --- /dev/null +++ b/src/modules/item-related/time-group/domain/usecases/managers/delete-time-group.manager.ts @@ -0,0 +1,51 @@ +import { Injectable } from '@nestjs/common'; +import { BaseDeleteManager } from 'src/core/modules/domain/usecase/managers/base-delete.manager'; +import { TimeGroupEntity } from '../../entities/time-group.entity'; +import { + EventTopics, + validateRelations, +} from 'src/core/strings/constants/interface.constants'; +import { TimeGroupModel } from '../../../data/models/time-group.model'; +import { TimeGroupDeletedEvent } from '../../entities/event/time-group-deleted.event'; + +@Injectable() +export class DeleteTimeGroupManager extends BaseDeleteManager { + getResult(): string { + return `Success`; + } + + async validateProcess(): Promise { + return; + } + + async beforeProcess(): Promise { + return; + } + + async afterProcess(): Promise { + return; + } + + get validateRelations(): validateRelations[] { + return [ + { + relation: 'items', + message: + 'Gagal! tidak dapat menghapus time group karena sudah berelasi dengan item', + }, + ]; + } + + get entityTarget(): any { + return TimeGroupModel; + } + + get eventTopics(): EventTopics[] { + return [ + { + topic: TimeGroupDeletedEvent, + data: this.data, + }, + ]; + } +} diff --git a/src/modules/item-related/time-group/domain/usecases/managers/detail-time-group.manager.ts b/src/modules/item-related/time-group/domain/usecases/managers/detail-time-group.manager.ts new file mode 100644 index 0000000..c900594 --- /dev/null +++ b/src/modules/item-related/time-group/domain/usecases/managers/detail-time-group.manager.ts @@ -0,0 +1,48 @@ +import { Injectable } from '@nestjs/common'; +import { BaseDetailManager } from 'src/core/modules/domain/usecase/managers/base-detail.manager'; +import { TimeGroupEntity } from '../../entities/time-group.entity'; +import { RelationParam } from 'src/core/modules/domain/entities/base-filter.entity'; + +@Injectable() +export class DetailTimeGroupManager extends BaseDetailManager { + async prepareData(): Promise { + return; + } + + async beforeProcess(): Promise { + return; + } + + async afterProcess(): Promise { + return; + } + + get relations(): RelationParam { + return { + joinRelations: [], + selectRelations: [], + countRelations: [], + }; + } + + get selects(): string[] { + return [ + `${this.tableName}.id`, + `${this.tableName}.status`, + `${this.tableName}.name`, + `${this.tableName}.start_time`, + `${this.tableName}.end_time`, + `${this.tableName}.max_usage_time`, + `${this.tableName}.created_at`, + `${this.tableName}.creator_name`, + `${this.tableName}.updated_at`, + `${this.tableName}.editor_name`, + ]; + } + + get setFindProperties(): any { + return { + id: this.dataId, + }; + } +} diff --git a/src/modules/item-related/time-group/domain/usecases/managers/inactive-time-group.manager.ts b/src/modules/item-related/time-group/domain/usecases/managers/inactive-time-group.manager.ts new file mode 100644 index 0000000..d6dca30 --- /dev/null +++ b/src/modules/item-related/time-group/domain/usecases/managers/inactive-time-group.manager.ts @@ -0,0 +1,51 @@ +import { Injectable } from '@nestjs/common'; +import { BaseUpdateStatusManager } from 'src/core/modules/domain/usecase/managers/base-update-status.manager'; +import { TimeGroupEntity } from '../../entities/time-group.entity'; +import { + EventTopics, + validateRelations, +} from 'src/core/strings/constants/interface.constants'; +import { TimeGroupModel } from '../../../data/models/time-group.model'; +import { TimeGroupChangeStatusEvent } from '../../entities/event/time-group-change-status.event'; + +@Injectable() +export class InactiveTimeGroupManager extends BaseUpdateStatusManager { + getResult(): string { + return `Success inactive data ${this.result.name}`; + } + + async validateProcess(): Promise { + return; + } + + async beforeProcess(): Promise { + return; + } + + async afterProcess(): Promise { + return; + } + + get validateRelations(): validateRelations[] { + return [ + { + relation: 'items', + message: + 'Gagal! tidak dapat mengubah status time group karena sudah berelasi dengan item', + }, + ]; + } + + get entityTarget(): any { + return TimeGroupModel; + } + + get eventTopics(): EventTopics[] { + return [ + { + topic: TimeGroupChangeStatusEvent, + data: this.data, + }, + ]; + } +} diff --git a/src/modules/item-related/time-group/domain/usecases/managers/index-public-time-group.manager.ts b/src/modules/item-related/time-group/domain/usecases/managers/index-public-time-group.manager.ts new file mode 100644 index 0000000..912fca8 --- /dev/null +++ b/src/modules/item-related/time-group/domain/usecases/managers/index-public-time-group.manager.ts @@ -0,0 +1,80 @@ +import { Injectable } from '@nestjs/common'; +import { BaseIndexManager } from 'src/core/modules/domain/usecase/managers/base-index.manager'; +import { TimeGroupEntity } from '../../entities/time-group.entity'; +import { SelectQueryBuilder } from 'typeorm'; +import { + Param, + RelationParam, +} from 'src/core/modules/domain/entities/base-filter.entity'; +import * as moment from 'moment'; +import { ORDER_TYPE } from 'src/core/strings/constants/base.constants'; + +// TODO: +// Implementasikan filter by start_time, end_timen, dan max_usage_time + +@Injectable() +export class IndexPublicTimeGroupManager extends BaseIndexManager { + async prepareData(): Promise { + Object.assign(this.filterParam, { + order_by: `${this.tableName}.start_time`, + order_type: ORDER_TYPE.ASC, + }); + return; + } + + async beforeProcess(): Promise { + return; + } + + async afterProcess(): Promise { + return; + } + + get relations(): RelationParam { + return { + joinRelations: ['items'], + selectRelations: [], + countRelations: ['items'], + }; + } + + get selects(): string[] { + return [ + `${this.tableName}.id`, + `${this.tableName}.status`, + `${this.tableName}.name`, + `${this.tableName}.start_time`, + `${this.tableName}.end_time`, + `${this.tableName}.max_usage_time`, + `${this.tableName}.created_at`, + `${this.tableName}.creator_name`, + `${this.tableName}.updated_at`, + `${this.tableName}.editor_name`, + ]; + } + + get specificFilter(): Param[] { + return [ + { + cols: `${this.tableName}.name`, + data: this.filterParam.names, + }, + ]; + } + + setQueryFilter( + queryBuilder: SelectQueryBuilder, + ): SelectQueryBuilder { + queryBuilder.andWhere(`items.id is not null`); + + if (!this.filterParam.date) { + const currentTime = moment().utcOffset('+07:00').format('HH:mm:ss'); + + queryBuilder.andWhere(`${this.tableName}.end_time >= :current_time`, { + current_time: currentTime, + }); + } + + return queryBuilder; + } +} diff --git a/src/modules/item-related/time-group/domain/usecases/managers/index-time-group.manager.ts b/src/modules/item-related/time-group/domain/usecases/managers/index-time-group.manager.ts new file mode 100644 index 0000000..3ffdd56 --- /dev/null +++ b/src/modules/item-related/time-group/domain/usecases/managers/index-time-group.manager.ts @@ -0,0 +1,64 @@ +import { Injectable } from '@nestjs/common'; +import { BaseIndexManager } from 'src/core/modules/domain/usecase/managers/base-index.manager'; +import { TimeGroupEntity } from '../../entities/time-group.entity'; +import { SelectQueryBuilder } from 'typeorm'; +import { + Param, + RelationParam, +} from 'src/core/modules/domain/entities/base-filter.entity'; + +// TODO: +// Implementasikan filter by start_time, end_timen, dan max_usage_time + +@Injectable() +export class IndexTimeGroupManager extends BaseIndexManager { + async prepareData(): Promise { + return; + } + + async beforeProcess(): Promise { + return; + } + + async afterProcess(): Promise { + return; + } + + get relations(): RelationParam { + return { + joinRelations: [], + selectRelations: [], + countRelations: [], + }; + } + + get selects(): string[] { + return [ + `${this.tableName}.id`, + `${this.tableName}.status`, + `${this.tableName}.name`, + `${this.tableName}.start_time`, + `${this.tableName}.end_time`, + `${this.tableName}.max_usage_time`, + `${this.tableName}.created_at`, + `${this.tableName}.creator_name`, + `${this.tableName}.updated_at`, + `${this.tableName}.editor_name`, + ]; + } + + get specificFilter(): Param[] { + return [ + { + cols: `${this.tableName}.name`, + data: this.filterParam.names, + }, + ]; + } + + setQueryFilter( + queryBuilder: SelectQueryBuilder, + ): SelectQueryBuilder { + return queryBuilder; + } +} diff --git a/src/modules/item-related/time-group/domain/usecases/managers/update-time-group.manager.ts b/src/modules/item-related/time-group/domain/usecases/managers/update-time-group.manager.ts new file mode 100644 index 0000000..f003866 --- /dev/null +++ b/src/modules/item-related/time-group/domain/usecases/managers/update-time-group.manager.ts @@ -0,0 +1,85 @@ +import { Injectable } from '@nestjs/common'; +import { BaseUpdateManager } from 'src/core/modules/domain/usecase/managers/base-update.manager'; +import { TimeGroupEntity } from '../../entities/time-group.entity'; +import { TimeGroupModel } from '../../../data/models/time-group.model'; +import { TimeGroupUpdatedEvent } from '../../entities/event/time-group-updated.event'; +import { + EventTopics, + columnUniques, + validateRelations, +} from 'src/core/strings/constants/interface.constants'; +import * as moment from 'moment'; + +@Injectable() +export class UpdateTimeGroupManager extends BaseUpdateManager { + async validateProcess(): Promise { + const queryBuilder = this.dataService + .getRepository() + .createQueryBuilder(this.tableName); + + const overlapping = await queryBuilder + .where(`${this.tableName}.start_time <= :end_time`, { + end_time: this.data.end_time, + }) + .andWhere(`${this.tableName}.end_time >= :start_time`, { + start_time: this.data.start_time, + }) + .andWhere(`${this.tableName}.id != :id`, { id: this.dataId ?? null }) + .getOne(); + + if (overlapping) { + throw new Error( + 'Rentang waktu yang dimasukkan beririsan dengan data lain.', + ); + } else if (this.data.max_usage_time) { + const format = 'HH:mm'; + const end_time = moment(this.data.end_time, format); + const max_usage_time = moment(this.data.max_usage_time, format); + + if (max_usage_time.isBefore(end_time)) { + throw new Error( + 'Waktu maksimum penggunaan harus lebih besar dari waktu selesai.', + ); + } + return; + } else if (this.data.start_time && this.data.end_time) { + const format = 'HH:mm'; + const start_time = moment(this.data.start_time, format); + const end_time = moment(this.data.end_time, format); + + if (end_time.isBefore(start_time)) { + throw new Error('Waktu akhir harus lebih besar dari waktu mulai.'); + } + return; + } + return; + } + + async beforeProcess(): Promise { + return; + } + + async afterProcess(): Promise { + return; + } + + get validateRelations(): validateRelations[] { + return []; + } + + get uniqueColumns(): columnUniques[] { + return [{ column: 'name' }]; + } + + get entityTarget(): any { + return TimeGroupModel; + } + + get eventTopics(): EventTopics[] { + return [ + { + topic: TimeGroupUpdatedEvent, + }, + ]; + } +} diff --git a/src/modules/item-related/time-group/domain/usecases/time-group-data.orchestrator.ts b/src/modules/item-related/time-group/domain/usecases/time-group-data.orchestrator.ts new file mode 100644 index 0000000..d474676 --- /dev/null +++ b/src/modules/item-related/time-group/domain/usecases/time-group-data.orchestrator.ts @@ -0,0 +1,119 @@ +import { Injectable } from '@nestjs/common'; +import { CreateTimeGroupManager } from './managers/create-time-group.manager'; +import { TimeGroupDataService } from '../../data/services/time-group-data.service'; +import { TimeGroupEntity } from '../entities/time-group.entity'; +import { DeleteTimeGroupManager } from './managers/delete-time-group.manager'; +import { UpdateTimeGroupManager } from './managers/update-time-group.manager'; +import { BaseDataTransactionOrchestrator } from 'src/core/modules/domain/usecase/orchestrators/base-data-transaction.orchestrator'; +import { ActiveTimeGroupManager } from './managers/active-time-group.manager'; +import { InactiveTimeGroupManager } from './managers/inactive-time-group.manager'; +import { ConfirmTimeGroupManager } from './managers/confirm-time-group.manager'; +import { STATUS } from 'src/core/strings/constants/base.constants'; +import { BatchResult } from 'src/core/response/domain/ok-response.interface'; +import { BatchConfirmTimeGroupManager } from './managers/batch-confirm-time-group.manager'; +import { BatchInactiveTimeGroupManager } from './managers/batch-inactive-time-group.manager'; +import { BatchActiveTimeGroupManager } from './managers/batch-active-time-group.manager'; +import { BatchDeleteTimeGroupManager } from './managers/batch-delete-time-group.manager'; +import { TABLE_NAME } from 'src/core/strings/constants/table.constants'; + +@Injectable() +export class TimeGroupDataOrchestrator extends BaseDataTransactionOrchestrator { + constructor( + private createManager: CreateTimeGroupManager, + private updateManager: UpdateTimeGroupManager, + private deleteManager: DeleteTimeGroupManager, + private activeManager: ActiveTimeGroupManager, + private confirmManager: ConfirmTimeGroupManager, + private inactiveManager: InactiveTimeGroupManager, + private batchDeleteManager: BatchDeleteTimeGroupManager, + private batchActiveManager: BatchActiveTimeGroupManager, + private batchConfirmManager: BatchConfirmTimeGroupManager, + private batchInactiveManager: BatchInactiveTimeGroupManager, + private serviceData: TimeGroupDataService, + ) { + super(); + } + + async create(data): Promise { + this.createManager.setData(data); + this.createManager.setService(this.serviceData, TABLE_NAME.TIME_GROUPS); + await this.createManager.execute(); + await this.createManager.generateConfig(); + return this.createManager.getResult(); + } + + async update(dataId, data): Promise { + this.updateManager.setData(dataId, data); + this.updateManager.setService(this.serviceData, TABLE_NAME.TIME_GROUPS); + await this.updateManager.execute(); + return this.updateManager.getResult(); + } + + async delete(dataId): Promise { + this.deleteManager.setData(dataId); + this.deleteManager.setService(this.serviceData, TABLE_NAME.TIME_GROUPS); + await this.deleteManager.execute(); + return this.deleteManager.getResult(); + } + + async batchDelete(dataIds: string[]): Promise { + this.batchDeleteManager.setData(dataIds); + this.batchDeleteManager.setService( + this.serviceData, + TABLE_NAME.TIME_GROUPS, + ); + await this.batchDeleteManager.execute(); + return this.batchDeleteManager.getResult(); + } + + async active(dataId): Promise { + this.activeManager.setData(dataId, STATUS.ACTIVE); + this.activeManager.setService(this.serviceData, TABLE_NAME.TIME_GROUPS); + await this.activeManager.execute(); + return this.activeManager.getResult(); + } + + async batchActive(dataIds: string[]): Promise { + this.batchActiveManager.setData(dataIds, STATUS.ACTIVE); + this.batchActiveManager.setService( + this.serviceData, + TABLE_NAME.TIME_GROUPS, + ); + await this.batchActiveManager.execute(); + return this.batchActiveManager.getResult(); + } + + async confirm(dataId): Promise { + this.confirmManager.setData(dataId, STATUS.ACTIVE); + this.confirmManager.setService(this.serviceData, TABLE_NAME.TIME_GROUPS); + await this.confirmManager.execute(); + return this.confirmManager.getResult(); + } + + async batchConfirm(dataIds: string[]): Promise { + this.batchConfirmManager.setData(dataIds, STATUS.ACTIVE); + this.batchConfirmManager.setService( + this.serviceData, + TABLE_NAME.TIME_GROUPS, + ); + await this.batchConfirmManager.execute(); + return this.batchConfirmManager.getResult(); + } + + async inactive(dataId): Promise { + this.inactiveManager.setData(dataId, STATUS.INACTIVE); + this.inactiveManager.setService(this.serviceData, TABLE_NAME.TIME_GROUPS); + await this.inactiveManager.execute(); + return this.inactiveManager.getResult(); + } + + async batchInactive(dataIds: string[]): Promise { + this.batchInactiveManager.setData(dataIds, STATUS.INACTIVE); + this.batchInactiveManager.setService( + this.serviceData, + TABLE_NAME.TIME_GROUPS, + ); + await this.batchInactiveManager.execute(); + return this.batchInactiveManager.getResult(); + } +} diff --git a/src/modules/item-related/time-group/domain/usecases/time-group-read.orchestrator.ts b/src/modules/item-related/time-group/domain/usecases/time-group-read.orchestrator.ts new file mode 100644 index 0000000..b0ef50f --- /dev/null +++ b/src/modules/item-related/time-group/domain/usecases/time-group-read.orchestrator.ts @@ -0,0 +1,45 @@ +import { Injectable } from '@nestjs/common'; +import { IndexTimeGroupManager } from './managers/index-time-group.manager'; +import { TimeGroupReadService } from '../../data/services/time-group-read.service'; +import { TimeGroupEntity } from '../entities/time-group.entity'; +import { PaginationResponse } from 'src/core/response/domain/ok-response.interface'; +import { BaseReadOrchestrator } from 'src/core/modules/domain/usecase/orchestrators/base-read.orchestrator'; +import { DetailTimeGroupManager } from './managers/detail-time-group.manager'; +import { TABLE_NAME } from 'src/core/strings/constants/table.constants'; +import { IndexPublicTimeGroupManager } from './managers/index-public-time-group.manager'; + +@Injectable() +export class TimeGroupReadOrchestrator extends BaseReadOrchestrator { + constructor( + private indexManager: IndexTimeGroupManager, + private indexPublicManager: IndexPublicTimeGroupManager, + private detailManager: DetailTimeGroupManager, + private serviceData: TimeGroupReadService, + ) { + super(); + } + + async index(params): Promise> { + this.indexManager.setFilterParam(params); + this.indexManager.setService(this.serviceData, TABLE_NAME.TIME_GROUPS); + await this.indexManager.execute(); + return this.indexManager.getResult(); + } + + async indexPublic(params): Promise> { + this.indexPublicManager.setFilterParam(params); + this.indexPublicManager.setService( + this.serviceData, + TABLE_NAME.TIME_GROUPS, + ); + await this.indexPublicManager.execute(); + return this.indexPublicManager.getResult(); + } + + async detail(dataId: string): Promise { + this.detailManager.setData(dataId); + this.detailManager.setService(this.serviceData, TABLE_NAME.TIME_GROUPS); + await this.detailManager.execute(); + return this.detailManager.getResult(); + } +} diff --git a/src/modules/item-related/time-group/infrastructure/dto/filter-time-group.dto.ts b/src/modules/item-related/time-group/infrastructure/dto/filter-time-group.dto.ts new file mode 100644 index 0000000..e445d85 --- /dev/null +++ b/src/modules/item-related/time-group/infrastructure/dto/filter-time-group.dto.ts @@ -0,0 +1,41 @@ +import { BaseFilterDto } from 'src/core/modules/infrastructure/dto/base-filter.dto'; +import { FilterITimeGroupEntity } from '../../domain/entities/filter-time-group.entity'; +import { ApiProperty } from '@nestjs/swagger'; +import { ValidateIf } from 'class-validator'; + +export class FilterTimeGroupDto + extends BaseFilterDto + implements FilterITimeGroupEntity +{ + @ApiProperty({ type: 'string', required: false }) + @ValidateIf((body) => body.start_time_from) + start_time_from: string; + + @ApiProperty({ type: 'string', required: false }) + @ValidateIf((body) => body.start_time_to) + start_time_to: string; + + @ApiProperty({ type: 'string', required: false }) + @ValidateIf((body) => body.end_time_from) + end_time_from: string; + + @ApiProperty({ type: 'string', required: false }) + @ValidateIf((body) => body.end_time_to) + end_time_to: string; + + @ApiProperty({ type: 'string', required: false }) + @ValidateIf((body) => body.max_usage_time_from) + max_usage_time_from: string; + + @ApiProperty({ type: 'string', required: false }) + @ValidateIf((body) => body.max_usage_time_to) + max_usage_time_to: string; + + @ApiProperty({ + type: Date, + required: false, + example: '2024-01-01', + }) + @ValidateIf((body) => body.date) + date: Date; +} diff --git a/src/modules/item-related/time-group/infrastructure/dto/time-group.dto.ts b/src/modules/item-related/time-group/infrastructure/dto/time-group.dto.ts new file mode 100644 index 0000000..f7a5bd9 --- /dev/null +++ b/src/modules/item-related/time-group/infrastructure/dto/time-group.dto.ts @@ -0,0 +1,47 @@ +import { BaseStatusDto } from 'src/core/modules/infrastructure/dto/base-status.dto'; +import { TimeGroupEntity } from '../../domain/entities/time-group.entity'; +import { IsString, ValidateIf } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class CreateTimeGroupDto + extends BaseStatusDto + implements TimeGroupEntity +{ + @ApiProperty({ name: 'name', required: true, example: 'Morning' }) + @IsString() + name: string; + + @ApiProperty({ name: 'start_time', required: true, example: '09:00' }) + @IsString() + start_time: string; + + @ApiProperty({ name: 'end_time', required: true, example: '10:00' }) + @IsString() + end_time: string; + + @ApiProperty({ name: 'max_usage_time', required: true, example: '10:30' }) + @IsString() + max_usage_time: string; +} + +export class EditTimeGroupDto extends BaseStatusDto implements TimeGroupEntity { + @ApiProperty({ name: 'name', example: 'Morning' }) + @IsString() + @ValidateIf((body) => body.name) + name: string; + + @ApiProperty({ name: 'start_time', example: '09:00' }) + @IsString() + @ValidateIf((body) => body.start_time) + start_time: string; + + @ApiProperty({ name: 'end_time', example: '10:00' }) + @IsString() + @ValidateIf((body) => body.end_time) + end_time: string; + + @ApiProperty({ name: 'max_usage_time', example: '10:30' }) + @IsString() + @ValidateIf((body) => body.max_usage_time) + max_usage_time: string; +} diff --git a/src/modules/item-related/time-group/infrastructure/time-group-data.controller.ts b/src/modules/item-related/time-group/infrastructure/time-group-data.controller.ts new file mode 100644 index 0000000..4de60fa --- /dev/null +++ b/src/modules/item-related/time-group/infrastructure/time-group-data.controller.ts @@ -0,0 +1,78 @@ +import { + Body, + Controller, + Delete, + Param, + Patch, + Post, + Put, +} from '@nestjs/common'; +import { TimeGroupDataOrchestrator } from '../domain/usecases/time-group-data.orchestrator'; +import { CreateTimeGroupDto, EditTimeGroupDto } from './dto/time-group.dto'; +import { MODULE_NAME } from 'src/core/strings/constants/module.constants'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { TimeGroupEntity } from '../domain/entities/time-group.entity'; +import { BatchResult } from 'src/core/response/domain/ok-response.interface'; +import { BatchIdsDto } from 'src/core/modules/infrastructure/dto/base-batch.dto'; +import { Public } from 'src/core/guards'; + +@ApiTags(`${MODULE_NAME.TIME_GROUPS.split('-').join(' ')} - data`) +@Controller(`v1/${MODULE_NAME.TIME_GROUPS}`) +@Public(false) +@ApiBearerAuth('JWT') +export class TimeGroupDataController { + constructor(private orchestrator: TimeGroupDataOrchestrator) {} + + @Post() + async create(@Body() data: CreateTimeGroupDto): Promise { + return await this.orchestrator.create(data); + } + + @Put('/batch-delete') + async batchDeleted(@Body() body: BatchIdsDto): Promise { + return await this.orchestrator.batchDelete(body.ids); + } + + @Patch(':id/active') + async active(@Param('id') dataId: string): Promise { + return await this.orchestrator.active(dataId); + } + + @Put('/batch-active') + async batchActive(@Body() body: BatchIdsDto): Promise { + return await this.orchestrator.batchActive(body.ids); + } + + @Patch(':id/confirm') + async confirm(@Param('id') dataId: string): Promise { + return await this.orchestrator.confirm(dataId); + } + + @Put('/batch-confirm') + async batchConfirm(@Body() body: BatchIdsDto): Promise { + return await this.orchestrator.batchConfirm(body.ids); + } + + @Patch(':id/inactive') + async inactive(@Param('id') dataId: string): Promise { + return await this.orchestrator.inactive(dataId); + } + + @Put('/batch-inactive') + async batchInactive(@Body() body: BatchIdsDto): Promise { + return await this.orchestrator.batchInactive(body.ids); + } + + @Put(':id') + async update( + @Param('id') dataId: string, + @Body() data: EditTimeGroupDto, + ): Promise { + return await this.orchestrator.update(dataId, data); + } + + @Delete(':id') + async delete(@Param('id') dataId: string): Promise { + return await this.orchestrator.delete(dataId); + } +} diff --git a/src/modules/item-related/time-group/infrastructure/time-group-read.controller.ts b/src/modules/item-related/time-group/infrastructure/time-group-read.controller.ts new file mode 100644 index 0000000..73b3a14 --- /dev/null +++ b/src/modules/item-related/time-group/infrastructure/time-group-read.controller.ts @@ -0,0 +1,46 @@ +import { Controller, Get, Param, Query } from '@nestjs/common'; +import { FilterTimeGroupDto } from './dto/filter-time-group.dto'; +import { Pagination } from 'src/core/response'; +import { PaginationResponse } from 'src/core/response/domain/ok-response.interface'; +import { TimeGroupEntity } from '../domain/entities/time-group.entity'; +import { TimeGroupReadOrchestrator } from '../domain/usecases/time-group-read.orchestrator'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { MODULE_NAME } from 'src/core/strings/constants/module.constants'; +import { Public } from 'src/core/guards'; + +@ApiTags(`${MODULE_NAME.TIME_GROUPS.split('-').join(' ')} - read`) +@Controller(`v1/${MODULE_NAME.TIME_GROUPS}`) +@Public(false) +@ApiBearerAuth('JWT') +export class TimeGroupReadController { + constructor(private orchestrator: TimeGroupReadOrchestrator) {} + + @Get() + @Pagination() + async index( + @Query() params: FilterTimeGroupDto, + ): Promise> { + return await this.orchestrator.index(params); + } + + @Get(':id') + async detail(@Param('id') id: string): Promise { + return await this.orchestrator.detail(id); + } +} + +@ApiTags(`${MODULE_NAME.TIME_GROUPS.split('-').join(' ')} List- read`) +// @Controller(`v1/${MODULE_NAME.TIME_GROUPS}-list`) +@Controller(``) +@Public() +export class TimeGroupPublicReadController { + constructor(private orchestrator: TimeGroupReadOrchestrator) {} + + @Get('v1/time-group-list-by-items') + @Pagination() + async indexPublic( + @Query() params: FilterTimeGroupDto, + ): Promise> { + return await this.orchestrator.indexPublic(params); + } +} diff --git a/src/modules/item-related/time-group/time-group.module.ts b/src/modules/item-related/time-group/time-group.module.ts new file mode 100644 index 0000000..87009a2 --- /dev/null +++ b/src/modules/item-related/time-group/time-group.module.ts @@ -0,0 +1,63 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { CONNECTION_NAME } from 'src/core/strings/constants/base.constants'; +import { TimeGroupDataService } from './data/services/time-group-data.service'; +import { TimeGroupReadService } from './data/services/time-group-read.service'; +import { + TimeGroupPublicReadController, + TimeGroupReadController, +} from './infrastructure/time-group-read.controller'; +import { TimeGroupReadOrchestrator } from './domain/usecases/time-group-read.orchestrator'; +import { TimeGroupDataController } from './infrastructure/time-group-data.controller'; +import { TimeGroupDataOrchestrator } from './domain/usecases/time-group-data.orchestrator'; +import { CreateTimeGroupManager } from './domain/usecases/managers/create-time-group.manager'; +import { CqrsModule } from '@nestjs/cqrs'; +import { IndexTimeGroupManager } from './domain/usecases/managers/index-time-group.manager'; +import { DeleteTimeGroupManager } from './domain/usecases/managers/delete-time-group.manager'; +import { UpdateTimeGroupManager } from './domain/usecases/managers/update-time-group.manager'; +import { ActiveTimeGroupManager } from './domain/usecases/managers/active-time-group.manager'; +import { ConfirmTimeGroupManager } from './domain/usecases/managers/confirm-time-group.manager'; +import { InactiveTimeGroupManager } from './domain/usecases/managers/inactive-time-group.manager'; +import { DetailTimeGroupManager } from './domain/usecases/managers/detail-time-group.manager'; +import { BatchDeleteTimeGroupManager } from './domain/usecases/managers/batch-delete-time-group.manager'; +import { BatchActiveTimeGroupManager } from './domain/usecases/managers/batch-active-time-group.manager'; +import { BatchConfirmTimeGroupManager } from './domain/usecases/managers/batch-confirm-time-group.manager'; +import { BatchInactiveTimeGroupManager } from './domain/usecases/managers/batch-inactive-time-group.manager'; +import { TimeGroupModel } from './data/models/time-group.model'; +import { IndexPublicTimeGroupManager } from './domain/usecases/managers/index-public-time-group.manager'; + +@Module({ + imports: [ + ConfigModule.forRoot(), + TypeOrmModule.forFeature([TimeGroupModel], CONNECTION_NAME.DEFAULT), + CqrsModule, + ], + controllers: [ + TimeGroupDataController, + TimeGroupReadController, + TimeGroupPublicReadController, + ], + providers: [ + IndexPublicTimeGroupManager, + IndexTimeGroupManager, + DetailTimeGroupManager, + CreateTimeGroupManager, + DeleteTimeGroupManager, + UpdateTimeGroupManager, + ActiveTimeGroupManager, + ConfirmTimeGroupManager, + InactiveTimeGroupManager, + BatchDeleteTimeGroupManager, + BatchActiveTimeGroupManager, + BatchConfirmTimeGroupManager, + BatchInactiveTimeGroupManager, + + TimeGroupDataService, + TimeGroupReadService, + + TimeGroupDataOrchestrator, + TimeGroupReadOrchestrator, + ], +}) +export class TimeGroupModule {} diff --git a/src/modules/queue/data/services/ticket.service.ts b/src/modules/queue/data/services/ticket.service.ts index 24827e4..812d4e3 100644 --- a/src/modules/queue/data/services/ticket.service.ts +++ b/src/modules/queue/data/services/ticket.service.ts @@ -40,35 +40,38 @@ export class TicketDataService extends BaseDataService { } async loginQueue(id: string): Promise { + const start = moment().startOf('day').valueOf(); + const end = moment().endOf('day').valueOf(); + const order = await this.order.findOne({ relations: ['tickets'], where: [ - { transaction_id: id }, - { code: id, transaction_id: Not(IsNull()) }, + { transaction_id: id, date: Between(start, end) }, + { code: id, transaction_id: Not(IsNull()), date: Between(start, end) }, ], }); - if (!order) { - const { customer_name, customer_phone } = - await this.transaction.findOneOrFail({ - where: { - id, - }, - }); + // if (!order) { + // const { customer_name, customer_phone } = + // await this.transaction.findOneOrFail({ + // where: { + // id, + // }, + // }); - const start = moment().startOf('day').valueOf(); - const end = moment().endOf('day').valueOf(); - const order = this.order.findOneOrFail({ - relations: ['tickets'], - where: { - customer: customer_name, - phone: customer_phone, - date: Between(start, end), - }, - }); + // const start = moment().startOf('day').valueOf(); + // const end = moment().endOf('day').valueOf(); + // const order = this.order.findOneOrFail({ + // relations: ['tickets'], + // where: { + // customer: customer_name, + // phone: customer_phone, + // date: Between(start, end), + // }, + // }); - return order; - } + // return order; + // } return order; } diff --git a/src/modules/queue/domain/queue.orchestrator.ts b/src/modules/queue/domain/queue.orchestrator.ts index 7ce8c81..39b40af 100644 --- a/src/modules/queue/domain/queue.orchestrator.ts +++ b/src/modules/queue/domain/queue.orchestrator.ts @@ -56,7 +56,7 @@ export class QueueOrchestrator { return order; } catch (error) { throw new UnauthorizedException({ - message: 'Invoice tidak ditemukan', + message: 'Invoice tidak ditemukan untuk tanggal hari ini', error: 'INVOICE_NOT_FOUND', }); } diff --git a/src/modules/queue/queue.module.ts b/src/modules/queue/queue.module.ts index b974dd3..172a800 100644 --- a/src/modules/queue/queue.module.ts +++ b/src/modules/queue/queue.module.ts @@ -39,7 +39,7 @@ import { ItemQueueModel } from '../item-related/item-queue/data/models/item-queu import { QueueTimeFormula } from './domain/usecases/formula/queue-time.formula'; import { QueueJobController } from './infrastructure/controllers/queue-job.controller'; import { GenerateQueueManager } from './domain/usecases/generate-queue.manager'; - +import { CouchModule } from 'src/modules/configuration/couch/couch.module'; @Module({ imports: [ ConfigModule.forRoot(), @@ -57,6 +57,7 @@ import { GenerateQueueManager } from './domain/usecases/generate-queue.manager'; CONNECTION_NAME.DEFAULT, ), CqrsModule, + CouchModule, ], controllers: [QueueController, QueueAdminController, QueueJobController], providers: [ diff --git a/src/modules/reports/shared/configs/transaction-report/configs/cancel-transaction.ts b/src/modules/reports/shared/configs/transaction-report/configs/cancel-transaction.ts index 468eee6..f1d195c 100644 --- a/src/modules/reports/shared/configs/transaction-report/configs/cancel-transaction.ts +++ b/src/modules/reports/shared/configs/transaction-report/configs/cancel-transaction.ts @@ -42,9 +42,16 @@ export default { }, column_configs: [ + // { + // column: 'main__payment_date', + // query: `to_char(main.payment_date, 'DD-MM-YYYY')`, + // label: 'Tgl. Pendapatan', + // type: DATA_TYPE.DIMENSION, + // format: DATA_FORMAT.TEXT, + // }, { column: 'main__payment_date', - query: `to_char(main.payment_date, 'DD-MM-YYYY')`, + query: `to_char(cast(to_timestamp(main.created_at/1000) as date),'DD-MM-YYYY')`, label: 'Tgl. Pendapatan', type: DATA_TYPE.DIMENSION, format: DATA_FORMAT.TEXT, @@ -77,6 +84,13 @@ export default { type: DATA_TYPE.DIMENSION, format: DATA_FORMAT.TEXT, }, + { + column: 'main__otp_code', + query: 'main.otp_code', + label: 'Kode OTP', + type: DATA_TYPE.DIMENSION, + format: DATA_FORMAT.TEXT, + }, { column: 'main__payment_code', query: `CASE WHEN main.type = 'counter' THEN main.invoice_code ELSE main.payment_code END`, @@ -282,15 +296,22 @@ export default { }, ], filter_configs: [ + // { + // filed_label: 'Tgl. Pembatalan', + // filter_column: 'main__payment_date', + // field_type: FILTER_FIELD_TYPE.date_range_picker, + // filter_type: FILTER_TYPE.DATE_IN_RANGE_TIMESTAMP, + // // date_format: 'DD-MM-YYYY', + // date_format: 'YYYY-MM-DD', + // }, + { filed_label: 'Tgl. Pembatalan', filter_column: 'main__payment_date', field_type: FILTER_FIELD_TYPE.date_range_picker, filter_type: FILTER_TYPE.DATE_IN_RANGE_TIMESTAMP, - // date_format: 'DD-MM-YYYY', - date_format: 'YYYY-MM-DD', + date_format: 'DD-MM-YYYY', }, - { filed_label: 'Sumber', filter_column: 'main__type', @@ -392,8 +413,9 @@ export default { }, ], customQueryColumn(column) { - if (column === 'main__payment_date') return 'main.payment_date'; - else if (column === 'refund__refund_date') return 'refund.refund_date'; + // if (column === 'main__payment_date') return 'main.payment_date'; + // else if (column === 'refund__refund_date') return 'refund.refund_date'; + if (column === 'refund__refund_date') return 'refund.refund_date'; return; }, }; diff --git a/src/modules/reports/shared/configs/transaction-report/configs/giving-discounts.ts b/src/modules/reports/shared/configs/transaction-report/configs/giving-discounts.ts index 99a71ee..e46ec43 100644 --- a/src/modules/reports/shared/configs/transaction-report/configs/giving-discounts.ts +++ b/src/modules/reports/shared/configs/transaction-report/configs/giving-discounts.ts @@ -119,6 +119,14 @@ export default { type: DATA_TYPE.DIMENSION, format: DATA_FORMAT.TEXT, }, + { + column: 'vip__otp_code', + query: 'vip.otp_code', + label: 'Kode OTP', + type: DATA_TYPE.DIMENSION, + format: DATA_FORMAT.TEXT, + }, + { column: 'privilege__name', query: 'privilege.name', diff --git a/src/modules/reports/shared/configs/transaction-report/configs/income-per-item-master.ts b/src/modules/reports/shared/configs/transaction-report/configs/income-per-item-master.ts index 62d46e8..9ab4af5 100644 --- a/src/modules/reports/shared/configs/transaction-report/configs/income-per-item-master.ts +++ b/src/modules/reports/shared/configs/transaction-report/configs/income-per-item-master.ts @@ -19,6 +19,7 @@ export default { LEFT JOIN refunds refund ON refund.transaction_id = main.id LEFT JOIN refund_items refund_item ON refund_item.refund_item_id = tr_item.item_id::uuid LEFT JOIN items item ON item.id::text = tr_item.item_id::text + LEFT JOIN time_groups tg on tg.id = item.time_group_id LEFT JOIN users tenant ON tenant.id::text = item.tenant_id::text`, main_table_alias: 'main', whereDefaultConditions: [ @@ -111,6 +112,13 @@ export default { type: DATA_TYPE.DIMENSION, format: DATA_FORMAT.TEXT, }, + { + column: 'tg__name', + query: 'tg.name', + label: 'Time Group', + type: DATA_TYPE.DIMENSION, + format: DATA_FORMAT.TEXT, + }, { column: 'tr_item__item_name', query: `CASE WHEN tr_item.item_type = 'bundling' THEN tr_item_bundling.item_name ELSE tr_item.item_name END`, @@ -338,6 +346,12 @@ export default { field_type: FILTER_FIELD_TYPE.input_tag, filter_type: FILTER_TYPE.TEXT_MULTIPLE_CONTAINS, }, + { + filed_label: 'Time Group', + filter_column: 'tg__name', + field_type: FILTER_FIELD_TYPE.input_tag, + filter_type: FILTER_TYPE.TEXT_MULTIPLE_CONTAINS, + }, { filed_label: 'Tipe Pelanggan', filter_column: 'main__customer_type', diff --git a/src/modules/reports/shared/configs/transaction-report/configs/income-per-item.ts b/src/modules/reports/shared/configs/transaction-report/configs/income-per-item.ts index e091d99..277b090 100644 --- a/src/modules/reports/shared/configs/transaction-report/configs/income-per-item.ts +++ b/src/modules/reports/shared/configs/transaction-report/configs/income-per-item.ts @@ -18,6 +18,7 @@ export default { LEFT JOIN refunds refund ON refund.transaction_id = main.id LEFT JOIN refund_items refund_item ON refund_item.refund_item_id = tr_item.item_id::uuid LEFT JOIN items item ON item.id::text = tr_item.item_id::text + LEFT JOIN time_groups tg on tg.id = item.time_group_id LEFT JOIN users tenant ON tenant.id::text = item.tenant_id::text`, main_table_alias: 'main', whereDefaultConditions: [ @@ -109,6 +110,13 @@ export default { type: DATA_TYPE.DIMENSION, format: DATA_FORMAT.TEXT, }, + { + column: 'tg__name', + query: 'tg.name', + label: 'Time Group', + type: DATA_TYPE.DIMENSION, + format: DATA_FORMAT.TEXT, + }, { column: 'main__customer_type', query: 'main.customer_type', @@ -296,6 +304,12 @@ export default { field_type: FILTER_FIELD_TYPE.input_tag, filter_type: FILTER_TYPE.TEXT_MULTIPLE_CONTAINS, }, + { + filed_label: 'Time Group', + filter_column: 'tg__name', + field_type: FILTER_FIELD_TYPE.input_tag, + filter_type: FILTER_TYPE.TEXT_MULTIPLE_CONTAINS, + }, { filed_label: 'Tipe Pelanggan', filter_column: 'main__customer_type', diff --git a/src/modules/reports/shared/configs/transaction-report/configs/income.ts b/src/modules/reports/shared/configs/transaction-report/configs/income.ts index b66bdf9..bb611ba 100644 --- a/src/modules/reports/shared/configs/transaction-report/configs/income.ts +++ b/src/modules/reports/shared/configs/transaction-report/configs/income.ts @@ -255,7 +255,14 @@ export default { { column: 'main__payment_card_information', query: 'main.payment_card_information', - label: 'Information', + label: 'Card Information', + type: DATA_TYPE.DIMENSION, + format: DATA_FORMAT.TEXT, + }, + { + column: 'main__payment_code_reference', + query: 'main.payment_code_reference', + label: 'Payment Reference', type: DATA_TYPE.DIMENSION, format: DATA_FORMAT.TEXT, }, @@ -334,6 +341,18 @@ export default { field_type: FILTER_FIELD_TYPE.input_tag, filter_type: FILTER_TYPE.TEXT_MULTIPLE_CONTAINS, }, + { + filed_label: 'Card Information', + filter_column: 'main__payment_card_information', + field_type: FILTER_FIELD_TYPE.input_tag, + filter_type: FILTER_TYPE.TEXT_IN_MEMBER, + }, + { + filed_label: 'Payment Reference', + filter_column: 'main__payment_code_reference', + field_type: FILTER_FIELD_TYPE.input_tag, + filter_type: FILTER_TYPE.TEXT_IN_MEMBER, + }, { filed_label: 'Tgl. Pengembalian', filter_column: 'refund__refund_date', diff --git a/src/modules/reports/shared/configs/transaction-report/configs/reconciliation.ts b/src/modules/reports/shared/configs/transaction-report/configs/reconciliation.ts index 8063ad3..acee33b 100644 --- a/src/modules/reports/shared/configs/transaction-report/configs/reconciliation.ts +++ b/src/modules/reports/shared/configs/transaction-report/configs/reconciliation.ts @@ -50,6 +50,13 @@ export default { type: DATA_TYPE.DIMENSION, format: DATA_FORMAT.TEXT, }, + { + column: 'main__otp_code', + query: 'main.otp_code', + label: 'Kode OTP Reject', + type: DATA_TYPE.DIMENSION, + format: DATA_FORMAT.TEXT, + }, { column: 'main__payment_date', query: `CASE WHEN main.payment_date is not null THEN to_char(main.payment_date, 'DD-MM-YYYY') ELSE null END`, diff --git a/src/modules/reports/shared/configs/transaction-report/configs/vip_code.ts b/src/modules/reports/shared/configs/transaction-report/configs/vip_code.ts index a23ece7..a8a4d5b 100644 --- a/src/modules/reports/shared/configs/transaction-report/configs/vip_code.ts +++ b/src/modules/reports/shared/configs/transaction-report/configs/vip_code.ts @@ -35,6 +35,13 @@ export default { type: DATA_TYPE.DIMENSION, format: DATA_FORMAT.TEXT, }, + { + column: 'main__otp_code', + query: 'main.otp_code', + label: 'Kode OTP', + type: DATA_TYPE.DIMENSION, + format: DATA_FORMAT.TEXT, + }, { column: 'main__discount', query: 'CASE WHEN main.discount > 0 THEN main.discount ELSE null END', diff --git a/src/modules/season-related/season-period/domain/usecases/managers/index-season-period-item.manager.ts b/src/modules/season-related/season-period/domain/usecases/managers/index-season-period-item.manager.ts index e082a3f..7a35c5d 100644 --- a/src/modules/season-related/season-period/domain/usecases/managers/index-season-period-item.manager.ts +++ b/src/modules/season-related/season-period/domain/usecases/managers/index-season-period-item.manager.ts @@ -28,7 +28,12 @@ export class IndexSeasonPeriodeItemManager extends BaseIndexManager { return await this.orchestrator.create(data); } @Post('/update-price') + @UseGuards(OtpCheckerGuard) async updatePrice(@Body() body: UpdateSeasonPriceDto): Promise { return await this.orchestrator.updatePrice(body); } @@ -80,7 +84,9 @@ export class SeasonPeriodDataController { } // pemisahan update data dengan update items dikarenakan payload (based on tampilan) berbeda + // TODO => simpan OTP update yang disikim dari request ini @Put(':id/items') + @UseGuards(OtpCheckerGuard) async updateItems( @Param('id') dataId: string, @Body() data: UpdateSeasonPeriodItemDto, diff --git a/src/modules/transaction/reconciliation/domain/usecases/managers/cancel-reconciliation.manager.ts b/src/modules/transaction/reconciliation/domain/usecases/managers/cancel-reconciliation.manager.ts index 0782fa6..71a71be 100644 --- a/src/modules/transaction/reconciliation/domain/usecases/managers/cancel-reconciliation.manager.ts +++ b/src/modules/transaction/reconciliation/domain/usecases/managers/cancel-reconciliation.manager.ts @@ -15,6 +15,12 @@ import { TransactionEntity } from 'src/modules/transaction/transaction/domain/en @Injectable() export class CancelReconciliationManager extends BaseUpdateStatusManager { + protected payloadBody: any; + + setCustomBodyRequest(body) { + this.payloadBody = body; + } + getResult(): string { return `Success active data ${this.result.id}`; } @@ -50,6 +56,7 @@ export class CancelReconciliationManager extends BaseUpdateStatusManager { if (this.data.is_recap_transaction) { Object.assign(this.data, { + otp_code: this.payloadBody?.otp_code, reconciliation_confirm_by: null, reconciliation_confirm_date: null, reconciliation_status: STATUS.PENDING, @@ -58,6 +65,7 @@ export class CancelReconciliationManager extends BaseUpdateStatusManager { + async cancel(dataId, body): Promise { this.cancelManager.setData(dataId, STATUS.REJECTED); this.cancelManager.setService(this.serviceData, TABLE_NAME.TRANSACTION); + this.cancelManager.setCustomBodyRequest(body); await this.cancelManager.execute(); return this.cancelManager.getResult(); } diff --git a/src/modules/transaction/reconciliation/infrastructure/dto/cancel-top-dto.ts b/src/modules/transaction/reconciliation/infrastructure/dto/cancel-top-dto.ts new file mode 100644 index 0000000..63cc18f --- /dev/null +++ b/src/modules/transaction/reconciliation/infrastructure/dto/cancel-top-dto.ts @@ -0,0 +1,14 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, ValidateIf } from 'class-validator'; + +export class OtpVerifyDto { + @ApiProperty({ + name: 'otp_code', + type: String, + required: true, + example: '2345', + }) + @IsString() + @ValidateIf((body) => body.otp_code) + otp_code: string; +} diff --git a/src/modules/transaction/reconciliation/infrastructure/reconciliation-data.controller.ts b/src/modules/transaction/reconciliation/infrastructure/reconciliation-data.controller.ts index c983d2f..4f76225 100644 --- a/src/modules/transaction/reconciliation/infrastructure/reconciliation-data.controller.ts +++ b/src/modules/transaction/reconciliation/infrastructure/reconciliation-data.controller.ts @@ -16,6 +16,7 @@ import { Public } from 'src/core/guards'; import { TransactionEntity } from '../../transaction/domain/entities/transaction.entity'; import { UpdateReconciliationDto } from './dto/reconciliation.dto'; import { RecapReconciliationDto } from './dto/recap.dto'; +import { OtpVerifyDto } from './dto/cancel-top-dto'; @ApiTags(`${MODULE_NAME.RECONCILIATION.split('-').join(' ')} - data`) @Controller(`v1/${MODULE_NAME.RECONCILIATION}`) @@ -40,8 +41,11 @@ export class ReconciliationDataController { } @Patch(':id/cancel') - async cancel(@Param('id') dataId: string): Promise { - return await this.orchestrator.cancel(dataId); + async cancel( + @Param('id') dataId: string, + @Body() body: OtpVerifyDto, + ): Promise { + return await this.orchestrator.cancel(dataId, body); } @Put('/batch-cancel') diff --git a/src/modules/transaction/reconciliation/reconciliation.module.ts b/src/modules/transaction/reconciliation/reconciliation.module.ts index e47d163..17d27ef 100644 --- a/src/modules/transaction/reconciliation/reconciliation.module.ts +++ b/src/modules/transaction/reconciliation/reconciliation.module.ts @@ -19,13 +19,13 @@ import { BatchCancelReconciliationManager } from './domain/usecases/managers/bat import { BatchConfirmReconciliationManager } from './domain/usecases/managers/batch-confirm-reconciliation.manager'; import { RecapReconciliationManager } from './domain/usecases/managers/recap-reconciliation.manager'; import { RecapPosTransactionHandler } from './domain/usecases/handlers/recap-pos-transaction.handler'; -import { SalesPriceFormulaDataService } from '../sales-price-formula/data/services/sales-price-formula-data.service'; - +import { CouchModule } from 'src/modules/configuration/couch/couch.module'; @Module({ imports: [ ConfigModule.forRoot(), TypeOrmModule.forFeature([TransactionModel], CONNECTION_NAME.DEFAULT), CqrsModule, + CouchModule, ], controllers: [ReconciliationDataController, ReconciliationReadController], providers: [ diff --git a/src/modules/transaction/refund/domain/usecases/managers/detail-refund.manager.ts b/src/modules/transaction/refund/domain/usecases/managers/detail-refund.manager.ts index 722f94b..b40b563 100644 --- a/src/modules/transaction/refund/domain/usecases/managers/detail-refund.manager.ts +++ b/src/modules/transaction/refund/domain/usecases/managers/detail-refund.manager.ts @@ -31,6 +31,10 @@ export class DetailRefundManager extends BaseDetailManager { 'items.bundling_items', 'items.refunds item_refunds', 'item_refunds.refund item_refunds_refund', + + 'transaction.items transaction_items', + 'transaction_items.item transaction_items_item', + 'transaction_items_item.time_group transaction_items_item_time_group', ], // relation yang hanya ingin dihitung (akan return number) @@ -65,6 +69,10 @@ export class DetailRefundManager extends BaseDetailManager { 'item_refunds', 'item_refunds_refund.id', 'item_refunds_refund.status', + + 'transaction_items', + 'transaction_items_item', + 'transaction_items_item_time_group', ]; } diff --git a/src/modules/transaction/refund/refund.module.ts b/src/modules/transaction/refund/refund.module.ts index c45fc5a..922dd14 100644 --- a/src/modules/transaction/refund/refund.module.ts +++ b/src/modules/transaction/refund/refund.module.ts @@ -23,7 +23,7 @@ import { CancelRefundManager } from './domain/usecases/managers/cancel-refund.ma import { RefundItemModel } from './data/models/refund-item.model'; import { TransactionDataService } from '../transaction/data/services/transaction-data.service'; import { TransactionModel } from '../transaction/data/models/transaction.model'; - +import { CouchModule } from 'src/modules/configuration/couch/couch.module'; @Module({ imports: [ ConfigModule.forRoot(), @@ -32,6 +32,7 @@ import { TransactionModel } from '../transaction/data/models/transaction.model'; CONNECTION_NAME.DEFAULT, ), CqrsModule, + CouchModule, ], controllers: [RefundDataController, RefundReadController], providers: [ diff --git a/src/modules/transaction/transaction/data/models/transaction.model.ts b/src/modules/transaction/transaction/data/models/transaction.model.ts index 053503c..4e47acd 100644 --- a/src/modules/transaction/transaction/data/models/transaction.model.ts +++ b/src/modules/transaction/transaction/data/models/transaction.model.ts @@ -274,4 +274,23 @@ export class TransactionModel onUpdate: 'CASCADE', }) refunds: RefundModel[]; + + @Column('varchar', { name: 'parent_id', nullable: true }) + parent_id: string; + + @ManyToOne(() => TransactionModel, (model) => model.id, { + nullable: true, + }) + @JoinColumn({ name: 'parent_id' }) + parent_transaction: TransactionModel; + + @OneToMany(() => TransactionModel, (model) => model.parent_transaction, { + cascade: true, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }) + children_transactions: TransactionModel[]; + + @Column('varchar', { name: 'otp_code', nullable: true }) + otp_code: string; } diff --git a/src/modules/transaction/transaction/data/services/transaction-data.service.ts b/src/modules/transaction/transaction/data/services/transaction-data.service.ts index 40b8f6a..70c8dc4 100644 --- a/src/modules/transaction/transaction/data/services/transaction-data.service.ts +++ b/src/modules/transaction/transaction/data/services/transaction-data.service.ts @@ -4,13 +4,32 @@ import { InjectRepository } from '@nestjs/typeorm'; import { TransactionModel } from '../models/transaction.model'; import { CONNECTION_NAME } from 'src/core/strings/constants/base.constants'; import { Repository } from 'typeorm'; +import { CouchService } from 'src/modules/configuration/couch/data/services/couch.service'; @Injectable() export class TransactionDataService extends BaseDataService { constructor( @InjectRepository(TransactionModel, CONNECTION_NAME.DEFAULT) private repo: Repository, + private couchService: CouchService, ) { super(repo); } + + async getTransactionWithReschedule(booking_id: string) { + return this.repo.findOne({ + relations: ['children_transactions', 'items'], + where: { id: booking_id }, + }); + } + + async saveTransactionToCouch(transaction) { + const id = transaction.id ?? transaction._id; + const couchData = await this.couchService.getDoc(id, 'transaction'); + if (!couchData) { + await this.couchService.createDoc(transaction, 'transaction'); + } else { + await this.couchService.updateDoc(transaction, 'transaction'); + } + } } diff --git a/src/modules/transaction/transaction/data/services/transaction-read.service.ts b/src/modules/transaction/transaction/data/services/transaction-read.service.ts index 7efd6bf..eefa87a 100644 --- a/src/modules/transaction/transaction/data/services/transaction-read.service.ts +++ b/src/modules/transaction/transaction/data/services/transaction-read.service.ts @@ -30,4 +30,64 @@ export class TransactionReadService extends BaseReadService { return transactions; } + + async getSummary(posId: string, startDate: string) { + const query = `select payment_type_counter, payment_type_method_name, sum(payment_total) payment_total, sum(payment_total_pay) payment_total_pay + from transactions t + where 1=1 + and t.creator_counter_no IN (${posId}) + and invoice_date = '${startDate}' + and status = 'settled' + group by payment_type_counter, payment_type_method_name;`; + const transactions = await this.repo.query(query); + + const qtyQuery = `select ti.item_id, ti.item_name, sum(ti.qty) total_qty, count(ti.item_name), sum(ti.qty), string_agg(distinct ti.item_price::text, '') price, + sum(payment_total) payment_total, sum(payment_total_pay) payment_total_pay + from transactions t + inner join transaction_items ti on t.id = ti.transaction_id + where t.creator_counter_no IN (${posId}) + and invoice_date = '${startDate}' + and t.status = 'settled' + group by ti.item_name, ti.item_id`; + const qtyTransactions = await this.repo.query(qtyQuery); + + const totalSalesQuery = `select sum(payment_total) payment_total + from transactions t + where 1=1 + and t.creator_counter_no IN (${posId}) + and invoice_date = '${startDate}' + and status = 'settled'`; + const totalSales = await this.repo.query(totalSalesQuery); + + return { + payment: transactions, + qty: qtyTransactions, + totalSales: totalSales?.[0]?.payment_total ?? 0, + }; + } + + async getLastTransactionByPos( + posId: string, + ): Promise { + const transaction = await this.repo.findOne({ + select: [ + 'id', + 'created_at', + 'updated_at', + 'status', + 'invoice_code', + 'creator_counter_no', + 'invoice_date', + 'payment_total', + ], + where: { + creator_counter_no: parseInt(posId), + }, + order: { + created_at: 'DESC', + }, + }); + + return transaction; + } } diff --git a/src/modules/transaction/transaction/domain/entities/transaction.entity.ts b/src/modules/transaction/transaction/domain/entities/transaction.entity.ts index 8aa0b55..040c023 100644 --- a/src/modules/transaction/transaction/domain/entities/transaction.entity.ts +++ b/src/modules/transaction/transaction/domain/entities/transaction.entity.ts @@ -86,6 +86,8 @@ export interface TransactionEntity extends BaseStatusEntity { sending_qr_at: number; sending_qr_status: STATUS; + parent_id?: string; + calendar_id?: string; calendar_link?: string; diff --git a/src/modules/transaction/transaction/domain/usecases/handlers/midtrans-transaction-callback.handler.ts b/src/modules/transaction/transaction/domain/usecases/handlers/midtrans-transaction-callback.handler.ts index a441d4b..c2cec81 100644 --- a/src/modules/transaction/transaction/domain/usecases/handlers/midtrans-transaction-callback.handler.ts +++ b/src/modules/transaction/transaction/domain/usecases/handlers/midtrans-transaction-callback.handler.ts @@ -11,6 +11,9 @@ import { TransactionChangeStatusEvent } from '../../entities/event/transaction-c import * as _ from 'lodash'; import { TABLE_NAME } from 'src/core/strings/constants/table.constants'; import { generateInvoiceCodeHelper } from '../managers/helpers/generate-invoice-code.helper'; +import { TransactionType } from '../../../constants'; +import { WhatsappService } from 'src/services/whatsapp/whatsapp.service'; +import * as moment from 'moment'; @EventsHandler(MidtransCallbackEvent) export class MidtransCallbackHandler @@ -22,7 +25,6 @@ export class MidtransCallbackHandler ) {} async handle(event: MidtransCallbackEvent) { - console.log('callbak mid', event); const data_id = event.data.id; const data = event.data.data; let old_data = undefined; @@ -58,16 +60,38 @@ export class MidtransCallbackHandler .manager.connection.createQueryRunner(); await this.dataService.create(queryRunner, TransactionModel, transaction); - console.log('update change status to tr', { - id: data_id, - old: old_data, - data: { ...data, status: transaction.status }, - user: BLANK_USER, - description: 'Midtrans Callback', - module: TABLE_NAME.TRANSACTION, - op: OPERATION.UPDATE, - }); - console.log({ data, old_data }); + + if ( + transaction.status === STATUS.SETTLED && + transaction.type === TransactionType.ONLINE + ) { + const whatsappService = new WhatsappService(); + const formattedDate = moment(transaction.booking_date); + const payload = { + id: transaction.id, + phone: transaction.customer_phone, + code: transaction.invoice_code, + name: transaction.customer_name, + time: formattedDate.valueOf(), + }; + await whatsappService.bookingCreated(payload); + } + + if ( + transaction.status === STATUS.EXPIRED && + transaction.type === TransactionType.ONLINE + ) { + const whatsappService = new WhatsappService(); + const formattedDate = moment(transaction.booking_date); + const payload = { + id: transaction.id, + phone: transaction.customer_phone, + code: transaction.invoice_code, + name: transaction.customer_name, + time: formattedDate.valueOf(), + }; + await whatsappService.bookingExpired(payload); + } this.eventBus.publish( new TransactionChangeStatusEvent({ diff --git a/src/modules/transaction/transaction/domain/usecases/managers/detail-transaction.manager.ts b/src/modules/transaction/transaction/domain/usecases/managers/detail-transaction.manager.ts index b788110..6d229b6 100644 --- a/src/modules/transaction/transaction/domain/usecases/managers/detail-transaction.manager.ts +++ b/src/modules/transaction/transaction/domain/usecases/managers/detail-transaction.manager.ts @@ -31,6 +31,9 @@ export class DetailTransactionManager extends BaseDetailManager item.refund.id == refundId); + const timeGroup = itemData?.item?.time_group; + return { item: { id: itemData.item_id, @@ -57,6 +59,7 @@ export function mappingTransaction(data, refundId?: string) { }, breakdown_bundling: itemData.breakdown_bundling, bundling_items: itemData.bundling_items, + time_group: timeGroup, }, id: itemData.id, refund: refund, diff --git a/src/modules/transaction/transaction/domain/usecases/transaction-data.orchestrator.ts b/src/modules/transaction/transaction/domain/usecases/transaction-data.orchestrator.ts index c717131..0a6e113 100644 --- a/src/modules/transaction/transaction/domain/usecases/transaction-data.orchestrator.ts +++ b/src/modules/transaction/transaction/domain/usecases/transaction-data.orchestrator.ts @@ -141,4 +141,10 @@ export class TransactionDataOrchestrator { await this.batchConfirmDataManager.execute(); return this.batchConfirmDataManager.getResult(); } + + async saveTransactionToCouch(transaction: any[]) { + for (const t of transaction) { + await this.serviceData.saveTransactionToCouch(t); + } + } } diff --git a/src/modules/transaction/transaction/domain/usecases/transaction-read.orchestrator.ts b/src/modules/transaction/transaction/domain/usecases/transaction-read.orchestrator.ts index cbc1912..b212bbd 100644 --- a/src/modules/transaction/transaction/domain/usecases/transaction-read.orchestrator.ts +++ b/src/modules/transaction/transaction/domain/usecases/transaction-read.orchestrator.ts @@ -7,6 +7,8 @@ import { BaseReadOrchestrator } from 'src/core/modules/domain/usecase/orchestrat import { DetailTransactionManager } from './managers/detail-transaction.manager'; import { TABLE_NAME } from 'src/core/strings/constants/table.constants'; import { PriceCalculator } from './calculator/price.calculator'; +import { In } from 'typeorm'; +import * as moment from 'moment'; @Injectable() export class TransactionReadOrchestrator extends BaseReadOrchestrator { @@ -26,6 +28,16 @@ export class TransactionReadOrchestrator extends BaseReadOrchestrator { this.detailManager.setData(dataId); this.detailManager.setService(this.serviceData, TABLE_NAME.TRANSACTION); @@ -50,17 +62,24 @@ export class TransactionReadOrchestrator extends BaseReadOrchestrator { const transactions = await this.serviceData.getManyByOptions({ where: { - is_recap_transaction: false, + // is_recap_transaction: false, + id: In(this.ids), }, relations: ['items', 'items.bundling_items'], }); + console.log(transactions.length); + // return; + for (const transaction of transactions) { try { const price = await this.calculator.calculate(transaction); diff --git a/src/modules/transaction/transaction/infrastructure/transaction-data.controller.ts b/src/modules/transaction/transaction/infrastructure/transaction-data.controller.ts index d9c7269..5b239ff 100644 --- a/src/modules/transaction/transaction/infrastructure/transaction-data.controller.ts +++ b/src/modules/transaction/transaction/infrastructure/transaction-data.controller.ts @@ -1,4 +1,5 @@ import { + BadRequestException, Body, Controller, Delete, @@ -7,17 +8,20 @@ import { Post, Put, Res, + UseGuards, } from '@nestjs/common'; import { Response } from 'express'; import { TransactionDataOrchestrator } from '../domain/usecases/transaction-data.orchestrator'; import { TransactionDto } from './dto/transaction.dto'; import { MODULE_NAME } from 'src/core/strings/constants/module.constants'; -import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { ApiBearerAuth, ApiBody, ApiTags } from '@nestjs/swagger'; import { TransactionEntity } from '../domain/entities/transaction.entity'; import { BatchResult } from 'src/core/response/domain/ok-response.interface'; import { BatchIdsDto } from 'src/core/modules/infrastructure/dto/base-batch.dto'; import { Public } from 'src/core/guards'; import { DownloadPdfDto } from './dto/donwload-pdf.dto'; +import { OtpAuthGuard } from 'src/modules/configuration/otp-verification/infrastructure/guards/otp-auth.guard'; +import { OtpCheckerGuard } from 'src/core/guards/domain/otp-checker.guard'; @ApiTags(`${MODULE_NAME.TRANSACTION.split('-').join(' ')} - data`) @Controller(`v1/${MODULE_NAME.TRANSACTION}`) @@ -50,6 +54,7 @@ export class TransactionDataController { } @Patch(':id/confirm-data') + @UseGuards(OtpCheckerGuard) async confirmData(@Param('id') dataId: string): Promise { return await this.orchestrator.confirmData(dataId); } @@ -60,6 +65,7 @@ export class TransactionDataController { } @Patch(':id/confirm') + @UseGuards(OtpCheckerGuard) async confirm(@Param('id') dataId: string): Promise { return await this.orchestrator.confirm(dataId); } @@ -91,4 +97,29 @@ export class TransactionDataController { async delete(@Param('id') dataId: string): Promise { return await this.orchestrator.delete(dataId); } + + @Post('save-to-couch') + @ApiBody({ + schema: { + type: 'array', + items: { + type: 'object', + }, + }, + }) + @Public(true) + // @UseGuards(OtpAuthGuard) + async saveToCouch(@Body() body: any[]) { + try { + await this.orchestrator.saveTransactionToCouch(body); + return { + message: 'Success', + }; + } catch (error) { + throw new BadRequestException({ + message: error.message, + error: error.stack, + }); + } + } } diff --git a/src/modules/transaction/transaction/infrastructure/transaction-read.controller.ts b/src/modules/transaction/transaction/infrastructure/transaction-read.controller.ts index 8446857..2a4b499 100644 --- a/src/modules/transaction/transaction/infrastructure/transaction-read.controller.ts +++ b/src/modules/transaction/transaction/infrastructure/transaction-read.controller.ts @@ -28,6 +28,12 @@ export class TransactionReadController { return await this.orchestrator.detail(id); } + @Public(true) + @Get('summary/:posId') + async summary(@Param('posId') posId: string) { + return await this.orchestrator.summary(posId); + } + @Public(true) @Get('dummy/:id') async calculate(@Param('id') id: string): Promise { diff --git a/src/modules/transaction/transaction/transaction.module.ts b/src/modules/transaction/transaction/transaction.module.ts index 7803e20..66f58d9 100644 --- a/src/modules/transaction/transaction/transaction.module.ts +++ b/src/modules/transaction/transaction/transaction.module.ts @@ -47,11 +47,22 @@ import { TransactionDemographyModel } from './data/models/transaction-demography import { PriceCalculator } from './domain/usecases/calculator/price.calculator'; import { ItemModel } from 'src/modules/item-related/item/data/models/item.model'; import { CouchModule } from 'src/modules/configuration/couch/couch.module'; +import { JWT_EXPIRED } from 'src/core/sessions/constants'; +import { JWT_SECRET } from 'src/core/sessions/constants'; +import { JwtModule } from '@nestjs/jwt'; @Module({ - exports: [TransactionReadService], + exports: [ + TransactionReadService, + TransactionDataService, + CreateTransactionManager, + ], imports: [ ConfigModule.forRoot(), + JwtModule.register({ + secret: JWT_SECRET, + signOptions: { expiresIn: JWT_EXPIRED }, + }), TypeOrmModule.forFeature( [ TransactionModel, diff --git a/src/modules/transaction/vip-code/data/models/vip-code.model.ts b/src/modules/transaction/vip-code/data/models/vip-code.model.ts index 314b047..57e45e0 100644 --- a/src/modules/transaction/vip-code/data/models/vip-code.model.ts +++ b/src/modules/transaction/vip-code/data/models/vip-code.model.ts @@ -27,4 +27,7 @@ export class VipCodeModel }) @JoinColumn({ name: 'vip_category_id' }) vip_category: VipCategoryModel; + + @Column('varchar', { name: 'otp_code', nullable: true }) + otp_code: string; } diff --git a/src/modules/transaction/vip-code/domain/usecases/handlers/create-vip-code.handler.ts b/src/modules/transaction/vip-code/domain/usecases/handlers/create-vip-code.handler.ts index 2c07dcb..006a81b 100644 --- a/src/modules/transaction/vip-code/domain/usecases/handlers/create-vip-code.handler.ts +++ b/src/modules/transaction/vip-code/domain/usecases/handlers/create-vip-code.handler.ts @@ -29,7 +29,7 @@ export class CreateVipCodeHandler implements IEventHandler { id: data._id ?? data.id, vip_category_id: data.vip_category?._id ?? data.vip_category?.id, }; - + console.log({ dataMapped }); try { await this.dataService.create(queryRunner, VipCodeModel, dataMapped); } catch (error) { diff --git a/src/modules/transaction/vip-code/infrastructure/dto/vip-code.dto.ts b/src/modules/transaction/vip-code/infrastructure/dto/vip-code.dto.ts index 1666c7f..be762ae 100644 --- a/src/modules/transaction/vip-code/infrastructure/dto/vip-code.dto.ts +++ b/src/modules/transaction/vip-code/infrastructure/dto/vip-code.dto.ts @@ -1,6 +1,6 @@ import { BaseDto } from 'src/core/modules/infrastructure/dto/base.dto'; import { VipCodeEntity } from '../../domain/entities/vip-code.entity'; -import { IsNumber, IsObject, IsString } from 'class-validator'; +import { IsNumber, IsObject, IsString, ValidateIf } from 'class-validator'; import { ApiProperty } from '@nestjs/swagger'; export class VipCodeDto extends BaseDto implements VipCodeEntity { @@ -29,6 +29,7 @@ export class VipCodeDto extends BaseDto implements VipCodeEntity { example: 25000, }) @IsNumber() + @ValidateIf((v) => v.discount_value) discount_value: number; @ApiProperty({ diff --git a/src/modules/user-related/user/infrastructure/user-data.controller.ts b/src/modules/user-related/user/infrastructure/user-data.controller.ts index 6377695..34adc6b 100644 --- a/src/modules/user-related/user/infrastructure/user-data.controller.ts +++ b/src/modules/user-related/user/infrastructure/user-data.controller.ts @@ -6,6 +6,7 @@ import { Patch, Post, Put, + UseGuards, } from '@nestjs/common'; import { UserDataOrchestrator } from '../domain/usecases/user-data.orchestrator'; import { UserDto } from './dto/user.dto'; @@ -17,6 +18,7 @@ import { BatchIdsDto } from 'src/core/modules/infrastructure/dto/base-batch.dto' import { Public } from 'src/core/guards'; import { UpdateUserDto } from './dto/update-user.dto'; import { UpdatePasswordUserDto } from './dto/update-password-user.dto'; +import { OtpCheckerGuard } from 'src/core/guards/domain/otp-checker.guard'; @ApiTags(`${MODULE_NAME.USER.split('-').join(' ')} - data`) @Controller(`v1/${MODULE_NAME.USER}`) @@ -36,6 +38,7 @@ export class UserDataController { } @Patch(':id/active') + @UseGuards(OtpCheckerGuard) async active(@Param('id') dataId: string): Promise { return await this.orchestrator.active(dataId); } @@ -46,6 +49,7 @@ export class UserDataController { } @Patch(':id/confirm') + @UseGuards(OtpCheckerGuard) async confirm(@Param('id') dataId: string): Promise { return await this.orchestrator.confirm(dataId); } diff --git a/src/services/whatsapp/entity/booking.entity.ts b/src/services/whatsapp/entity/booking.entity.ts new file mode 100644 index 0000000..90f5b95 --- /dev/null +++ b/src/services/whatsapp/entity/booking.entity.ts @@ -0,0 +1,7 @@ +export interface WhatsappBookingCreate { + id: string; + phone: string; + code: string; + name: string; + time: number; +} diff --git a/src/services/whatsapp/whatsapp.constant.ts b/src/services/whatsapp/whatsapp.constant.ts index 0f568ca..07d8b76 100644 --- a/src/services/whatsapp/whatsapp.constant.ts +++ b/src/services/whatsapp/whatsapp.constant.ts @@ -1,8 +1,16 @@ export const WHATSAPP_BUSINESS_API_URL = process.env.WHATSAPP_BUSINESS_API_URL ?? 'https://graph.facebook.com/'; +export const BOOKING_QR_URL = + process.env.BOOKING_QR_URL ?? + 'https://www.drupal.org/files/project-images/qrcode-module_0.png?'; + +export const BOOKING_TICKET_URL = + process.env.BOOKING_TICKET_URL ?? + 'https://booking.sky.eigen.co.id/public/ticket/'; + export const WHATSAPP_BUSINESS_VERSION = - process.env.WHATSAPP_BUSINESS_VERSION ?? 'v21.0'; + process.env.WHATSAPP_BUSINESS_VERSION ?? 'v22.0'; export const WHATSAPP_BUSINESS_QUEUE_URL = process.env.WHATSAPP_BUSINESS_QUEUE_URL ?? 'auth/login'; diff --git a/src/services/whatsapp/whatsapp.service.ts b/src/services/whatsapp/whatsapp.service.ts index efd855f..3d8209a 100644 --- a/src/services/whatsapp/whatsapp.service.ts +++ b/src/services/whatsapp/whatsapp.service.ts @@ -4,6 +4,8 @@ import { } from 'src/modules/queue/domain/helpers/time.helper'; import { WhatsappQueue } from './entity/whatsapp-queue.entity'; import { + BOOKING_QR_URL, + BOOKING_TICKET_URL, WHATSAPP_BUSINESS_ACCESS_TOKEN, WHATSAPP_BUSINESS_ACCOUNT_NUMBER_ID, WHATSAPP_BUSINESS_API_URL, @@ -13,6 +15,8 @@ import { import axios from 'axios'; import { Logger } from '@nestjs/common'; import { apm } from 'src/core/apm'; +import { WhatsappBookingCreate } from './entity/booking.entity'; +import * as moment from 'moment'; export class WhatsappService { async sendMessage(data) { @@ -30,6 +34,20 @@ export class WhatsappService { const response = await axios(config); return response.data; } catch (error) { + if (axios.isAxiosError(error)) { + if (error.response) { + console.error('Axios error response:', { + status: error.response.status, + data: error.response.data, + headers: error.response.headers, + error: error.response.data?.error?.error_data, + }); + } else if (error.request) { + console.error('Axios error request:', error.request); + } else { + console.error('Axios error message:', error.message); + } + } Logger.error(error); apm?.captureError(error); return null; @@ -105,6 +123,402 @@ export class WhatsappService { ); } + async bookingCreated(data: WhatsappBookingCreate) { + const imageUrl = `${BOOKING_QR_URL}${data.id}`; + + const momentDate = moment(data.time); + const fallbackValue = momentDate.locale('id').format('dddd, DD MMMM YYYY'); + // const dayOfWeek = momentDate.day(); + // const dayOfMonth = momentDate.date(); + // const year = momentDate.year(); + // const month = momentDate.month() + 1; + // const hour = momentDate.hour(); + // const minute = momentDate.minute(); + + const payload = { + messaging_product: 'whatsapp', + to: phoneNumberOnly(data.phone), // recipient's phone number + type: 'template', + template: { + name: 'booking_online', + language: { + code: 'id', // language code + }, + components: [ + { + type: 'header', + parameters: [ + { + type: 'image', + image: { + link: imageUrl, + }, + }, + ], + }, + { + type: 'body', + parameters: [ + { + type: 'text', + parameter_name: 'customer', + text: data.name, // replace with name variable + }, + { + type: 'text', + parameter_name: 'booking_code', + text: data.code, // replace with queue_code variable + }, + { + type: 'text', + parameter_name: 'booking_date', + text: fallbackValue, + }, + ], + }, + { + type: 'button', + sub_type: 'url', + index: '0', + parameters: [ + { + type: 'text', + text: data.id, // replace with dynamic URL + }, + ], + }, + ], + }, + }; + + const response = await this.sendMessage(payload); + if (response) + Logger.log( + `Notification register Booking for ${data.code} send to ${data.phone}`, + ); + } + + async bookingExpired(data: WhatsappBookingCreate) { + const momentDate = moment(data.time); + const fallbackValue = momentDate.locale('id').format('dddd, DD MMMM YYYY'); + // const dayOfWeek = momentDate.day(); + // const dayOfMonth = momentDate.date(); + // const year = momentDate.year(); + // const month = momentDate.month() + 1; + // const hour = momentDate.hour(); + // const minute = momentDate.minute(); + + const payload = { + messaging_product: 'whatsapp', + to: phoneNumberOnly(data.phone), // recipient's phone number + type: 'template', + template: { + name: 'booking_expired', + language: { + code: 'id', // language code + }, + components: [ + { + type: 'body', + parameters: [ + { + type: 'text', + parameter_name: 'customer', + text: data.name, // replace with name variable + }, + { + type: 'text', + parameter_name: 'code', + text: data.code, // replace with queue_code variable + }, + { + type: 'text', + parameter_name: 'booking_date', + text: fallbackValue, + }, + ], + }, + ], + }, + }; + + const response = await this.sendMessage(payload); + if (response) + Logger.log( + `Notification register Booking for ${data.code} send to ${data.phone}`, + ); + } + + async rescheduleCreated(data: WhatsappBookingCreate) { + const imageUrl = `${BOOKING_QR_URL}${data.id}`; + + const momentDate = moment(data.time); + const fallbackValue = momentDate.locale('id').format('dddd, DD MMMM YYYY'); + // const dayOfWeek = momentDate.day(); + // const dayOfMonth = momentDate.date(); + // const year = momentDate.year(); + // const month = momentDate.month() + 1; + // const hour = momentDate.hour(); + // const minute = momentDate.minute(); + + const payload = { + messaging_product: 'whatsapp', + to: phoneNumberOnly(data.phone), // recipient's phone number + type: 'template', + template: { + name: 'reschedule_created', + language: { + code: 'id', // language code + }, + components: [ + { + type: 'header', + parameters: [ + { + type: 'image', + image: { + link: imageUrl, + }, + }, + ], + }, + { + type: 'body', + parameters: [ + { + type: 'text', + parameter_name: 'customer', + text: data.name, // replace with name variable + }, + { + type: 'text', + parameter_name: 'booking_code', + text: data.code, // replace with queue_code variable + }, + { + type: 'text', + parameter_name: 'booking_date', + text: fallbackValue, + }, + ], + }, + { + type: 'button', + sub_type: 'url', + index: '0', + parameters: [ + { + type: 'text', + text: data.id, // replace with dynamic URL + }, + ], + }, + ], + }, + }; + + const response = await this.sendMessage(payload); + if (response) + Logger.log( + `Notification register Booking for ${data.code} send to ${data.phone}`, + ); + } + + async bookingRegister( + data: WhatsappBookingCreate, + total: number, + paymentUrl: string, + ) { + const momentDate = moment(data.time); + const fallbackValue = momentDate.locale('id').format('dddd, DD MMMM YYYY'); + + const formattedTotal = new Intl.NumberFormat('id-ID', { + style: 'currency', + currency: 'IDR', + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }) + .format(total) + .replace('IDR', 'Rp'); + + const payload = { + messaging_product: 'whatsapp', + to: phoneNumberOnly(data.phone), // recipient's phone number + type: 'template', + template: { + name: 'booking_register', + language: { + code: 'id', // language code + }, + components: [ + { + type: 'body', + parameters: [ + { + type: 'text', + parameter_name: 'customer', + text: data.name, // replace with name variable + }, + { + type: 'text', + parameter_name: 'booking_date', + text: fallbackValue, + }, + { + type: 'text', + parameter_name: 'booking_code', + text: data.code, + }, + { + type: 'text', + parameter_name: 'total', + text: formattedTotal, + }, + ], + }, + { + type: 'button', + sub_type: 'url', + index: '0', + parameters: [ + { + type: 'text', + text: paymentUrl, // replace with dynamic URL + }, + ], + }, + ], + }, + }; + + const response = await this.sendMessage(payload); + if (response) + Logger.log( + `Notification register Booking for ${data.code} send to ${data.phone}`, + ); + } + + async bookingRescheduleOTP(data: WhatsappBookingCreate) { + const momentDate = moment(data.time); + const fallbackValue = momentDate.locale('id').format('dddd, DD MMMM YYYY'); + + const payload = { + messaging_product: 'whatsapp', + to: phoneNumberOnly(data.phone), // recipient's phone number + type: 'template', + template: { + name: 'booking_reschedule', + language: { + code: 'id', // language code + }, + components: [ + { + type: 'body', + parameters: [ + { + type: 'text', + parameter_name: 'customer', + text: data.name, // replace with name variable + }, + { + type: 'text', + parameter_name: 'booking_code', + text: data.code, // replace with queue_code variable + }, + { + type: 'text', + parameter_name: 'booking_date', + text: fallbackValue, + }, + { + type: 'text', + parameter_name: 'otp', + text: data.code, + }, + ], + }, + { + type: 'button', + sub_type: 'copy_code', + index: '0', + parameters: [ + { + type: 'coupon_code', + coupon_code: data.code, + }, + ], + }, + ], + }, + }; + + const response = await this.sendMessage(payload); + if (response) + Logger.log( + `Notification reschedule Booking for ${data.code} send to ${data.phone}`, + ); + } + + async sendOtpNotification(data: { phone: string; code: string }) { + // Compose the WhatsApp message payload for OTP using Facebook WhatsApp API + const payload = { + messaging_product: 'whatsapp', + to: data.phone, // recipient's phone number in international format + type: 'template', + template: { + name: 'booking_otp', // Make sure this template is approved in WhatsApp Business Manager + language: { + code: 'id', // or 'en' if you want English + }, + components: [ + { + type: 'body', + parameters: [ + { + type: 'text', + text: parseInt(data.code), // OTP code + }, + ], + }, + { + type: 'button', + sub_type: 'url', + index: '0', + parameters: [ + { + type: 'text', + text: `${data.code}`, + }, + ], + }, + ], + }, + }; + + const response = await this.sendMessage(payload); + if (response) { + Logger.log( + `OTP notification for code ${data.code} sent to ${data.phone}`, + ); + } + } + + async sendTemplateMessage(data: { phone: string; templateMsg: any }) { + const payload = { + messaging_product: 'whatsapp', + to: data.phone, + type: 'template', + template: data?.templateMsg, + }; + + const response = await this.sendMessage(payload); + if (response) { + Logger.log( + `OTP notification for template ${data.templateMsg} sent to ${data.phone}`, + ); + } + } + async queueProcess(data: WhatsappQueue) { const queueUrl = `${WHATSAPP_BUSINESS_QUEUE_URL}?id=${data.id}`; const payload = { diff --git a/yarn.lock b/yarn.lock index ddf1626..a4abb7f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1239,6 +1239,13 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-14.18.63.tgz#1788fa8da838dbb5f9ea994b834278205db6ca2b" integrity sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ== +"@types/qrcode@^1.5.5": + version "1.5.5" + resolved "https://registry.yarnpkg.com/@types/qrcode/-/qrcode-1.5.5.tgz#993ff7c6b584277eee7aac0a20861eab682f9dac" + integrity sha512-CdfBi/e3Qk+3Z/fXYShipBT13OJ2fDO2Q2w5CIP5anLTLIndQG9z6P1cnm+8zCWSpm5dnxMFd/uREtb0EXuQzg== + dependencies: + "@types/node" "*" + "@types/qs@*": version "6.9.15" resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.15.tgz#adde8a060ec9c305a82de1babc1056e73bd64dce" @@ -2861,6 +2868,11 @@ diff@^4.0.1: resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d" integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A== +dijkstrajs@^1.0.1: + version "1.0.3" + resolved "https://registry.yarnpkg.com/dijkstrajs/-/dijkstrajs-1.0.3.tgz#4c8dbdea1f0f6478bff94d9c49c784d623e4fc23" + integrity sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA== + dir-glob@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-3.0.1.tgz#56dbf73d992a4a93ba1584f4534063fd2e41717f" @@ -6363,6 +6375,11 @@ png-js@^1.0.0: resolved "https://registry.yarnpkg.com/png-js/-/png-js-1.0.0.tgz#e5484f1e8156996e383aceebb3789fd75df1874d" integrity sha512-k+YsbhpA9e+EFfKjTCH3VW6aoKlyNYI6NYdTfDL4CIvFnvsuO84ttonmZE7rc+v23SLTH8XX+5w/Ak9v0xGY4g== +pngjs@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-5.0.0.tgz#e79dd2b215767fd9c04561c01236df960bce7fbb" + integrity sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw== + postgres-array@~2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/postgres-array/-/postgres-array-2.0.0.tgz#48f8fce054fbc69671999329b8834b772652d82e" @@ -6464,6 +6481,15 @@ pure-rand@^6.0.0: resolved "https://registry.yarnpkg.com/pure-rand/-/pure-rand-6.1.0.tgz#d173cf23258231976ccbdb05247c9787957604f2" integrity sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA== +qrcode@^1.5.4: + version "1.5.4" + resolved "https://registry.yarnpkg.com/qrcode/-/qrcode-1.5.4.tgz#5cb81d86eb57c675febb08cf007fff963405da88" + integrity sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg== + dependencies: + dijkstrajs "^1.0.1" + pngjs "^5.0.0" + yargs "^15.3.1" + qs@6.11.0: version "6.11.0" resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.0.tgz#fd0d963446f7a65e1367e01abd85429453f0c37a" @@ -8070,7 +8096,7 @@ yargs-parser@21.1.1, yargs-parser@^21.0.1, yargs-parser@^21.1.1: resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35" integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw== -yargs-parser@^18.1.1: +yargs-parser@^18.1.1, yargs-parser@^18.1.2: version "18.1.3" resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-18.1.3.tgz#be68c4975c6b2abf469236b0c870362fab09a7b0" integrity sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ== @@ -8108,6 +8134,23 @@ yargs@15.3.1: y18n "^4.0.0" yargs-parser "^18.1.1" +yargs@^15.3.1: + version "15.4.1" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-15.4.1.tgz#0d87a16de01aee9d8bec2bfbf74f67851730f4f8" + integrity sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A== + dependencies: + cliui "^6.0.0" + decamelize "^1.2.0" + find-up "^4.1.0" + get-caller-file "^2.0.1" + require-directory "^2.1.1" + require-main-filename "^2.0.0" + set-blocking "^2.0.0" + string-width "^4.2.0" + which-module "^2.0.0" + y18n "^4.0.0" + yargs-parser "^18.1.2" + yargs@^16.0.0: version "16.2.0" resolved "https://registry.yarnpkg.com/yargs/-/yargs-16.2.0.tgz#1c82bf0f6b6a66eafce7ef30e376f49a12477f66"