Merge pull request 'feat/otp-cancel' (#144) from feat/otp-cancel into development

Reviewed-on: #144
pull/147/head 1.6.4-alpha.1
firmanr 2025-06-05 14:32:34 +07:00
commit 73c87f6819
26 changed files with 610 additions and 3 deletions

View File

@ -101,6 +101,11 @@ import { BookingOnlineAuthModule } from './modules/booking-online/authentication
import { BookingOrderModule } from './modules/booking-online/order/order.module'; import { BookingOrderModule } from './modules/booking-online/order/order.module';
import { TimeGroupModule } from './modules/item-related/time-group/time-group.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 { 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';
@Module({ @Module({
imports: [ imports: [
ApmModule.register(), ApmModule.register(),
@ -165,6 +170,9 @@ import { TimeGroupModel } from './modules/item-related/time-group/data/models/ti
// Booking Online // Booking Online
VerificationModel, VerificationModel,
OtpVerificationModel,
OtpVerifierModel,
], ],
synchronize: false, synchronize: false,
}), }),
@ -230,6 +238,7 @@ import { TimeGroupModel } from './modules/item-related/time-group/data/models/ti
BookingOnlineAuthModule, BookingOnlineAuthModule,
BookingOrderModule, BookingOrderModule,
OtpVerificationModule,
], ],
controllers: [], controllers: [],
providers: [ providers: [

View File

@ -0,0 +1,57 @@
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<string, number> = {};
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);
}
public generateSecureOTP(): string {
let otp: string;
do {
otp = Array.from({ length: this.otpLength }, () =>
Math.floor(Math.random() * 10).toString(),
).join('');
} while (
this.hasSequentialDigits(otp) ||
this.hasRepeatedDigits(otp) ||
this.isPalindrome(otp) ||
this.hasPartiallyRepeatedDigits(otp)
);
return otp;
}
}

View File

@ -30,4 +30,5 @@ export enum MODULE_NAME {
QUEUE = 'queue', QUEUE = 'queue',
TIME_GROUPS = 'time-groups', TIME_GROUPS = 'time-groups',
OTP_VERIFICATIONS = 'otp-verification',
} }

View File

@ -45,4 +45,6 @@ export enum TABLE_NAME {
QUEUE_BUCKET = 'queue_bucket', QUEUE_BUCKET = 'queue_bucket',
TIME_GROUPS = 'time_groups', TIME_GROUPS = 'time_groups',
OTP_VERIFICATIONS = 'otp_verifications',
OTP_VERIFIER = 'otp_verifier',
} }

View File

@ -0,0 +1,21 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddColumnOtpCode1748935417155 implements MigrationInterface {
name = 'AddColumnOtpCode1748935417155';
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
await queryRunner.query(
`ALTER TABLE "transactions" DROP COLUMN "otp_code"`,
);
await queryRunner.query(`ALTER TABLE "vip_codes" DROP COLUMN "otp_code"`);
}
}

View File

@ -0,0 +1,29 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateTableOtpCerifications1749028279580
implements MigrationInterface
{
name = 'CreateTableOtpCerifications1749028279580';
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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"`,
);
}
}

View File

@ -0,0 +1,19 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddColumnIsReplacedOtpVerification1749030419440
implements MigrationInterface
{
name = 'AddColumnIsReplacedOtpVerification1749030419440';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "otp_verifications" ADD "is_replaced" boolean NOT NULL DEFAULT false`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "otp_verifications" DROP COLUMN "is_replaced"`,
);
}
}

View File

@ -0,0 +1,15 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddTableOtpVerifier1749043616622 implements MigrationInterface {
name = 'AddTableOtpVerifier1749043616622';
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
await queryRunner.query(`DROP TABLE "otp_verifier"`);
}
}

View File

@ -0,0 +1,37 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class UpdateEnumOtpActionType1749046285398
implements MigrationInterface
{
name = 'UpdateEnumOtpActionType1749046285398';
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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"`,
);
}
}

View File

@ -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<OtpVerificationEntity>
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
}

View File

@ -0,0 +1,16 @@
import { TABLE_NAME } from 'src/core/strings/constants/table.constants';
import { 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<OtpVerifierEntity>
implements OtpVerifierEntity
{
@Column({ type: 'varchar', nullable: true })
name: string;
@Column({ type: 'varchar', nullable: false })
phone_number: string;
}

View File

@ -0,0 +1,174 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { OtpVerificationModel } from '../models/otp-verification.model';
import {
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<OtpVerificationModel>,
@InjectRepository(OtpVerifierModel)
private readonly otpVerifierRepo: Repository<OtpVerifierModel>,
) {}
private generateOtpExpiration(minutes = 5): number {
return moment().add(minutes, 'minutes').valueOf(); // epoch millis expired time
}
private generateResendAvailableAt(seconds = 90): number {
return moment().add(seconds, 'seconds').valueOf(); // epoch millis
}
private generateTimestamp(): number {
return moment().valueOf(); // epoch millis verification time (now)
}
async requestOTP(payload: OtpRequestEntity) {
const otpService = new OtpService({ length: 4 });
const otpCode = otpService.generateSecureOTP();
const dateNow = this.generateTimestamp();
const expiredAt = this.generateOtpExpiration();
//TODO implementation from auth
const creator = { id: null, name: null };
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: creator.id,
creator_name: creator.name,
created_at: dateNow,
verified_at: null,
editor_id: creator.id,
editor_name: creator.name,
updated_at: dateNow,
};
const activeOTP = await this.getActiveOtp(
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');
if (diffSeconds < 90) {
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.otpVerifierRepo.find();
const notificationService = new WhatsappService();
verifiers.map((v) => {
notificationService.sendOtpNotification({
phone: v.phone_number,
code: otpCode,
});
});
return {
message: `OTP has been sent to the admin's WhatsApp.`,
updated_at: expiredAt,
resend_available_at: this.generateResendAvailableAt(),
};
}
async verifyOTP(payload: OtpVerifyEntity) {
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.',
);
}
// Build a where condition with OR between target_id and reference
const otp = await this.otpVerificationRepo.findOne({
where: [
{
otp_code,
action_type,
target_id,
source,
is_used: false,
is_replaced: false,
},
{
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;
// 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();
}
}

View File

@ -0,0 +1,40 @@
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',
}
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;
}

View File

@ -0,0 +1,55 @@
import { IsNotEmpty, IsString, ValidateIf } from 'class-validator';
import {
OTP_ACTION_TYPE,
OTP_SOURCE,
OtpRequestEntity,
OtpVerifyEntity,
} from '../../domain/entities/otp-verification.entity';
import { ApiProperty } from '@nestjs/swagger';
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;
}

View File

@ -0,0 +1,31 @@
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { 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, OtpVerifyDto } from './dto/otp-verification.dto';
//TODO implementation auth
@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')
async request(@Body() body: OtpRequestDto) {
return await this.otpVerificationService.requestOTP(body);
}
@Post('verify')
async verify(@Body() body: OtpVerifyDto) {
return await this.otpVerificationService.verifyOTP(body);
}
@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);
}
}

View File

@ -0,0 +1,21 @@
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 } from './infrastructure/otp-verification-data.controller';
import { OtpVerificationService } from './data/services/otp-verification.service';
import { OtpVerifierModel } from './data/models/otp-verifier.model';
@Module({
imports: [
ConfigModule.forRoot(),
TypeOrmModule.forFeature(
[OtpVerificationModel, OtpVerifierModel],
CONNECTION_NAME.DEFAULT,
),
],
controllers: [OtpVerificationController],
providers: [OtpVerificationService],
})
export class OtpVerificationModule {}

View File

@ -26,6 +26,7 @@ export class DetailItemManager extends BaseDetailManager<ItemEntity> {
selectRelations: [ selectRelations: [
'item_category', 'item_category',
'bundling_items', 'bundling_items',
'bundling_items.time_group bundling_time_groups',
'tenant', 'tenant',
'time_group', 'time_group',
], ],
@ -64,6 +65,9 @@ export class DetailItemManager extends BaseDetailManager<ItemEntity> {
'bundling_items.hpp', 'bundling_items.hpp',
'bundling_items.base_price', 'bundling_items.base_price',
'bundling_time_groups.id',
'bundling_time_groups.name',
'tenant.id', 'tenant.id',
'tenant.name', 'tenant.name',

View File

@ -37,7 +37,7 @@ export class CreateTimeGroupManager extends BaseCreateManager<TimeGroupEntity> {
if (max_usage_time.isBefore(end_time)) { if (max_usage_time.isBefore(end_time)) {
throw new Error( throw new Error(
'Waktu maksimum penggunaan harus lebih kecil dari waktu selesai.', 'Waktu maksimum penggunaan harus lebih besar dari waktu selesai.',
); );
} }
return; return;

View File

@ -38,7 +38,7 @@ export class UpdateTimeGroupManager extends BaseUpdateManager<TimeGroupEntity> {
if (max_usage_time.isBefore(end_time)) { if (max_usage_time.isBefore(end_time)) {
throw new Error( throw new Error(
'Waktu maksimum penggunaan harus lebih kecil dari waktu selesai.', 'Waktu maksimum penggunaan harus lebih besar dari waktu selesai.',
); );
} }
return; return;

View File

@ -84,6 +84,13 @@ export default <ReportConfigEntity>{
type: DATA_TYPE.DIMENSION, type: DATA_TYPE.DIMENSION,
format: DATA_FORMAT.TEXT, 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', column: 'main__payment_code',
query: `CASE WHEN main.type = 'counter' THEN main.invoice_code ELSE main.payment_code END`, query: `CASE WHEN main.type = 'counter' THEN main.invoice_code ELSE main.payment_code END`,

View File

@ -119,6 +119,14 @@ export default <ReportConfigEntity>{
type: DATA_TYPE.DIMENSION, type: DATA_TYPE.DIMENSION,
format: DATA_FORMAT.TEXT, 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', column: 'privilege__name',
query: 'privilege.name', query: 'privilege.name',

View File

@ -50,6 +50,13 @@ export default <ReportConfigEntity>{
type: DATA_TYPE.DIMENSION, type: DATA_TYPE.DIMENSION,
format: DATA_FORMAT.TEXT, 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', 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`, query: `CASE WHEN main.payment_date is not null THEN to_char(main.payment_date, 'DD-MM-YYYY') ELSE null END`,

View File

@ -35,6 +35,13 @@ export default <ReportConfigEntity>{
type: DATA_TYPE.DIMENSION, type: DATA_TYPE.DIMENSION,
format: DATA_FORMAT.TEXT, 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', column: 'main__discount',
query: 'CASE WHEN main.discount > 0 THEN main.discount ELSE null END', query: 'CASE WHEN main.discount > 0 THEN main.discount ELSE null END',

View File

@ -274,4 +274,7 @@ export class TransactionModel
onUpdate: 'CASCADE', onUpdate: 'CASCADE',
}) })
refunds: RefundModel[]; refunds: RefundModel[];
@Column('varchar', { name: 'otp_code', nullable: true })
otp_code: string;
} }

View File

@ -27,4 +27,7 @@ export class VipCodeModel
}) })
@JoinColumn({ name: 'vip_category_id' }) @JoinColumn({ name: 'vip_category_id' })
vip_category: VipCategoryModel; vip_category: VipCategoryModel;
@Column('varchar', { name: 'otp_code', nullable: true })
otp_code: string;
} }

View File

@ -29,7 +29,7 @@ export class CreateVipCodeHandler implements IEventHandler<ChangeDocEvent> {
id: data._id ?? data.id, id: data._id ?? data.id,
vip_category_id: data.vip_category?._id ?? data.vip_category?.id, vip_category_id: data.vip_category?._id ?? data.vip_category?.id,
}; };
console.log({ dataMapped });
try { try {
await this.dataService.create(queryRunner, VipCodeModel, dataMapped); await this.dataService.create(queryRunner, VipCodeModel, dataMapped);
} catch (error) { } catch (error) {