From 881d88c8d8a56e513949d632f4a2495946237ba4 Mon Sep 17 00:00:00 2001 From: Abdalhamid Alhamad Date: Mon, 19 May 2025 14:16:18 +0300 Subject: [PATCH 01/32] feat: add customer details to customer entity --- src/common/modules/neoleap/neoleap.module.ts | 8 +++++++ src/common/modules/neoleap/services/index.ts | 0 .../neoleap/services/neoleap.service.ts | 6 +++++ src/customer/entities/customer.entity.ts | 18 +++++++++++++++ ...9536067-add-address-fields-to-customers.ts | 23 +++++++++++++++++++ src/db/migrations/index.ts | 1 + 6 files changed, 56 insertions(+) create mode 100644 src/common/modules/neoleap/neoleap.module.ts create mode 100644 src/common/modules/neoleap/services/index.ts create mode 100644 src/common/modules/neoleap/services/neoleap.service.ts create mode 100644 src/db/migrations/1747569536067-add-address-fields-to-customers.ts diff --git a/src/common/modules/neoleap/neoleap.module.ts b/src/common/modules/neoleap/neoleap.module.ts new file mode 100644 index 0000000..c68989f --- /dev/null +++ b/src/common/modules/neoleap/neoleap.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; + +@Module({ + imports: [], + controllers: [], + providers: [], +}) +export class NeoLeapModule {} diff --git a/src/common/modules/neoleap/services/index.ts b/src/common/modules/neoleap/services/index.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/common/modules/neoleap/services/neoleap.service.ts b/src/common/modules/neoleap/services/neoleap.service.ts new file mode 100644 index 0000000..f172937 --- /dev/null +++ b/src/common/modules/neoleap/services/neoleap.service.ts @@ -0,0 +1,6 @@ +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class NeoLeapService { + createApplication() {} +} diff --git a/src/customer/entities/customer.entity.ts b/src/customer/entities/customer.entity.ts index 48be217..0dd917d 100644 --- a/src/customer/entities/customer.entity.ts +++ b/src/customer/entities/customer.entity.ts @@ -75,6 +75,24 @@ export class Customer extends BaseEntity { @Column('varchar', { name: 'user_id' }) userId!: string; + @Column('varchar', { name: 'country', length: 255, nullable: true }) + country!: string; + + @Column('varchar', { name: 'region', length: 255, nullable: true }) + region!: string; + + @Column('varchar', { name: 'city', length: 255, nullable: true }) + city!: string; + + @Column('varchar', { name: 'neighborhood', length: 255, nullable: true }) + neighborhood!: string; + + @Column('varchar', { name: 'street', length: 255, nullable: true }) + street!: string; + + @Column('varchar', { name: 'building', length: 255, nullable: true }) + building!: string; + @Column('varchar', { name: 'profile_picture_id', nullable: true }) profilePictureId!: string; diff --git a/src/db/migrations/1747569536067-add-address-fields-to-customers.ts b/src/db/migrations/1747569536067-add-address-fields-to-customers.ts new file mode 100644 index 0000000..4d8a8ec --- /dev/null +++ b/src/db/migrations/1747569536067-add-address-fields-to-customers.ts @@ -0,0 +1,23 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddAddressFieldsToCustomers1747569536067 implements MigrationInterface { + name = 'AddAddressFieldsToCustomers1747569536067'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "customers" ADD "country" character varying(255)`); + await queryRunner.query(`ALTER TABLE "customers" ADD "region" character varying(255)`); + await queryRunner.query(`ALTER TABLE "customers" ADD "city" character varying(255)`); + await queryRunner.query(`ALTER TABLE "customers" ADD "neighborhood" character varying(255)`); + await queryRunner.query(`ALTER TABLE "customers" ADD "street" character varying(255)`); + await queryRunner.query(`ALTER TABLE "customers" ADD "building" character varying(255)`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "building"`); + await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "street"`); + await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "neighborhood"`); + await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "city"`); + await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "region"`); + await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "country"`); + } +} diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index 9ca8914..3ad20d0 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -24,3 +24,4 @@ export * from './1739954239949-add-civilid-to-customers-and-update-notifications export * from './1740045960580-create-user-registration-table'; export * from './1741087742821-add-used-flag-to-otp-and-remove-constraints-from-customers'; export * from './1742112997024-update-customer-table'; +export * from './1747569536067-add-address-fields-to-customers'; From a358cd2e7ac5be4686175e7ae3913952d3cea527 Mon Sep 17 00:00:00 2001 From: Abdalhamid Alhamad Date: Mon, 26 May 2025 12:04:00 +0300 Subject: [PATCH 02/32] feat: add neoleap service and mock create application api --- .../dtos/request/verify-user.request.dto.ts | 19 +- .../countries-numeric-iso.constant.ts | 253 ++++++++++++++++++ src/common/constants/index.ts | 1 + src/common/enums/countries-iso.enum.ts | 251 +++++++++++++++++ src/common/enums/index.ts | 1 + .../create-application.request.interface.ts | 52 ++++ .../modules/neoleap/interfaces/index.ts | 2 + .../neoleap-header.request.interface.ts | 9 + .../neoleap/services/neoleap.service.ts | 97 ++++++- .../request/create-customer.request.dto.ts | 8 +- src/customer/entities/customer.entity.ts | 3 +- .../repositories/customer.repository.ts | 1 + 12 files changed, 687 insertions(+), 10 deletions(-) create mode 100644 src/common/constants/countries-numeric-iso.constant.ts create mode 100644 src/common/enums/countries-iso.enum.ts create mode 100644 src/common/enums/index.ts create mode 100644 src/common/modules/neoleap/interfaces/create-application.request.interface.ts create mode 100644 src/common/modules/neoleap/interfaces/index.ts create mode 100644 src/common/modules/neoleap/interfaces/neoleap-header.request.interface.ts diff --git a/src/auth/dtos/request/verify-user.request.dto.ts b/src/auth/dtos/request/verify-user.request.dto.ts index cb5c74f..c1adc87 100644 --- a/src/auth/dtos/request/verify-user.request.dto.ts +++ b/src/auth/dtos/request/verify-user.request.dto.ts @@ -1,6 +1,16 @@ import { ApiProperty, PickType } from '@nestjs/swagger'; -import { IsDateString, IsNotEmpty, IsNumberString, IsOptional, IsString, MaxLength, MinLength } from 'class-validator'; +import { + IsDateString, + IsEnum, + IsNotEmpty, + IsNumberString, + IsOptional, + IsString, + MaxLength, + MinLength, +} from 'class-validator'; import { i18nValidationMessage as i18n } from 'nestjs-i18n'; +import { CountryIso } from '~/common/enums'; import { DEFAULT_OTP_LENGTH } from '~/common/modules/otp/constants'; import { IsAbove18 } from '~/core/decorators/validations'; import { CreateUnverifiedUserRequestDto } from './create-unverified-user.request.dto'; @@ -22,10 +32,11 @@ export class VerifyUserRequestDto extends PickType(CreateUnverifiedUserRequestDt dateOfBirth!: Date; @ApiProperty({ example: 'JO' }) - @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.countryOfResidence' }) }) - @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.countryOfResidence' }) }) + @IsEnum(CountryIso, { + message: i18n('validation.IsEnum', { path: 'general', property: 'customer.countryOfResidence' }), + }) @IsOptional() - countryOfResidence: string = 'SA'; + countryOfResidence: CountryIso = CountryIso.SAUDI_ARABIA; @ApiProperty({ example: '111111' }) @IsNumberString( diff --git a/src/common/constants/countries-numeric-iso.constant.ts b/src/common/constants/countries-numeric-iso.constant.ts new file mode 100644 index 0000000..dc45cec --- /dev/null +++ b/src/common/constants/countries-numeric-iso.constant.ts @@ -0,0 +1,253 @@ +import { CountryIso } from '../enums'; + +export const CountriesNumericISO: Record = { + [CountryIso.ARUBA]: '533', + [CountryIso.AFGHANISTAN]: '004', + [CountryIso.ANGOLA]: '024', + [CountryIso.ANGUILLA]: '660', + [CountryIso.ALAND_ISLANDS]: '248', + [CountryIso.ALBANIA]: '008', + [CountryIso.ANDORRA]: '020', + [CountryIso.UNITED_ARAB_EMIRATES]: '784', + [CountryIso.ARGENTINA]: '032', + [CountryIso.ARMENIA]: '051', + [CountryIso.AMERICAN_SAMOA]: '016', + [CountryIso.ANTARCTICA]: '010', + [CountryIso.FRENCH_SOUTHERN_TERRITORIES]: '260', + [CountryIso.ANTIGUA_AND_BARBUDA]: '028', + [CountryIso.AUSTRALIA]: '036', + [CountryIso.AUSTRIA]: '040', + [CountryIso.AZERBAIJAN]: '031', + [CountryIso.BURUNDI]: '108', + [CountryIso.BELGIUM]: '056', + [CountryIso.BENIN]: '204', + [CountryIso.BONAIRE_SINT_EUSTATIUS_AND_SABA]: '535', + [CountryIso.BURKINA_FASO]: '854', + [CountryIso.BANGLADESH]: '050', + [CountryIso.BULGARIA]: '100', + [CountryIso.BAHRAIN]: '048', + [CountryIso.BAHAMAS]: '044', + [CountryIso.BOSNIA_AND_HERZEGOVINA]: '070', + [CountryIso.SAINT_BARTHÉLEMY]: '652', + [CountryIso.BELARUS]: '112', + [CountryIso.BELIZE]: '084', + [CountryIso.BERMUDA]: '060', + [CountryIso.BOLIVIA_PLURINATIONAL_STATE_OF]: '068', + [CountryIso.BRAZIL]: '076', + [CountryIso.BARBADOS]: '052', + [CountryIso.BRUNEI_DARUSSALAM]: '096', + [CountryIso.BHUTAN]: '064', + [CountryIso.BOUVET_ISLAND]: '074', + [CountryIso.BOTSWANA]: '072', + [CountryIso.CENTRAL_AFRICAN_REPUBLIC]: '140', + [CountryIso.CANADA]: '124', + [CountryIso.COCOS_KEELING_ISLANDS]: '166', + [CountryIso.SWITZERLAND]: '756', + [CountryIso.CHILE]: '152', + [CountryIso.CHINA]: '156', + [CountryIso.COTE_DIVOIRE]: '384', + [CountryIso.CAMEROON]: '120', + [CountryIso.CONGO_THE_DEMOCRATIC_REPUBLIC_OF_THE]: '180', + [CountryIso.CONGO]: '178', + [CountryIso.COOK_ISLANDS]: '184', + [CountryIso.COLOMBIA]: '170', + [CountryIso.COMOROS]: '174', + [CountryIso.CABO_VERDE]: '132', + [CountryIso.COSTA_RICA]: '188', + [CountryIso.CUBA]: '192', + [CountryIso.CURAÇAO]: '531', + [CountryIso.CHRISTMAS_ISLAND]: '162', + [CountryIso.CAYMAN_ISLANDS]: '136', + [CountryIso.CYPRUS]: '196', + [CountryIso.CZECHIA]: '203', + [CountryIso.GERMANY]: '276', + [CountryIso.DJIBOUTI]: '262', + [CountryIso.DOMINICA]: '212', + [CountryIso.DENMARK]: '208', + [CountryIso.DOMINICAN_REPUBLIC]: '214', + [CountryIso.ALGERIA]: '012', + [CountryIso.ECUADOR]: '218', + [CountryIso.EGYPT]: '818', + [CountryIso.ERITREA]: '232', + [CountryIso.WESTERN_SAHARA]: '732', + [CountryIso.SPAIN]: '724', + [CountryIso.ESTONIA]: '233', + [CountryIso.ETHIOPIA]: '231', + [CountryIso.FINLAND]: '246', + [CountryIso.FIJI]: '242', + [CountryIso.FALKLAND_ISLANDS_MALVINAS]: '238', + [CountryIso.FRANCE]: '250', + [CountryIso.FAROE_ISLANDS]: '234', + [CountryIso.MICRONESIA_FEDERATED_STATES_OF]: '583', + [CountryIso.GABON]: '266', + [CountryIso.UNITED_KINGDOM]: '826', + [CountryIso.GEORGIA]: '268', + [CountryIso.GUERNSEY]: '831', + [CountryIso.GHANA]: '288', + [CountryIso.GIBRALTAR]: '292', + [CountryIso.GUINEA]: '324', + [CountryIso.GUADELOUPE]: '312', + [CountryIso.GAMBIA]: '270', + [CountryIso.GUINEA_BISSAU]: '624', + [CountryIso.EQUATORIAL_GUINEA]: '226', + [CountryIso.GREECE]: '300', + [CountryIso.GRENADA]: '308', + [CountryIso.GREENLAND]: '304', + [CountryIso.GUATEMALA]: '320', + [CountryIso.FRENCH_GUIANA]: '254', + [CountryIso.GUAM]: '316', + [CountryIso.GUYANA]: '328', + [CountryIso.HONG_KONG]: '344', + [CountryIso.HEARD_ISLAND_AND_MCDONALD_ISLANDS]: '334', + [CountryIso.HONDURAS]: '340', + [CountryIso.CROATIA]: '191', + [CountryIso.HAITI]: '332', + [CountryIso.HUNGARY]: '348', + [CountryIso.INDONESIA]: '360', + [CountryIso.ISLE_OF_MAN]: '833', + [CountryIso.INDIA]: '356', + [CountryIso.BRITISH_INDIAN_OCEAN_TERRITORY]: '086', + [CountryIso.IRELAND]: '372', + [CountryIso.IRAN_ISLAMIC_REPUBLIC_OF]: '364', + [CountryIso.IRAQ]: '368', + [CountryIso.ICELAND]: '352', + [CountryIso.ISRAEL]: '376', + [CountryIso.ITALY]: '380', + [CountryIso.JAMAICA]: '388', + [CountryIso.JERSEY]: '832', + [CountryIso.JORDAN]: '400', + [CountryIso.JAPAN]: '392', + [CountryIso.KAZAKHSTAN]: '398', + [CountryIso.KENYA]: '404', + [CountryIso.KYRGYZSTAN]: '417', + [CountryIso.CAMBODIA]: '116', + [CountryIso.KIRIBATI]: '296', + [CountryIso.SAINT_KITTS_AND_NEVIS]: '659', + [CountryIso.KOREA_REPUBLIC_OF]: '410', + [CountryIso.KUWAIT]: '414', + [CountryIso.LAO_PEOPLES_DEMOCRATIC_REPUBLIC]: '418', + [CountryIso.LEBANON]: '422', + [CountryIso.LIBERIA]: '430', + [CountryIso.LIBYA]: '434', + [CountryIso.SAINT_LUCIA]: '662', + [CountryIso.LIECHTENSTEIN]: '438', + [CountryIso.SRI_LANKA]: '144', + [CountryIso.LESOTHO]: '426', + [CountryIso.LITHUANIA]: '440', + [CountryIso.LUXEMBOURG]: '442', + [CountryIso.LATVIA]: '428', + [CountryIso.MACAO]: '446', + [CountryIso.SAINT_MARTIN_FRENCH_PART]: '663', + [CountryIso.MOROCCO]: '504', + [CountryIso.MONACO]: '492', + [CountryIso.MOLDOVA_REPUBLIC_OF]: '498', + [CountryIso.MADAGASCAR]: '450', + [CountryIso.MALDIVES]: '462', + [CountryIso.MEXICO]: '484', + [CountryIso.MARSHALL_ISLANDS]: '584', + [CountryIso.NORTH_MACEDONIA]: '807', + [CountryIso.MALI]: '466', + [CountryIso.MALTA]: '470', + [CountryIso.MYANMAR]: '104', + [CountryIso.MONTENEGRO]: '499', + [CountryIso.MONGOLIA]: '496', + [CountryIso.NORTHERN_MARIANA_ISLANDS]: '580', + [CountryIso.MOZAMBIQUE]: '508', + [CountryIso.MAURITANIA]: '478', + [CountryIso.MONTSERRAT]: '500', + [CountryIso.MARTINIQUE]: '474', + [CountryIso.MAURITIUS]: '480', + [CountryIso.MALAWI]: '454', + [CountryIso.MALAYSIA]: '458', + [CountryIso.MAYOTTE]: '175', + [CountryIso.NAMIBIA]: '516', + [CountryIso.NEW_CALEDONIA]: '540', + [CountryIso.NIGER]: '562', + [CountryIso.NORFOLK_ISLAND]: '574', + [CountryIso.NIGERIA]: '566', + [CountryIso.NICARAGUA]: '558', + [CountryIso.NIUE]: '570', + [CountryIso.NETHERLANDS]: '528', + [CountryIso.NORWAY]: '578', + [CountryIso.NEPAL]: '524', + [CountryIso.NAURU]: '520', + [CountryIso.NEW_ZEALAND]: '554', + [CountryIso.OMAN]: '512', + [CountryIso.PAKISTAN]: '586', + [CountryIso.PANAMA]: '591', + [CountryIso.PITCAIRN]: '612', + [CountryIso.PERU]: '604', + [CountryIso.PHILIPPINES]: '608', + [CountryIso.PALAU]: '585', + [CountryIso.PAPUA_NEW_GUINEA]: '598', + [CountryIso.POLAND]: '616', + [CountryIso.PUERTO_RICO]: '630', + [CountryIso.KOREA_DEMOCRATIC_PEOPLES_REPUBLIC_OF]: '408', + [CountryIso.PORTUGAL]: '620', + [CountryIso.PARAGUAY]: '600', + [CountryIso.PALESTINE_STATE_OF]: '275', + [CountryIso.FRENCH_POLYNESIA]: '258', + [CountryIso.QATAR]: '634', + [CountryIso.REUNION]: '638', + [CountryIso.ROMANIA]: '642', + [CountryIso.RUSSIAN_FEDERATION]: '643', + [CountryIso.RWANDA]: '646', + [CountryIso.SAUDI_ARABIA]: '682', + [CountryIso.SUDAN]: '729', + [CountryIso.SENEGAL]: '686', + [CountryIso.SINGAPORE]: '702', + [CountryIso.SOUTH_GEORGIA_AND_THE_SOUTH_SANDWICH_ISLANDS]: '239', + [CountryIso.SAINT_HELENA_ASCENSION_AND_TRISTAN_DA_CUNHA]: '654', + [CountryIso.SVALBARD_AND_JAN_MAYEN]: '744', + [CountryIso.SOLOMON_ISLANDS]: '090', + [CountryIso.SIERRA_LEONE]: '694', + [CountryIso.EL_SALVADOR]: '222', + [CountryIso.SAN_MARINO]: '674', + [CountryIso.SOMALIA]: '706', + [CountryIso.SAINT_PIERRE_AND_MIQUELON]: '666', + [CountryIso.SERBIA]: '688', + [CountryIso.SOUTH_SUDAN]: '728', + [CountryIso.SAO_TOME_AND_PRINCIPE]: '678', + [CountryIso.SURINAME]: '740', + [CountryIso.SLOVAKIA]: '703', + [CountryIso.SLOVENIA]: '705', + [CountryIso.SWEDEN]: '752', + [CountryIso.ESWATINI]: '748', + [CountryIso.SINT_MAARTEN_DUTCH_PART]: '534', + [CountryIso.SEYCHELLES]: '690', + [CountryIso.SYRIAN_ARAB_REPUBLIC]: '760', + [CountryIso.TURKS_AND_CAICOS_ISLANDS]: '796', + [CountryIso.CHAD]: '148', + [CountryIso.TOGO]: '768', + [CountryIso.THAILAND]: '764', + [CountryIso.TAJIKISTAN]: '762', + [CountryIso.TOKELAU]: '772', + [CountryIso.TURKMENISTAN]: '795', + [CountryIso.TIMOR_LESTE]: '626', + [CountryIso.TONGA]: '776', + [CountryIso.TRINIDAD_AND_TOBAGO]: '780', + [CountryIso.TUNISIA]: '788', + [CountryIso.TURKEY]: '792', + [CountryIso.TUVALU]: '798', + [CountryIso.TAIWAN_PROVINCE_OF_CHINA]: '158', + [CountryIso.TANZANIA_UNITED_REPUBLIC_OF]: '834', + [CountryIso.UGANDA]: '800', + [CountryIso.UKRAINE]: '804', + [CountryIso.UNITED_STATES_MINOR_OUTLYING_ISLANDS]: '581', + [CountryIso.URUGUAY]: '858', + [CountryIso.UNITED_STATES]: '840', + [CountryIso.UZBEKISTAN]: '860', + [CountryIso.HOLY_SEE_VATICAN_CITY_STATE]: '336', + [CountryIso.SAINT_VINCENT_AND_THE_GRENADINES]: '670', + [CountryIso.VENEZUELA_BOLIVARIAN_REPUBLIC_OF]: '862', + [CountryIso.VIRGIN_ISLANDS_BRITISH]: '092', + [CountryIso.VIRGIN_ISLANDS_US]: '850', + [CountryIso.VIET_NAM]: '704', + [CountryIso.VANUATU]: '548', + [CountryIso.WALLIS_AND_FUTUNA]: '876', + [CountryIso.SAMOA]: '882', + [CountryIso.YEMEN]: '887', + [CountryIso.SOUTH_AFRICA]: '710', + [CountryIso.ZAMBIA]: '894', + [CountryIso.ZIMBABWE]: '716', +}; diff --git a/src/common/constants/index.ts b/src/common/constants/index.ts index c1f9866..ad8d1dc 100644 --- a/src/common/constants/index.ts +++ b/src/common/constants/index.ts @@ -1 +1,2 @@ +export * from './countries-numeric-iso.constant'; export * from './global.constant'; diff --git a/src/common/enums/countries-iso.enum.ts b/src/common/enums/countries-iso.enum.ts new file mode 100644 index 0000000..c996051 --- /dev/null +++ b/src/common/enums/countries-iso.enum.ts @@ -0,0 +1,251 @@ +export enum CountryIso { + ARUBA = 'AW', + AFGHANISTAN = 'AF', + ANGOLA = 'AO', + ANGUILLA = 'AI', + ALAND_ISLANDS = 'AX', + ALBANIA = 'AL', + ANDORRA = 'AD', + UNITED_ARAB_EMIRATES = 'AE', + ARGENTINA = 'AR', + ARMENIA = 'AM', + AMERICAN_SAMOA = 'AS', + ANTARCTICA = 'AQ', + FRENCH_SOUTHERN_TERRITORIES = 'TF', + ANTIGUA_AND_BARBUDA = 'AG', + AUSTRALIA = 'AU', + AUSTRIA = 'AT', + AZERBAIJAN = 'AZ', + BURUNDI = 'BI', + BELGIUM = 'BE', + BENIN = 'BJ', + BONAIRE_SINT_EUSTATIUS_AND_SABA = 'BQ', + BURKINA_FASO = 'BF', + BANGLADESH = 'BD', + BULGARIA = 'BG', + BAHRAIN = 'BH', + BAHAMAS = 'BS', + BOSNIA_AND_HERZEGOVINA = 'BA', + SAINT_BARTHÉLEMY = 'BL', + BELARUS = 'BY', + BELIZE = 'BZ', + BERMUDA = 'BM', + BOLIVIA_PLURINATIONAL_STATE_OF = 'BO', + BRAZIL = 'BR', + BARBADOS = 'BB', + BRUNEI_DARUSSALAM = 'BN', + BHUTAN = 'BT', + BOUVET_ISLAND = 'BV', + BOTSWANA = 'BW', + CENTRAL_AFRICAN_REPUBLIC = 'CF', + CANADA = 'CA', + COCOS_KEELING_ISLANDS = 'CC', + SWITZERLAND = 'CH', + CHILE = 'CL', + CHINA = 'CN', + COTE_DIVOIRE = 'CI', + CAMEROON = 'CM', + CONGO_THE_DEMOCRATIC_REPUBLIC_OF_THE = 'CD', + CONGO = 'CG', + COOK_ISLANDS = 'CK', + COLOMBIA = 'CO', + COMOROS = 'KM', + CABO_VERDE = 'CV', + COSTA_RICA = 'CR', + CUBA = 'CU', + CURAÇAO = 'CW', + CHRISTMAS_ISLAND = 'CX', + CAYMAN_ISLANDS = 'KY', + CYPRUS = 'CY', + CZECHIA = 'CZ', + GERMANY = 'DE', + DJIBOUTI = 'DJ', + DOMINICA = 'DM', + DENMARK = 'DK', + DOMINICAN_REPUBLIC = 'DO', + ALGERIA = 'DZ', + ECUADOR = 'EC', + EGYPT = 'EG', + ERITREA = 'ER', + WESTERN_SAHARA = 'EH', + SPAIN = 'ES', + ESTONIA = 'EE', + ETHIOPIA = 'ET', + FINLAND = 'FI', + FIJI = 'FJ', + FALKLAND_ISLANDS_MALVINAS = 'FK', + FRANCE = 'FR', + FAROE_ISLANDS = 'FO', + MICRONESIA_FEDERATED_STATES_OF = 'FM', + GABON = 'GA', + UNITED_KINGDOM = 'GB', + GEORGIA = 'GE', + GUERNSEY = 'GG', + GHANA = 'GH', + GIBRALTAR = 'GI', + GUINEA = 'GN', + GUADELOUPE = 'GP', + GAMBIA = 'GM', + GUINEA_BISSAU = 'GW', + EQUATORIAL_GUINEA = 'GQ', + GREECE = 'GR', + GRENADA = 'GD', + GREENLAND = 'GL', + GUATEMALA = 'GT', + FRENCH_GUIANA = 'GF', + GUAM = 'GU', + GUYANA = 'GY', + HONG_KONG = 'HK', + HEARD_ISLAND_AND_MCDONALD_ISLANDS = 'HM', + HONDURAS = 'HN', + CROATIA = 'HR', + HAITI = 'HT', + HUNGARY = 'HU', + INDONESIA = 'ID', + ISLE_OF_MAN = 'IM', + INDIA = 'IN', + BRITISH_INDIAN_OCEAN_TERRITORY = 'IO', + IRELAND = 'IE', + IRAN_ISLAMIC_REPUBLIC_OF = 'IR', + IRAQ = 'IQ', + ICELAND = 'IS', + ISRAEL = 'IL', + ITALY = 'IT', + JAMAICA = 'JM', + JERSEY = 'JE', + JORDAN = 'JO', + JAPAN = 'JP', + KAZAKHSTAN = 'KZ', + KENYA = 'KE', + KYRGYZSTAN = 'KG', + CAMBODIA = 'KH', + KIRIBATI = 'KI', + SAINT_KITTS_AND_NEVIS = 'KN', + KOREA_REPUBLIC_OF = 'KR', + KUWAIT = 'KW', + LAO_PEOPLES_DEMOCRATIC_REPUBLIC = 'LA', + LEBANON = 'LB', + LIBERIA = 'LR', + LIBYA = 'LY', + SAINT_LUCIA = 'LC', + LIECHTENSTEIN = 'LI', + SRI_LANKA = 'LK', + LESOTHO = 'LS', + LITHUANIA = 'LT', + LUXEMBOURG = 'LU', + LATVIA = 'LV', + MACAO = 'MO', + SAINT_MARTIN_FRENCH_PART = 'MF', + MOROCCO = 'MA', + MONACO = 'MC', + MOLDOVA_REPUBLIC_OF = 'MD', + MADAGASCAR = 'MG', + MALDIVES = 'MV', + MEXICO = 'MX', + MARSHALL_ISLANDS = 'MH', + NORTH_MACEDONIA = 'MK', + MALI = 'ML', + MALTA = 'MT', + MYANMAR = 'MM', + MONTENEGRO = 'ME', + MONGOLIA = 'MN', + NORTHERN_MARIANA_ISLANDS = 'MP', + MOZAMBIQUE = 'MZ', + MAURITANIA = 'MR', + MONTSERRAT = 'MS', + MARTINIQUE = 'MQ', + MAURITIUS = 'MU', + MALAWI = 'MW', + MALAYSIA = 'MY', + MAYOTTE = 'YT', + NAMIBIA = 'NA', + NEW_CALEDONIA = 'NC', + NIGER = 'NE', + NORFOLK_ISLAND = 'NF', + NIGERIA = 'NG', + NICARAGUA = 'NI', + NIUE = 'NU', + NETHERLANDS = 'NL', + NORWAY = 'NO', + NEPAL = 'NP', + NAURU = 'NR', + NEW_ZEALAND = 'NZ', + OMAN = 'OM', + PAKISTAN = 'PK', + PANAMA = 'PA', + PITCAIRN = 'PN', + PERU = 'PE', + PHILIPPINES = 'PH', + PALAU = 'PW', + PAPUA_NEW_GUINEA = 'PG', + POLAND = 'PL', + PUERTO_RICO = 'PR', + KOREA_DEMOCRATIC_PEOPLES_REPUBLIC_OF = 'KP', + PORTUGAL = 'PT', + PARAGUAY = 'PY', + PALESTINE_STATE_OF = 'PS', + FRENCH_POLYNESIA = 'PF', + QATAR = 'QA', + REUNION = 'RE', + ROMANIA = 'RO', + RUSSIAN_FEDERATION = 'RU', + RWANDA = 'RW', + SAUDI_ARABIA = 'SA', + SUDAN = 'SD', + SENEGAL = 'SN', + SINGAPORE = 'SG', + SOUTH_GEORGIA_AND_THE_SOUTH_SANDWICH_ISLANDS = 'GS', + SAINT_HELENA_ASCENSION_AND_TRISTAN_DA_CUNHA = 'SH', + SVALBARD_AND_JAN_MAYEN = 'SJ', + SOLOMON_ISLANDS = 'SB', + SIERRA_LEONE = 'SL', + EL_SALVADOR = 'SV', + SAN_MARINO = 'SM', + SOMALIA = 'SO', + SAINT_PIERRE_AND_MIQUELON = 'PM', + SERBIA = 'RS', + SOUTH_SUDAN = 'SS', + SAO_TOME_AND_PRINCIPE = 'ST', + SURINAME = 'SR', + SLOVAKIA = 'SK', + SLOVENIA = 'SI', + SWEDEN = 'SE', + ESWATINI = 'SZ', + SINT_MAARTEN_DUTCH_PART = 'SX', + SEYCHELLES = 'SC', + SYRIAN_ARAB_REPUBLIC = 'SY', + TURKS_AND_CAICOS_ISLANDS = 'TC', + CHAD = 'TD', + TOGO = 'TG', + THAILAND = 'TH', + TAJIKISTAN = 'TJ', + TOKELAU = 'TK', + TURKMENISTAN = 'TM', + TIMOR_LESTE = 'TL', + TONGA = 'TO', + TRINIDAD_AND_TOBAGO = 'TT', + TUNISIA = 'TN', + TURKEY = 'TR', + TUVALU = 'TV', + TAIWAN_PROVINCE_OF_CHINA = 'TW', + TANZANIA_UNITED_REPUBLIC_OF = 'TZ', + UGANDA = 'UG', + UKRAINE = 'UA', + UNITED_STATES_MINOR_OUTLYING_ISLANDS = 'UM', + URUGUAY = 'UY', + UNITED_STATES = 'US', + UZBEKISTAN = 'UZ', + HOLY_SEE_VATICAN_CITY_STATE = 'VA', + SAINT_VINCENT_AND_THE_GRENADINES = 'VC', + VENEZUELA_BOLIVARIAN_REPUBLIC_OF = 'VE', + VIRGIN_ISLANDS_BRITISH = 'VG', + VIRGIN_ISLANDS_US = 'VI', + VIET_NAM = 'VN', + VANUATU = 'VU', + WALLIS_AND_FUTUNA = 'WF', + SAMOA = 'WS', + YEMEN = 'YE', + SOUTH_AFRICA = 'ZA', + ZAMBIA = 'ZM', + ZIMBABWE = 'ZW', +} diff --git a/src/common/enums/index.ts b/src/common/enums/index.ts new file mode 100644 index 0000000..f5c60b9 --- /dev/null +++ b/src/common/enums/index.ts @@ -0,0 +1 @@ +export * from './countries-iso.enum'; diff --git a/src/common/modules/neoleap/interfaces/create-application.request.interface.ts b/src/common/modules/neoleap/interfaces/create-application.request.interface.ts new file mode 100644 index 0000000..9b26632 --- /dev/null +++ b/src/common/modules/neoleap/interfaces/create-application.request.interface.ts @@ -0,0 +1,52 @@ +import { INeoleapHeaderRequest } from './neoleap-header.request.interface'; + +export interface ICreateApplicationRequest extends INeoleapHeaderRequest { + CreateNewApplicationRequestDetails: { + ApplicationRequestDetails: { + InstitutionCode: string; + ExternalApplicationNumber: string; + ApplicationType: string; + Product: string; + ApplicationDate: string; + BranchCode: '000'; + ApplicationSource: 'O'; + DeliveryMethod: 'V'; + }; + ApplicationProcessingDetails: { + ProcessControl: string; + RequestedLimit: number; + SuggestedLimit: number; + AssignedLimit: number; + }; + + ApplicationCustomerDetails: { + Title: string; + FirstName: string; + LastName: string; + FullName: string; + EmbossName: string; + DateOfBirth: string; + LocalizedDateOfBirth: string; + Gender: string; + Nationality: string; + IdType: string; + IdNumber: string; + IdExpiryDate: string; + }; + + ApplicationAddress: { + AddressLine1: string; + AddressLine2: string; + City: string; + Region: string; + Country: string; + CountryDetails: { + DefaultCurrency: {}; + Description: []; + }; + Phone1: string; + Email: string; + AddressRole: number; + }; + }; +} diff --git a/src/common/modules/neoleap/interfaces/index.ts b/src/common/modules/neoleap/interfaces/index.ts new file mode 100644 index 0000000..97472ef --- /dev/null +++ b/src/common/modules/neoleap/interfaces/index.ts @@ -0,0 +1,2 @@ +export * from './create-application.request.interface'; +export * from './neoleap-header.request.interface'; diff --git a/src/common/modules/neoleap/interfaces/neoleap-header.request.interface.ts b/src/common/modules/neoleap/interfaces/neoleap-header.request.interface.ts new file mode 100644 index 0000000..5e106f4 --- /dev/null +++ b/src/common/modules/neoleap/interfaces/neoleap-header.request.interface.ts @@ -0,0 +1,9 @@ +export interface INeoleapHeaderRequest { + RequestHeader: { + Version: string; + MsgUid: string; + Source: 'FINTECH'; + ServiceId: string; + ReqDateTime: Date; + }; +} diff --git a/src/common/modules/neoleap/services/neoleap.service.ts b/src/common/modules/neoleap/services/neoleap.service.ts index f172937..ac10e07 100644 --- a/src/common/modules/neoleap/services/neoleap.service.ts +++ b/src/common/modules/neoleap/services/neoleap.service.ts @@ -1,6 +1,99 @@ +import { HttpService } from '@nestjs/axios'; import { Injectable } from '@nestjs/common'; - +import { ConfigService } from '@nestjs/config'; +import moment from 'moment'; +import { v4 as uuid } from 'uuid'; +import { CountriesNumericISO } from '~/common/constants'; +import { Customer } from '~/customer/entities'; +import { Gender } from '~/customer/enums'; +import { ICreateApplicationRequest, INeoleapHeaderRequest } from '../interfaces'; @Injectable() export class NeoLeapService { - createApplication() {} + private readonly baseUrl: string; + private readonly apiKey: string; + private readonly useMock: boolean; + private readonly institutionCode = '1100'; + constructor(private readonly httpService: HttpService, private readonly configService: ConfigService) { + this.baseUrl = this.configService.getOrThrow('NEOLEAP_BASE_URL'); + this.apiKey = this.configService.getOrThrow('NEOLEAP_API_KEY'); + this.useMock = [true, 'true'].includes(this.configService.get('USE_MOCK', false)); + } + + async createApplication(customer: Customer) { + if (this.useMock) { + //@TODO return mock data + return; + } + + const payload: ICreateApplicationRequest = { + CreateNewApplicationRequestDetails: { + ApplicationRequestDetails: { + InstitutionCode: this.institutionCode, + ExternalApplicationNumber: customer.waitingNumber.toString(), + ApplicationType: 'New', + Product: '3001', + ApplicationDate: moment().format('YYYY-MM-DD'), + BranchCode: '000', + ApplicationSource: 'O', + DeliveryMethod: 'V', + }, + ApplicationProcessingDetails: { + SuggestedLimit: 0, + RequestedLimit: 0, + AssignedLimit: 0, + ProcessControl: 'STND', + }, + ApplicationCustomerDetails: { + FirstName: customer.firstName, + LastName: customer.lastName, + FullName: `${customer.firstName} ${customer.lastName}`, + DateOfBirth: moment(customer.dateOfBirth).format('YYYY-MM-DD'), + EmbossName: `${customer.firstName} ${customer.lastName}`, // TODO Enter Emboss Name + IdType: '001', + IdNumber: customer.nationalId, + IdExpiryDate: moment(customer.nationalIdExpiry).format('YYYY-MM-DD'), + Title: customer.gender === Gender.MALE ? 'Mr' : 'Ms', + Gender: customer.gender === Gender.MALE ? 'M' : 'F', + LocalizedDateOfBirth: moment(customer.dateOfBirth).format('YYYY-MM-DD'), + Nationality: '682', + }, + ApplicationAddress: { + City: customer.city, + Country: CountriesNumericISO[customer.countryOfResidence], + Region: customer.region, + AddressLine1: `${customer.street} ${customer.building}`, + AddressLine2: customer.neighborhood, + AddressRole: 0, + Email: customer.user.email, + Phone1: customer.user.phoneNumber, + CountryDetails: { + DefaultCurrency: {}, + Description: [], + }, + }, + }, + ...this.prepareHeaders('CreateNewApplication'), + }; + + const response = await this.httpService.axiosRef.post(`${this.baseUrl}/create-application`, payload, { + headers: { + 'Content-Type': 'application/json', + Authorization: `${this.apiKey}`, + }, + }); + + //@TODO handle response + } + + private prepareHeaders(serviceName: string): INeoleapHeaderRequest { + return { + RequestHeader: { + Version: '1.0.0', + MsgUid: uuid(), + Source: 'FINTECH', + ServiceId: serviceName, + ReqDateTime: new Date(), + }, + }; + } } diff --git a/src/customer/dtos/request/create-customer.request.dto.ts b/src/customer/dtos/request/create-customer.request.dto.ts index 55e5023..83cc614 100644 --- a/src/customer/dtos/request/create-customer.request.dto.ts +++ b/src/customer/dtos/request/create-customer.request.dto.ts @@ -1,6 +1,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsBoolean, IsDateString, IsEnum, IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator'; import { i18nValidationMessage as i18n } from 'nestjs-i18n'; +import { CountryIso } from '~/common/enums'; import { IsAbove18 } from '~/core/decorators/validations'; import { Gender } from '~/customer/enums'; export class CreateCustomerRequestDto { @@ -20,9 +21,10 @@ export class CreateCustomerRequestDto { gender?: Gender; @ApiProperty({ example: 'JO' }) - @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.countryOfResidence' }) }) - @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.countryOfResidence' }) }) - countryOfResidence!: string; + @IsEnum(CountryIso, { + message: i18n('validation.IsEnum', { path: 'general', property: 'customer.countryOfResidence' }), + }) + countryOfResidence!: CountryIso; @ApiProperty({ example: '2021-01-01' }) @IsDateString({}, { message: i18n('validation.IsDateString', { path: 'general', property: 'customer.dateOfBirth' }) }) diff --git a/src/customer/entities/customer.entity.ts b/src/customer/entities/customer.entity.ts index 0dd917d..01230ae 100644 --- a/src/customer/entities/customer.entity.ts +++ b/src/customer/entities/customer.entity.ts @@ -9,6 +9,7 @@ import { PrimaryColumn, UpdateDateColumn, } from 'typeorm'; +import { CountryIso } from '~/common/enums'; import { Document } from '~/document/entities'; import { Guardian } from '~/guardian/entities/guradian.entity'; import { Junior } from '~/junior/entities'; @@ -45,7 +46,7 @@ export class Customer extends BaseEntity { nationalIdExpiry!: Date; @Column('varchar', { length: 255, nullable: true, name: 'country_of_residence' }) - countryOfResidence!: string; + countryOfResidence!: CountryIso; @Column('varchar', { length: 255, nullable: true, name: 'source_of_income' }) sourceOfIncome!: string; diff --git a/src/customer/repositories/customer.repository.ts b/src/customer/repositories/customer.repository.ts index 100348f..a17b383 100644 --- a/src/customer/repositories/customer.repository.ts +++ b/src/customer/repositories/customer.repository.ts @@ -29,6 +29,7 @@ export class CustomerRepository { firstName: body.firstName, lastName: body.lastName, dateOfBirth: body.dateOfBirth, + countryOfResidence: body.countryOfResidence, }), ); } From d4fe3b3fc3c7042216e12767bdb79b0f8a7f10dd Mon Sep 17 00:00:00 2001 From: Abdalhamid Alhamad Date: Mon, 26 May 2025 16:34:09 +0300 Subject: [PATCH 03/32] feat: finish working on mocking inquire application api --- src/app.module.ts | 2 + src/common/modules/neoleap/__mocks__/index.ts | 1 + .../__mocks__/inquire-application.mock.ts | 91 ++++++++++++ .../neoleap/controllers/neotest.controller.ts | 14 ++ .../modules/neoleap/dtos/response/index.ts | 1 + .../response/inquire-application.response.ts | 133 ++++++++++++++++++ .../modules/neoleap/interfaces/index.ts | 1 + .../inquire-application.request.interface.ts | 22 +++ src/common/modules/neoleap/neoleap.module.ts | 9 +- .../neoleap/services/neoleap.service.ts | 49 ++++++- 10 files changed, 318 insertions(+), 5 deletions(-) create mode 100644 src/common/modules/neoleap/__mocks__/index.ts create mode 100644 src/common/modules/neoleap/__mocks__/inquire-application.mock.ts create mode 100644 src/common/modules/neoleap/controllers/neotest.controller.ts create mode 100644 src/common/modules/neoleap/dtos/response/index.ts create mode 100644 src/common/modules/neoleap/dtos/response/inquire-application.response.ts create mode 100644 src/common/modules/neoleap/interfaces/inquire-application.request.interface.ts diff --git a/src/app.module.ts b/src/app.module.ts index 4c2b94b..8b9bc17 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -12,6 +12,7 @@ import { AllowanceModule } from './allowance/allowance.module'; import { AuthModule } from './auth/auth.module'; import { CacheModule } from './common/modules/cache/cache.module'; import { LookupModule } from './common/modules/lookup/lookup.module'; +import { NeoLeapModule } from './common/modules/neoleap/neoleap.module'; import { NotificationModule } from './common/modules/notification/notification.module'; import { OtpModule } from './common/modules/otp/otp.module'; import { AllExceptionsFilter, buildI18nValidationExceptionFilter } from './core/filters'; @@ -80,6 +81,7 @@ import { UserModule } from './user/user.module'; UserModule, CronModule, + NeoLeapModule, ], providers: [ // Global Pipes diff --git a/src/common/modules/neoleap/__mocks__/index.ts b/src/common/modules/neoleap/__mocks__/index.ts new file mode 100644 index 0000000..653007b --- /dev/null +++ b/src/common/modules/neoleap/__mocks__/index.ts @@ -0,0 +1 @@ +export * from './inquire-application.mock'; diff --git a/src/common/modules/neoleap/__mocks__/inquire-application.mock.ts b/src/common/modules/neoleap/__mocks__/inquire-application.mock.ts new file mode 100644 index 0000000..3a74ca7 --- /dev/null +++ b/src/common/modules/neoleap/__mocks__/inquire-application.mock.ts @@ -0,0 +1,91 @@ +export const INQUIRE_APPLICATION_MOCK = { + ResponseHeader: { + Version: '1.0.0', + MsgUid: 'stringstringstringstringstringstring', + Source: 'string', + ServiceId: 'string', + ReqDateTime: '2025-05-26T10:23:13.812Z', + RspDateTime: '2025-05-26T10:23:13.812Z', + ResponseCode: 'string', + ResponseType: 'Validation Error', + ProcessingTime: 0, + EncryptionKey: 'string', + ResponseDescription: 'string', + LocalizedResponseDescription: { + Locale: 'string', + LocalizedDescription: 'string', + }, + CustomerSpecificResponseDescriptionList: [ + { + Locale: 'string', + ResponseDescription: 'string', + }, + ], + HeaderUserDataList: [ + { + Tag: 'string', + Value: 'string', + }, + ], + }, + + InquireApplicationResponseDetails: { + InstitutionCode: 'stri', + ApplicationTypeDetails: { + TypeCode: 'st', + Description: 'string', + Additional: true, + Corporate: true, + UserData: 'string', + }, + ApplicationDetails: { + ApplicationNumber: 'string', + ExternalApplicationNumber: 'string', + ApplicationStatus: 'st', + Organization: 0, + Product: 'stri', + ApplicatonDate: '2025-05-26', + ApplicationSource: 's', + SalesSource: 'string', + DeliveryMethod: 's', + ProgramCode: 'string', + Campaign: 'string', + Plastic: 'string', + Design: 'string', + ProcessStage: 'st', + ProcessStageStatus: 's', + Score: 'string', + ExternalScore: 'string', + RequestedLimit: 0, + SuggestedLimit: 0, + AssignedLimit: 0, + AllowedLimitList: [ + { + CreditLimit: 0, + EvaluationCode: 'string', + }, + ], + EligibilityCheckResult: 'string', + EligibilityCheckDescription: 'string', + Title: 'string', + FirstName: 'string', + SecondName: 'string', + ThirdName: 'string', + LastName: 'string', + FullName: 'string', + EmbossName: 'string', + PlaceOfBirth: 'string', + DateOfBirth: '2025-05-26', + LocalizedDateOfBirth: '2025-05-26', + Age: 20, + Gender: 'M', + Married: 'S', + Nationality: 'str', + IdType: 'st', + IdNumber: 'string', + IdExpiryDate: '2025-05-26', + EducationLevel: 'stri', + ProfessionCode: 0, + }, + }, +}; diff --git a/src/common/modules/neoleap/controllers/neotest.controller.ts b/src/common/modules/neoleap/controllers/neotest.controller.ts new file mode 100644 index 0000000..9505f2f --- /dev/null +++ b/src/common/modules/neoleap/controllers/neotest.controller.ts @@ -0,0 +1,14 @@ +import { Controller, Get } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { NeoLeapService } from '../services/neoleap.service'; + +@Controller('neotest') +@ApiTags('NeoTest') +export class NeoTestController { + constructor(private readonly neoleapService: NeoLeapService) {} + + @Get('inquire-application') + async inquireApplication() { + return this.neoleapService.inquireApplication('1234567890'); + } +} diff --git a/src/common/modules/neoleap/dtos/response/index.ts b/src/common/modules/neoleap/dtos/response/index.ts new file mode 100644 index 0000000..6a8d1fe --- /dev/null +++ b/src/common/modules/neoleap/dtos/response/index.ts @@ -0,0 +1 @@ +export * from './inquire-application.response'; diff --git a/src/common/modules/neoleap/dtos/response/inquire-application.response.ts b/src/common/modules/neoleap/dtos/response/inquire-application.response.ts new file mode 100644 index 0000000..96ce358 --- /dev/null +++ b/src/common/modules/neoleap/dtos/response/inquire-application.response.ts @@ -0,0 +1,133 @@ +import { Expose, Transform } from 'class-transformer'; + +export class InquireApplicationResponse { + @Transform(({ obj }) => obj.ApplicationDetails?.ApplicationNumber) + @Expose() + applicationNumber!: string; + + @Transform(({ obj }) => obj.ApplicationDetails?.ExternalApplicationNumber) + @Expose() + externalApplicationNumber!: string; + + @Transform(({ obj }) => obj.ApplicationDetails?.ApplicationStatus) + @Expose() + applicationStatus!: string; + + @Transform(({ obj }) => obj.ApplicationDetails?.Organization) + @Expose() + organization!: number; + + @Transform(({ obj }) => obj.ApplicationDetails?.Product) + @Expose() + product!: string; + + @Transform(({ obj }) => obj.ApplicationDetails?.ApplicatonDate) + @Expose() + applicationDate!: string; + @Transform(({ obj }) => obj.ApplicationDetails?.ApplicationSource) + @Expose() + applicationSource!: string; + + @Transform(({ obj }) => obj.ApplicationDetails?.SalesSource) + @Expose() + salesSource!: string; + + @Transform(({ obj }) => obj.ApplicationDetails?.DeliveryMethod) + @Expose() + deliveryMethod!: string; + + @Transform(({ obj }) => obj.ApplicationDetails?.ProgramCode) + @Expose() + ProgramCode!: string; + + @Transform(({ obj }) => obj.ApplicationDetails?.Plastic) + @Expose() + plastic!: string; + + @Transform(({ obj }) => obj.ApplicationDetails?.Design) + @Expose() + design!: string; + + @Transform(({ obj }) => obj.ApplicationDetails?.ProcessStage) + @Expose() + processStage!: string; + + @Transform(({ obj }) => obj.ApplicationDetails?.ProcessStageStatus) + @Expose() + processStageStatus!: string; + + @Transform(({ obj }) => obj.ApplicationDetails?.EligibilityCheckResult) + @Expose() + eligibilityCheckResult!: string; + @Transform(({ obj }) => obj.ApplicationDetails?.EligibilityCheckDescription) + @Expose() + eligibilityCheckDescription!: string; + + @Transform(({ obj }) => obj.ApplicationDetails?.Title) + @Expose() + title!: string; + + @Transform(({ obj }) => obj.ApplicationDetails?.FirstName) + @Expose() + firstName!: string; + + @Transform(({ obj }) => obj.ApplicationDetails?.SecondName) + @Expose() + secondName!: string; + + @Transform(({ obj }) => obj.ApplicationDetails?.ThirdName) + @Expose() + thirdName!: string; + + @Transform(({ obj }) => obj.ApplicationDetails?.LastName) + @Expose() + lastName!: string; + + @Transform(({ obj }) => obj.ApplicationDetails?.FullName) + @Expose() + fullName!: string; + + @Transform(({ obj }) => obj.ApplicationDetails?.EmbossName) + @Expose() + embossName!: string; + + @Transform(({ obj }) => obj.ApplicationDetails?.PlaceOfBirth) + @Expose() + placeOfBirth!: string; + + @Transform(({ obj }) => obj.ApplicationDetails?.DateOfBirth) + @Expose() + dateOfBirth!: string; + + @Transform(({ obj }) => obj.ApplicationDetails?.LocalizedDateOfBirth) + @Expose() + localizedDateOfBirth!: string; + + @Transform(({ obj }) => obj.ApplicationDetails?.Age) + @Expose() + age!: number; + + @Transform(({ obj }) => obj.ApplicationDetails?.Gender) + @Expose() + gender!: string; + + @Transform(({ obj }) => obj.ApplicationDetails?.Married) + @Expose() + married!: string; + + @Transform(({ obj }) => obj.ApplicationDetails.Nationality) + @Expose() + nationality!: string; + + @Transform(({ obj }) => obj.ApplicationDetails.IdType) + @Expose() + idType!: string; + + @Transform(({ obj }) => obj.ApplicationDetails.IdNumber) + @Expose() + idNumber!: string; + + @Transform(({ obj }) => obj.ApplicationDetails.IdExpiryDate) + @Expose() + idExpiryDate!: string; +} diff --git a/src/common/modules/neoleap/interfaces/index.ts b/src/common/modules/neoleap/interfaces/index.ts index 97472ef..e6757c9 100644 --- a/src/common/modules/neoleap/interfaces/index.ts +++ b/src/common/modules/neoleap/interfaces/index.ts @@ -1,2 +1,3 @@ export * from './create-application.request.interface'; +export * from './inquire-application.request.interface'; export * from './neoleap-header.request.interface'; diff --git a/src/common/modules/neoleap/interfaces/inquire-application.request.interface.ts b/src/common/modules/neoleap/interfaces/inquire-application.request.interface.ts new file mode 100644 index 0000000..866c61b --- /dev/null +++ b/src/common/modules/neoleap/interfaces/inquire-application.request.interface.ts @@ -0,0 +1,22 @@ +import { INeoleapHeaderRequest } from './neoleap-header.request.interface'; + +export interface IInquireApplicationRequest extends INeoleapHeaderRequest { + InquireApplicationRequestDetails: { + ApplicationIdentifier: { + InstitutionCode: string; + ExternalApplicationNumber: string; + }; + AdditionalData?: { + ReturnApplicationType?: boolean; + ReturnApplicationStatus?: boolean; + ReturnAddresses?: boolean; + ReturnBranch?: boolean; + ReturnHistory?: boolean; + ReturnCard?: boolean; + ReturnCustomer?: boolean; + ReturnAccount?: boolean; + ReturnDirectDebitDetails?: boolean; + }; + HistoryTypeFilterList?: number[]; + }; +} diff --git a/src/common/modules/neoleap/neoleap.module.ts b/src/common/modules/neoleap/neoleap.module.ts index c68989f..e3563db 100644 --- a/src/common/modules/neoleap/neoleap.module.ts +++ b/src/common/modules/neoleap/neoleap.module.ts @@ -1,8 +1,11 @@ +import { HttpModule } from '@nestjs/axios'; import { Module } from '@nestjs/common'; +import { NeoTestController } from './controllers/neotest.controller'; +import { NeoLeapService } from './services/neoleap.service'; @Module({ - imports: [], - controllers: [], - providers: [], + imports: [HttpModule], + controllers: [NeoTestController], + providers: [NeoLeapService], }) export class NeoLeapModule {} diff --git a/src/common/modules/neoleap/services/neoleap.service.ts b/src/common/modules/neoleap/services/neoleap.service.ts index ac10e07..950d231 100644 --- a/src/common/modules/neoleap/services/neoleap.service.ts +++ b/src/common/modules/neoleap/services/neoleap.service.ts @@ -1,11 +1,14 @@ import { HttpService } from '@nestjs/axios'; import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { plainToInstance } from 'class-transformer'; import moment from 'moment'; import { v4 as uuid } from 'uuid'; import { CountriesNumericISO } from '~/common/constants'; import { Customer } from '~/customer/entities'; import { Gender } from '~/customer/enums'; +import { INQUIRE_APPLICATION_MOCK } from '../__mocks__/'; +import { InquireApplicationResponse } from '../dtos/response'; import { ICreateApplicationRequest, INeoleapHeaderRequest } from '../interfaces'; @Injectable() export class NeoLeapService { @@ -55,7 +58,7 @@ export class NeoLeapService { Title: customer.gender === Gender.MALE ? 'Mr' : 'Ms', Gender: customer.gender === Gender.MALE ? 'M' : 'F', LocalizedDateOfBirth: moment(customer.dateOfBirth).format('YYYY-MM-DD'), - Nationality: '682', + Nationality: CountriesNumericISO[customer.countryOfResidence], }, ApplicationAddress: { City: customer.city, @@ -75,7 +78,7 @@ export class NeoLeapService { ...this.prepareHeaders('CreateNewApplication'), }; - const response = await this.httpService.axiosRef.post(`${this.baseUrl}/create-application`, payload, { + const response = await this.httpService.axiosRef.post(`${this.baseUrl}/application/CreateNewApplication`, payload, { headers: { 'Content-Type': 'application/json', Authorization: `${this.apiKey}`, @@ -85,6 +88,48 @@ export class NeoLeapService { //@TODO handle response } + async inquireApplication(externalApplicationNumber: string) { + const responseKey = 'InquireApplicationResponseDetails'; + if (this.useMock) { + return plainToInstance(InquireApplicationResponse, INQUIRE_APPLICATION_MOCK[responseKey], { + excludeExtraneousValues: true, + }); + } + + const payload = { + InquireApplicationRequestDetails: { + ApplicationIdentifier: { + InstitutionCode: this.institutionCode, + ExternalApplicationNumber: externalApplicationNumber, + }, + AdditionalData: { + ReturnApplicationType: true, + ReturnApplicationStatus: true, + ReturnCard: true, + ReturnCustomer: true, + }, + }, + ...this.prepareHeaders('InquireApplication'), + }; + + const response = await this.httpService.axiosRef.post(`${this.baseUrl}/application/InquireApplication`, payload, { + headers: { + 'Content-Type': 'application/json', + Authorization: `${this.apiKey}`, + }, + }); + + if (response.data?.[responseKey]) { + return plainToInstance(InquireApplicationResponse, response.data[responseKey], { + excludeExtraneousValues: true, + }); + } else { + throw new Error('Invalid response from NeoLeap API'); + } + + //@TODO handle response + } + private prepareHeaders(serviceName: string): INeoleapHeaderRequest { return { RequestHeader: { From 1ea1f4216990e652c7799f91223faaa40226df56 Mon Sep 17 00:00:00 2001 From: Abdalhamid Alhamad Date: Tue, 3 Jun 2025 14:51:36 +0300 Subject: [PATCH 04/32] feat: finish create and inquire application api and handle response and errors --- src/app.module.ts | 2 + src/card/card.module.ts | 8 + src/card/entities/card.entity.ts | 66 ++ src/card/entities/index.ts | 1 + src/card/enums/card-colors.enum.ts | 4 + src/card/enums/card-issuers.enum.ts | 3 + src/card/enums/card-scheme.enum.ts | 4 + src/card/enums/card-status.enum.ts | 6 + src/card/enums/customer-type.enum.ts | 4 + src/card/enums/index.ts | 5 + .../__mocks__/create-application.mock.ts | 750 +++++++++++++++++ src/common/modules/neoleap/__mocks__/index.ts | 1 + .../__mocks__/inquire-application.mock.ts | 785 ++++++++++++++++-- .../neoleap/controllers/neotest.controller.ts | 12 +- .../create-application.response.dto.ts | 12 + .../modules/neoleap/dtos/response/index.ts | 1 + .../response/inquire-application.response.ts | 10 + .../create-application.request.interface.ts | 9 + .../neoleap-header.request.interface.ts | 2 +- src/common/modules/neoleap/neoleap.module.ts | 3 +- .../neoleap/services/neoleap.service.ts | 116 ++- src/customer/entities/customer.entity.ts | 14 + src/customer/services/customer.service.ts | 4 + 23 files changed, 1698 insertions(+), 124 deletions(-) create mode 100644 src/card/card.module.ts create mode 100644 src/card/entities/card.entity.ts create mode 100644 src/card/entities/index.ts create mode 100644 src/card/enums/card-colors.enum.ts create mode 100644 src/card/enums/card-issuers.enum.ts create mode 100644 src/card/enums/card-scheme.enum.ts create mode 100644 src/card/enums/card-status.enum.ts create mode 100644 src/card/enums/customer-type.enum.ts create mode 100644 src/card/enums/index.ts create mode 100644 src/common/modules/neoleap/__mocks__/create-application.mock.ts create mode 100644 src/common/modules/neoleap/dtos/response/create-application.response.dto.ts diff --git a/src/app.module.ts b/src/app.module.ts index 8b9bc17..a2cae02 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -31,6 +31,7 @@ import { MoneyRequestModule } from './money-request/money-request.module'; import { SavingGoalsModule } from './saving-goals/saving-goals.module'; import { TaskModule } from './task/task.module'; import { UserModule } from './user/user.module'; +import { CardModule } from './card/card.module'; @Module({ controllers: [], @@ -82,6 +83,7 @@ import { UserModule } from './user/user.module'; CronModule, NeoLeapModule, + CardModule, ], providers: [ // Global Pipes diff --git a/src/card/card.module.ts b/src/card/card.module.ts new file mode 100644 index 0000000..f6f616f --- /dev/null +++ b/src/card/card.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Card } from './entities'; + +@Module({ + imports: [TypeOrmModule.forFeature([Card])], +}) +export class CardModule {} diff --git a/src/card/entities/card.entity.ts b/src/card/entities/card.entity.ts new file mode 100644 index 0000000..bc992da --- /dev/null +++ b/src/card/entities/card.entity.ts @@ -0,0 +1,66 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { Customer } from '~/customer/entities'; +import { CardColors, CardIssuers, CardScheme, CardStatus, CustomerType } from '../enums'; + +@Entity() +export class Card { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Index({ unique: true }) + @Column({ name: 'card_reference', nullable: false, type: 'varchar' }) + cardReference!: string; + + @Column({ length: 5, name: 'first_five_digits', nullable: false, type: 'varchar' }) + firstFiveDigits!: string; + + @Column({ length: 4, name: 'last_four_digits', nullable: false, type: 'varchar' }) + lastFourDigits!: string; + + @Column({ type: 'date', nullable: false }) + expiry!: Date; + + @Column({ type: 'varchar', nullable: false, name: 'customer_type' }) + customerType!: CustomerType; + + @Column({ type: 'varchar', nullable: false, default: CardColors.BLUE }) + color!: CardColors; + + @Column({ type: 'varchar', nullable: false, default: CardStatus.PENDING }) + status!: CardStatus; + + @Column({ type: 'varchar', nullable: false, default: CardScheme.VISA }) + scheme!: CardScheme; + + @Column({ type: 'varchar', nullable: false }) + issuer!: CardIssuers; + + @Column({ type: 'uuid', name: 'customer_id', nullable: false }) + customerId!: string; + + @Column({ type: 'uuid', name: 'parent_id', nullable: true }) + parentId?: string; + + @ManyToOne(() => Customer, (customer) => customer.childCards) + @JoinColumn({ name: 'parent_id' }) + parentCustomer?: Customer; + + @ManyToOne(() => Customer, (customer) => customer.cards, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'customer_id' }) + customer!: Customer; + + @CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone' }) + createdAt!: Date; + + @UpdateDateColumn({ type: 'timestamp with time zone', name: 'updated_at' }) + updatedAt!: Date; +} diff --git a/src/card/entities/index.ts b/src/card/entities/index.ts new file mode 100644 index 0000000..1c77083 --- /dev/null +++ b/src/card/entities/index.ts @@ -0,0 +1 @@ +export * from './card.entity'; diff --git a/src/card/enums/card-colors.enum.ts b/src/card/enums/card-colors.enum.ts new file mode 100644 index 0000000..9fe8e47 --- /dev/null +++ b/src/card/enums/card-colors.enum.ts @@ -0,0 +1,4 @@ +export enum CardColors { + RED = 'RED', + BLUE = 'BLUE', +} diff --git a/src/card/enums/card-issuers.enum.ts b/src/card/enums/card-issuers.enum.ts new file mode 100644 index 0000000..8c31985 --- /dev/null +++ b/src/card/enums/card-issuers.enum.ts @@ -0,0 +1,3 @@ +export enum CardIssuers { + NEOLEAP = 'NEOLEAP', +} diff --git a/src/card/enums/card-scheme.enum.ts b/src/card/enums/card-scheme.enum.ts new file mode 100644 index 0000000..f4c6dcf --- /dev/null +++ b/src/card/enums/card-scheme.enum.ts @@ -0,0 +1,4 @@ +export enum CardScheme { + VISA = 'VISA', + MASTERCARD = 'MASTERCARD', +} diff --git a/src/card/enums/card-status.enum.ts b/src/card/enums/card-status.enum.ts new file mode 100644 index 0000000..e48ad71 --- /dev/null +++ b/src/card/enums/card-status.enum.ts @@ -0,0 +1,6 @@ +export enum CardStatus { + ACTIVE = 'ACTIVE', + CANCELED = 'CANCELED', + BLOCKED = 'BLOCKED', + PENDING = 'PENDING', +} diff --git a/src/card/enums/customer-type.enum.ts b/src/card/enums/customer-type.enum.ts new file mode 100644 index 0000000..4a236e3 --- /dev/null +++ b/src/card/enums/customer-type.enum.ts @@ -0,0 +1,4 @@ +export enum CustomerType { + PARENT = 'PARENT', + CHILD = 'CHILD', +} diff --git a/src/card/enums/index.ts b/src/card/enums/index.ts new file mode 100644 index 0000000..6a9d197 --- /dev/null +++ b/src/card/enums/index.ts @@ -0,0 +1,5 @@ +export * from './card-colors.enum'; +export * from './card-issuers.enum'; +export * from './card-scheme.enum'; +export * from './card-status.enum'; +export * from './customer-type.enum'; diff --git a/src/common/modules/neoleap/__mocks__/create-application.mock.ts b/src/common/modules/neoleap/__mocks__/create-application.mock.ts new file mode 100644 index 0000000..760d2c4 --- /dev/null +++ b/src/common/modules/neoleap/__mocks__/create-application.mock.ts @@ -0,0 +1,750 @@ +export const CREATE_APPLICATION_MOCK = { + ResponseHeader: { + Version: '1.0.0', + MsgUid: 'adaa1893-9f95-48a8-b7a1-0422bcf629b5', + Source: 'ZOD', + ServiceId: 'CreateNewApplication', + ReqDateTime: '2025-06-03T07:32:16.304Z', + RspDateTime: '2025-06-03T08:21:15.662', + ResponseCode: '000', + ResponseType: 'Success', + ProcessingTime: 1665, + EncryptionKey: null, + ResponseDescription: 'Operation Successful', + LocalizedResponseDescription: null, + CustomerSpecificResponseDescriptionList: null, + HeaderUserDataList: null, + }, + + CreateNewApplicationResponseDetails: { + InstitutionCode: '1100', + ApplicationTypeDetails: { + TypeCode: '01', + Description: 'Normal Primary', + Additional: false, + Corporate: false, + UserData: null, + }, + ApplicationDetails: { + cif: null, + ApplicationNumber: '3300000000073', + ExternalApplicationNumber: '3', + ApplicationStatus: '04', + Organization: 0, + Product: '1101', + ApplicatonDate: '2025-05-29', + ApplicationSource: 'O', + SalesSource: null, + DeliveryMethod: 'V', + ProgramCode: null, + Campaign: null, + Plastic: null, + Design: null, + ProcessStage: '99', + ProcessStageStatus: 'S', + Score: null, + ExternalScore: null, + RequestedLimit: 0, + SuggestedLimit: null, + AssignedLimit: 0, + AllowedLimitList: null, + EligibilityCheckResult: '00', + EligibilityCheckDescription: null, + Title: 'Mr.', + FirstName: 'Abdalhamid', + SecondName: null, + ThirdName: null, + LastName: ' Ahmad', + FullName: 'Abdalhamid Ahmad', + EmbossName: 'ABDALHAMID AHMAD', + PlaceOfBirth: null, + DateOfBirth: '1999-01-07', + LocalizedDateOfBirth: '1999-01-07', + Age: 26, + Gender: 'M', + Married: 'U', + Nationality: '682', + IdType: '01', + IdNumber: '1089055972', + IdExpiryDate: '2031-09-17', + EducationLevel: null, + ProfessionCode: 0, + NumberOfDependents: 0, + EmployerName: 'N/A', + EmploymentYears: 0, + EmploymentMonths: 0, + EmployerPhoneArea: null, + EmployerPhoneNumber: null, + EmployerPhoneExtension: null, + EmployerMobile: null, + EmployerFaxArea: null, + EmployerFax: null, + EmployerCity: null, + EmployerAddress: null, + EmploymentActivity: null, + EmploymentStatus: null, + CIF: null, + BankAccountNumber: ' ', + Currency: { + CurrCode: '682', + AlphaCode: 'SAR', + }, + RequestedCurrencyList: null, + CreditAccountNumber: '6000000000000000', + AccountType: '30', + OpenDate: null, + Income: 0, + AdditionalIncome: 0, + TotalIncome: 0, + CurrentBalance: 0, + AverageBalance: 0, + AssetsBalance: 0, + InsuranceBalance: 0, + DepositAmount: 0, + GuarenteeAccountNumber: null, + GuarenteeAmount: 0, + InstalmentAmount: 0, + AutoDebit: 'N', + PaymentMethod: '2', + BillingCycle: 'C1', + OldIssueDate: null, + OtherPaymentsDate: null, + MaximumDelinquency: null, + CreditBureauDecision: null, + CreditBureauUserData: null, + ECommerce: 'N', + NumberOfCards: 0, + OtherBank: null, + OtherBankDescription: null, + InsuranceProduct: null, + SocialCode: '000', + JobGrade: 0, + Flags: [ + { + Position: 1, + Value: '0', + }, + { + Position: 2, + Value: '0', + }, + { + Position: 3, + Value: '0', + }, + { + Position: 4, + Value: '0', + }, + { + Position: 5, + Value: '0', + }, + { + Position: 6, + Value: '0', + }, + { + Position: 7, + Value: '0', + }, + { + Position: 8, + Value: '0', + }, + { + Position: 9, + Value: '0', + }, + { + Position: 10, + Value: '0', + }, + { + Position: 11, + Value: '0', + }, + { + Position: 12, + Value: '0', + }, + { + Position: 13, + Value: '0', + }, + { + Position: 14, + Value: '0', + }, + { + Position: 15, + Value: '0', + }, + { + Position: 16, + Value: '0', + }, + { + Position: 17, + Value: '0', + }, + { + Position: 18, + Value: '0', + }, + { + Position: 19, + Value: '0', + }, + { + Position: 20, + Value: '0', + }, + { + Position: 21, + Value: '0', + }, + { + Position: 22, + Value: '0', + }, + { + Position: 23, + Value: '0', + }, + { + Position: 24, + Value: '0', + }, + { + Position: 25, + Value: '0', + }, + { + Position: 26, + Value: '0', + }, + { + Position: 27, + Value: '0', + }, + { + Position: 28, + Value: '0', + }, + { + Position: 29, + Value: '0', + }, + { + Position: 30, + Value: '0', + }, + { + Position: 31, + Value: '0', + }, + { + Position: 32, + Value: '0', + }, + { + Position: 33, + Value: '0', + }, + { + Position: 34, + Value: '0', + }, + { + Position: 35, + Value: '0', + }, + { + Position: 36, + Value: '0', + }, + { + Position: 37, + Value: '0', + }, + { + Position: 38, + Value: '0', + }, + { + Position: 39, + Value: '0', + }, + { + Position: 40, + Value: '0', + }, + { + Position: 41, + Value: '0', + }, + { + Position: 42, + Value: '0', + }, + { + Position: 43, + Value: '0', + }, + { + Position: 44, + Value: '0', + }, + { + Position: 45, + Value: '0', + }, + { + Position: 46, + Value: '0', + }, + { + Position: 47, + Value: '0', + }, + { + Position: 48, + Value: '0', + }, + { + Position: 49, + Value: '0', + }, + { + Position: 50, + Value: '0', + }, + { + Position: 51, + Value: '0', + }, + { + Position: 52, + Value: '0', + }, + { + Position: 53, + Value: '0', + }, + { + Position: 54, + Value: '0', + }, + { + Position: 55, + Value: '0', + }, + { + Position: 56, + Value: '0', + }, + { + Position: 57, + Value: '0', + }, + { + Position: 58, + Value: '0', + }, + { + Position: 59, + Value: '0', + }, + { + Position: 60, + Value: '0', + }, + { + Position: 61, + Value: '0', + }, + { + Position: 62, + Value: '0', + }, + { + Position: 63, + Value: '0', + }, + { + Position: 64, + Value: '0', + }, + ], + CheckFlags: [ + { + Position: 1, + Value: '0', + }, + { + Position: 2, + Value: '0', + }, + { + Position: 3, + Value: '0', + }, + { + Position: 4, + Value: '0', + }, + { + Position: 5, + Value: '0', + }, + { + Position: 6, + Value: '0', + }, + { + Position: 7, + Value: '0', + }, + { + Position: 8, + Value: '0', + }, + { + Position: 9, + Value: '0', + }, + { + Position: 10, + Value: '0', + }, + { + Position: 11, + Value: '0', + }, + { + Position: 12, + Value: '0', + }, + { + Position: 13, + Value: '0', + }, + { + Position: 14, + Value: '0', + }, + { + Position: 15, + Value: '0', + }, + { + Position: 16, + Value: '0', + }, + { + Position: 17, + Value: '0', + }, + { + Position: 18, + Value: '0', + }, + { + Position: 19, + Value: '0', + }, + { + Position: 20, + Value: '0', + }, + { + Position: 21, + Value: '0', + }, + { + Position: 22, + Value: '0', + }, + { + Position: 23, + Value: '0', + }, + { + Position: 24, + Value: '0', + }, + { + Position: 25, + Value: '0', + }, + { + Position: 26, + Value: '0', + }, + { + Position: 27, + Value: '0', + }, + { + Position: 28, + Value: '0', + }, + { + Position: 29, + Value: '0', + }, + { + Position: 30, + Value: '0', + }, + { + Position: 31, + Value: '0', + }, + { + Position: 32, + Value: '0', + }, + { + Position: 33, + Value: '0', + }, + { + Position: 34, + Value: '0', + }, + { + Position: 35, + Value: '0', + }, + { + Position: 36, + Value: '0', + }, + { + Position: 37, + Value: '0', + }, + { + Position: 38, + Value: '0', + }, + { + Position: 39, + Value: '0', + }, + { + Position: 40, + Value: '0', + }, + { + Position: 41, + Value: '0', + }, + { + Position: 42, + Value: '0', + }, + { + Position: 43, + Value: '0', + }, + { + Position: 44, + Value: '0', + }, + { + Position: 45, + Value: '0', + }, + { + Position: 46, + Value: '0', + }, + { + Position: 47, + Value: '0', + }, + { + Position: 48, + Value: '0', + }, + { + Position: 49, + Value: '0', + }, + { + Position: 50, + Value: '0', + }, + { + Position: 51, + Value: '0', + }, + { + Position: 52, + Value: '0', + }, + { + Position: 53, + Value: '0', + }, + { + Position: 54, + Value: '0', + }, + { + Position: 55, + Value: '0', + }, + { + Position: 56, + Value: '0', + }, + { + Position: 57, + Value: '0', + }, + { + Position: 58, + Value: '0', + }, + { + Position: 59, + Value: '0', + }, + { + Position: 60, + Value: '0', + }, + { + Position: 61, + Value: '0', + }, + { + Position: 62, + Value: '0', + }, + { + Position: 63, + Value: '0', + }, + { + Position: 64, + Value: '0', + }, + ], + Maker: null, + Checker: null, + ReferredTo: null, + ReferralReason: null, + UserData1: null, + UserData2: null, + UserData3: null, + UserData4: null, + UserData5: null, + AdditionalFields: [], + }, + ApplicationStatusDetails: { + StatusCode: '04', + Description: 'Approved', + Canceled: false, + }, + CorporateDetails: null, + CustomerDetails: { + Id: 115158, + CustomerCode: '100000024619', + IdNumber: ' ', + TypeId: 0, + PreferredLanguage: 'EN', + ExternalCustomerCode: null, + Title: ' ', + FirstName: ' ', + LastName: ' ', + DateOfBirth: null, + UserData1: '2031-09-17', + UserData2: '01', + UserData3: null, + UserData4: '682', + CustomerSegment: null, + Gender: 'U', + Married: 'U', + }, + AccountDetailsList: [ + { + Id: 21017, + InstitutionCode: '1100', + AccountNumber: '6899999999999999', + Currency: { + CurrCode: '682', + AlphaCode: 'SAR', + }, + AccountTypeCode: '30', + ClassId: '2', + AccountStatus: '00', + VipFlag: '0', + BlockedAmount: 0, + EquivalentBlockedAmount: null, + UnclearCredit: 0, + EquivalentUnclearCredit: null, + AvailableBalance: 0, + EquivalentAvailableBalance: null, + AvailableBalanceToSpend: 0, + CreditLimit: 0, + RemainingCashLimit: null, + UserData1: 'D36407C9AE4C28D2185', + UserData2: null, + UserData3: 'D36407C9AE4C28D2185', + UserData4: null, + UserData5: 'SA2380900000752991120011', + }, + ], + CardDetailsList: [ + { + pvv: null, + ResponseCardIdentifier: { + Id: 28595, + Pan: 'DDDDDDDDDDDDDDDDDDD', + MaskedPan: '999999_9999', + VPan: '1100000000000000', + Seqno: 0, + }, + ExpiryDate: '2031-09-30', + EffectiveDate: '2025-06-02', + CardStatus: '30', + OldPlasticExpiryDate: null, + OldPlasticCardStatus: null, + EmbossingName: 'ABDALHAMID AHMAD', + Title: 'Mr.', + FirstName: 'Abdalhamid', + LastName: ' Ahmad', + Additional: false, + BatchNumber: 8849, + ServiceCode: '226', + Kinship: null, + DateOfBirth: '1999-01-07', + LastActivity: null, + LastStatusChangeDate: '2025-06-03', + ActivationDate: null, + DateLastIssued: null, + PVV: null, + UserData: '4', + UserData1: '3', + UserData2: null, + UserData3: null, + UserData4: null, + UserData5: null, + Memo: null, + CardAuthorizationParameters: null, + L10NTitle: null, + L10NFirstName: null, + L10NLastName: null, + PinStatus: '40', + OldPinStatus: '0', + CustomerIdNumber: '1089055972', + Language: 0, + }, + ], + }, +}; diff --git a/src/common/modules/neoleap/__mocks__/index.ts b/src/common/modules/neoleap/__mocks__/index.ts index 653007b..db8964a 100644 --- a/src/common/modules/neoleap/__mocks__/index.ts +++ b/src/common/modules/neoleap/__mocks__/index.ts @@ -1 +1,2 @@ +export * from './create-application.mock'; export * from './inquire-application.mock'; diff --git a/src/common/modules/neoleap/__mocks__/inquire-application.mock.ts b/src/common/modules/neoleap/__mocks__/inquire-application.mock.ts index 3a74ca7..6f2b7d2 100644 --- a/src/common/modules/neoleap/__mocks__/inquire-application.mock.ts +++ b/src/common/modules/neoleap/__mocks__/inquire-application.mock.ts @@ -1,91 +1,728 @@ export const INQUIRE_APPLICATION_MOCK = { ResponseHeader: { Version: '1.0.0', - MsgUid: 'stringstringstringstringstringstring', - Source: 'string', - ServiceId: 'string', - ReqDateTime: '2025-05-26T10:23:13.812Z', - RspDateTime: '2025-05-26T10:23:13.812Z', - ResponseCode: 'string', - ResponseType: 'Validation Error', - ProcessingTime: 0, - EncryptionKey: 'string', - ResponseDescription: 'string', - LocalizedResponseDescription: { - Locale: 'string', - LocalizedDescription: 'string', - }, - CustomerSpecificResponseDescriptionList: [ - { - Locale: 'string', - ResponseDescription: 'string', - }, - ], - HeaderUserDataList: [ - { - Tag: 'string', - Value: 'string', - }, - ], + MsgUid: 'adaa1893-9f95-48a8-b7a1-0422bcf629b4', + Source: 'ZOD', + ServiceId: 'InquireApplication', + ReqDateTime: '2023-07-18T10:34:12.553Z', + RspDateTime: '2025-06-03T11:14:54.748', + ResponseCode: '000', + ResponseType: 'Success', + ProcessingTime: 476, + EncryptionKey: null, + ResponseDescription: 'Operation Successful', + LocalizedResponseDescription: null, + CustomerSpecificResponseDescriptionList: null, + HeaderUserDataList: null, }, - InquireApplicationResponseDetails: { - InstitutionCode: 'stri', + InstitutionCode: '1100', ApplicationTypeDetails: { - TypeCode: 'st', - Description: 'string', - Additional: true, - Corporate: true, - UserData: 'string', + TypeCode: '01', + Description: 'Normal Primary', + Additional: false, + Corporate: false, + UserData: null, }, ApplicationDetails: { - ApplicationNumber: 'string', - ExternalApplicationNumber: 'string', - ApplicationStatus: 'st', + cif: null, + ApplicationNumber: '3300000000070', + ExternalApplicationNumber: '10000002', + ApplicationStatus: '04', Organization: 0, - Product: 'stri', - ApplicatonDate: '2025-05-26', - ApplicationSource: 's', - SalesSource: 'string', - DeliveryMethod: 's', - ProgramCode: 'string', - Campaign: 'string', - Plastic: 'string', - Design: 'string', - ProcessStage: 'st', - ProcessStageStatus: 's', - Score: 'string', - ExternalScore: 'string', + Product: '1101', + ApplicatonDate: '2025-05-29', + ApplicationSource: 'O', + SalesSource: null, + DeliveryMethod: 'V', + ProgramCode: null, + Campaign: null, + Plastic: null, + Design: null, + ProcessStage: '99', + ProcessStageStatus: 'S', + Score: null, + ExternalScore: null, RequestedLimit: 0, - SuggestedLimit: 0, + SuggestedLimit: null, AssignedLimit: 0, - AllowedLimitList: [ + AllowedLimitList: null, + EligibilityCheckResult: '00', + EligibilityCheckDescription: null, + Title: 'Mr.', + FirstName: 'Abdalhamid', + SecondName: null, + ThirdName: null, + LastName: ' Ahmad', + FullName: 'Abdalhamid Ahmad', + EmbossName: 'ABDALHAMID AHMAD', + PlaceOfBirth: null, + DateOfBirth: '1999-01-07', + LocalizedDateOfBirth: '1999-01-07', + Age: 26, + Gender: 'M', + Married: 'U', + Nationality: '682', + IdType: '01', + IdNumber: '1089055972', + IdExpiryDate: '2031-09-17', + EducationLevel: null, + ProfessionCode: 0, + NumberOfDependents: 0, + EmployerName: 'N/A', + EmploymentYears: 0, + EmploymentMonths: 0, + EmployerPhoneArea: null, + EmployerPhoneNumber: null, + EmployerPhoneExtension: null, + EmployerMobile: null, + EmployerFaxArea: null, + EmployerFax: null, + EmployerCity: null, + EmployerAddress: null, + EmploymentActivity: null, + EmploymentStatus: null, + CIF: null, + BankAccountNumber: ' ', + Currency: { + CurrCode: '682', + AlphaCode: 'SAR', + }, + RequestedCurrencyList: null, + CreditAccountNumber: '6823000000000019', + AccountType: '30', + OpenDate: null, + Income: 0, + AdditionalIncome: 0, + TotalIncome: 0, + CurrentBalance: 0, + AverageBalance: 0, + AssetsBalance: 0, + InsuranceBalance: 0, + DepositAmount: 0, + GuarenteeAccountNumber: null, + GuarenteeAmount: 0, + InstalmentAmount: 0, + AutoDebit: 'N', + PaymentMethod: '2', + BillingCycle: 'C1', + OldIssueDate: null, + OtherPaymentsDate: null, + MaximumDelinquency: null, + CreditBureauDecision: null, + CreditBureauUserData: null, + ECommerce: 'N', + NumberOfCards: 0, + OtherBank: null, + OtherBankDescription: null, + InsuranceProduct: null, + SocialCode: '000', + JobGrade: 0, + Flags: [ { - CreditLimit: 0, - EvaluationCode: 'string', + Position: 1, + Value: '0', + }, + { + Position: 2, + Value: '0', + }, + { + Position: 3, + Value: '0', + }, + { + Position: 4, + Value: '0', + }, + { + Position: 5, + Value: '0', + }, + { + Position: 6, + Value: '0', + }, + { + Position: 7, + Value: '0', + }, + { + Position: 8, + Value: '0', + }, + { + Position: 9, + Value: '0', + }, + { + Position: 10, + Value: '0', + }, + { + Position: 11, + Value: '0', + }, + { + Position: 12, + Value: '0', + }, + { + Position: 13, + Value: '0', + }, + { + Position: 14, + Value: '0', + }, + { + Position: 15, + Value: '0', + }, + { + Position: 16, + Value: '0', + }, + { + Position: 17, + Value: '0', + }, + { + Position: 18, + Value: '0', + }, + { + Position: 19, + Value: '0', + }, + { + Position: 20, + Value: '0', + }, + { + Position: 21, + Value: '0', + }, + { + Position: 22, + Value: '0', + }, + { + Position: 23, + Value: '0', + }, + { + Position: 24, + Value: '0', + }, + { + Position: 25, + Value: '0', + }, + { + Position: 26, + Value: '0', + }, + { + Position: 27, + Value: '0', + }, + { + Position: 28, + Value: '0', + }, + { + Position: 29, + Value: '0', + }, + { + Position: 30, + Value: '0', + }, + { + Position: 31, + Value: '0', + }, + { + Position: 32, + Value: '0', + }, + { + Position: 33, + Value: '0', + }, + { + Position: 34, + Value: '0', + }, + { + Position: 35, + Value: '0', + }, + { + Position: 36, + Value: '0', + }, + { + Position: 37, + Value: '0', + }, + { + Position: 38, + Value: '0', + }, + { + Position: 39, + Value: '0', + }, + { + Position: 40, + Value: '0', + }, + { + Position: 41, + Value: '0', + }, + { + Position: 42, + Value: '0', + }, + { + Position: 43, + Value: '0', + }, + { + Position: 44, + Value: '0', + }, + { + Position: 45, + Value: '0', + }, + { + Position: 46, + Value: '0', + }, + { + Position: 47, + Value: '0', + }, + { + Position: 48, + Value: '0', + }, + { + Position: 49, + Value: '0', + }, + { + Position: 50, + Value: '0', + }, + { + Position: 51, + Value: '0', + }, + { + Position: 52, + Value: '0', + }, + { + Position: 53, + Value: '0', + }, + { + Position: 54, + Value: '0', + }, + { + Position: 55, + Value: '0', + }, + { + Position: 56, + Value: '0', + }, + { + Position: 57, + Value: '0', + }, + { + Position: 58, + Value: '0', + }, + { + Position: 59, + Value: '0', + }, + { + Position: 60, + Value: '0', + }, + { + Position: 61, + Value: '0', + }, + { + Position: 62, + Value: '0', + }, + { + Position: 63, + Value: '0', + }, + { + Position: 64, + Value: '0', }, ], - EligibilityCheckResult: 'string', - EligibilityCheckDescription: 'string', - Title: 'string', - FirstName: 'string', - SecondName: 'string', - ThirdName: 'string', - LastName: 'string', - FullName: 'string', - EmbossName: 'string', - PlaceOfBirth: 'string', - DateOfBirth: '2025-05-26', - LocalizedDateOfBirth: '2025-05-26', - Age: 20, - Gender: 'M', - Married: 'S', - Nationality: 'str', - IdType: 'st', - IdNumber: 'string', - IdExpiryDate: '2025-05-26', - EducationLevel: 'stri', - ProfessionCode: 0, + CheckFlags: [ + { + Position: 1, + Value: '0', + }, + { + Position: 2, + Value: '0', + }, + { + Position: 3, + Value: '0', + }, + { + Position: 4, + Value: '0', + }, + { + Position: 5, + Value: '0', + }, + { + Position: 6, + Value: '0', + }, + { + Position: 7, + Value: '0', + }, + { + Position: 8, + Value: '0', + }, + { + Position: 9, + Value: '0', + }, + { + Position: 10, + Value: '0', + }, + { + Position: 11, + Value: '0', + }, + { + Position: 12, + Value: '0', + }, + { + Position: 13, + Value: '0', + }, + { + Position: 14, + Value: '0', + }, + { + Position: 15, + Value: '0', + }, + { + Position: 16, + Value: '0', + }, + { + Position: 17, + Value: '0', + }, + { + Position: 18, + Value: '0', + }, + { + Position: 19, + Value: '0', + }, + { + Position: 20, + Value: '0', + }, + { + Position: 21, + Value: '0', + }, + { + Position: 22, + Value: '0', + }, + { + Position: 23, + Value: '0', + }, + { + Position: 24, + Value: '0', + }, + { + Position: 25, + Value: '0', + }, + { + Position: 26, + Value: '0', + }, + { + Position: 27, + Value: '0', + }, + { + Position: 28, + Value: '0', + }, + { + Position: 29, + Value: '0', + }, + { + Position: 30, + Value: '0', + }, + { + Position: 31, + Value: '0', + }, + { + Position: 32, + Value: '0', + }, + { + Position: 33, + Value: '0', + }, + { + Position: 34, + Value: '0', + }, + { + Position: 35, + Value: '0', + }, + { + Position: 36, + Value: '0', + }, + { + Position: 37, + Value: '0', + }, + { + Position: 38, + Value: '0', + }, + { + Position: 39, + Value: '0', + }, + { + Position: 40, + Value: '0', + }, + { + Position: 41, + Value: '0', + }, + { + Position: 42, + Value: '0', + }, + { + Position: 43, + Value: '0', + }, + { + Position: 44, + Value: '0', + }, + { + Position: 45, + Value: '0', + }, + { + Position: 46, + Value: '0', + }, + { + Position: 47, + Value: '0', + }, + { + Position: 48, + Value: '0', + }, + { + Position: 49, + Value: '0', + }, + { + Position: 50, + Value: '0', + }, + { + Position: 51, + Value: '0', + }, + { + Position: 52, + Value: '0', + }, + { + Position: 53, + Value: '0', + }, + { + Position: 54, + Value: '0', + }, + { + Position: 55, + Value: '0', + }, + { + Position: 56, + Value: '0', + }, + { + Position: 57, + Value: '0', + }, + { + Position: 58, + Value: '0', + }, + { + Position: 59, + Value: '0', + }, + { + Position: 60, + Value: '0', + }, + { + Position: 61, + Value: '0', + }, + { + Position: 62, + Value: '0', + }, + { + Position: 63, + Value: '0', + }, + { + Position: 64, + Value: '0', + }, + ], + Maker: null, + Checker: null, + ReferredTo: null, + ReferralReason: null, + UserData1: null, + UserData2: null, + UserData3: null, + UserData4: null, + UserData5: null, + AdditionalFields: [], }, + ApplicationStatusDetails: { + StatusCode: '04', + Description: 'Approved', + Canceled: false, + }, + ApplicationHistoryList: null, + ApplicationAddressList: [ + { + Id: 43859, + AddressLine1: '5536 abdullah Ibn al zubair ', + AddressLine2: ' Umm Alarad Dist.', + AddressLine3: null, + AddressLine4: null, + AddressLine5: null, + Directions: null, + City: 'AT TAIF', + PostalCode: null, + Province: null, + Territory: null, + State: null, + Region: null, + County: null, + Country: '682', + CountryDetails: { + IsoCode: '682', + Alpha3: 'SAU', + Alpha2: 'SA', + DefaultCurrency: { + CurrCode: '682', + AlphaCode: 'SAR', + }, + Description: [ + { + Language: 'EN', + Description: 'SAUDI ARABIA', + }, + { + Language: 'GB', + Description: 'SAUDI ARABIA', + }, + ], + }, + Phone1: '+966541884784', + Phone2: null, + Extension: null, + Email: 'a.ahmad@zod-alkhair.com', + Fax: null, + District: null, + PoBox: null, + OwnershipType: 'O', + UserData1: null, + UserData2: null, + AddressRole: 0, + AddressCustomValues: null, + }, + ], + CorporateDetails: null, + CustomerDetails: { + Id: 115129, + CustomerCode: '100000024552', + IdNumber: ' ', + TypeId: 0, + PreferredLanguage: 'EN', + ExternalCustomerCode: null, + Title: ' ', + FirstName: ' ', + LastName: ' ', + DateOfBirth: null, + UserData1: '2031-09-17', + UserData2: '01', + UserData3: null, + UserData4: '682', + CustomerSegment: null, + Gender: 'U', + Married: 'U', + }, + + BranchDetails: null, + CardAccountLinkageList: null, }, }; diff --git a/src/common/modules/neoleap/controllers/neotest.controller.ts b/src/common/modules/neoleap/controllers/neotest.controller.ts index 9505f2f..5475442 100644 --- a/src/common/modules/neoleap/controllers/neotest.controller.ts +++ b/src/common/modules/neoleap/controllers/neotest.controller.ts @@ -1,14 +1,22 @@ import { Controller, Get } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; +import { Customer } from '~/customer/entities'; +import { CustomerService } from '~/customer/services'; import { NeoLeapService } from '../services/neoleap.service'; @Controller('neotest') -@ApiTags('NeoTest') +@ApiTags('Neoleap Test API , for testing purposes only, will be removed in production') export class NeoTestController { - constructor(private readonly neoleapService: NeoLeapService) {} + constructor(private readonly neoleapService: NeoLeapService, private readonly customerService: CustomerService) {} @Get('inquire-application') async inquireApplication() { return this.neoleapService.inquireApplication('1234567890'); } + + @Get('create-application') + async createApplication() { + const customer = await this.customerService.findAnyCustomer(); + return this.neoleapService.createApplication(customer as Customer); + } } diff --git a/src/common/modules/neoleap/dtos/response/create-application.response.dto.ts b/src/common/modules/neoleap/dtos/response/create-application.response.dto.ts new file mode 100644 index 0000000..3e186f8 --- /dev/null +++ b/src/common/modules/neoleap/dtos/response/create-application.response.dto.ts @@ -0,0 +1,12 @@ +import { Expose, Transform } from 'class-transformer'; +import { InquireApplicationResponse } from './inquire-application.response'; + +export class CreateApplicationResponse extends InquireApplicationResponse { + @Transform(({ obj }) => obj.CardDetailsList?.[0]?.ResponseCardIdentifier.Id) + @Expose() + cardId!: number; + + @Transform(({ obj }) => obj.CardDetailsList?.[0]?.ResponseCardIdentifier.VPan) + @Expose() + vpan!: string; +} diff --git a/src/common/modules/neoleap/dtos/response/index.ts b/src/common/modules/neoleap/dtos/response/index.ts index 6a8d1fe..50e4920 100644 --- a/src/common/modules/neoleap/dtos/response/index.ts +++ b/src/common/modules/neoleap/dtos/response/index.ts @@ -1 +1,2 @@ +export * from './create-application.response.dto'; export * from './inquire-application.response'; diff --git a/src/common/modules/neoleap/dtos/response/inquire-application.response.ts b/src/common/modules/neoleap/dtos/response/inquire-application.response.ts index 96ce358..b31c69d 100644 --- a/src/common/modules/neoleap/dtos/response/inquire-application.response.ts +++ b/src/common/modules/neoleap/dtos/response/inquire-application.response.ts @@ -21,9 +21,11 @@ export class InquireApplicationResponse { @Expose() product!: string; + // this typo is from neoleap, so we keep it as is @Transform(({ obj }) => obj.ApplicationDetails?.ApplicatonDate) @Expose() applicationDate!: string; + @Transform(({ obj }) => obj.ApplicationDetails?.ApplicationSource) @Expose() applicationSource!: string; @@ -130,4 +132,12 @@ export class InquireApplicationResponse { @Transform(({ obj }) => obj.ApplicationDetails.IdExpiryDate) @Expose() idExpiryDate!: string; + + @Transform(({ obj }) => obj.ApplicationStatusDetails?.Description) + @Expose() + applicationStatusDescription!: string; + + @Transform(({ obj }) => obj.ApplicationStatusDetails?.Canceled) + @Expose() + canceled!: boolean; } diff --git a/src/common/modules/neoleap/interfaces/create-application.request.interface.ts b/src/common/modules/neoleap/interfaces/create-application.request.interface.ts index 9b26632..9b313c6 100644 --- a/src/common/modules/neoleap/interfaces/create-application.request.interface.ts +++ b/src/common/modules/neoleap/interfaces/create-application.request.interface.ts @@ -19,6 +19,15 @@ export interface ICreateApplicationRequest extends INeoleapHeaderRequest { AssignedLimit: number; }; + ApplicationFinancialInformation: { + Currency: { + AlphaCode: 'SAR'; + }; + BillingCycle: 'C1'; + }; + + ApplicationOtherInfo: object; + ApplicationCustomerDetails: { Title: string; FirstName: string; diff --git a/src/common/modules/neoleap/interfaces/neoleap-header.request.interface.ts b/src/common/modules/neoleap/interfaces/neoleap-header.request.interface.ts index 5e106f4..81f4592 100644 --- a/src/common/modules/neoleap/interfaces/neoleap-header.request.interface.ts +++ b/src/common/modules/neoleap/interfaces/neoleap-header.request.interface.ts @@ -2,7 +2,7 @@ export interface INeoleapHeaderRequest { RequestHeader: { Version: string; MsgUid: string; - Source: 'FINTECH'; + Source: 'ZOD'; ServiceId: string; ReqDateTime: Date; }; diff --git a/src/common/modules/neoleap/neoleap.module.ts b/src/common/modules/neoleap/neoleap.module.ts index e3563db..232bef5 100644 --- a/src/common/modules/neoleap/neoleap.module.ts +++ b/src/common/modules/neoleap/neoleap.module.ts @@ -1,10 +1,11 @@ import { HttpModule } from '@nestjs/axios'; import { Module } from '@nestjs/common'; +import { CustomerModule } from '~/customer/customer.module'; import { NeoTestController } from './controllers/neotest.controller'; import { NeoLeapService } from './services/neoleap.service'; @Module({ - imports: [HttpModule], + imports: [HttpModule, CustomerModule], controllers: [NeoTestController], providers: [NeoLeapService], }) diff --git a/src/common/modules/neoleap/services/neoleap.service.ts b/src/common/modules/neoleap/services/neoleap.service.ts index 950d231..0a68b68 100644 --- a/src/common/modules/neoleap/services/neoleap.service.ts +++ b/src/common/modules/neoleap/services/neoleap.service.ts @@ -1,15 +1,15 @@ import { HttpService } from '@nestjs/axios'; -import { Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable, InternalServerErrorException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { plainToInstance } from 'class-transformer'; +import { ClassConstructor, plainToInstance } from 'class-transformer'; import moment from 'moment'; import { v4 as uuid } from 'uuid'; import { CountriesNumericISO } from '~/common/constants'; import { Customer } from '~/customer/entities'; import { Gender } from '~/customer/enums'; -import { INQUIRE_APPLICATION_MOCK } from '../__mocks__/'; -import { InquireApplicationResponse } from '../dtos/response'; -import { ICreateApplicationRequest, INeoleapHeaderRequest } from '../interfaces'; +import { CREATE_APPLICATION_MOCK, INQUIRE_APPLICATION_MOCK } from '../__mocks__/'; +import { CreateApplicationResponse, InquireApplicationResponse } from '../dtos/response'; +import { ICreateApplicationRequest, IInquireApplicationRequest, INeoleapHeaderRequest } from '../interfaces'; @Injectable() export class NeoLeapService { private readonly baseUrl: string; @@ -23,9 +23,11 @@ export class NeoLeapService { } async createApplication(customer: Customer) { + const responseKey = 'CreateNewApplicationResponseDetails'; if (this.useMock) { - //@TODO return mock data - return; + return plainToInstance(CreateApplicationResponse, CREATE_APPLICATION_MOCK[responseKey], { + excludeExtraneousValues: true, + }); } const payload: ICreateApplicationRequest = { @@ -33,8 +35,8 @@ export class NeoLeapService { ApplicationRequestDetails: { InstitutionCode: this.institutionCode, ExternalApplicationNumber: customer.waitingNumber.toString(), - ApplicationType: 'New', - Product: '3001', + ApplicationType: '01', + Product: '1101', ApplicationDate: moment().format('YYYY-MM-DD'), BranchCode: '000', ApplicationSource: 'O', @@ -46,12 +48,19 @@ export class NeoLeapService { AssignedLimit: 0, ProcessControl: 'STND', }, + ApplicationFinancialInformation: { + Currency: { + AlphaCode: 'SAR', + }, + BillingCycle: 'C1', + }, + ApplicationOtherInfo: {}, ApplicationCustomerDetails: { FirstName: customer.firstName, LastName: customer.lastName, - FullName: `${customer.firstName} ${customer.lastName}`, + FullName: customer.fullName, DateOfBirth: moment(customer.dateOfBirth).format('YYYY-MM-DD'), - EmbossName: `${customer.firstName} ${customer.lastName}`, // TODO Enter Emboss Name + EmbossName: customer.fullName.toUpperCase(), // TODO Enter Emboss Name IdType: '001', IdNumber: customer.nationalId, IdExpiryDate: moment(customer.nationalIdExpiry).format('YYYY-MM-DD'), @@ -75,17 +84,15 @@ export class NeoLeapService { }, }, }, - ...this.prepareHeaders('CreateNewApplication'), + RequestHeader: this.prepareHeaders('CreateNewApplication'), }; - const response = await this.httpService.axiosRef.post(`${this.baseUrl}/application/CreateNewApplication`, payload, { - headers: { - 'Content-Type': 'application/json', - Authorization: `${this.apiKey}`, - }, - }); - - //@TODO handle response + return this.sendRequestToNeoLeap( + 'application/CreateNewApplication', + payload, + responseKey, + CreateApplicationResponse, + ); } async inquireApplication(externalApplicationNumber: string) { @@ -105,40 +112,57 @@ export class NeoLeapService { AdditionalData: { ReturnApplicationType: true, ReturnApplicationStatus: true, - ReturnCard: true, ReturnCustomer: true, + ReturnAddresses: true, }, }, - ...this.prepareHeaders('InquireApplication'), + RequestHeader: this.prepareHeaders('InquireApplication'), }; - const response = await this.httpService.axiosRef.post(`${this.baseUrl}/application/InquireApplication`, payload, { - headers: { - 'Content-Type': 'application/json', - Authorization: `${this.apiKey}`, - }, - }); + return this.sendRequestToNeoLeap( + 'application/InquireApplication', + payload, + responseKey, + InquireApplicationResponse, + ); + } - if (response.data?.[responseKey]) { - return plainToInstance(InquireApplicationResponse, response.data[responseKey], { + private prepareHeaders(serviceName: string): INeoleapHeaderRequest['RequestHeader'] { + return { + Version: '1.0.0', + MsgUid: uuid(), + Source: 'ZOD', + ServiceId: serviceName, + ReqDateTime: new Date(), + }; + } + + private async sendRequestToNeoLeap( + endpoint: string, + payload: T, + responseKey: string, + responseClass: ClassConstructor, + ): Promise { + try { + const response = await this.httpService.axiosRef.post(`${this.baseUrl}/${endpoint}`, payload, { + headers: { + 'Content-Type': 'application/json', + Authorization: `${this.apiKey}`, + }, + }); + + if (response.data?.ResponseHeader.ResponseCode !== '000' || !response.data[responseKey]) { + throw new BadRequestException( + response.data.ResponseHeader.ResponseDescription || 'Unexpected Error from Service Provider', + ); + } + + return plainToInstance(responseClass, response.data[responseKey], { excludeExtraneousValues: true, }); - } else { - throw new Error('Invalid response from NeoLeap API'); + } catch (error) { + console.error('Error sending request to NeoLeap:', error); + throw new InternalServerErrorException('Error communicating with NeoLeap service'); } - - //@TODO handle response - } - - private prepareHeaders(serviceName: string): INeoleapHeaderRequest { - return { - RequestHeader: { - Version: '1.0.0', - MsgUid: uuid(), - Source: 'FINTECH', - ServiceId: serviceName, - ReqDateTime: new Date(), - }, - }; } } diff --git a/src/customer/entities/customer.entity.ts b/src/customer/entities/customer.entity.ts index 01230ae..b8bdc65 100644 --- a/src/customer/entities/customer.entity.ts +++ b/src/customer/entities/customer.entity.ts @@ -5,10 +5,12 @@ import { Entity, Generated, JoinColumn, + OneToMany, OneToOne, PrimaryColumn, UpdateDateColumn, } from 'typeorm'; +import { Card } from '~/card/entities'; import { CountryIso } from '~/common/enums'; import { Document } from '~/document/entities'; import { Guardian } from '~/guardian/entities/guradian.entity'; @@ -125,9 +127,21 @@ export class Customer extends BaseEntity { @JoinColumn({ name: 'civil_id_back_id' }) civilIdBack!: Document; + // relation ship between customer and card + @OneToMany(() => Card, (card) => card.customer) + cards!: Card[]; + + // relationship between cards and their parent customer + @OneToMany(() => Card, (card) => card.parentCustomer) + childCards!: Card[]; + @CreateDateColumn({ type: 'timestamp with time zone', default: () => 'CURRENT_TIMESTAMP', name: 'created_at' }) createdAt!: Date; @UpdateDateColumn({ type: 'timestamp with time zone', default: () => 'CURRENT_TIMESTAMP', name: 'updated_at' }) updatedAt!: Date; + + get fullName(): string { + return `${this.firstName} ${this.lastName}`; + } } diff --git a/src/customer/services/customer.service.ts b/src/customer/services/customer.service.ts index 85c4d0a..dff93ff 100644 --- a/src/customer/services/customer.service.ts +++ b/src/customer/services/customer.service.ts @@ -198,4 +198,8 @@ export class CustomerService { customer.civilIdBack.url = civilIdBackUrl; return customer; } + + async findAnyCustomer() { + return this.customerRepository.findOne({ isGuardian: true }); + } } From d1a6d3e71516f58e1700496efc0ccdcbb9fbf1d1 Mon Sep 17 00:00:00 2001 From: Abdalhamid Alhamad Date: Wed, 4 Jun 2025 10:04:45 +0300 Subject: [PATCH 05/32] feat: add test controller for integartion --- .gitignore | 2 ++ nest-cli.json | 1 + .../neoleap/controllers/neotest.controller.ts | 2 +- .../modules/neoleap/services/neoleap.service.ts | 14 ++++++++++++++ 4 files changed, 18 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 6732258..4cb3dae 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,5 @@ pids # Diagnostic reports (https://nodejs.org/api/report.html) report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +zod-certs diff --git a/nest-cli.json b/nest-cli.json index 60e4148..87dfbbf 100644 --- a/nest-cli.json +++ b/nest-cli.json @@ -11,6 +11,7 @@ "exclude": "**/*.md" }, { "include": "common/modules/**/templates/**/*", "watchAssets": true }, + { "include": "common/modules/neoleap/zod-certs" }, "i18n", "files" ] diff --git a/src/common/modules/neoleap/controllers/neotest.controller.ts b/src/common/modules/neoleap/controllers/neotest.controller.ts index 5475442..b8a3250 100644 --- a/src/common/modules/neoleap/controllers/neotest.controller.ts +++ b/src/common/modules/neoleap/controllers/neotest.controller.ts @@ -11,7 +11,7 @@ export class NeoTestController { @Get('inquire-application') async inquireApplication() { - return this.neoleapService.inquireApplication('1234567890'); + return this.neoleapService.inquireApplication('1'); } @Get('create-application') diff --git a/src/common/modules/neoleap/services/neoleap.service.ts b/src/common/modules/neoleap/services/neoleap.service.ts index 0a68b68..fd9fbce 100644 --- a/src/common/modules/neoleap/services/neoleap.service.ts +++ b/src/common/modules/neoleap/services/neoleap.service.ts @@ -2,7 +2,10 @@ import { HttpService } from '@nestjs/axios'; import { BadRequestException, Injectable, InternalServerErrorException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { ClassConstructor, plainToInstance } from 'class-transformer'; +import { readFileSync } from 'fs'; +import { Agent } from 'https'; import moment from 'moment'; +import path from 'path'; import { v4 as uuid } from 'uuid'; import { CountriesNumericISO } from '~/common/constants'; import { Customer } from '~/customer/entities'; @@ -16,10 +19,12 @@ export class NeoLeapService { private readonly apiKey: string; private readonly useMock: boolean; private readonly institutionCode = '1100'; + useLocalCert: boolean; constructor(private readonly httpService: HttpService, private readonly configService: ConfigService) { this.baseUrl = this.configService.getOrThrow('NEOLEAP_BASE_URL'); this.apiKey = this.configService.getOrThrow('NEOLEAP_API_KEY'); this.useMock = [true, 'true'].includes(this.configService.get('USE_MOCK', false)); + this.useLocalCert = this.configService.get('USE_LOCAL_CERT', false); } async createApplication(customer: Customer) { @@ -148,7 +153,16 @@ export class NeoLeapService { headers: { 'Content-Type': 'application/json', Authorization: `${this.apiKey}`, + Host: 'apigw-uat.neoleap.com.sa', }, + httpsAgent: new Agent({ + rejectUnauthorized: false, // Disable SSL verification for development purposes + ca: this.useLocalCert ? readFileSync(path.join(__dirname, '../zod-certs/My_CA_Bundle.ca-bundle')) : undefined, + cert: this.useLocalCert + ? readFileSync(path.join(__dirname, '../zod-certs/gw-dev_zodwallet_com.crt')) + : undefined, + key: this.useLocalCert ? readFileSync(path.join(__dirname, '../zod-certs/server.key')) : undefined, + }), }); if (response.data?.ResponseHeader.ResponseCode !== '000' || !response.data[responseKey]) { From d3057beb54384afca9799fe11f37c838c2e9b9fe Mon Sep 17 00:00:00 2001 From: Abdalhamid Alhamad Date: Wed, 2 Jul 2025 18:42:38 +0300 Subject: [PATCH 06/32] feat: add transaction, card , and account entities --- src/card/card.module.ts | 19 ++- src/card/entities/account.entity.ts | 31 ++++ src/card/entities/card.entity.ts | 23 ++- src/card/entities/transaction.entity.ts | 62 +++++++ src/card/enums/index.ts | 1 + src/card/enums/transaction-type.enum.ts | 4 + src/card/repositories/account.repository.ts | 19 +++ src/card/repositories/card.repository.ts | 37 +++++ src/card/repositories/index.ts | 1 + .../repositories/transaction.repository.ts | 34 ++++ src/card/services/account.service.ts | 12 ++ src/card/services/card.service.ts | 37 +++++ src/card/services/index.ts | 1 + src/card/services/transaction.service.ts | 16 ++ .../neoleap-webhooks.controller.ts | 14 ++ .../neoleap/controllers/neotest.controller.ts | 25 ++- .../card-transaction-webhook.request.dto.ts | 153 ++++++++++++++++++ .../modules/neoleap/dtos/requests/index.ts | 1 + .../create-application.response.dto.ts | 32 +++- .../response/inquire-application.response.ts | 36 +++++ src/common/modules/neoleap/neoleap.module.ts | 6 +- .../neoleap/services/neoleap.service.ts | 15 +- src/core/pipes/validation.pipe.ts | 2 +- src/customer/entities/customer.entity.ts | 2 +- .../repositories/customer.repository.ts | 2 +- .../1749633935436-create-card-entity.ts | 40 +++++ .../1751456987627-create-account-entity.ts | 45 ++++++ .../1751466314709-create-transaction-table.ts | 18 +++ src/db/migrations/index.ts | 3 + 29 files changed, 670 insertions(+), 21 deletions(-) create mode 100644 src/card/entities/account.entity.ts create mode 100644 src/card/entities/transaction.entity.ts create mode 100644 src/card/enums/transaction-type.enum.ts create mode 100644 src/card/repositories/account.repository.ts create mode 100644 src/card/repositories/card.repository.ts create mode 100644 src/card/repositories/index.ts create mode 100644 src/card/repositories/transaction.repository.ts create mode 100644 src/card/services/account.service.ts create mode 100644 src/card/services/card.service.ts create mode 100644 src/card/services/index.ts create mode 100644 src/card/services/transaction.service.ts create mode 100644 src/common/modules/neoleap/controllers/neoleap-webhooks.controller.ts create mode 100644 src/common/modules/neoleap/dtos/requests/card-transaction-webhook.request.dto.ts create mode 100644 src/common/modules/neoleap/dtos/requests/index.ts create mode 100644 src/db/migrations/1749633935436-create-card-entity.ts create mode 100644 src/db/migrations/1751456987627-create-account-entity.ts create mode 100644 src/db/migrations/1751466314709-create-transaction-table.ts diff --git a/src/card/card.module.ts b/src/card/card.module.ts index f6f616f..92500ad 100644 --- a/src/card/card.module.ts +++ b/src/card/card.module.ts @@ -1,8 +1,25 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Card } from './entities'; +import { Account } from './entities/account.entity'; +import { Transaction } from './entities/transaction.entity'; +import { CardRepository } from './repositories'; +import { AccountRepository } from './repositories/account.repository'; +import { TransactionRepository } from './repositories/transaction.repository'; +import { CardService } from './services'; +import { AccountService } from './services/account.service'; +import { TransactionService } from './services/transaction.service'; @Module({ - imports: [TypeOrmModule.forFeature([Card])], + imports: [TypeOrmModule.forFeature([Card, Account, Transaction])], + providers: [ + CardService, + CardRepository, + TransactionService, + TransactionRepository, + AccountService, + AccountRepository, + ], + exports: [CardService, TransactionService], }) export class CardModule {} diff --git a/src/card/entities/account.entity.ts b/src/card/entities/account.entity.ts new file mode 100644 index 0000000..cdd7847 --- /dev/null +++ b/src/card/entities/account.entity.ts @@ -0,0 +1,31 @@ +import { Column, CreateDateColumn, Entity, Index, OneToMany, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'; +import { Card } from './card.entity'; +import { Transaction } from './transaction.entity'; + +@Entity('accounts') +export class Account { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column('varchar', { length: 255, nullable: false, unique: true, name: 'account_reference' }) + @Index({ unique: true }) + accountReference!: string; + + @Column('varchar', { length: 255, nullable: false, name: 'currency' }) + currency!: string; + + @Column('decimal', { precision: 10, scale: 2, default: 0.0, name: 'balance' }) + balance!: number; + + @OneToMany(() => Card, (card) => card.account, { cascade: true }) + cards!: Card[]; + + @OneToMany(() => Transaction, (transaction) => transaction.account, { cascade: true }) + transactions!: Transaction[]; + + @CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamp with time zone' }) + updatedAt!: Date; +} diff --git a/src/card/entities/card.entity.ts b/src/card/entities/card.entity.ts index bc992da..4984a61 100644 --- a/src/card/entities/card.entity.ts +++ b/src/card/entities/card.entity.ts @@ -5,13 +5,16 @@ import { Index, JoinColumn, ManyToOne, + OneToMany, PrimaryGeneratedColumn, UpdateDateColumn, } from 'typeorm'; import { Customer } from '~/customer/entities'; import { CardColors, CardIssuers, CardScheme, CardStatus, CustomerType } from '../enums'; +import { Account } from './account.entity'; +import { Transaction } from './transaction.entity'; -@Entity() +@Entity('cards') export class Card { @PrimaryGeneratedColumn('uuid') id!: string; @@ -20,14 +23,14 @@ export class Card { @Column({ name: 'card_reference', nullable: false, type: 'varchar' }) cardReference!: string; - @Column({ length: 5, name: 'first_five_digits', nullable: false, type: 'varchar' }) - firstFiveDigits!: string; + @Column({ length: 6, name: 'first_six_digits', nullable: false, type: 'varchar' }) + firstSixDigits!: string; @Column({ length: 4, name: 'last_four_digits', nullable: false, type: 'varchar' }) lastFourDigits!: string; - @Column({ type: 'date', nullable: false }) - expiry!: Date; + @Column({ type: 'varchar', nullable: false }) + expiry!: string; @Column({ type: 'varchar', nullable: false, name: 'customer_type' }) customerType!: CustomerType; @@ -50,6 +53,9 @@ export class Card { @Column({ type: 'uuid', name: 'parent_id', nullable: true }) parentId?: string; + @Column({ type: 'uuid', name: 'account_id', nullable: false }) + accountId!: string; + @ManyToOne(() => Customer, (customer) => customer.childCards) @JoinColumn({ name: 'parent_id' }) parentCustomer?: Customer; @@ -58,6 +64,13 @@ export class Card { @JoinColumn({ name: 'customer_id' }) customer!: Customer; + @ManyToOne(() => Account, (account) => account.cards, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'account_id' }) + account!: Account; + + @OneToMany(() => Transaction, (transaction) => transaction.card, { cascade: true }) + transactions!: Transaction[]; + @CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone' }) createdAt!: Date; diff --git a/src/card/entities/transaction.entity.ts b/src/card/entities/transaction.entity.ts new file mode 100644 index 0000000..6e4d693 --- /dev/null +++ b/src/card/entities/transaction.entity.ts @@ -0,0 +1,62 @@ +import { Column, CreateDateColumn, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; +import { TransactionType } from '../enums'; +import { Account } from './account.entity'; +import { Card } from './card.entity'; + +@Entity('transactions') +export class Transaction { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ name: 'card_reference', nullable: true, type: 'varchar' }) + cardReference!: string; + + @Column({ name: 'transaction_type', type: 'varchar', default: TransactionType.EXTERNAL }) + transactionType!: TransactionType; + + @Column({ name: 'account_reference', nullable: true, type: 'varchar' }) + accountReference!: string; + + @Column({ name: 'transaction_id', unique: true, nullable: true, type: 'varchar' }) + transactionId!: string; + + @Column({ name: 'card_masked_number', nullable: true, type: 'varchar' }) + cardMaskedNumber!: string; + + @Column({ type: 'timestamp with time zone', name: 'transaction_date', nullable: true }) + transactionDate!: Date; + + @Column({ name: 'rrn', nullable: true, type: 'varchar' }) + rrn!: string; + + @Column({ type: 'decimal', precision: 12, scale: 2, name: 'transaction_amount' }) + transactionAmount!: number; + + @Column({ type: 'varchar', name: 'transaction_currency' }) + transactionCurrency!: string; + + @Column({ type: 'decimal', name: 'billing_amount', precision: 12, scale: 2 }) + billingAmount!: number; + + @Column({ type: 'decimal', name: 'settlement_amount', precision: 12, scale: 2 }) + settlementAmount!: number; + + @Column({ type: 'decimal', name: 'fees', precision: 12, scale: 2 }) + fees!: number; + + @Column({ name: 'card_id', type: 'uuid', nullable: true }) + cardId!: string; + + @Column({ name: 'account_id', type: 'uuid', nullable: true }) + accountId!: string; + + @ManyToOne(() => Card, (card) => card.transactions, { onDelete: 'CASCADE', nullable: true }) + @JoinColumn({ name: 'card_id' }) + card!: Card; + + @ManyToOne(() => Account, (account) => account.transactions, { onDelete: 'CASCADE', nullable: true }) + @JoinColumn({ name: 'account_id' }) + account!: Account; + @CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone' }) + createdAt!: Date; +} diff --git a/src/card/enums/index.ts b/src/card/enums/index.ts index 6a9d197..03f577c 100644 --- a/src/card/enums/index.ts +++ b/src/card/enums/index.ts @@ -3,3 +3,4 @@ export * from './card-issuers.enum'; export * from './card-scheme.enum'; export * from './card-status.enum'; export * from './customer-type.enum'; +export * from './transaction-type.enum'; diff --git a/src/card/enums/transaction-type.enum.ts b/src/card/enums/transaction-type.enum.ts new file mode 100644 index 0000000..a65e819 --- /dev/null +++ b/src/card/enums/transaction-type.enum.ts @@ -0,0 +1,4 @@ +export enum TransactionType { + INTERNAL = 'INTERNAL', + EXTERNAL = 'EXTERNAL', +} diff --git a/src/card/repositories/account.repository.ts b/src/card/repositories/account.repository.ts new file mode 100644 index 0000000..5f4a357 --- /dev/null +++ b/src/card/repositories/account.repository.ts @@ -0,0 +1,19 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Account } from '../entities/account.entity'; + +@Injectable() +export class AccountRepository { + constructor(@InjectRepository(Account) private readonly accountRepository: Repository) {} + + createAccount(accountId: string): Promise { + return this.accountRepository.save( + this.accountRepository.create({ + accountReference: accountId, + balance: 0, + currency: '682', + }), + ); + } +} diff --git a/src/card/repositories/card.repository.ts b/src/card/repositories/card.repository.ts new file mode 100644 index 0000000..a83d71c --- /dev/null +++ b/src/card/repositories/card.repository.ts @@ -0,0 +1,37 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { CreateApplicationResponse } from '~/common/modules/neoleap/dtos/response'; +import { Card } from '../entities'; +import { CardColors, CardIssuers, CardScheme, CardStatus, CustomerType } from '../enums'; + +@Injectable() +export class CardRepository { + constructor(@InjectRepository(Card) private readonly cardRepository: Repository) {} + + createCard(customerId: string, accountId: string, card: CreateApplicationResponse): Promise { + return this.cardRepository.save( + this.cardRepository.create({ + customerId: customerId, + expiry: card.expiryDate, + cardReference: card.cardId, + customerType: CustomerType.PARENT, + firstSixDigits: card.firstSixDigits, + lastFourDigits: card.lastFourDigits, + color: CardColors.BLUE, + status: CardStatus.ACTIVE, + scheme: CardScheme.VISA, + issuer: CardIssuers.NEOLEAP, + accountId: accountId, + }), + ); + } + + getCardById(id: string): Promise { + return this.cardRepository.findOne({ where: { id } }); + } + + getCardByReferenceNumber(referenceNumber: string): Promise { + return this.cardRepository.findOne({ where: { cardReference: referenceNumber }, relations: ['account'] }); + } +} diff --git a/src/card/repositories/index.ts b/src/card/repositories/index.ts new file mode 100644 index 0000000..8458740 --- /dev/null +++ b/src/card/repositories/index.ts @@ -0,0 +1 @@ +export * from './card.repository'; diff --git a/src/card/repositories/transaction.repository.ts b/src/card/repositories/transaction.repository.ts new file mode 100644 index 0000000..fa11dd8 --- /dev/null +++ b/src/card/repositories/transaction.repository.ts @@ -0,0 +1,34 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import moment from 'moment'; +import { Repository } from 'typeorm'; +import { CardTransactionWebhookRequest } from '~/common/modules/neoleap/dtos/requests'; +import { Card } from '../entities'; +import { Transaction } from '../entities/transaction.entity'; +import { TransactionType } from '../enums'; + +@Injectable() +export class TransactionRepository { + constructor(@InjectRepository(Transaction) private transactionRepository: Repository) {} + + createCardTransaction(card: Card, transactionData: CardTransactionWebhookRequest): Promise { + return this.transactionRepository.save( + this.transactionRepository.create({ + transactionId: transactionData.transactionId, + cardReference: transactionData.cardId, + transactionAmount: transactionData.transactionAmount, + transactionCurrency: transactionData.transactionCurrency, + billingAmount: transactionData.billingAmount, + settlementAmount: transactionData.settlementAmount, + transactionDate: moment(transactionData.date + transactionData.time, 'YYYYMMDDHHmmss').toDate(), + rrn: transactionData.rrn, + cardMaskedNumber: transactionData.cardMaskedNumber, + fees: transactionData.fees, + cardId: card.id, + accountId: card.account!.id, + transactionType: TransactionType.EXTERNAL, + accountReference: card.account!.accountReference, + }), + ); + } +} diff --git a/src/card/services/account.service.ts b/src/card/services/account.service.ts new file mode 100644 index 0000000..a8c4791 --- /dev/null +++ b/src/card/services/account.service.ts @@ -0,0 +1,12 @@ +import { Injectable } from '@nestjs/common'; +import { Account } from '../entities/account.entity'; +import { AccountRepository } from '../repositories/account.repository'; + +@Injectable() +export class AccountService { + constructor(private readonly accountRepository: AccountRepository) {} + + createAccount(accountId: string): Promise { + return this.accountRepository.createAccount(accountId); + } +} diff --git a/src/card/services/card.service.ts b/src/card/services/card.service.ts new file mode 100644 index 0000000..25573da --- /dev/null +++ b/src/card/services/card.service.ts @@ -0,0 +1,37 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { Transactional } from 'typeorm-transactional'; +import { CreateApplicationResponse } from '~/common/modules/neoleap/dtos/response'; +import { Card } from '../entities'; +import { CardRepository } from '../repositories'; +import { AccountService } from './account.service'; + +@Injectable() +export class CardService { + constructor(private readonly cardRepository: CardRepository, private readonly accountService: AccountService) {} + + @Transactional() + async createCard(customerId: string, cardData: CreateApplicationResponse): Promise { + const account = await this.accountService.createAccount(cardData.accountId); + return this.cardRepository.createCard(customerId, account.id, cardData); + } + + async getCardById(id: string): Promise { + const card = await this.cardRepository.getCardById(id); + + if (!card) { + throw new BadRequestException('CARD.NOT_FOUND'); + } + + return card; + } + + async getCardByReferenceNumber(referenceNumber: string): Promise { + const card = await this.cardRepository.getCardByReferenceNumber(referenceNumber); + + if (!card) { + throw new BadRequestException('CARD.NOT_FOUND'); + } + + return card; + } +} diff --git a/src/card/services/index.ts b/src/card/services/index.ts new file mode 100644 index 0000000..ea35f0f --- /dev/null +++ b/src/card/services/index.ts @@ -0,0 +1 @@ +export * from './card.service'; diff --git a/src/card/services/transaction.service.ts b/src/card/services/transaction.service.ts new file mode 100644 index 0000000..533bd16 --- /dev/null +++ b/src/card/services/transaction.service.ts @@ -0,0 +1,16 @@ +import { Injectable } from '@nestjs/common'; +import { CardTransactionWebhookRequest } from '~/common/modules/neoleap/dtos/requests'; +import { TransactionRepository } from '../repositories/transaction.repository'; +import { CardService } from './card.service'; + +@Injectable() +export class TransactionService { + constructor( + private readonly transactionRepository: TransactionRepository, + private readonly cardService: CardService, + ) {} + async createCardTransaction(body: CardTransactionWebhookRequest) { + const card = await this.cardService.getCardByReferenceNumber(body.cardId); + return this.transactionRepository.createCardTransaction(card, body); + } +} diff --git a/src/common/modules/neoleap/controllers/neoleap-webhooks.controller.ts b/src/common/modules/neoleap/controllers/neoleap-webhooks.controller.ts new file mode 100644 index 0000000..0089a66 --- /dev/null +++ b/src/common/modules/neoleap/controllers/neoleap-webhooks.controller.ts @@ -0,0 +1,14 @@ +import { Body, Controller, Post } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { TransactionService } from '~/card/services/transaction.service'; +import { CardTransactionWebhookRequest } from '../dtos/requests'; + +@Controller('neoleap-webhooks') +@ApiTags('Neoleap Webhooks') +export class NeoLeapWebhooksController { + constructor(private readonly transactionService: TransactionService) {} + @Post('account-transaction') + async handleAccountTransactionWebhook(@Body() body: CardTransactionWebhookRequest) { + await this.transactionService.createCardTransaction(body); + } +} diff --git a/src/common/modules/neoleap/controllers/neotest.controller.ts b/src/common/modules/neoleap/controllers/neotest.controller.ts index b8a3250..5d8ff8a 100644 --- a/src/common/modules/neoleap/controllers/neotest.controller.ts +++ b/src/common/modules/neoleap/controllers/neotest.controller.ts @@ -1,22 +1,39 @@ import { Controller, Get } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { ApiTags } from '@nestjs/swagger'; +import { CardService } from '~/card/services'; +import { ApiDataResponse } from '~/core/decorators'; +import { ResponseFactory } from '~/core/utils'; import { Customer } from '~/customer/entities'; import { CustomerService } from '~/customer/services'; +import { CreateApplicationResponse, InquireApplicationResponse } from '../dtos/response'; import { NeoLeapService } from '../services/neoleap.service'; @Controller('neotest') @ApiTags('Neoleap Test API , for testing purposes only, will be removed in production') export class NeoTestController { - constructor(private readonly neoleapService: NeoLeapService, private readonly customerService: CustomerService) {} + constructor( + private readonly neoleapService: NeoLeapService, + private readonly customerService: CustomerService, + private readonly cardService: CardService, + private readonly configService: ConfigService, + ) {} @Get('inquire-application') + @ApiDataResponse(InquireApplicationResponse) async inquireApplication() { - return this.neoleapService.inquireApplication('1'); + const data = await this.neoleapService.inquireApplication('15'); + return ResponseFactory.data(data); } @Get('create-application') + @ApiDataResponse(CreateApplicationResponse) async createApplication() { - const customer = await this.customerService.findAnyCustomer(); - return this.neoleapService.createApplication(customer as Customer); + const customer = await this.customerService.findCustomerById( + this.configService.get('MOCK_CUSTOMER_ID', '0778c431-f604-4b91-af53-49c33849b5ff'), + ); + const data = await this.neoleapService.createApplication(customer as Customer); + await this.cardService.createCard(customer.id, data); + return ResponseFactory.data(data); } } diff --git a/src/common/modules/neoleap/dtos/requests/card-transaction-webhook.request.dto.ts b/src/common/modules/neoleap/dtos/requests/card-transaction-webhook.request.dto.ts new file mode 100644 index 0000000..8a59b19 --- /dev/null +++ b/src/common/modules/neoleap/dtos/requests/card-transaction-webhook.request.dto.ts @@ -0,0 +1,153 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Expose, Type } from 'class-transformer'; +import { IsNumber, IsString, ValidateNested } from 'class-validator'; + +export class CardAcceptorLocationDto { + @Expose() + @IsString() + @ApiProperty() + merchantId!: string; + + @Expose() + @IsString() + @ApiProperty() + merchantName!: string; + + @Expose() + @IsString() + @ApiProperty() + merchantCountry!: string; + + @Expose() + @IsString() + @ApiProperty() + merchantCity!: string; + + @Expose() + @IsString() + @ApiProperty() + mcc!: string; +} + +export class CardTransactionWebhookRequest { + @Expose({ name: 'InstId' }) + @IsString() + @ApiProperty({ name: 'InstId', example: '1100' }) + instId!: string; + + @Expose() + @IsString() + @ApiProperty({ example: '30829' }) + cardId!: string; + + @Expose() + @IsString() + @ApiProperty({ example: '1234567890123456' }) + transactionId!: string; + + @Expose() + @IsString() + @ApiProperty({ example: '277012*****3456' }) + cardMaskedNumber!: string; + + @Expose() + @IsString() + @ApiProperty({ example: '1234567890123456' }) + accountNumber!: string; + + @Expose({ name: 'Date' }) + @IsString() + @ApiProperty({ name: 'Date', example: '20241112' }) + date!: string; + + @Expose({ name: 'Time' }) + @IsString() + @ApiProperty({ name: 'Time', example: '125250' }) + time!: string; + + @Expose() + @Type(() => Number) + @IsNumber() + @ApiProperty({ example: '132' }) + otb!: number; + + @Expose() + @IsString() + @ApiProperty({ example: '0' }) + transactionCode!: string; + + @Expose() + @IsString() + @ApiProperty({ example: '1' }) + messageClass!: string; + + @Expose({ name: 'RRN' }) + @IsString() + @ApiProperty({ name: 'RRN', example: '431712003306' }) + rrn!: string; + + @Expose() + @IsString() + @ApiProperty({ example: '3306' }) + stan!: string; + + @Expose() + @ValidateNested() + @Type(() => CardAcceptorLocationDto) + @ApiProperty({ type: CardAcceptorLocationDto }) + cardAcceptorLocation!: CardAcceptorLocationDto; + + @Type(() => Number) + @IsNumber() + @ApiProperty({ example: '100.5' }) + transactionAmount!: number; + + @IsString() + @ApiProperty({ example: '682' }) + transactionCurrency!: string; + + @Type(() => Number) + @IsNumber() + @ApiProperty({ example: '100.5' }) + billingAmount!: number; + + @IsString() + @ApiProperty({ example: '682' }) + billingCurrency!: string; + + @Type(() => Number) + @IsNumber() + @ApiProperty({ example: '100.5' }) + settlementAmount!: number; + + @IsString() + @ApiProperty({ example: '682' }) + settlementCurrency!: string; + + @Expose() + @Type(() => Number) + @IsNumber() + @ApiProperty({ example: '20' }) + fees!: number; + + @Expose() + @Type(() => Number) + @IsNumber() + @ApiProperty({ example: '4.5' }) + vatOnFees!: number; + + @Expose() + @IsString() + @ApiProperty({ example: '9' }) + posEntryMode!: string; + + @Expose() + @IsString() + @ApiProperty({ example: '036657' }) + authIdResponse!: string; + + @Expose({ name: 'POSCDIM' }) + @IsString() + @ApiProperty({ name: 'POSCDIM', example: '9' }) + posCdim!: string; +} diff --git a/src/common/modules/neoleap/dtos/requests/index.ts b/src/common/modules/neoleap/dtos/requests/index.ts new file mode 100644 index 0000000..a62ed5c --- /dev/null +++ b/src/common/modules/neoleap/dtos/requests/index.ts @@ -0,0 +1 @@ +export * from './card-transaction-webhook.request.dto'; diff --git a/src/common/modules/neoleap/dtos/response/create-application.response.dto.ts b/src/common/modules/neoleap/dtos/response/create-application.response.dto.ts index 3e186f8..f135b4f 100644 --- a/src/common/modules/neoleap/dtos/response/create-application.response.dto.ts +++ b/src/common/modules/neoleap/dtos/response/create-application.response.dto.ts @@ -1,12 +1,40 @@ +import { ApiProperty } from '@nestjs/swagger'; import { Expose, Transform } from 'class-transformer'; import { InquireApplicationResponse } from './inquire-application.response'; export class CreateApplicationResponse extends InquireApplicationResponse { - @Transform(({ obj }) => obj.CardDetailsList?.[0]?.ResponseCardIdentifier.Id) + @Transform(({ obj }) => obj.CardDetailsList?.[0]?.ResponseCardIdentifier.Id.toString()) @Expose() - cardId!: number; + @ApiProperty() + cardId!: string; @Transform(({ obj }) => obj.CardDetailsList?.[0]?.ResponseCardIdentifier.VPan) @Expose() + @ApiProperty() vpan!: string; + + @Transform(({ obj }) => obj.CardDetailsList?.[0]?.ExpiryDate) + @Expose() + @ApiProperty() + expiryDate!: string; + + @Transform(({ obj }) => obj.CardDetailsList?.[0]?.CardStatus) + @Expose() + @ApiProperty() + cardStatus!: string; + + @Transform(({ obj }) => obj.CardDetailsList?.[0]?.ResponseCardIdentifier?.MaskedPan?.split('_')[0]) + @Expose() + @ApiProperty() + firstSixDigits!: string; + + @Transform(({ obj }) => obj.CardDetailsList?.[0]?.ResponseCardIdentifier?.MaskedPan?.split('_')[1]) + @Expose() + @ApiProperty() + lastFourDigits!: string; + + @Transform(({ obj }) => obj.AccountDetailsList?.[0]?.Id.toString()) + @Expose() + @ApiProperty() + accountId!: string; } diff --git a/src/common/modules/neoleap/dtos/response/inquire-application.response.ts b/src/common/modules/neoleap/dtos/response/inquire-application.response.ts index b31c69d..aeb7165 100644 --- a/src/common/modules/neoleap/dtos/response/inquire-application.response.ts +++ b/src/common/modules/neoleap/dtos/response/inquire-application.response.ts @@ -1,143 +1,179 @@ +import { ApiProperty } from '@nestjs/swagger'; import { Expose, Transform } from 'class-transformer'; export class InquireApplicationResponse { @Transform(({ obj }) => obj.ApplicationDetails?.ApplicationNumber) @Expose() + @ApiProperty() applicationNumber!: string; @Transform(({ obj }) => obj.ApplicationDetails?.ExternalApplicationNumber) @Expose() + @ApiProperty() externalApplicationNumber!: string; @Transform(({ obj }) => obj.ApplicationDetails?.ApplicationStatus) @Expose() + @ApiProperty() applicationStatus!: string; @Transform(({ obj }) => obj.ApplicationDetails?.Organization) @Expose() + @ApiProperty() organization!: number; @Transform(({ obj }) => obj.ApplicationDetails?.Product) @Expose() + @ApiProperty() product!: string; // this typo is from neoleap, so we keep it as is @Transform(({ obj }) => obj.ApplicationDetails?.ApplicatonDate) @Expose() + @ApiProperty() applicationDate!: string; @Transform(({ obj }) => obj.ApplicationDetails?.ApplicationSource) @Expose() + @ApiProperty() applicationSource!: string; @Transform(({ obj }) => obj.ApplicationDetails?.SalesSource) @Expose() + @ApiProperty() salesSource!: string; @Transform(({ obj }) => obj.ApplicationDetails?.DeliveryMethod) @Expose() + @ApiProperty() deliveryMethod!: string; @Transform(({ obj }) => obj.ApplicationDetails?.ProgramCode) @Expose() + @ApiProperty() ProgramCode!: string; @Transform(({ obj }) => obj.ApplicationDetails?.Plastic) @Expose() + @ApiProperty() plastic!: string; @Transform(({ obj }) => obj.ApplicationDetails?.Design) @Expose() + @ApiProperty() design!: string; @Transform(({ obj }) => obj.ApplicationDetails?.ProcessStage) @Expose() + @ApiProperty() processStage!: string; @Transform(({ obj }) => obj.ApplicationDetails?.ProcessStageStatus) @Expose() + @ApiProperty() processStageStatus!: string; @Transform(({ obj }) => obj.ApplicationDetails?.EligibilityCheckResult) @Expose() + @ApiProperty() eligibilityCheckResult!: string; @Transform(({ obj }) => obj.ApplicationDetails?.EligibilityCheckDescription) @Expose() + @ApiProperty() eligibilityCheckDescription!: string; @Transform(({ obj }) => obj.ApplicationDetails?.Title) @Expose() + @ApiProperty() title!: string; @Transform(({ obj }) => obj.ApplicationDetails?.FirstName) @Expose() + @ApiProperty() firstName!: string; @Transform(({ obj }) => obj.ApplicationDetails?.SecondName) @Expose() + @ApiProperty() secondName!: string; @Transform(({ obj }) => obj.ApplicationDetails?.ThirdName) @Expose() + @ApiProperty() thirdName!: string; @Transform(({ obj }) => obj.ApplicationDetails?.LastName) @Expose() + @ApiProperty() lastName!: string; @Transform(({ obj }) => obj.ApplicationDetails?.FullName) @Expose() + @ApiProperty() fullName!: string; @Transform(({ obj }) => obj.ApplicationDetails?.EmbossName) @Expose() + @ApiProperty() embossName!: string; @Transform(({ obj }) => obj.ApplicationDetails?.PlaceOfBirth) @Expose() + @ApiProperty() placeOfBirth!: string; @Transform(({ obj }) => obj.ApplicationDetails?.DateOfBirth) @Expose() + @ApiProperty() dateOfBirth!: string; @Transform(({ obj }) => obj.ApplicationDetails?.LocalizedDateOfBirth) @Expose() + @ApiProperty() localizedDateOfBirth!: string; @Transform(({ obj }) => obj.ApplicationDetails?.Age) @Expose() + @ApiProperty() age!: number; @Transform(({ obj }) => obj.ApplicationDetails?.Gender) @Expose() + @ApiProperty() gender!: string; @Transform(({ obj }) => obj.ApplicationDetails?.Married) @Expose() + @ApiProperty() married!: string; @Transform(({ obj }) => obj.ApplicationDetails.Nationality) @Expose() + @ApiProperty() nationality!: string; @Transform(({ obj }) => obj.ApplicationDetails.IdType) @Expose() + @ApiProperty() idType!: string; @Transform(({ obj }) => obj.ApplicationDetails.IdNumber) @Expose() + @ApiProperty() idNumber!: string; @Transform(({ obj }) => obj.ApplicationDetails.IdExpiryDate) @Expose() + @ApiProperty() idExpiryDate!: string; @Transform(({ obj }) => obj.ApplicationStatusDetails?.Description) @Expose() + @ApiProperty() applicationStatusDescription!: string; @Transform(({ obj }) => obj.ApplicationStatusDetails?.Canceled) @Expose() + @ApiProperty() canceled!: boolean; } diff --git a/src/common/modules/neoleap/neoleap.module.ts b/src/common/modules/neoleap/neoleap.module.ts index 232bef5..78c0224 100644 --- a/src/common/modules/neoleap/neoleap.module.ts +++ b/src/common/modules/neoleap/neoleap.module.ts @@ -1,12 +1,14 @@ import { HttpModule } from '@nestjs/axios'; import { Module } from '@nestjs/common'; +import { CardModule } from '~/card/card.module'; import { CustomerModule } from '~/customer/customer.module'; +import { NeoLeapWebhooksController } from './controllers/neoleap-webhooks.controller'; import { NeoTestController } from './controllers/neotest.controller'; import { NeoLeapService } from './services/neoleap.service'; @Module({ - imports: [HttpModule, CustomerModule], - controllers: [NeoTestController], + imports: [HttpModule, CustomerModule, CardModule], + controllers: [NeoTestController, NeoLeapWebhooksController], providers: [NeoLeapService], }) export class NeoLeapModule {} diff --git a/src/common/modules/neoleap/services/neoleap.service.ts b/src/common/modules/neoleap/services/neoleap.service.ts index fd9fbce..3c7802b 100644 --- a/src/common/modules/neoleap/services/neoleap.service.ts +++ b/src/common/modules/neoleap/services/neoleap.service.ts @@ -29,6 +29,9 @@ export class NeoLeapService { async createApplication(customer: Customer) { const responseKey = 'CreateNewApplicationResponseDetails'; + if (customer.cards.length > 0) { + throw new BadRequestException('CUSTOMER.ALREADY_HAS_CARD'); + } if (this.useMock) { return plainToInstance(CreateApplicationResponse, CREATE_APPLICATION_MOCK[responseKey], { excludeExtraneousValues: true, @@ -39,7 +42,7 @@ export class NeoLeapService { CreateNewApplicationRequestDetails: { ApplicationRequestDetails: { InstitutionCode: this.institutionCode, - ExternalApplicationNumber: customer.waitingNumber.toString(), + ExternalApplicationNumber: (customer.waitingNumber * 64).toString(), ApplicationType: '01', Product: '1101', ApplicationDate: moment().format('YYYY-MM-DD'), @@ -66,7 +69,7 @@ export class NeoLeapService { FullName: customer.fullName, DateOfBirth: moment(customer.dateOfBirth).format('YYYY-MM-DD'), EmbossName: customer.fullName.toUpperCase(), // TODO Enter Emboss Name - IdType: '001', + IdType: '01', IdNumber: customer.nationalId, IdExpiryDate: moment(customer.nationalIdExpiry).format('YYYY-MM-DD'), Title: customer.gender === Gender.MALE ? 'Mr' : 'Ms', @@ -76,7 +79,7 @@ export class NeoLeapService { }, ApplicationAddress: { City: customer.city, - Country: CountriesNumericISO[customer.countryOfResidence], + Country: CountriesNumericISO[customer.country], Region: customer.region, AddressLine1: `${customer.street} ${customer.building}`, AddressLine2: customer.neighborhood, @@ -174,7 +177,11 @@ export class NeoLeapService { return plainToInstance(responseClass, response.data[responseKey], { excludeExtraneousValues: true, }); - } catch (error) { + } catch (error: any) { + if (error.status === 400) { + console.error('Error sending request to NeoLeap:', error); + throw new BadRequestException(error.response?.data?.ResponseHeader?.ResponseDescription || error.message); + } console.error('Error sending request to NeoLeap:', error); throw new InternalServerErrorException('Error communicating with NeoLeap service'); } diff --git a/src/core/pipes/validation.pipe.ts b/src/core/pipes/validation.pipe.ts index 505fb03..5153b10 100644 --- a/src/core/pipes/validation.pipe.ts +++ b/src/core/pipes/validation.pipe.ts @@ -9,7 +9,7 @@ export function buildValidationPipe(config: ConfigService): ValidationPipe { transform: true, validateCustomDecorators: true, stopAtFirstError: true, - forbidNonWhitelisted: true, + forbidNonWhitelisted: false, dismissDefaultMessages: true, enableDebugMessages: config.getOrThrow('NODE_ENV') === Environment.DEV, exceptionFactory: i18nValidationErrorFactory, diff --git a/src/customer/entities/customer.entity.ts b/src/customer/entities/customer.entity.ts index b8bdc65..96786c0 100644 --- a/src/customer/entities/customer.entity.ts +++ b/src/customer/entities/customer.entity.ts @@ -79,7 +79,7 @@ export class Customer extends BaseEntity { userId!: string; @Column('varchar', { name: 'country', length: 255, nullable: true }) - country!: string; + country!: CountryIso; @Column('varchar', { name: 'region', length: 255, nullable: true }) region!: string; diff --git a/src/customer/repositories/customer.repository.ts b/src/customer/repositories/customer.repository.ts index a17b383..85565a6 100644 --- a/src/customer/repositories/customer.repository.ts +++ b/src/customer/repositories/customer.repository.ts @@ -15,7 +15,7 @@ export class CustomerRepository { findOne(where: FindOptionsWhere) { return this.customerRepository.findOne({ where, - relations: ['profilePicture', 'user', 'civilIdFront', 'civilIdBack'], + relations: ['profilePicture', 'user', 'civilIdFront', 'civilIdBack', 'cards'], }); } diff --git a/src/db/migrations/1749633935436-create-card-entity.ts b/src/db/migrations/1749633935436-create-card-entity.ts new file mode 100644 index 0000000..4ae45ab --- /dev/null +++ b/src/db/migrations/1749633935436-create-card-entity.ts @@ -0,0 +1,40 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateCardEntity1749633935436 implements MigrationInterface { + name = 'CreateCardEntity1749633935436'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "cards" + ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "card_reference" character varying NOT NULL, + "first_six_digits" character varying(6) NOT NULL, + "last_four_digits" character varying(4) NOT NULL, + "expiry" character varying NOT NULL, + "customer_type" character varying NOT NULL, + "color" character varying NOT NULL DEFAULT 'BLUE', + "status" character varying NOT NULL DEFAULT 'PENDING', + "scheme" character varying NOT NULL DEFAULT 'VISA', + "issuer" character varying NOT NULL, + "customer_id" uuid NOT NULL, + "parent_id" uuid, + "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + CONSTRAINT "PK_9451069b6f1199730791a7f4ae4" PRIMARY KEY ("id"))`, + ); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_6fb82820c0b1b7ec32a8dbf911" ON "card" ("card_reference") `); + await queryRunner.query( + `ALTER TABLE "card" ADD CONSTRAINT "FK_d46837f6ab27271d8125517d0b6" FOREIGN KEY ("parent_id") REFERENCES "customers"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "card" ADD CONSTRAINT "FK_f5aa0baf4ff1b397b3f946a443e" FOREIGN KEY ("customer_id") REFERENCES "customers"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "card" DROP CONSTRAINT "FK_f5aa0baf4ff1b397b3f946a443e"`); + await queryRunner.query(`ALTER TABLE "card" DROP CONSTRAINT "FK_d46837f6ab27271d8125517d0b6"`); + await queryRunner.query(`DROP INDEX "public"."IDX_6fb82820c0b1b7ec32a8dbf911"`); + await queryRunner.query(`DROP TABLE "card"`); + } +} diff --git a/src/db/migrations/1751456987627-create-account-entity.ts b/src/db/migrations/1751456987627-create-account-entity.ts new file mode 100644 index 0000000..40e7a1b --- /dev/null +++ b/src/db/migrations/1751456987627-create-account-entity.ts @@ -0,0 +1,45 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateAccountEntity1751456987627 implements MigrationInterface { + name = 'CreateAccountEntity1751456987627'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "cards" DROP CONSTRAINT "FK_f5aa0baf4ff1b397b3f946a443e"`); + await queryRunner.query(`ALTER TABLE "cards" DROP CONSTRAINT "FK_d46837f6ab27271d8125517d0b6"`); + await queryRunner.query(`DROP INDEX "public"."IDX_6fb82820c0b1b7ec32a8dbf911"`); + await queryRunner.query( + `CREATE TABLE "accounts" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "account_reference" character varying(255) NOT NULL, "currency" character varying(255) NOT NULL, "balance" numeric(10,2) NOT NULL DEFAULT '0', "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "UQ_829183fe026a0ce5fc8026b2417" UNIQUE ("account_reference"), CONSTRAINT "PK_5a7a02c20412299d198e097a8fe" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_829183fe026a0ce5fc8026b241" ON "accounts" ("account_reference") `, + ); + await queryRunner.query(`ALTER TABLE "cards" ADD "account_id" uuid NOT NULL`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_4f7df5c5dc950295dc417a11d8" ON "cards" ("card_reference") `); + await queryRunner.query( + `ALTER TABLE "cards" ADD CONSTRAINT "FK_8ba18e7060c38ddae12e5bdf907" FOREIGN KEY ("parent_id") REFERENCES "customers"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "cards" ADD CONSTRAINT "FK_2fd0ee722ec57594d2e448c73d7" FOREIGN KEY ("customer_id") REFERENCES "customers"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "cards" ADD CONSTRAINT "FK_b2874ef49ff7da2dee49e4bc6d3" FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "cards" DROP CONSTRAINT "FK_b2874ef49ff7da2dee49e4bc6d3"`); + await queryRunner.query(`ALTER TABLE "cards" DROP CONSTRAINT "FK_2fd0ee722ec57594d2e448c73d7"`); + await queryRunner.query(`ALTER TABLE "cards" DROP CONSTRAINT "FK_8ba18e7060c38ddae12e5bdf907"`); + await queryRunner.query(`DROP INDEX "public"."IDX_4f7df5c5dc950295dc417a11d8"`); + await queryRunner.query(`ALTER TABLE "cards" DROP COLUMN "account_id"`); + await queryRunner.query(`DROP INDEX "public"."IDX_829183fe026a0ce5fc8026b241"`); + await queryRunner.query(`DROP TABLE "accounts"`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_6fb82820c0b1b7ec32a8dbf911" ON "cards" ("card_reference") `); + await queryRunner.query( + `ALTER TABLE "cards" ADD CONSTRAINT "FK_d46837f6ab27271d8125517d0b6" FOREIGN KEY ("parent_id") REFERENCES "customers"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "cards" ADD CONSTRAINT "FK_f5aa0baf4ff1b397b3f946a443e" FOREIGN KEY ("customer_id") REFERENCES "customers"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } +} diff --git a/src/db/migrations/1751466314709-create-transaction-table.ts b/src/db/migrations/1751466314709-create-transaction-table.ts new file mode 100644 index 0000000..4c5e773 --- /dev/null +++ b/src/db/migrations/1751466314709-create-transaction-table.ts @@ -0,0 +1,18 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class CreateTransactionTable1751466314709 implements MigrationInterface { + name = 'CreateTransactionTable1751466314709' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "transactions" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "card_reference" character varying, "transaction_type" character varying NOT NULL DEFAULT 'EXTERNAL', "account_reference" character varying, "transaction_id" character varying, "card_masked_number" character varying, "transaction_date" TIMESTAMP WITH TIME ZONE, "rrn" character varying, "transaction_amount" numeric(12,2) NOT NULL, "transaction_currency" character varying NOT NULL, "billing_amount" numeric(12,2) NOT NULL, "settlement_amount" numeric(12,2) NOT NULL, "fees" numeric(12,2) NOT NULL, "card_id" uuid, "account_id" uuid, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "UQ_9162bf9ab4e31961a8f7932974c" UNIQUE ("transaction_id"), CONSTRAINT "PK_a219afd8dd77ed80f5a862f1db9" PRIMARY KEY ("id"))`); + await queryRunner.query(`ALTER TABLE "transactions" ADD CONSTRAINT "FK_80ad48141be648db2d84ff32f79" FOREIGN KEY ("card_id") REFERENCES "cards"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "transactions" ADD CONSTRAINT "FK_49c0d6e8ba4bfb5582000d851f0" FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "transactions" DROP CONSTRAINT "FK_49c0d6e8ba4bfb5582000d851f0"`); + await queryRunner.query(`ALTER TABLE "transactions" DROP CONSTRAINT "FK_80ad48141be648db2d84ff32f79"`); + await queryRunner.query(`DROP TABLE "transactions"`); + } + +} diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index 3ad20d0..9e5e580 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -25,3 +25,6 @@ export * from './1740045960580-create-user-registration-table'; export * from './1741087742821-add-used-flag-to-otp-and-remove-constraints-from-customers'; export * from './1742112997024-update-customer-table'; export * from './1747569536067-add-address-fields-to-customers'; +export * from './1749633935436-create-card-entity'; +export * from './1751456987627-create-account-entity'; +export * from './1751466314709-create-transaction-table'; From 492e538eb86eba54c57cf5addfb77325c0094eb7 Mon Sep 17 00:00:00 2001 From: Abdalhamid Alhamad Date: Sun, 6 Jul 2025 16:44:23 +0300 Subject: [PATCH 07/32] feat: send request via gateway --- .../neoleap-webhooks.controller.ts | 7 ++-- .../neoleap/controllers/neotest.controller.ts | 2 +- .../neoleap/services/neoleap.service.ts | 39 +++++++------------ 3 files changed, 19 insertions(+), 29 deletions(-) diff --git a/src/common/modules/neoleap/controllers/neoleap-webhooks.controller.ts b/src/common/modules/neoleap/controllers/neoleap-webhooks.controller.ts index 0089a66..ae0aadb 100644 --- a/src/common/modules/neoleap/controllers/neoleap-webhooks.controller.ts +++ b/src/common/modules/neoleap/controllers/neoleap-webhooks.controller.ts @@ -7,8 +7,9 @@ import { CardTransactionWebhookRequest } from '../dtos/requests'; @ApiTags('Neoleap Webhooks') export class NeoLeapWebhooksController { constructor(private readonly transactionService: TransactionService) {} - @Post('account-transaction') - async handleAccountTransactionWebhook(@Body() body: CardTransactionWebhookRequest) { - await this.transactionService.createCardTransaction(body); + + @Post('card-transaction') + async handleCardTransactionWebhook(@Body() body: CardTransactionWebhookRequest) { + return this.transactionService.createCardTransaction(body); } } diff --git a/src/common/modules/neoleap/controllers/neotest.controller.ts b/src/common/modules/neoleap/controllers/neotest.controller.ts index 5d8ff8a..f7ba156 100644 --- a/src/common/modules/neoleap/controllers/neotest.controller.ts +++ b/src/common/modules/neoleap/controllers/neotest.controller.ts @@ -30,7 +30,7 @@ export class NeoTestController { @ApiDataResponse(CreateApplicationResponse) async createApplication() { const customer = await this.customerService.findCustomerById( - this.configService.get('MOCK_CUSTOMER_ID', '0778c431-f604-4b91-af53-49c33849b5ff'), + this.configService.get('MOCK_CUSTOMER_ID', 'ff462af1-1e0c-4216-8865-738e5b525ac1'), ); const data = await this.neoleapService.createApplication(customer as Customer); await this.cardService.createCard(customer.id, data); diff --git a/src/common/modules/neoleap/services/neoleap.service.ts b/src/common/modules/neoleap/services/neoleap.service.ts index 3c7802b..05ff545 100644 --- a/src/common/modules/neoleap/services/neoleap.service.ts +++ b/src/common/modules/neoleap/services/neoleap.service.ts @@ -2,10 +2,7 @@ import { HttpService } from '@nestjs/axios'; import { BadRequestException, Injectable, InternalServerErrorException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { ClassConstructor, plainToInstance } from 'class-transformer'; -import { readFileSync } from 'fs'; -import { Agent } from 'https'; import moment from 'moment'; -import path from 'path'; import { v4 as uuid } from 'uuid'; import { CountriesNumericISO } from '~/common/constants'; import { Customer } from '~/customer/entities'; @@ -17,22 +14,22 @@ import { ICreateApplicationRequest, IInquireApplicationRequest, INeoleapHeaderRe export class NeoLeapService { private readonly baseUrl: string; private readonly apiKey: string; - private readonly useMock: boolean; + private readonly useGateway: boolean; private readonly institutionCode = '1100'; useLocalCert: boolean; constructor(private readonly httpService: HttpService, private readonly configService: ConfigService) { - this.baseUrl = this.configService.getOrThrow('NEOLEAP_BASE_URL'); - this.apiKey = this.configService.getOrThrow('NEOLEAP_API_KEY'); - this.useMock = [true, 'true'].includes(this.configService.get('USE_MOCK', false)); + this.baseUrl = this.configService.getOrThrow('GATEWAY_URL'); + this.apiKey = this.configService.getOrThrow('GATEWAY_API_KEY'); + this.useGateway = [true, 'true'].includes(this.configService.get('USE_GATEWAY', false)); this.useLocalCert = this.configService.get('USE_LOCAL_CERT', false); } async createApplication(customer: Customer) { const responseKey = 'CreateNewApplicationResponseDetails'; - if (customer.cards.length > 0) { - throw new BadRequestException('CUSTOMER.ALREADY_HAS_CARD'); - } - if (this.useMock) { + // if (customer.cards.length > 0) { + // throw new BadRequestException('CUSTOMER.ALREADY_HAS_CARD'); + // } + if (!this.useGateway) { return plainToInstance(CreateApplicationResponse, CREATE_APPLICATION_MOCK[responseKey], { excludeExtraneousValues: true, }); @@ -42,7 +39,7 @@ export class NeoLeapService { CreateNewApplicationRequestDetails: { ApplicationRequestDetails: { InstitutionCode: this.institutionCode, - ExternalApplicationNumber: (customer.waitingNumber * 64).toString(), + ExternalApplicationNumber: customer.waitingNumber.toString(), ApplicationType: '01', Product: '1101', ApplicationDate: moment().format('YYYY-MM-DD'), @@ -105,7 +102,7 @@ export class NeoLeapService { async inquireApplication(externalApplicationNumber: string) { const responseKey = 'InquireApplicationResponseDetails'; - if (this.useMock) { + if (!this.useGateway) { return plainToInstance(InquireApplicationResponse, INQUIRE_APPLICATION_MOCK[responseKey], { excludeExtraneousValues: true, }); @@ -152,29 +149,21 @@ export class NeoLeapService { responseClass: ClassConstructor, ): Promise { try { - const response = await this.httpService.axiosRef.post(`${this.baseUrl}/${endpoint}`, payload, { + const { data } = await this.httpService.axiosRef.post(`${this.baseUrl}/${endpoint}`, payload, { headers: { 'Content-Type': 'application/json', Authorization: `${this.apiKey}`, Host: 'apigw-uat.neoleap.com.sa', }, - httpsAgent: new Agent({ - rejectUnauthorized: false, // Disable SSL verification for development purposes - ca: this.useLocalCert ? readFileSync(path.join(__dirname, '../zod-certs/My_CA_Bundle.ca-bundle')) : undefined, - cert: this.useLocalCert - ? readFileSync(path.join(__dirname, '../zod-certs/gw-dev_zodwallet_com.crt')) - : undefined, - key: this.useLocalCert ? readFileSync(path.join(__dirname, '../zod-certs/server.key')) : undefined, - }), }); - if (response.data?.ResponseHeader.ResponseCode !== '000' || !response.data[responseKey]) { + if (data.data?.ResponseHeader?.ResponseCode !== '000' || !data.data[responseKey]) { throw new BadRequestException( - response.data.ResponseHeader.ResponseDescription || 'Unexpected Error from Service Provider', + data.data.ResponseHeader.ResponseDescription || 'Unexpected Error from Service Provider', ); } - return plainToInstance(responseClass, response.data[responseKey], { + return plainToInstance(responseClass, data.data[responseKey], { excludeExtraneousValues: true, }); } catch (error: any) { From 2770cf8774336aa86f103fba6a2722b5afffa5a1 Mon Sep 17 00:00:00 2001 From: Abdalhamid Alhamad Date: Mon, 7 Jul 2025 12:06:01 +0300 Subject: [PATCH 08/32] fix:fix card migration --- .../migrations/1749633935436-create-card-entity.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/db/migrations/1749633935436-create-card-entity.ts b/src/db/migrations/1749633935436-create-card-entity.ts index 4ae45ab..5a2ed55 100644 --- a/src/db/migrations/1749633935436-create-card-entity.ts +++ b/src/db/migrations/1749633935436-create-card-entity.ts @@ -22,19 +22,19 @@ export class CreateCardEntity1749633935436 implements MigrationInterface { "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_9451069b6f1199730791a7f4ae4" PRIMARY KEY ("id"))`, ); - await queryRunner.query(`CREATE UNIQUE INDEX "IDX_6fb82820c0b1b7ec32a8dbf911" ON "card" ("card_reference") `); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_6fb82820c0b1b7ec32a8dbf911" ON "cards" ("card_reference") `); await queryRunner.query( - `ALTER TABLE "card" ADD CONSTRAINT "FK_d46837f6ab27271d8125517d0b6" FOREIGN KEY ("parent_id") REFERENCES "customers"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + `ALTER TABLE "cards" ADD CONSTRAINT "FK_d46837f6ab27271d8125517d0b6" FOREIGN KEY ("parent_id") REFERENCES "customers"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, ); await queryRunner.query( - `ALTER TABLE "card" ADD CONSTRAINT "FK_f5aa0baf4ff1b397b3f946a443e" FOREIGN KEY ("customer_id") REFERENCES "customers"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + `ALTER TABLE "cards" ADD CONSTRAINT "FK_f5aa0baf4ff1b397b3f946a443e" FOREIGN KEY ("customer_id") REFERENCES "customers"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, ); } public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`ALTER TABLE "card" DROP CONSTRAINT "FK_f5aa0baf4ff1b397b3f946a443e"`); - await queryRunner.query(`ALTER TABLE "card" DROP CONSTRAINT "FK_d46837f6ab27271d8125517d0b6"`); + await queryRunner.query(`ALTER TABLE "cards" DROP CONSTRAINT "FK_f5aa0baf4ff1b397b3f946a443e"`); + await queryRunner.query(`ALTER TABLE "cards" DROP CONSTRAINT "FK_d46837f6ab27271d8125517d0b6"`); await queryRunner.query(`DROP INDEX "public"."IDX_6fb82820c0b1b7ec32a8dbf911"`); - await queryRunner.query(`DROP TABLE "card"`); + await queryRunner.query(`DROP TABLE "cards"`); } } From 3b3f8c010475dd3b6f7df3d9753e5768550aa45d Mon Sep 17 00:00:00 2001 From: Abdalhamid Alhamad Date: Mon, 7 Jul 2025 16:34:45 +0300 Subject: [PATCH 09/32] fix: remove host from request --- src/common/modules/neoleap/services/neoleap.service.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/common/modules/neoleap/services/neoleap.service.ts b/src/common/modules/neoleap/services/neoleap.service.ts index 05ff545..8e6403b 100644 --- a/src/common/modules/neoleap/services/neoleap.service.ts +++ b/src/common/modules/neoleap/services/neoleap.service.ts @@ -153,7 +153,6 @@ export class NeoLeapService { headers: { 'Content-Type': 'application/json', Authorization: `${this.apiKey}`, - Host: 'apigw-uat.neoleap.com.sa', }, }); From 038b8ef6e3657e56d006624ab050d4d193cb360c Mon Sep 17 00:00:00 2001 From: Abdalhamid Alhamad Date: Wed, 9 Jul 2025 13:31:08 +0300 Subject: [PATCH 10/32] feat: finish working on account transaction webhook --- package-lock.json | 7 ++ package.json | 1 + src/card/entities/transaction.entity.ts | 13 +++- src/card/enums/index.ts | 1 + src/card/enums/transaction-scope.enum.ts | 4 ++ src/card/repositories/account.repository.ts | 15 ++++ .../repositories/transaction.repository.ts | 35 +++++++++- src/card/services/account.service.ts | 28 +++++++- src/card/services/transaction.service.ts | 56 ++++++++++++++- .../neoleap-webhooks.controller.ts | 7 +- ...account-transaction-webhook.request.dto.ts | 68 +++++++++++++++++++ .../card-transaction-webhook.request.dto.ts | 7 +- .../modules/neoleap/dtos/requests/index.ts | 1 + .../1752056898465-edit-transaction-table.ts | 16 +++++ src/db/migrations/index.ts | 1 + 15 files changed, 249 insertions(+), 11 deletions(-) create mode 100644 src/card/enums/transaction-scope.enum.ts create mode 100644 src/common/modules/neoleap/dtos/requests/account-transaction-webhook.request.dto.ts create mode 100644 src/db/migrations/1752056898465-edit-transaction-table.ts diff --git a/package-lock.json b/package-lock.json index 2a54f0c..3cd82fa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,6 +33,7 @@ "cacheable": "^1.8.5", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", + "decimal.js": "^10.6.0", "firebase-admin": "^13.0.2", "google-libphonenumber": "^3.2.39", "handlebars": "^4.7.8", @@ -5167,6 +5168,12 @@ "node": ">=0.10.0" } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "license": "MIT" + }, "node_modules/dedent": { "version": "1.5.3", "dev": true, diff --git a/package.json b/package.json index a991cb2..f486a74 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "cacheable": "^1.8.5", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", + "decimal.js": "^10.6.0", "firebase-admin": "^13.0.2", "google-libphonenumber": "^3.2.39", "handlebars": "^4.7.8", diff --git a/src/card/entities/transaction.entity.ts b/src/card/entities/transaction.entity.ts index 6e4d693..029ef29 100644 --- a/src/card/entities/transaction.entity.ts +++ b/src/card/entities/transaction.entity.ts @@ -1,5 +1,5 @@ import { Column, CreateDateColumn, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; -import { TransactionType } from '../enums'; +import { TransactionScope, TransactionType } from '../enums'; import { Account } from './account.entity'; import { Card } from './card.entity'; @@ -8,12 +8,15 @@ export class Transaction { @PrimaryGeneratedColumn('uuid') id!: string; - @Column({ name: 'card_reference', nullable: true, type: 'varchar' }) - cardReference!: string; + @Column({ name: 'transaction_scope', type: 'varchar', nullable: false }) + transactionScope!: TransactionScope; @Column({ name: 'transaction_type', type: 'varchar', default: TransactionType.EXTERNAL }) transactionType!: TransactionType; + @Column({ name: 'card_reference', nullable: true, type: 'varchar' }) + cardReference!: string; + @Column({ name: 'account_reference', nullable: true, type: 'varchar' }) accountReference!: string; @@ -44,6 +47,9 @@ export class Transaction { @Column({ type: 'decimal', name: 'fees', precision: 12, scale: 2 }) fees!: number; + @Column({ type: 'decimal', name: 'vat_on_fees', precision: 12, scale: 2, default: 0.0 }) + vatOnFees!: number; + @Column({ name: 'card_id', type: 'uuid', nullable: true }) cardId!: string; @@ -57,6 +63,7 @@ export class Transaction { @ManyToOne(() => Account, (account) => account.transactions, { onDelete: 'CASCADE', nullable: true }) @JoinColumn({ name: 'account_id' }) account!: Account; + @CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone' }) createdAt!: Date; } diff --git a/src/card/enums/index.ts b/src/card/enums/index.ts index 03f577c..cac8b22 100644 --- a/src/card/enums/index.ts +++ b/src/card/enums/index.ts @@ -3,4 +3,5 @@ export * from './card-issuers.enum'; export * from './card-scheme.enum'; export * from './card-status.enum'; export * from './customer-type.enum'; +export * from './transaction-scope.enum'; export * from './transaction-type.enum'; diff --git a/src/card/enums/transaction-scope.enum.ts b/src/card/enums/transaction-scope.enum.ts new file mode 100644 index 0000000..d1afd17 --- /dev/null +++ b/src/card/enums/transaction-scope.enum.ts @@ -0,0 +1,4 @@ +export enum TransactionScope { + CARD = 'CARD', + ACCOUNT = 'ACCOUNT', +} diff --git a/src/card/repositories/account.repository.ts b/src/card/repositories/account.repository.ts index 5f4a357..da15be6 100644 --- a/src/card/repositories/account.repository.ts +++ b/src/card/repositories/account.repository.ts @@ -16,4 +16,19 @@ export class AccountRepository { }), ); } + + getAccountByReferenceNumber(accountReference: string): Promise { + return this.accountRepository.findOne({ + where: { accountReference }, + relations: ['cards'], + }); + } + + topUpAccountBalance(accountReference: string, amount: number) { + return this.accountRepository.increment({ accountReference }, 'balance', amount); + } + + decreaseAccountBalance(accountReference: string, amount: number) { + return this.accountRepository.decrement({ accountReference }, 'balance', amount); + } } diff --git a/src/card/repositories/transaction.repository.ts b/src/card/repositories/transaction.repository.ts index fa11dd8..39d8665 100644 --- a/src/card/repositories/transaction.repository.ts +++ b/src/card/repositories/transaction.repository.ts @@ -2,10 +2,14 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import moment from 'moment'; import { Repository } from 'typeorm'; -import { CardTransactionWebhookRequest } from '~/common/modules/neoleap/dtos/requests'; +import { + AccountTransactionWebhookRequest, + CardTransactionWebhookRequest, +} from '~/common/modules/neoleap/dtos/requests'; import { Card } from '../entities'; +import { Account } from '../entities/account.entity'; import { Transaction } from '../entities/transaction.entity'; -import { TransactionType } from '../enums'; +import { TransactionScope, TransactionType } from '../enums'; @Injectable() export class TransactionRepository { @@ -28,7 +32,34 @@ export class TransactionRepository { accountId: card.account!.id, transactionType: TransactionType.EXTERNAL, accountReference: card.account!.accountReference, + transactionScope: TransactionScope.CARD, + vatOnFees: transactionData.vatOnFees, }), ); } + + createAccountTransaction(account: Account, transactionData: AccountTransactionWebhookRequest): Promise { + return this.transactionRepository.save( + this.transactionRepository.create({ + transactionId: transactionData.transactionId, + transactionAmount: transactionData.amount, + transactionCurrency: transactionData.currency, + billingAmount: 0, + settlementAmount: 0, + transactionDate: moment(transactionData.date + transactionData.time, 'YYYYMMDDHHmmss').toDate(), + fees: 0, + accountReference: account.accountReference, + accountId: account.id, + transactionType: TransactionType.EXTERNAL, + transactionScope: TransactionScope.ACCOUNT, + vatOnFees: 0, + }), + ); + } + + findTransactionByReference(transactionId: string, accountReference: string): Promise { + return this.transactionRepository.findOne({ + where: { transactionId, accountReference }, + }); + } } diff --git a/src/card/services/account.service.ts b/src/card/services/account.service.ts index a8c4791..cce8e68 100644 --- a/src/card/services/account.service.ts +++ b/src/card/services/account.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, UnprocessableEntityException } from '@nestjs/common'; import { Account } from '../entities/account.entity'; import { AccountRepository } from '../repositories/account.repository'; @@ -9,4 +9,30 @@ export class AccountService { createAccount(accountId: string): Promise { return this.accountRepository.createAccount(accountId); } + + async getAccountByReferenceNumber(accountReference: string): Promise { + const account = await this.accountRepository.getAccountByReferenceNumber(accountReference); + if (!account) { + throw new UnprocessableEntityException('ACCOUNT.NOT_FOUND'); + } + return account; + } + + async creditAccountBalance(accountReference: string, amount: number) { + return this.accountRepository.topUpAccountBalance(accountReference, amount); + } + + async decreaseAccountBalance(accountReference: string, amount: number) { + const account = await this.getAccountByReferenceNumber(accountReference); + /** + * While there is no need to check for insufficient balance because this is a webhook handler, + * I just added this check to ensure we don't have corruption in our data especially if this service is used elsewhere. + */ + + if (account.balance < amount) { + throw new UnprocessableEntityException('ACCOUNT.INSUFFICIENT_BALANCE'); + } + + return this.accountRepository.decreaseAccountBalance(accountReference, amount); + } } diff --git a/src/card/services/transaction.service.ts b/src/card/services/transaction.service.ts index 533bd16..6c98b9c 100644 --- a/src/card/services/transaction.service.ts +++ b/src/card/services/transaction.service.ts @@ -1,6 +1,13 @@ -import { Injectable } from '@nestjs/common'; -import { CardTransactionWebhookRequest } from '~/common/modules/neoleap/dtos/requests'; +import { Injectable, UnprocessableEntityException } from '@nestjs/common'; +import Decimal from 'decimal.js'; +import { Transactional } from 'typeorm-transactional'; +import { + AccountTransactionWebhookRequest, + CardTransactionWebhookRequest, +} from '~/common/modules/neoleap/dtos/requests'; +import { Transaction } from '../entities/transaction.entity'; import { TransactionRepository } from '../repositories/transaction.repository'; +import { AccountService } from './account.service'; import { CardService } from './card.service'; @Injectable() @@ -8,9 +15,52 @@ export class TransactionService { constructor( private readonly transactionRepository: TransactionRepository, private readonly cardService: CardService, + private readonly accountService: AccountService, ) {} + + @Transactional() async createCardTransaction(body: CardTransactionWebhookRequest) { const card = await this.cardService.getCardByReferenceNumber(body.cardId); - return this.transactionRepository.createCardTransaction(card, body); + const existingTransaction = await this.findExistingTransaction(body.transactionId, card.account.accountReference); + + if (existingTransaction) { + throw new UnprocessableEntityException('TRANSACTION.ALREADY_EXISTS'); + } + + const transaction = await this.transactionRepository.createCardTransaction(card, body); + const total = new Decimal(body.transactionAmount) + .plus(body.billingAmount) + .plus(body.settlementAmount) + .plus(body.fees) + .plus(body.vatOnFees); + + await this.accountService.decreaseAccountBalance(card.account.accountReference, total.toNumber()); + + return transaction; + } + + @Transactional() + async createAccountTransaction(body: AccountTransactionWebhookRequest) { + const account = await this.accountService.getAccountByReferenceNumber(body.accountId); + + const existingTransaction = await this.findExistingTransaction(body.transactionId, account.accountReference); + + if (existingTransaction) { + throw new UnprocessableEntityException('TRANSACTION.ALREADY_EXISTS'); + } + + const transaction = await this.transactionRepository.createAccountTransaction(account, body); + await this.accountService.creditAccountBalance(account.accountReference, body.amount); + + return transaction; + } + + private async findExistingTransaction(transactionId: string, accountReference: string): Promise { + const existingTransaction = await this.transactionRepository.findTransactionByReference( + transactionId, + accountReference, + ); + + return existingTransaction; } } diff --git a/src/common/modules/neoleap/controllers/neoleap-webhooks.controller.ts b/src/common/modules/neoleap/controllers/neoleap-webhooks.controller.ts index ae0aadb..05d3bf9 100644 --- a/src/common/modules/neoleap/controllers/neoleap-webhooks.controller.ts +++ b/src/common/modules/neoleap/controllers/neoleap-webhooks.controller.ts @@ -1,7 +1,7 @@ import { Body, Controller, Post } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { TransactionService } from '~/card/services/transaction.service'; -import { CardTransactionWebhookRequest } from '../dtos/requests'; +import { AccountTransactionWebhookRequest, CardTransactionWebhookRequest } from '../dtos/requests'; @Controller('neoleap-webhooks') @ApiTags('Neoleap Webhooks') @@ -12,4 +12,9 @@ export class NeoLeapWebhooksController { async handleCardTransactionWebhook(@Body() body: CardTransactionWebhookRequest) { return this.transactionService.createCardTransaction(body); } + + @Post('account-transaction') + async handleAccountTransactionWebhook(@Body() body: AccountTransactionWebhookRequest) { + return this.transactionService.createAccountTransaction(body); + } } diff --git a/src/common/modules/neoleap/dtos/requests/account-transaction-webhook.request.dto.ts b/src/common/modules/neoleap/dtos/requests/account-transaction-webhook.request.dto.ts new file mode 100644 index 0000000..06e1437 --- /dev/null +++ b/src/common/modules/neoleap/dtos/requests/account-transaction-webhook.request.dto.ts @@ -0,0 +1,68 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Expose, Type } from 'class-transformer'; +import { IsNumber, IsPositive, IsString } from 'class-validator'; + +export class AccountTransactionWebhookRequest { + @Expose({ name: 'InstId' }) + @IsString() + @ApiProperty({ name: 'InstId', example: '9000' }) + instId!: string; + + @Expose() + @IsString() + @ApiProperty({ example: '143761' }) + transactionId!: string; + + @Expose() + @IsString() + @ApiProperty({ example: '0037' }) + transactionType!: string; + + @Expose() + @IsString() + @ApiProperty({ example: '26' }) + transactionCode!: string; + + @Expose() + @IsString() + @ApiProperty({ example: '6823000000000018' }) + accountNumber!: string; + + @Expose() + @IsString() + @ApiProperty({ example: '6823000000000018' }) + accountId!: string; + + @Expose() + @Type(() => Number) + @IsNumber() + @ApiProperty({ example: '7080.15' }) + otb!: number; + + @Expose() + @Type(() => Number) + @IsNumber() + @IsPositive() + @ApiProperty({ example: '3050.95' }) + amount!: number; + + @Expose() + @IsString() + @ApiProperty({ example: 'C' }) + sign!: string; + + @Expose() + @IsString() + @ApiProperty({ example: '682' }) + currency!: string; + + @Expose() + @IsString() + @ApiProperty({ name: 'Date', example: '20241112' }) + date!: string; + + @Expose() + @IsString() + @ApiProperty({ name: 'Time', example: '125340' }) + time!: string; +} diff --git a/src/common/modules/neoleap/dtos/requests/card-transaction-webhook.request.dto.ts b/src/common/modules/neoleap/dtos/requests/card-transaction-webhook.request.dto.ts index 8a59b19..db8afe5 100644 --- a/src/common/modules/neoleap/dtos/requests/card-transaction-webhook.request.dto.ts +++ b/src/common/modules/neoleap/dtos/requests/card-transaction-webhook.request.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; import { Expose, Type } from 'class-transformer'; -import { IsNumber, IsString, ValidateNested } from 'class-validator'; +import { IsNumber, IsString, Min, ValidateNested } from 'class-validator'; export class CardAcceptorLocationDto { @Expose() @@ -99,6 +99,7 @@ export class CardTransactionWebhookRequest { @Type(() => Number) @IsNumber() + @Min(0, { message: 'amount must be zero or a positive number' }) @ApiProperty({ example: '100.5' }) transactionAmount!: number; @@ -108,6 +109,7 @@ export class CardTransactionWebhookRequest { @Type(() => Number) @IsNumber() + @Min(0, { message: 'amount must be zero or a positive number' }) @ApiProperty({ example: '100.5' }) billingAmount!: number; @@ -117,6 +119,7 @@ export class CardTransactionWebhookRequest { @Type(() => Number) @IsNumber() + @Min(0, { message: 'amount must be zero or a positive number' }) @ApiProperty({ example: '100.5' }) settlementAmount!: number; @@ -127,12 +130,14 @@ export class CardTransactionWebhookRequest { @Expose() @Type(() => Number) @IsNumber() + @Min(0, { message: 'amount must be zero or a positive number' }) @ApiProperty({ example: '20' }) fees!: number; @Expose() @Type(() => Number) @IsNumber() + @Min(0, { message: 'amount must be zero or a positive number' }) @ApiProperty({ example: '4.5' }) vatOnFees!: number; diff --git a/src/common/modules/neoleap/dtos/requests/index.ts b/src/common/modules/neoleap/dtos/requests/index.ts index a62ed5c..bf49fc9 100644 --- a/src/common/modules/neoleap/dtos/requests/index.ts +++ b/src/common/modules/neoleap/dtos/requests/index.ts @@ -1 +1,2 @@ +export * from './account-transaction-webhook.request.dto'; export * from './card-transaction-webhook.request.dto'; diff --git a/src/db/migrations/1752056898465-edit-transaction-table.ts b/src/db/migrations/1752056898465-edit-transaction-table.ts new file mode 100644 index 0000000..d4bab41 --- /dev/null +++ b/src/db/migrations/1752056898465-edit-transaction-table.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class EditTransactionTable1752056898465 implements MigrationInterface { + name = 'EditTransactionTable1752056898465' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "transactions" ADD "transaction_scope" character varying NOT NULL`); + await queryRunner.query(`ALTER TABLE "transactions" ADD "vat_on_fees" numeric(12,2) NOT NULL DEFAULT '0'`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "transactions" DROP COLUMN "vat_on_fees"`); + await queryRunner.query(`ALTER TABLE "transactions" DROP COLUMN "transaction_scope"`); + } + +} diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index 9e5e580..5efe087 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -28,3 +28,4 @@ export * from './1747569536067-add-address-fields-to-customers'; export * from './1749633935436-create-card-entity'; export * from './1751456987627-create-account-entity'; export * from './1751466314709-create-transaction-table'; +export * from './1752056898465-edit-transaction-table'; From 5a780eeb1760020afa89472a52417ea2df07bd99 Mon Sep 17 00:00:00 2001 From: Abdalhamid Alhamad Date: Mon, 14 Jul 2025 11:57:51 +0300 Subject: [PATCH 11/32] feat/working on update card control --- src/card/repositories/card.repository.ts | 6 ++ src/card/services/card.service.ts | 8 ++ src/card/services/transaction.service.ts | 6 +- .../neoleap/controllers/neotest.controller.ts | 47 ++++++++--- .../modules/neoleap/dtos/requests/index.ts | 1 + .../update-card-controls.request.dto.ts | 15 ++++ .../modules/neoleap/dtos/response/index.ts | 1 + .../update-card-controls.response.dto.ts | 1 + .../modules/neoleap/interfaces/index.ts | 1 + .../update-card-control.request.interface.ts | 77 +++++++++++++++++++ .../neoleap/services/neoleap.service.ts | 54 +++++++++++-- .../dtos/response/customer.response.dto.ts | 25 +++++- src/customer/services/customer.service.ts | 43 ++++++++++- src/i18n/ar/app.json | 3 +- src/i18n/en/app.json | 3 +- 15 files changed, 263 insertions(+), 28 deletions(-) create mode 100644 src/common/modules/neoleap/dtos/requests/update-card-controls.request.dto.ts create mode 100644 src/common/modules/neoleap/dtos/response/update-card-controls.response.dto.ts create mode 100644 src/common/modules/neoleap/interfaces/update-card-control.request.interface.ts diff --git a/src/card/repositories/card.repository.ts b/src/card/repositories/card.repository.ts index a83d71c..ebab1f7 100644 --- a/src/card/repositories/card.repository.ts +++ b/src/card/repositories/card.repository.ts @@ -34,4 +34,10 @@ export class CardRepository { getCardByReferenceNumber(referenceNumber: string): Promise { return this.cardRepository.findOne({ where: { cardReference: referenceNumber }, relations: ['account'] }); } + + getActiveCardForCustomer(customerId: string): Promise { + return this.cardRepository.findOne({ + where: { customerId, status: CardStatus.ACTIVE }, + }); + } } diff --git a/src/card/services/card.service.ts b/src/card/services/card.service.ts index 25573da..bc71cfd 100644 --- a/src/card/services/card.service.ts +++ b/src/card/services/card.service.ts @@ -34,4 +34,12 @@ export class CardService { return card; } + + async getActiveCardForCustomer(customerId: string): Promise { + const card = await this.cardRepository.getActiveCardForCustomer(customerId); + if (!card) { + throw new BadRequestException('CARD.NOT_FOUND'); + } + return card; + } } diff --git a/src/card/services/transaction.service.ts b/src/card/services/transaction.service.ts index 6c98b9c..d512299 100644 --- a/src/card/services/transaction.service.ts +++ b/src/card/services/transaction.service.ts @@ -28,11 +28,7 @@ export class TransactionService { } const transaction = await this.transactionRepository.createCardTransaction(card, body); - const total = new Decimal(body.transactionAmount) - .plus(body.billingAmount) - .plus(body.settlementAmount) - .plus(body.fees) - .plus(body.vatOnFees); + const total = new Decimal(body.transactionAmount).plus(body.billingAmount).plus(body.fees).plus(body.vatOnFees); await this.accountService.decreaseAccountBalance(card.account.accountReference, total.toNumber()); diff --git a/src/common/modules/neoleap/controllers/neotest.controller.ts b/src/common/modules/neoleap/controllers/neotest.controller.ts index f7ba156..47ed777 100644 --- a/src/common/modules/neoleap/controllers/neotest.controller.ts +++ b/src/common/modules/neoleap/controllers/neotest.controller.ts @@ -1,16 +1,22 @@ -import { Controller, Get } from '@nestjs/common'; +import { Body, Controller, Post, UseGuards } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { ApiTags } from '@nestjs/swagger'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { IJwtPayload } from '~/auth/interfaces'; import { CardService } from '~/card/services'; +import { AuthenticatedUser } from '~/common/decorators'; +import { AccessTokenGuard } from '~/common/guards'; import { ApiDataResponse } from '~/core/decorators'; import { ResponseFactory } from '~/core/utils'; -import { Customer } from '~/customer/entities'; +import { CustomerResponseDto } from '~/customer/dtos/response'; import { CustomerService } from '~/customer/services'; +import { UpdateCardControlsRequestDto } from '../dtos/requests'; import { CreateApplicationResponse, InquireApplicationResponse } from '../dtos/response'; import { NeoLeapService } from '../services/neoleap.service'; @Controller('neotest') @ApiTags('Neoleap Test API , for testing purposes only, will be removed in production') +@UseGuards(AccessTokenGuard) +@ApiBearerAuth() export class NeoTestController { constructor( private readonly neoleapService: NeoLeapService, @@ -19,21 +25,38 @@ export class NeoTestController { private readonly configService: ConfigService, ) {} - @Get('inquire-application') + @Post('update-kys') + @ApiDataResponse(CustomerResponseDto) + async updateKys(@AuthenticatedUser() user: IJwtPayload) { + const customer = await this.customerService.updateKyc(user.sub); + + return ResponseFactory.data(new CustomerResponseDto(customer)); + } + + @Post('inquire-application') @ApiDataResponse(InquireApplicationResponse) - async inquireApplication() { - const data = await this.neoleapService.inquireApplication('15'); + async inquireApplication(@AuthenticatedUser() user: IJwtPayload) { + const customer = await this.customerService.findCustomerById(user.sub); + const data = await this.neoleapService.inquireApplication(customer.waitingNumber.toString()); return ResponseFactory.data(data); } - @Get('create-application') + @Post('create-application') @ApiDataResponse(CreateApplicationResponse) - async createApplication() { - const customer = await this.customerService.findCustomerById( - this.configService.get('MOCK_CUSTOMER_ID', 'ff462af1-1e0c-4216-8865-738e5b525ac1'), - ); - const data = await this.neoleapService.createApplication(customer as Customer); + async createApplication(@AuthenticatedUser() user: IJwtPayload) { + const customer = await this.customerService.findCustomerById(user.sub); + const data = await this.neoleapService.createApplication(customer); await this.cardService.createCard(customer.id, data); return ResponseFactory.data(data); } + + @Post('update-card-controls') + async updateCardControls( + @AuthenticatedUser() user: IJwtPayload, + @Body() { amount, count }: UpdateCardControlsRequestDto, + ) { + const card = await this.cardService.getActiveCardForCustomer(user.sub); + await this.neoleapService.updateCardControl(card.cardReference, amount, count); + return ResponseFactory.data({ message: 'Card controls updated successfully' }); + } } diff --git a/src/common/modules/neoleap/dtos/requests/index.ts b/src/common/modules/neoleap/dtos/requests/index.ts index bf49fc9..d4cad6f 100644 --- a/src/common/modules/neoleap/dtos/requests/index.ts +++ b/src/common/modules/neoleap/dtos/requests/index.ts @@ -1,2 +1,3 @@ export * from './account-transaction-webhook.request.dto'; export * from './card-transaction-webhook.request.dto'; +export * from './update-card-controls.request.dto'; diff --git a/src/common/modules/neoleap/dtos/requests/update-card-controls.request.dto.ts b/src/common/modules/neoleap/dtos/requests/update-card-controls.request.dto.ts new file mode 100644 index 0000000..280c05e --- /dev/null +++ b/src/common/modules/neoleap/dtos/requests/update-card-controls.request.dto.ts @@ -0,0 +1,15 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsNumber, IsOptional, IsPositive } from 'class-validator'; + +export class UpdateCardControlsRequestDto { + @ApiProperty() + @IsNumber() + @IsPositive() + amount!: number; + + @IsNumber() + @IsPositive() + @IsOptional() + @ApiPropertyOptional() + count?: number; +} diff --git a/src/common/modules/neoleap/dtos/response/index.ts b/src/common/modules/neoleap/dtos/response/index.ts index 50e4920..457e4bc 100644 --- a/src/common/modules/neoleap/dtos/response/index.ts +++ b/src/common/modules/neoleap/dtos/response/index.ts @@ -1,2 +1,3 @@ export * from './create-application.response.dto'; export * from './inquire-application.response'; +export * from './update-card-controls.response.dto'; diff --git a/src/common/modules/neoleap/dtos/response/update-card-controls.response.dto.ts b/src/common/modules/neoleap/dtos/response/update-card-controls.response.dto.ts new file mode 100644 index 0000000..f39eaa3 --- /dev/null +++ b/src/common/modules/neoleap/dtos/response/update-card-controls.response.dto.ts @@ -0,0 +1 @@ +export class UpdateCardControlsResponseDto {} diff --git a/src/common/modules/neoleap/interfaces/index.ts b/src/common/modules/neoleap/interfaces/index.ts index e6757c9..911935c 100644 --- a/src/common/modules/neoleap/interfaces/index.ts +++ b/src/common/modules/neoleap/interfaces/index.ts @@ -1,3 +1,4 @@ export * from './create-application.request.interface'; export * from './inquire-application.request.interface'; export * from './neoleap-header.request.interface'; +export * from './update-card-control.request.interface'; diff --git a/src/common/modules/neoleap/interfaces/update-card-control.request.interface.ts b/src/common/modules/neoleap/interfaces/update-card-control.request.interface.ts new file mode 100644 index 0000000..3764887 --- /dev/null +++ b/src/common/modules/neoleap/interfaces/update-card-control.request.interface.ts @@ -0,0 +1,77 @@ +import { INeoleapHeaderRequest } from './neoleap-header.request.interface'; + +export interface IUpdateCardControlRequest extends INeoleapHeaderRequest { + UpdateCardControlsRequestDetails: { + InstitutionCode: string; + CardIdentifier: { + Id: string; + InstitutionCode: string; + }; + + UsageTransactionLimit?: { + AmountLimit: number; + CountLimit: number; + }; + DomesticCashDailyLimit?: { + AmountLimit: number; + CountLimit: number; + }; + InternationalCashDailyLimit?: { + AmountLimit: number; + CountLimit: number; + }; + DomesticPosDailyLimit?: { + AmountLimit: number; + CountLimit: number; + lenient?: boolean; + }; + InternationalPosDailyLimit?: { + AmountLimit: number; + CountLimit: number; + }; + EcommerceDailyLimit?: { + AmountLimit: number; + CountLimit: number; + }; + DomesticCashMonthlyLimit?: { + AmountLimit: number; + CountLimit: number; + }; + InternationalCashMonthlyLimit?: { + AmountLimit: number; + CountLimit: number; + }; + DomesticPosMonthlyLimit?: { + AmountLimit: number; + CountLimit: number; + }; + InternationalPosMonthlyLimit?: { + AmountLimit: number; + CountLimit: number; + }; + EcommerceMonthlyLimit?: { + AmountLimit: number; + CountLimit: number; + }; + DomesticCashTransactionLimit?: { + AmountLimit: number; + CountLimit: number; + }; + InternationalCashTransactionLimit?: { + AmountLimit: number; + CountLimit: number; + }; + DomesticPosTransactionLimit?: { + AmountLimit: number; + CountLimit: number; + }; + InternationalPosTransactionLimit?: { + AmountLimit: number; + CountLimit: number; + }; + EcommerceTransactionLimit?: { + AmountLimit: number; + CountLimit: number; + }; + }; +} diff --git a/src/common/modules/neoleap/services/neoleap.service.ts b/src/common/modules/neoleap/services/neoleap.service.ts index 8e6403b..6f31ac4 100644 --- a/src/common/modules/neoleap/services/neoleap.service.ts +++ b/src/common/modules/neoleap/services/neoleap.service.ts @@ -6,10 +6,15 @@ import moment from 'moment'; import { v4 as uuid } from 'uuid'; import { CountriesNumericISO } from '~/common/constants'; import { Customer } from '~/customer/entities'; -import { Gender } from '~/customer/enums'; +import { Gender, KycStatus } from '~/customer/enums'; import { CREATE_APPLICATION_MOCK, INQUIRE_APPLICATION_MOCK } from '../__mocks__/'; -import { CreateApplicationResponse, InquireApplicationResponse } from '../dtos/response'; -import { ICreateApplicationRequest, IInquireApplicationRequest, INeoleapHeaderRequest } from '../interfaces'; +import { CreateApplicationResponse, InquireApplicationResponse, UpdateCardControlsResponseDto } from '../dtos/response'; +import { + ICreateApplicationRequest, + IInquireApplicationRequest, + INeoleapHeaderRequest, + IUpdateCardControlRequest, +} from '../interfaces'; @Injectable() export class NeoLeapService { private readonly baseUrl: string; @@ -26,9 +31,15 @@ export class NeoLeapService { async createApplication(customer: Customer) { const responseKey = 'CreateNewApplicationResponseDetails'; - // if (customer.cards.length > 0) { - // throw new BadRequestException('CUSTOMER.ALREADY_HAS_CARD'); - // } + + if (customer.kycStatus !== KycStatus.APPROVED) { + throw new BadRequestException('CUSTOMER.KYC_NOT_APPROVED'); + } + + if (customer.cards.length > 0) { + throw new BadRequestException('CUSTOMER.ALREADY_HAS_CARD'); + } + if (!this.useGateway) { return plainToInstance(CreateApplicationResponse, CREATE_APPLICATION_MOCK[responseKey], { excludeExtraneousValues: true, @@ -132,6 +143,35 @@ export class NeoLeapService { ); } + async updateCardControl(cardId: string, amount: number, count?: number) { + const responseKey = 'UpdateCardControlResponseDetails'; + if (!this.useGateway) { + return; + } + + const payload: IUpdateCardControlRequest = { + UpdateCardControlsRequestDetails: { + InstitutionCode: this.institutionCode, + CardIdentifier: { + InstitutionCode: this.institutionCode, + Id: cardId, + }, + UsageTransactionLimit: { + AmountLimit: amount, + CountLimit: count || 10, + }, + }, + RequestHeader: this.prepareHeaders('UpdateCardControl'), + }; + + return this.sendRequestToNeoLeap( + 'cardcontrol/UpdateCardControl', + payload, + responseKey, + UpdateCardControlsResponseDto, + ); + } + private prepareHeaders(serviceName: string): INeoleapHeaderRequest['RequestHeader'] { return { Version: '1.0.0', @@ -156,7 +196,7 @@ export class NeoLeapService { }, }); - if (data.data?.ResponseHeader?.ResponseCode !== '000' || !data.data[responseKey]) { + if (data.data?.ResponseHeader?.ResponseCode !== '000') { throw new BadRequestException( data.data.ResponseHeader.ResponseDescription || 'Unexpected Error from Service Provider', ); diff --git a/src/customer/dtos/response/customer.response.dto.ts b/src/customer/dtos/response/customer.response.dto.ts index 3d40cf7..f49d12c 100644 --- a/src/customer/dtos/response/customer.response.dto.ts +++ b/src/customer/dtos/response/customer.response.dto.ts @@ -58,6 +58,24 @@ export class CustomerResponseDto { @ApiProperty() waitingNumber!: number; + @ApiProperty() + country!: string | null; + + @ApiProperty() + region!: string | null; + + @ApiProperty() + city!: string | null; + + @ApiProperty() + neighborhood!: string | null; + + @ApiProperty() + street!: string | null; + + @ApiProperty() + building!: string | null; + @ApiPropertyOptional({ type: DocumentMetaResponseDto }) profilePicture!: DocumentMetaResponseDto | null; @@ -80,7 +98,12 @@ export class CustomerResponseDto { this.isJunior = customer.isJunior; this.isGuardian = customer.isGuardian; this.waitingNumber = customer.waitingNumber; - + this.country = customer.country; + this.region = customer.region; + this.city = customer.city; + this.neighborhood = customer.neighborhood; + this.street = customer.street; + this.building = customer.building; this.profilePicture = customer.profilePicture ? new DocumentMetaResponseDto(customer.profilePicture) : null; } } diff --git a/src/customer/services/customer.service.ts b/src/customer/services/customer.service.ts index dff93ff..5690858 100644 --- a/src/customer/services/customer.service.ts +++ b/src/customer/services/customer.service.ts @@ -1,8 +1,11 @@ import { BadRequestException, Injectable, Logger } from '@nestjs/common'; +import moment from 'moment'; import { Transactional } from 'typeorm-transactional'; +import { CountryIso } from '~/common/enums'; import { DocumentService, OciService } from '~/document/services'; import { GuardianService } from '~/guardian/services'; import { CreateJuniorRequestDto } from '~/junior/dtos/request'; +import { User } from '~/user/entities'; import { CreateCustomerRequestDto, CustomerFiltersRequestDto, @@ -10,7 +13,7 @@ import { UpdateCustomerRequestDto, } from '../dtos/request'; import { Customer } from '../entities'; -import { KycStatus } from '../enums'; +import { Gender, KycStatus } from '../enums'; import { CustomerRepository } from '../repositories/customer.repository'; @Injectable() @@ -125,6 +128,33 @@ export class CustomerService { this.logger.log(`KYC rejected for customer ${customerId}`); } + // this function is for testing only and will be removed + @Transactional() + async updateKyc(userId: string) { + this.logger.log(`Updating KYC for customer ${userId}`); + await this.customerRepository.updateCustomer(userId, { + kycStatus: KycStatus.APPROVED, + gender: Gender.MALE, + nationalId: '1089055972', + nationalIdExpiry: moment('2031-09-17').toDate(), + countryOfResidence: CountryIso.SAUDI_ARABIA, + country: CountryIso.SAUDI_ARABIA, + region: 'Mecca', + city: 'AT Taif', + neighborhood: 'Al Faisaliah', + street: 'Al Faisaliah Street', + building: '4', + }); + + await User.update(userId, { + phoneNumber: this.generateSaudiPhoneNumber(), + countryCode: '+966', + }); + + this.logger.log(`KYC updated for customer ${userId}`); + return this.findCustomerById(userId); + } + private async validateProfilePictureForCustomer(userId: string, profilePictureId?: string) { if (!profilePictureId) return; @@ -202,4 +232,15 @@ export class CustomerService { async findAnyCustomer() { return this.customerRepository.findOne({ isGuardian: true }); } + + // TO BE REMOVED: This function is for testing only and will be removed + private generateSaudiPhoneNumber(): string { + // Saudi mobile numbers are 9 digits, always starting with '5' + const firstDigit = '5'; + let rest = ''; + for (let i = 0; i < 8; i++) { + rest += Math.floor(Math.random() * 10); + } + return `${firstDigit}${rest}`; + } } diff --git a/src/i18n/ar/app.json b/src/i18n/ar/app.json index 1ce9b9e..754a4b5 100644 --- a/src/i18n/ar/app.json +++ b/src/i18n/ar/app.json @@ -43,7 +43,8 @@ }, "CUSTOMER": { "NOT_FOUND": "لم يتم العثور على العميل.", - "ALREADY_EXISTS": "العميل موجود بالفعل." + "ALREADY_EXISTS": "العميل موجود بالفعل.", + "KYC_NOT_APPROVED": "لم يتم الموافقة على هوية العميل بعد." }, "GIFT": { diff --git a/src/i18n/en/app.json b/src/i18n/en/app.json index 64b6c26..677a7df 100644 --- a/src/i18n/en/app.json +++ b/src/i18n/en/app.json @@ -42,7 +42,8 @@ }, "CUSTOMER": { "NOT_FOUND": "The customer was not found.", - "ALREADY_EXISTS": "The customer already exists." + "ALREADY_EXISTS": "The customer already exists.", + "KYC_NOT_APPROVED": "The customer's KYC has not been approved yet." }, "GIFT": { From bf43e62b17bf651ec35fa2d0905df731fe47f805 Mon Sep 17 00:00:00 2001 From: Abdalhamid Alhamad Date: Mon, 21 Jul 2025 15:30:55 +0300 Subject: [PATCH 12/32] feat: handle card status changed webhook --- src/card/entities/card.entity.ts | 8 +- .../enums/card-status-description.enum.ts | 68 +++++++++++ src/card/enums/index.ts | 1 + .../mappers/card-status-description.mapper.ts | 109 ++++++++++++++++++ src/card/mappers/card-status.mapper.ts | 37 ++++++ src/card/repositories/card.repository.ts | 10 +- src/card/services/card.service.ts | 9 ++ .../neoleap-webhooks.controller.ts | 19 ++- .../neoleap/controllers/neotest.controller.ts | 2 +- ...card-status-changed-webhook.request.dto.ts | 20 ++++ .../modules/neoleap/dtos/requests/index.ts | 1 + src/common/modules/neoleap/neoleap.module.ts | 3 +- src/common/modules/neoleap/services/index.ts | 2 + .../services/neoleap-webook.service.ts | 25 ++++ .../neoleap/services/neoleap.service.ts | 2 +- src/core/enums/user-locale.enum.ts | 12 +- .../dtos/response/customer.response.dto.ts | 2 +- src/customer/entities/customer.entity.ts | 4 +- .../1753098116701-update-card-table.ts | 16 +++ .../1753098326876-edit-customer-table.ts | 16 +++ src/db/migrations/index.ts | 2 + 21 files changed, 350 insertions(+), 18 deletions(-) create mode 100644 src/card/enums/card-status-description.enum.ts create mode 100644 src/card/mappers/card-status-description.mapper.ts create mode 100644 src/card/mappers/card-status.mapper.ts create mode 100644 src/common/modules/neoleap/dtos/requests/account-card-status-changed-webhook.request.dto.ts create mode 100644 src/common/modules/neoleap/services/neoleap-webook.service.ts create mode 100644 src/db/migrations/1753098116701-update-card-table.ts create mode 100644 src/db/migrations/1753098326876-edit-customer-table.ts diff --git a/src/card/entities/card.entity.ts b/src/card/entities/card.entity.ts index 4984a61..53ac864 100644 --- a/src/card/entities/card.entity.ts +++ b/src/card/entities/card.entity.ts @@ -10,7 +10,7 @@ import { UpdateDateColumn, } from 'typeorm'; import { Customer } from '~/customer/entities'; -import { CardColors, CardIssuers, CardScheme, CardStatus, CustomerType } from '../enums'; +import { CardColors, CardIssuers, CardScheme, CardStatus, CardStatusDescription, CustomerType } from '../enums'; import { Account } from './account.entity'; import { Transaction } from './transaction.entity'; @@ -41,6 +41,12 @@ export class Card { @Column({ type: 'varchar', nullable: false, default: CardStatus.PENDING }) status!: CardStatus; + @Column({ type: 'varchar', nullable: false, default: CardStatusDescription.PENDING_ACTIVATION }) + statusDescription!: CardStatusDescription; + + @Column({ type: 'decimal', precision: 10, scale: 2, default: 0.0, name: 'limit' }) + limit!: number; + @Column({ type: 'varchar', nullable: false, default: CardScheme.VISA }) scheme!: CardScheme; diff --git a/src/card/enums/card-status-description.enum.ts b/src/card/enums/card-status-description.enum.ts new file mode 100644 index 0000000..1c277a8 --- /dev/null +++ b/src/card/enums/card-status-description.enum.ts @@ -0,0 +1,68 @@ +/** + * import { CardStatus, CardStatusDescription } from '../enums'; + + export const CardStatusMapper: Record = { + //ACTIVE + '00': { description: 'NORMAL', status: CardStatus.ACTIVE }, + + //PENDING + '02': { description: 'NOT_YET_ISSUED', status: CardStatus.PENDING }, + '20': { description: 'PENDING_ISSUANCE', status: CardStatus.PENDING }, + '21': { description: 'CARD_EXTRACTED', status: CardStatus.PENDING }, + '22': { description: 'EXTRACTION_FAILED', status: CardStatus.PENDING }, + '23': { description: 'FAILED_PRINTING_BULK', status: CardStatus.PENDING }, + '24': { description: 'FAILED_PRINTING_INST', status: CardStatus.PENDING }, + '30': { description: 'PENDING_ACTIVATION', status: CardStatus.PENDING }, + '27': { description: 'PENDING_PIN', status: CardStatus.PENDING }, + '16': { description: 'PREPARE_TO_CLOSE', status: CardStatus.PENDING }, + + //BLOCKED + '01': { description: 'PIN_TRIES_EXCEEDED', status: CardStatus.BLOCKED }, + '03': { description: 'CARD_EXPIRED', status: CardStatus.BLOCKED }, + '04': { description: 'LOST', status: CardStatus.BLOCKED }, + '05': { description: 'STOLEN', status: CardStatus.BLOCKED }, + '06': { description: 'CUSTOMER_CLOSE', status: CardStatus.BLOCKED }, + '07': { description: 'BANK_CANCELLED', status: CardStatus.BLOCKED }, + '08': { description: 'FRAUD', status: CardStatus.BLOCKED }, + '09': { description: 'DAMAGED', status: CardStatus.BLOCKED }, + '50': { description: 'SAFE_BLOCK', status: CardStatus.BLOCKED }, + '51': { description: 'TEMPORARY_BLOCK', status: CardStatus.BLOCKED }, + '52': { description: 'RISK_BLOCK', status: CardStatus.BLOCKED }, + '53': { description: 'OVERDRAFT', status: CardStatus.BLOCKED }, + '54': { description: 'BLOCKED_FOR_FEES', status: CardStatus.BLOCKED }, + '67': { description: 'CLOSED_CUSTOMER_DEAD', status: CardStatus.BLOCKED }, + '75': { description: 'RETURN_CARD', status: CardStatus.BLOCKED }, + + //Fallback + '99': { description: 'UNKNOWN', status: CardStatus.PENDING }, + }; + + */ +export enum CardStatusDescription { + NORMAL = 'NORMAL', + NOT_YET_ISSUED = 'NOT_YET_ISSUED', + PENDING_ISSUANCE = 'PENDING_ISSUANCE', + CARD_EXTRACTED = 'CARD_EXTRACTED', + EXTRACTION_FAILED = 'EXTRACTION_FAILED', + FAILED_PRINTING_BULK = 'FAILED_PRINTING_BULK', + FAILED_PRINTING_INST = 'FAILED_PRINTING_INST', + PENDING_ACTIVATION = 'PENDING_ACTIVATION', + PENDING_PIN = 'PENDING_PIN', + PREPARE_TO_CLOSE = 'PREPARE_TO_CLOSE', + PIN_TRIES_EXCEEDED = 'PIN_TRIES_EXCEEDED', + CARD_EXPIRED = 'CARD_EXPIRED', + LOST = 'LOST', + STOLEN = 'STOLEN', + CUSTOMER_CLOSE = 'CUSTOMER_CLOSE', + BANK_CANCELLED = 'BANK_CANCELLED', + FRAUD = 'FRAUD', + DAMAGED = 'DAMAGED', + SAFE_BLOCK = 'SAFE_BLOCK', + TEMPORARY_BLOCK = 'TEMPORARY_BLOCK', + RISK_BLOCK = 'RISK_BLOCK', + OVERDRAFT = 'OVERDRAFT', + BLOCKED_FOR_FEES = 'BLOCKED_FOR_FEES', + CLOSED_CUSTOMER_DEAD = 'CLOSED_CUSTOMER_DEAD', + RETURN_CARD = 'RETURN_CARD', + UNKNOWN = 'UNKNOWN', +} diff --git a/src/card/enums/index.ts b/src/card/enums/index.ts index cac8b22..16b52c2 100644 --- a/src/card/enums/index.ts +++ b/src/card/enums/index.ts @@ -1,6 +1,7 @@ export * from './card-colors.enum'; export * from './card-issuers.enum'; export * from './card-scheme.enum'; +export * from './card-status-description.enum'; export * from './card-status.enum'; export * from './customer-type.enum'; export * from './transaction-scope.enum'; diff --git a/src/card/mappers/card-status-description.mapper.ts b/src/card/mappers/card-status-description.mapper.ts new file mode 100644 index 0000000..85fe6d9 --- /dev/null +++ b/src/card/mappers/card-status-description.mapper.ts @@ -0,0 +1,109 @@ +import { UserLocale } from '~/core/enums'; +import { CardStatusDescription } from '../enums'; + +export const CardStatusMapper: Record = { + [CardStatusDescription.NORMAL]: { + [UserLocale.ENGLISH]: { description: 'The card is active' }, + [UserLocale.ARABIC]: { description: 'البطاقة نشطة' }, + }, + [CardStatusDescription.NOT_YET_ISSUED]: { + [UserLocale.ENGLISH]: { description: 'The card is not yet issued' }, + [UserLocale.ARABIC]: { description: 'البطاقة لم تصدر بعد' }, + }, + [CardStatusDescription.PENDING_ISSUANCE]: { + [UserLocale.ENGLISH]: { description: 'The card is pending issuance' }, + [UserLocale.ARABIC]: { description: 'البطاقة قيد الإصدار' }, + }, + [CardStatusDescription.CARD_EXTRACTED]: { + [UserLocale.ENGLISH]: { description: 'The card has been extracted' }, + [UserLocale.ARABIC]: { description: 'تم استخراج البطاقة' }, + }, + [CardStatusDescription.EXTRACTION_FAILED]: { + [UserLocale.ENGLISH]: { description: 'The card extraction has failed' }, + [UserLocale.ARABIC]: { description: 'فشل استخراج البطاقة' }, + }, + [CardStatusDescription.FAILED_PRINTING_BULK]: { + [UserLocale.ENGLISH]: { description: 'The card printing in bulk has failed' }, + [UserLocale.ARABIC]: { description: 'فشل الطباعة بالجملة للبطاقة' }, + }, + [CardStatusDescription.FAILED_PRINTING_INST]: { + [UserLocale.ENGLISH]: { description: 'The card printing in institution has failed' }, + [UserLocale.ARABIC]: { description: 'فشل الطباعة في المؤسسة للبطاقة' }, + }, + [CardStatusDescription.PENDING_ACTIVATION]: { + [UserLocale.ENGLISH]: { description: 'The card is pending activation' }, + [UserLocale.ARABIC]: { description: 'البطاقة قيد التفعيل' }, + }, + [CardStatusDescription.PENDING_PIN]: { + [UserLocale.ENGLISH]: { description: 'The card is pending PIN' }, + [UserLocale.ARABIC]: { description: 'البطاقة قيد الانتظار لرقم التعريف الشخصي' }, + }, + [CardStatusDescription.PREPARE_TO_CLOSE]: { + [UserLocale.ENGLISH]: { description: 'The card is being prepared for closure' }, + [UserLocale.ARABIC]: { description: 'البطاقة قيد التحضير للإغلاق' }, + }, + [CardStatusDescription.PIN_TRIES_EXCEEDED]: { + [UserLocale.ENGLISH]: { description: 'The card PIN tries have been exceeded' }, + [UserLocale.ARABIC]: { description: 'تم تجاوز محاولات رقم التعريف الشخصي للبطاقة' }, + }, + [CardStatusDescription.CARD_EXPIRED]: { + [UserLocale.ENGLISH]: { description: 'The card has expired' }, + [UserLocale.ARABIC]: { description: 'انتهت صلاحية البطاقة' }, + }, + [CardStatusDescription.LOST]: { + [UserLocale.ENGLISH]: { description: 'The card is lost' }, + [UserLocale.ARABIC]: { description: 'البطاقة ضائعة' }, + }, + [CardStatusDescription.STOLEN]: { + [UserLocale.ENGLISH]: { description: 'The card is stolen' }, + [UserLocale.ARABIC]: { description: 'البطاقة مسروقة' }, + }, + [CardStatusDescription.CUSTOMER_CLOSE]: { + [UserLocale.ENGLISH]: { description: 'The card is being closed by the customer' }, + [UserLocale.ARABIC]: { description: 'البطاقة قيد الإغلاق من قبل العميل' }, + }, + [CardStatusDescription.BANK_CANCELLED]: { + [UserLocale.ENGLISH]: { description: 'The card has been cancelled by the bank' }, + [UserLocale.ARABIC]: { description: 'البطاقة ألغيت من قبل البنك' }, + }, + [CardStatusDescription.FRAUD]: { + [UserLocale.ENGLISH]: { description: 'Fraud' }, + [UserLocale.ARABIC]: { description: 'احتيال' }, + }, + [CardStatusDescription.DAMAGED]: { + [UserLocale.ENGLISH]: { description: 'The card is damaged' }, + [UserLocale.ARABIC]: { description: 'البطاقة تالفة' }, + }, + [CardStatusDescription.SAFE_BLOCK]: { + [UserLocale.ENGLISH]: { description: 'The card is in a safe block' }, + [UserLocale.ARABIC]: { description: 'البطاقة في حظر آمن' }, + }, + [CardStatusDescription.TEMPORARY_BLOCK]: { + [UserLocale.ENGLISH]: { description: 'The card is in a temporary block' }, + [UserLocale.ARABIC]: { description: 'البطاقة في حظر مؤقت' }, + }, + [CardStatusDescription.RISK_BLOCK]: { + [UserLocale.ENGLISH]: { description: 'The card is in a risk block' }, + [UserLocale.ARABIC]: { description: 'البطاقة في حظر المخاطر' }, + }, + [CardStatusDescription.OVERDRAFT]: { + [UserLocale.ENGLISH]: { description: 'The card is in overdraft' }, + [UserLocale.ARABIC]: { description: 'البطاقة في السحب على المكشوف' }, + }, + [CardStatusDescription.BLOCKED_FOR_FEES]: { + [UserLocale.ENGLISH]: { description: 'The card is blocked for fees' }, + [UserLocale.ARABIC]: { description: 'البطاقة محظورة للرسوم' }, + }, + [CardStatusDescription.CLOSED_CUSTOMER_DEAD]: { + [UserLocale.ENGLISH]: { description: 'The card is closed because the customer is dead' }, + [UserLocale.ARABIC]: { description: 'البطاقة مغلقة لأن العميل متوفى' }, + }, + [CardStatusDescription.RETURN_CARD]: { + [UserLocale.ENGLISH]: { description: 'The card is being returned' }, + [UserLocale.ARABIC]: { description: 'البطاقة قيد الإرجاع' }, + }, + [CardStatusDescription.UNKNOWN]: { + [UserLocale.ENGLISH]: { description: 'The card status is unknown' }, + [UserLocale.ARABIC]: { description: 'حالة البطاقة غير معروفة' }, + }, +}; diff --git a/src/card/mappers/card-status.mapper.ts b/src/card/mappers/card-status.mapper.ts new file mode 100644 index 0000000..3c6da90 --- /dev/null +++ b/src/card/mappers/card-status.mapper.ts @@ -0,0 +1,37 @@ +import { CardStatus, CardStatusDescription } from '../enums'; + +export const CardStatusMapper: Record = { + //ACTIVE + '00': { description: CardStatusDescription.NORMAL, status: CardStatus.ACTIVE }, + + //PENDING + '02': { description: CardStatusDescription.NOT_YET_ISSUED, status: CardStatus.PENDING }, + '20': { description: CardStatusDescription.PENDING_ISSUANCE, status: CardStatus.PENDING }, + '21': { description: CardStatusDescription.CARD_EXTRACTED, status: CardStatus.PENDING }, + '22': { description: CardStatusDescription.EXTRACTION_FAILED, status: CardStatus.PENDING }, + '23': { description: CardStatusDescription.FAILED_PRINTING_BULK, status: CardStatus.PENDING }, + '24': { description: CardStatusDescription.FAILED_PRINTING_INST, status: CardStatus.PENDING }, + '30': { description: CardStatusDescription.PENDING_ACTIVATION, status: CardStatus.PENDING }, + '27': { description: CardStatusDescription.PENDING_PIN, status: CardStatus.PENDING }, + '16': { description: CardStatusDescription.PREPARE_TO_CLOSE, status: CardStatus.PENDING }, + + //BLOCKED + '01': { description: CardStatusDescription.PIN_TRIES_EXCEEDED, status: CardStatus.BLOCKED }, + '03': { description: CardStatusDescription.CARD_EXPIRED, status: CardStatus.BLOCKED }, + '04': { description: CardStatusDescription.LOST, status: CardStatus.BLOCKED }, + '05': { description: CardStatusDescription.STOLEN, status: CardStatus.BLOCKED }, + '06': { description: CardStatusDescription.CUSTOMER_CLOSE, status: CardStatus.BLOCKED }, + '07': { description: CardStatusDescription.BANK_CANCELLED, status: CardStatus.BLOCKED }, + '08': { description: CardStatusDescription.FRAUD, status: CardStatus.BLOCKED }, + '09': { description: CardStatusDescription.DAMAGED, status: CardStatus.BLOCKED }, + '50': { description: CardStatusDescription.SAFE_BLOCK, status: CardStatus.BLOCKED }, + '51': { description: CardStatusDescription.TEMPORARY_BLOCK, status: CardStatus.BLOCKED }, + '52': { description: CardStatusDescription.RISK_BLOCK, status: CardStatus.BLOCKED }, + '53': { description: CardStatusDescription.OVERDRAFT, status: CardStatus.BLOCKED }, + '54': { description: CardStatusDescription.BLOCKED_FOR_FEES, status: CardStatus.BLOCKED }, + '67': { description: CardStatusDescription.CLOSED_CUSTOMER_DEAD, status: CardStatus.BLOCKED }, + '75': { description: CardStatusDescription.RETURN_CARD, status: CardStatus.BLOCKED }, + + //Fallback + '99': { description: CardStatusDescription.UNKNOWN, status: CardStatus.PENDING }, +}; diff --git a/src/card/repositories/card.repository.ts b/src/card/repositories/card.repository.ts index ebab1f7..d747feb 100644 --- a/src/card/repositories/card.repository.ts +++ b/src/card/repositories/card.repository.ts @@ -3,7 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { CreateApplicationResponse } from '~/common/modules/neoleap/dtos/response'; import { Card } from '../entities'; -import { CardColors, CardIssuers, CardScheme, CardStatus, CustomerType } from '../enums'; +import { CardColors, CardIssuers, CardScheme, CardStatus, CardStatusDescription, CustomerType } from '../enums'; @Injectable() export class CardRepository { @@ -19,7 +19,6 @@ export class CardRepository { firstSixDigits: card.firstSixDigits, lastFourDigits: card.lastFourDigits, color: CardColors.BLUE, - status: CardStatus.ACTIVE, scheme: CardScheme.VISA, issuer: CardIssuers.NEOLEAP, accountId: accountId, @@ -40,4 +39,11 @@ export class CardRepository { where: { customerId, status: CardStatus.ACTIVE }, }); } + + updateCardStatus(id: string, status: CardStatus, statusDescription: CardStatusDescription) { + return this.cardRepository.update(id, { + status: status, + statusDescription: statusDescription, + }); + } } diff --git a/src/card/services/card.service.ts b/src/card/services/card.service.ts index bc71cfd..61f785e 100644 --- a/src/card/services/card.service.ts +++ b/src/card/services/card.service.ts @@ -1,7 +1,9 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { Transactional } from 'typeorm-transactional'; +import { AccountCardStatusChangedWebhookRequest } from '~/common/modules/neoleap/dtos/requests'; import { CreateApplicationResponse } from '~/common/modules/neoleap/dtos/response'; import { Card } from '../entities'; +import { CardStatusMapper } from '../mappers/card-status.mapper'; import { CardRepository } from '../repositories'; import { AccountService } from './account.service'; @@ -42,4 +44,11 @@ export class CardService { } return card; } + + async updateCardStatus(body: AccountCardStatusChangedWebhookRequest) { + const card = await this.getCardByReferenceNumber(body.cardId); + const { description, status } = CardStatusMapper[body.newStatus] || CardStatusMapper['99']; + + return this.cardRepository.updateCardStatus(card.id, status, description); + } } diff --git a/src/common/modules/neoleap/controllers/neoleap-webhooks.controller.ts b/src/common/modules/neoleap/controllers/neoleap-webhooks.controller.ts index 05d3bf9..1fb8d60 100644 --- a/src/common/modules/neoleap/controllers/neoleap-webhooks.controller.ts +++ b/src/common/modules/neoleap/controllers/neoleap-webhooks.controller.ts @@ -1,20 +1,29 @@ import { Body, Controller, Post } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; -import { TransactionService } from '~/card/services/transaction.service'; -import { AccountTransactionWebhookRequest, CardTransactionWebhookRequest } from '../dtos/requests'; +import { + AccountCardStatusChangedWebhookRequest, + AccountTransactionWebhookRequest, + CardTransactionWebhookRequest, +} from '../dtos/requests'; +import { NeoLeapWebhookService } from '../services'; @Controller('neoleap-webhooks') @ApiTags('Neoleap Webhooks') export class NeoLeapWebhooksController { - constructor(private readonly transactionService: TransactionService) {} + constructor(private readonly neoleapWebhookService: NeoLeapWebhookService) {} @Post('card-transaction') async handleCardTransactionWebhook(@Body() body: CardTransactionWebhookRequest) { - return this.transactionService.createCardTransaction(body); + return this.neoleapWebhookService.handleCardTransactionWebhook(body); } @Post('account-transaction') async handleAccountTransactionWebhook(@Body() body: AccountTransactionWebhookRequest) { - return this.transactionService.createAccountTransaction(body); + return this.neoleapWebhookService.handleAccountTransactionWebhook(body); + } + + @Post('account-card-status-changed') + async handleAccountCardStatusChangedWebhook(@Body() body: AccountCardStatusChangedWebhookRequest) { + return this.neoleapWebhookService.handleAccountCardStatusChangedWebhook(body); } } diff --git a/src/common/modules/neoleap/controllers/neotest.controller.ts b/src/common/modules/neoleap/controllers/neotest.controller.ts index 47ed777..5ea191d 100644 --- a/src/common/modules/neoleap/controllers/neotest.controller.ts +++ b/src/common/modules/neoleap/controllers/neotest.controller.ts @@ -37,7 +37,7 @@ export class NeoTestController { @ApiDataResponse(InquireApplicationResponse) async inquireApplication(@AuthenticatedUser() user: IJwtPayload) { const customer = await this.customerService.findCustomerById(user.sub); - const data = await this.neoleapService.inquireApplication(customer.waitingNumber.toString()); + const data = await this.neoleapService.inquireApplication(customer.applicationNumber.toString()); return ResponseFactory.data(data); } diff --git a/src/common/modules/neoleap/dtos/requests/account-card-status-changed-webhook.request.dto.ts b/src/common/modules/neoleap/dtos/requests/account-card-status-changed-webhook.request.dto.ts new file mode 100644 index 0000000..412effc --- /dev/null +++ b/src/common/modules/neoleap/dtos/requests/account-card-status-changed-webhook.request.dto.ts @@ -0,0 +1,20 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Expose } from 'class-transformer'; +import { IsString } from 'class-validator'; + +export class AccountCardStatusChangedWebhookRequest { + @ApiProperty() + @Expose({ name: 'InstId' }) + @IsString() + instId!: string; + + @ApiProperty() + @Expose({ name: 'cardId' }) + @IsString() + cardId!: string; + + @ApiProperty() + @Expose({ name: 'newStatus' }) + @IsString() + newStatus!: string; +} diff --git a/src/common/modules/neoleap/dtos/requests/index.ts b/src/common/modules/neoleap/dtos/requests/index.ts index d4cad6f..9360a77 100644 --- a/src/common/modules/neoleap/dtos/requests/index.ts +++ b/src/common/modules/neoleap/dtos/requests/index.ts @@ -1,3 +1,4 @@ +export * from './account-card-status-changed-webhook.request.dto'; export * from './account-transaction-webhook.request.dto'; export * from './card-transaction-webhook.request.dto'; export * from './update-card-controls.request.dto'; diff --git a/src/common/modules/neoleap/neoleap.module.ts b/src/common/modules/neoleap/neoleap.module.ts index 78c0224..39951f0 100644 --- a/src/common/modules/neoleap/neoleap.module.ts +++ b/src/common/modules/neoleap/neoleap.module.ts @@ -4,11 +4,12 @@ import { CardModule } from '~/card/card.module'; import { CustomerModule } from '~/customer/customer.module'; import { NeoLeapWebhooksController } from './controllers/neoleap-webhooks.controller'; import { NeoTestController } from './controllers/neotest.controller'; +import { NeoLeapWebhookService } from './services'; import { NeoLeapService } from './services/neoleap.service'; @Module({ imports: [HttpModule, CustomerModule, CardModule], controllers: [NeoTestController, NeoLeapWebhooksController], - providers: [NeoLeapService], + providers: [NeoLeapService, NeoLeapWebhookService], }) export class NeoLeapModule {} diff --git a/src/common/modules/neoleap/services/index.ts b/src/common/modules/neoleap/services/index.ts index e69de29..4fac8d7 100644 --- a/src/common/modules/neoleap/services/index.ts +++ b/src/common/modules/neoleap/services/index.ts @@ -0,0 +1,2 @@ +export * from './neoleap-webook.service'; +export * from './neoleap.service'; diff --git a/src/common/modules/neoleap/services/neoleap-webook.service.ts b/src/common/modules/neoleap/services/neoleap-webook.service.ts new file mode 100644 index 0000000..83b68c4 --- /dev/null +++ b/src/common/modules/neoleap/services/neoleap-webook.service.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@nestjs/common'; +import { CardService } from '~/card/services'; +import { TransactionService } from '~/card/services/transaction.service'; +import { + AccountCardStatusChangedWebhookRequest, + AccountTransactionWebhookRequest, + CardTransactionWebhookRequest, +} from '../dtos/requests'; + +@Injectable() +export class NeoLeapWebhookService { + constructor(private readonly transactionService: TransactionService, private readonly cardService: CardService) {} + + handleCardTransactionWebhook(body: CardTransactionWebhookRequest) { + return this.transactionService.createCardTransaction(body); + } + + handleAccountTransactionWebhook(body: AccountTransactionWebhookRequest) { + return this.transactionService.createAccountTransaction(body); + } + + handleAccountCardStatusChangedWebhook(body: AccountCardStatusChangedWebhookRequest) { + return this.cardService.updateCardStatus(body); + } +} diff --git a/src/common/modules/neoleap/services/neoleap.service.ts b/src/common/modules/neoleap/services/neoleap.service.ts index 6f31ac4..3abd065 100644 --- a/src/common/modules/neoleap/services/neoleap.service.ts +++ b/src/common/modules/neoleap/services/neoleap.service.ts @@ -50,7 +50,7 @@ export class NeoLeapService { CreateNewApplicationRequestDetails: { ApplicationRequestDetails: { InstitutionCode: this.institutionCode, - ExternalApplicationNumber: customer.waitingNumber.toString(), + ExternalApplicationNumber: customer.applicationNumber.toString(), ApplicationType: '01', Product: '1101', ApplicationDate: moment().format('YYYY-MM-DD'), diff --git a/src/core/enums/user-locale.enum.ts b/src/core/enums/user-locale.enum.ts index b9aa1f4..66339fe 100644 --- a/src/core/enums/user-locale.enum.ts +++ b/src/core/enums/user-locale.enum.ts @@ -1,4 +1,8 @@ -export enum UserLocale { - ARABIC = 'ar', - ENGLISH = 'en', -} +import { ObjectValues } from '../types'; + +export const UserLocale = { + ARABIC: 'ar', + ENGLISH: 'en', +} as const; + +export type UserLocale = ObjectValues; diff --git a/src/customer/dtos/response/customer.response.dto.ts b/src/customer/dtos/response/customer.response.dto.ts index f49d12c..6475221 100644 --- a/src/customer/dtos/response/customer.response.dto.ts +++ b/src/customer/dtos/response/customer.response.dto.ts @@ -97,7 +97,7 @@ export class CustomerResponseDto { this.gender = customer.gender; this.isJunior = customer.isJunior; this.isGuardian = customer.isGuardian; - this.waitingNumber = customer.waitingNumber; + this.waitingNumber = customer.applicationNumber; this.country = customer.country; this.region = customer.region; this.city = customer.city; diff --git a/src/customer/entities/customer.entity.ts b/src/customer/entities/customer.entity.ts index 96786c0..aa029ef 100644 --- a/src/customer/entities/customer.entity.ts +++ b/src/customer/entities/customer.entity.ts @@ -71,9 +71,9 @@ export class Customer extends BaseEntity { @Column('boolean', { default: false, name: 'is_guardian' }) isGuardian!: boolean; - @Column('int', { name: 'waiting_number' }) + @Column('int', { name: 'application_number' }) @Generated('increment') - waitingNumber!: number; + applicationNumber!: number; @Column('varchar', { name: 'user_id' }) userId!: string; diff --git a/src/db/migrations/1753098116701-update-card-table.ts b/src/db/migrations/1753098116701-update-card-table.ts new file mode 100644 index 0000000..56cb15c --- /dev/null +++ b/src/db/migrations/1753098116701-update-card-table.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class UpdateCardTable1753098116701 implements MigrationInterface { + name = 'UpdateCardTable1753098116701' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "cards" ADD "statusDescription" character varying NOT NULL DEFAULT 'PENDING_ACTIVATION'`); + await queryRunner.query(`ALTER TABLE "cards" ADD "limit" numeric(10,2) NOT NULL DEFAULT '0'`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "cards" DROP COLUMN "limit"`); + await queryRunner.query(`ALTER TABLE "cards" DROP COLUMN "statusDescription"`); + } + +} diff --git a/src/db/migrations/1753098326876-edit-customer-table.ts b/src/db/migrations/1753098326876-edit-customer-table.ts new file mode 100644 index 0000000..2595e2b --- /dev/null +++ b/src/db/migrations/1753098326876-edit-customer-table.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class EditCustomerTable1753098326876 implements MigrationInterface { + name = 'EditCustomerTable1753098326876' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "customers" RENAME COLUMN "waiting_number" TO "application_number"`); + await queryRunner.query(`ALTER SEQUENCE "customers_waiting_number_seq" RENAME TO "customers_application_number_seq"`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER SEQUENCE "customers_application_number_seq" RENAME TO "customers_waiting_number_seq"`); + await queryRunner.query(`ALTER TABLE "customers" RENAME COLUMN "application_number" TO "waiting_number"`); + } + +} diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index 5efe087..a0238a5 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -29,3 +29,5 @@ export * from './1749633935436-create-card-entity'; export * from './1751456987627-create-account-entity'; export * from './1751466314709-create-transaction-table'; export * from './1752056898465-edit-transaction-table'; +export * from './1753098116701-update-card-table'; +export * from './1753098326876-edit-customer-table'; From c493bd57e17ac093fc28bd9aca3ea72c4bb4aaf2 Mon Sep 17 00:00:00 2001 From: Abdalhamid Alhamad Date: Sun, 27 Jul 2025 13:15:54 +0300 Subject: [PATCH 13/32] feat: onboarding signup journey --- src/auth/auth.module.ts | 4 +- src/auth/controllers/auth.v2.controller.ts | 24 +++++++ src/auth/controllers/index.ts | 1 + .../create-unverified-user.request.v2.dto.ts | 4 ++ src/auth/dtos/request/index.ts | 2 + .../request/verify-user.v2.request.dto.ts | 71 +++++++++++++++++++ .../send-register-otp.v2.response.dto.ts | 10 +++ src/auth/services/auth.service.ts | 54 ++++++++++++++ src/i18n/ar/app.json | 3 +- src/i18n/en/app.json | 3 +- src/user/repositories/user.repository.ts | 3 +- src/user/services/user.service.ts | 65 +++++++++++++++-- 12 files changed, 234 insertions(+), 10 deletions(-) create mode 100644 src/auth/controllers/auth.v2.controller.ts create mode 100644 src/auth/dtos/request/create-unverified-user.request.v2.dto.ts create mode 100644 src/auth/dtos/request/verify-user.v2.request.dto.ts create mode 100644 src/auth/dtos/response/send-register-otp.v2.response.dto.ts diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 138e689..60dd928 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -3,14 +3,14 @@ import { Module } from '@nestjs/common'; import { JwtModule } from '@nestjs/jwt'; import { JuniorModule } from '~/junior/junior.module'; import { UserModule } from '~/user/user.module'; -import { AuthController } from './controllers'; +import { AuthController, AuthV2Controller } from './controllers'; import { AuthService, Oauth2Service } from './services'; import { AccessTokenStrategy } from './strategies'; @Module({ imports: [JwtModule.register({}), UserModule, JuniorModule, HttpModule], providers: [AuthService, AccessTokenStrategy, Oauth2Service], - controllers: [AuthController], + controllers: [AuthController, AuthV2Controller], exports: [], }) export class AuthModule {} diff --git a/src/auth/controllers/auth.v2.controller.ts b/src/auth/controllers/auth.v2.controller.ts new file mode 100644 index 0000000..af8b147 --- /dev/null +++ b/src/auth/controllers/auth.v2.controller.ts @@ -0,0 +1,24 @@ +import { Body, Controller, Post } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { ResponseFactory } from '~/core/utils'; +import { CreateUnverifiedUserV2RequestDto, VerifyUserV2RequestDto } from '../dtos/request'; +import { LoginResponseDto } from '../dtos/response/login.response.dto'; +import { SendRegisterOtpV2ResponseDto } from '../dtos/response/send-register-otp.v2.response.dto'; +import { AuthService } from '../services'; + +@Controller('auth/v2') +@ApiTags('Auth V2') +export class AuthV2Controller { + constructor(private readonly authService: AuthService) {} + @Post('register/otp') + async register(@Body() createUnverifiedUserDto: CreateUnverifiedUserV2RequestDto) { + const phoneNumber = await this.authService.sendPhoneRegisterOtp(createUnverifiedUserDto); + return ResponseFactory.data(new SendRegisterOtpV2ResponseDto(phoneNumber)); + } + + @Post('register/verify') + async verifyUser(@Body() verifyUserDto: VerifyUserV2RequestDto) { + const [loginResponse, user] = await this.authService.verifyUserV2(verifyUserDto); + return ResponseFactory.data(new LoginResponseDto(loginResponse, user)); + } +} diff --git a/src/auth/controllers/index.ts b/src/auth/controllers/index.ts index 04d02fa..37a52b8 100644 --- a/src/auth/controllers/index.ts +++ b/src/auth/controllers/index.ts @@ -1 +1,2 @@ export * from './auth.controller'; +export * from './auth.v2.controller'; diff --git a/src/auth/dtos/request/create-unverified-user.request.v2.dto.ts b/src/auth/dtos/request/create-unverified-user.request.v2.dto.ts new file mode 100644 index 0000000..7df2980 --- /dev/null +++ b/src/auth/dtos/request/create-unverified-user.request.v2.dto.ts @@ -0,0 +1,4 @@ +import { OmitType } from '@nestjs/swagger'; +import { VerifyUserV2RequestDto } from './verify-user.v2.request.dto'; + +export class CreateUnverifiedUserV2RequestDto extends OmitType(VerifyUserV2RequestDto, ['otp']) {} diff --git a/src/auth/dtos/request/index.ts b/src/auth/dtos/request/index.ts index d5c81cf..ab51125 100644 --- a/src/auth/dtos/request/index.ts +++ b/src/auth/dtos/request/index.ts @@ -1,5 +1,6 @@ export * from './apple-login.request.dto'; export * from './create-unverified-user.request.dto'; +export * from './create-unverified-user.request.v2.dto'; export * from './disable-biometric.request.dto'; export * from './enable-biometric.request.dto'; export * from './forget-password.request.dto'; @@ -14,3 +15,4 @@ export * from './set-passcode.request.dto'; export * from './verify-login-otp.request.dto'; export * from './verify-otp.request.dto'; export * from './verify-user.request.dto'; +export * from './verify-user.v2.request.dto'; diff --git a/src/auth/dtos/request/verify-user.v2.request.dto.ts b/src/auth/dtos/request/verify-user.v2.request.dto.ts new file mode 100644 index 0000000..ce0f216 --- /dev/null +++ b/src/auth/dtos/request/verify-user.v2.request.dto.ts @@ -0,0 +1,71 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { + IsDateString, + IsEmail, + IsEnum, + IsNotEmpty, + IsNumberString, + IsOptional, + IsString, + Matches, + MaxLength, + MinLength, +} from 'class-validator'; +import { i18nValidationMessage as i18n } from 'nestjs-i18n'; +import { COUNTRY_CODE_REGEX } from '~/auth/constants'; +import { CountryIso } from '~/common/enums'; +import { DEFAULT_OTP_LENGTH } from '~/common/modules/otp/constants'; +import { IsAbove18, IsValidPhoneNumber } from '~/core/decorators/validations'; + +export class VerifyUserV2RequestDto { + @ApiProperty({ example: '+962' }) + @Matches(COUNTRY_CODE_REGEX, { + message: i18n('validation.Matches', { path: 'general', property: 'auth.countryCode' }), + }) + countryCode!: string; + + @ApiProperty({ example: '787259134' }) + @IsValidPhoneNumber({ + message: i18n('validation.IsValidPhoneNumber', { path: 'general', property: 'auth.phoneNumber' }), + }) + phoneNumber!: string; + @ApiProperty({ example: 'John' }) + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.firstName' }) }) + @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.firstName' }) }) + firstName!: string; + + @ApiProperty({ example: 'Doe' }) + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.lastName' }) }) + @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.lastName' }) }) + lastName!: string; + + @ApiProperty({ example: '2021-01-01' }) + @IsDateString({}, { message: i18n('validation.IsDateString', { path: 'general', property: 'customer.dateOfBirth' }) }) + @IsAbove18({ message: i18n('validation.IsAbove18', { path: 'general', property: 'customer.dateOfBirth' }) }) + dateOfBirth!: Date; + + @ApiProperty({ example: 'JO' }) + @IsEnum(CountryIso, { + message: i18n('validation.IsEnum', { path: 'general', property: 'customer.countryOfResidence' }), + }) + @IsOptional() + countryOfResidence: CountryIso = CountryIso.SAUDI_ARABIA; + + @ApiProperty({ example: 'test@test.com' }) + @IsEmail({}, { message: i18n('validation.IsEmail', { path: 'general', property: 'auth.email' }) }) + @IsOptional() + email!: string; + + @ApiProperty({ example: '111111' }) + @IsNumberString( + { no_symbols: true }, + { message: i18n('validation.IsNumberString', { path: 'general', property: 'auth.otp' }) }, + ) + @MaxLength(DEFAULT_OTP_LENGTH, { + message: i18n('validation.MaxLength', { path: 'general', property: 'auth.otp', length: DEFAULT_OTP_LENGTH }), + }) + @MinLength(DEFAULT_OTP_LENGTH, { + message: i18n('validation.MinLength', { path: 'general', property: 'auth.otp', length: DEFAULT_OTP_LENGTH }), + }) + otp!: string; +} diff --git a/src/auth/dtos/response/send-register-otp.v2.response.dto.ts b/src/auth/dtos/response/send-register-otp.v2.response.dto.ts new file mode 100644 index 0000000..27c72ec --- /dev/null +++ b/src/auth/dtos/response/send-register-otp.v2.response.dto.ts @@ -0,0 +1,10 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class SendRegisterOtpV2ResponseDto { + @ApiProperty() + maskedNumber!: string; + + constructor(maskedNumber: string) { + this.maskedNumber = maskedNumber; + } +} diff --git a/src/auth/services/auth.service.ts b/src/auth/services/auth.service.ts index 0d192b2..908e6ed 100644 --- a/src/auth/services/auth.service.ts +++ b/src/auth/services/auth.service.ts @@ -14,6 +14,7 @@ import { PASSCODE_REGEX } from '../constants'; import { AppleLoginRequestDto, CreateUnverifiedUserRequestDto, + CreateUnverifiedUserV2RequestDto, DisableBiometricRequestDto, EnableBiometricRequestDto, ForgetPasswordRequestDto, @@ -25,6 +26,7 @@ import { setJuniorPasswordRequestDto, VerifyLoginOtpRequestDto, VerifyUserRequestDto, + VerifyUserV2RequestDto, } from '../dtos/request'; import { Roles } from '../enums'; import { IJwtPayload, ILoginResponse } from '../interfaces'; @@ -59,6 +61,25 @@ export class AuthService { }); } + async sendPhoneRegisterOtp(body: CreateUnverifiedUserV2RequestDto) { + if (body.email) { + const isEmailUsed = await this.userService.findUser({ email: body.email, isEmailVerified: true }); + if (isEmailUsed) { + this.logger.error(`Email ${body.email} is already used`); + throw new BadRequestException('USER.EMAIL_ALREADY_TAKEN'); + } + } + + this.logger.log(`Sending OTP to ${body.countryCode + body.phoneNumber}`); + const user = await this.userService.findOrCreateByPhoneNumber(body); + return this.otpService.generateAndSendOtp({ + userId: user.id, + recipient: user.fullPhoneNumber, + scope: OtpScope.VERIFY_PHONE, + otpType: OtpType.SMS, + }); + } + async verifyUser(verifyUserDto: VerifyUserRequestDto): Promise<[ILoginResponse, User]> { this.logger.log(`Verifying user with email ${verifyUserDto.email}`); const user = await this.userService.findUserOrThrow({ email: verifyUserDto.email }); @@ -89,6 +110,39 @@ export class AuthService { return [tokens, user]; } + async verifyUserV2(verifyUserDto: VerifyUserV2RequestDto): Promise<[ILoginResponse, User]> { + this.logger.log(`Verifying user with phone number ${verifyUserDto.countryCode + verifyUserDto.phoneNumber}`); + const user = await this.userService.findUserOrThrow({ + phoneNumber: verifyUserDto.phoneNumber, + countryCode: verifyUserDto.countryCode, + }); + + if (user.isPhoneVerified) { + this.logger.error(`User with phone number ${user.fullPhoneNumber} already verified`); + throw new BadRequestException('USER.PHONE_NUMBER_ALREADY_VERIFIED'); + } + + const isOtpValid = await this.otpService.verifyOtp({ + userId: user.id, + scope: OtpScope.VERIFY_PHONE, + otpType: OtpType.SMS, + value: verifyUserDto.otp, + }); + + if (!isOtpValid) { + this.logger.error(`Invalid OTP for user with phone number ${user.fullPhoneNumber}`); + throw new BadRequestException('OTP.INVALID_OTP'); + } + + await this.userService.verifyUserV2(user.id, verifyUserDto); + + await user.reload(); + + const tokens = await this.generateAuthToken(user); + this.logger.log(`User with phone number ${user.fullPhoneNumber} verified successfully`); + return [tokens, user]; + } + async setEmail(userId: string, { email }: SetEmailRequestDto) { this.logger.log(`Setting email for user with id ${userId}`); const user = await this.userService.findUserOrThrow({ id: userId }); diff --git a/src/i18n/ar/app.json b/src/i18n/ar/app.json index 754a4b5..455ddaf 100644 --- a/src/i18n/ar/app.json +++ b/src/i18n/ar/app.json @@ -25,7 +25,8 @@ "ALREADY_EXISTS": "المستخدم موجود بالفعل.", "NOT_FOUND": "لم يتم العثور على المستخدم.", "PHONE_NUMBER_ALREADY_EXISTS": "رقم الهاتف موجود بالفعل.", - "JUNIOR_UPGRADE_NOT_SUPPORTED_YET": "ترقية الحساب من حساب طفل إلى حساب ولي أمر غير مدعومة حاليًا." + "JUNIOR_UPGRADE_NOT_SUPPORTED_YET": "ترقية الحساب من حساب طفل إلى حساب ولي أمر غير مدعومة حاليًا.", + "PHONE_NUMBER_ALREADY_TAKEN": "رقم الهاتف الذي أدخلته مستخدم بالفعل. يرجى استخدام رقم هاتف آخر." }, "ALLOWANCE": { diff --git a/src/i18n/en/app.json b/src/i18n/en/app.json index 677a7df..105afc9 100644 --- a/src/i18n/en/app.json +++ b/src/i18n/en/app.json @@ -25,7 +25,8 @@ "ALREADY_EXISTS": "The user already exists.", "NOT_FOUND": "The user was not found.", "PHONE_NUMBER_ALREADY_EXISTS": "The phone number already exists.", - "JUNIOR_UPGRADE_NOT_SUPPORTED_YET": "Upgrading account from junior to guardian is not yet supported." + "JUNIOR_UPGRADE_NOT_SUPPORTED_YET": "Upgrading account from junior to guardian is not yet supported.", + "PHONE_NUMBER_ALREADY_TAKEN": "The phone number you entered is already in use. Please use a different phone number." }, "ALLOWANCE": { "START_DATE_BEFORE_TODAY": "The start date cannot be before today.", diff --git a/src/user/repositories/user.repository.ts b/src/user/repositories/user.repository.ts index 79b8489..d740cd8 100644 --- a/src/user/repositories/user.repository.ts +++ b/src/user/repositories/user.repository.ts @@ -11,8 +11,7 @@ export class UserRepository { createUnverifiedUser(data: Partial) { return this.userRepository.save( this.userRepository.create({ - email: data.email, - roles: data.roles, + ...data, }), ); } diff --git a/src/user/services/user.service.ts b/src/user/services/user.service.ts index ec32953..3279780 100644 --- a/src/user/services/user.service.ts +++ b/src/user/services/user.service.ts @@ -7,7 +7,12 @@ import { Transactional } from 'typeorm-transactional'; import { CountryIso } from '~/common/enums'; import { NotificationsService } from '~/common/modules/notification/services'; import { CustomerService } from '~/customer/services'; -import { CreateUnverifiedUserRequestDto, VerifyUserRequestDto } from '../../auth/dtos/request'; +import { + CreateUnverifiedUserRequestDto, + CreateUnverifiedUserV2RequestDto, + VerifyUserRequestDto, + VerifyUserV2RequestDto, +} from '../../auth/dtos/request'; import { Roles } from '../../auth/enums'; import { CreateCheckerRequestDto, @@ -55,8 +60,8 @@ export class UserService { } @Transactional() - async verifyUser(userId: string, body: VerifyUserRequestDto) { - this.logger.log(`Verifying user email with id ${userId}`); + async verifyUser(userId: string, body: VerifyUserRequestDto | VerifyUserV2RequestDto) { + this.logger.log(`Verifying user with id ${userId}`); await Promise.all([ this.customerService.createGuardianCustomer(userId, { firstName: body.firstName, @@ -64,7 +69,27 @@ export class UserService { dateOfBirth: body.dateOfBirth, countryOfResidence: body.countryOfResidence, }), - this.userRepository.update(userId, { isEmailVerified: true }), + this.userRepository.update(userId, { + isEmailVerified: true, + }), + ]); + } + + @Transactional() + async verifyUserV2(userId: string, body: VerifyUserV2RequestDto) { + this.logger.log(`Verifying user with id ${userId}`); + await Promise.all([ + this.customerService.createGuardianCustomer(userId, { + firstName: body.firstName, + lastName: body.lastName, + dateOfBirth: body.dateOfBirth, + countryOfResidence: body.countryOfResidence, + }), + this.userRepository.update(userId, { + isPhoneVerified: true, + + ...(body.email && { email: body.email }), + }), ]); } @@ -110,6 +135,38 @@ export class UserService { return user; } + async findOrCreateByPhoneNumber(body: CreateUnverifiedUserV2RequestDto) { + this.logger.log(`Finding or creating user with phone number ${body.countryCode + body.phoneNumber}`); + const user = await this.userRepository.findOne({ + phoneNumber: body.phoneNumber, + countryCode: body.countryCode, + }); + + if (!user) { + this.logger.log(`User with phone number ${body.phoneNumber} not found, creating new user`); + return this.userRepository.createUnverifiedUser({ + phoneNumber: body.phoneNumber, + countryCode: body.countryCode, + email: body.email, + roles: [Roles.GUARDIAN], + }); + } + + if (user && user.roles.includes(Roles.GUARDIAN) && user.isPhoneVerified) { + this.logger.error(`User with phone number ${body.phoneNumber} already exists`); + throw new BadRequestException('USER.PHONE_NUMBER_ALREADY_TAKEN'); + } + + if (user && user.roles.includes(Roles.JUNIOR)) { + this.logger.error(`User with phone number ${body.phoneNumber} is an already registered junior`); + throw new BadRequestException('USER.JUNIOR_UPGRADE_NOT_SUPPORTED_YET'); + //TODO add role Guardian to the existing user and send OTP + } + + this.logger.log(`User with phone number ${body.phoneNumber} found successfully`); + return user; + } + async findOrCreateByEmail(email: string) { this.logger.log(`Finding or creating user with email ${email} `); const user = await this.userRepository.findOne({ email }); From 1541c374edf9cec328f10d8f0ef3de3760512dcb Mon Sep 17 00:00:00 2001 From: Abdalhamid Alhamad Date: Sun, 27 Jul 2025 13:26:21 +0300 Subject: [PATCH 14/32] feat: fix swagger examples --- src/auth/controllers/auth.v2.controller.ts | 3 ++ src/auth/dtos/response/login.response.dto.ts | 2 +- .../dtos/response/customer.response.dto.ts | 50 +++++++++---------- 3 files changed, 29 insertions(+), 26 deletions(-) diff --git a/src/auth/controllers/auth.v2.controller.ts b/src/auth/controllers/auth.v2.controller.ts index af8b147..3b79641 100644 --- a/src/auth/controllers/auth.v2.controller.ts +++ b/src/auth/controllers/auth.v2.controller.ts @@ -1,5 +1,6 @@ import { Body, Controller, Post } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; +import { ApiDataResponse } from '~/core/decorators'; import { ResponseFactory } from '~/core/utils'; import { CreateUnverifiedUserV2RequestDto, VerifyUserV2RequestDto } from '../dtos/request'; import { LoginResponseDto } from '../dtos/response/login.response.dto'; @@ -11,12 +12,14 @@ import { AuthService } from '../services'; export class AuthV2Controller { constructor(private readonly authService: AuthService) {} @Post('register/otp') + @ApiDataResponse(SendRegisterOtpV2ResponseDto) async register(@Body() createUnverifiedUserDto: CreateUnverifiedUserV2RequestDto) { const phoneNumber = await this.authService.sendPhoneRegisterOtp(createUnverifiedUserDto); return ResponseFactory.data(new SendRegisterOtpV2ResponseDto(phoneNumber)); } @Post('register/verify') + @ApiDataResponse(LoginResponseDto) async verifyUser(@Body() verifyUserDto: VerifyUserV2RequestDto) { const [loginResponse, user] = await this.authService.verifyUserV2(verifyUserDto); return ResponseFactory.data(new LoginResponseDto(loginResponse, user)); diff --git a/src/auth/dtos/response/login.response.dto.ts b/src/auth/dtos/response/login.response.dto.ts index 6e9ee15..6a64ef4 100644 --- a/src/auth/dtos/response/login.response.dto.ts +++ b/src/auth/dtos/response/login.response.dto.ts @@ -17,7 +17,7 @@ export class LoginResponseDto { @ApiProperty({ example: UserResponseDto }) user!: UserResponseDto; - @ApiProperty({ example: CustomerResponseDto }) + @ApiProperty({ type: CustomerResponseDto }) customer!: CustomerResponseDto | null; constructor(IVerifyUserResponse: ILoginResponse, user: User) { diff --git a/src/customer/dtos/response/customer.response.dto.ts b/src/customer/dtos/response/customer.response.dto.ts index 6475221..116b73a 100644 --- a/src/customer/dtos/response/customer.response.dto.ts +++ b/src/customer/dtos/response/customer.response.dto.ts @@ -1,79 +1,79 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Customer } from '~/customer/entities'; -import { CustomerStatus, KycStatus } from '~/customer/enums'; +import { CustomerStatus, Gender, KycStatus } from '~/customer/enums'; import { DocumentMetaResponseDto } from '~/document/dtos/response'; export class CustomerResponseDto { - @ApiProperty() + @ApiProperty({ example: '123e4567-e89b-12d3-a456-426614174000' }) id!: string; - @ApiProperty() + @ApiProperty({ example: CustomerStatus.PENDING }) customerStatus!: CustomerStatus; - @ApiProperty() + @ApiProperty({ example: KycStatus.PENDING }) kycStatus!: KycStatus; - @ApiProperty() + @ApiProperty({ example: 'Rejection reason if any' }) rejectionReason!: string | null; - @ApiProperty() + @ApiProperty({ example: 'John' }) firstName!: string; - @ApiProperty() + @ApiProperty({ example: 'Doe' }) lastName!: string; - @ApiProperty() + @ApiProperty({ example: '1990-01-01' }) dateOfBirth!: Date; - @ApiProperty() + @ApiProperty({ example: '123456789' }) nationalId!: string; - @ApiProperty() + @ApiProperty({ example: '2025-01-01' }) nationalIdExpiry!: Date; - @ApiProperty() + @ApiProperty({ example: 'JO' }) countryOfResidence!: string; - @ApiProperty() + @ApiProperty({ example: 'Employee' }) sourceOfIncome!: string; - @ApiProperty() + @ApiProperty({ example: 'Software Development' }) profession!: string; - @ApiProperty() + @ApiProperty({ example: 'Full-time' }) professionType!: string; - @ApiProperty() + @ApiProperty({ example: false }) isPep!: boolean; - @ApiProperty() + @ApiProperty({ example: Gender.MALE }) gender!: string; - @ApiProperty() + @ApiProperty({ example: false }) isJunior!: boolean; - @ApiProperty() + @ApiProperty({ example: true }) isGuardian!: boolean; - @ApiProperty() + @ApiProperty({ example: 12345 }) waitingNumber!: number; - @ApiProperty() + @ApiProperty({ example: 'SA' }) country!: string | null; - @ApiProperty() + @ApiProperty({ example: 'Riyadh' }) region!: string | null; - @ApiProperty() + @ApiProperty({ example: 'Riyadh City' }) city!: string | null; - @ApiProperty() + @ApiProperty({ example: 'Al-Masif' }) neighborhood!: string | null; - @ApiProperty() + @ApiProperty({ example: 'King Fahd Road' }) street!: string | null; - @ApiProperty() + @ApiProperty({ example: '123' }) building!: string | null; @ApiPropertyOptional({ type: DocumentMetaResponseDto }) From 4cb5814cd31026d0dde1761f3eac5740d5cd3ea8 Mon Sep 17 00:00:00 2001 From: Abdalhamid Alhamad Date: Wed, 30 Jul 2025 14:18:10 +0300 Subject: [PATCH 15/32] fix: organize migrations --- ...9536067-add-address-fields-to-customers.ts | 23 ---------- .../1749633935436-create-card-entity.ts | 40 ----------------- .../1751456987627-create-account-entity.ts | 45 ------------------- .../1751466314709-create-transaction-table.ts | 18 -------- .../1752056898465-edit-transaction-table.ts | 16 ------- .../1753098116701-update-card-table.ts | 16 ------- .../1753098326876-edit-customer-table.ts | 16 ------- ...3874205042-add-neoleap-related-entities.ts | 44 ++++++++++++++++++ src/db/migrations/index.ts | 1 + 9 files changed, 45 insertions(+), 174 deletions(-) delete mode 100644 src/db/migrations/1747569536067-add-address-fields-to-customers.ts delete mode 100644 src/db/migrations/1749633935436-create-card-entity.ts delete mode 100644 src/db/migrations/1751456987627-create-account-entity.ts delete mode 100644 src/db/migrations/1751466314709-create-transaction-table.ts delete mode 100644 src/db/migrations/1752056898465-edit-transaction-table.ts delete mode 100644 src/db/migrations/1753098116701-update-card-table.ts delete mode 100644 src/db/migrations/1753098326876-edit-customer-table.ts create mode 100644 src/db/migrations/1753874205042-add-neoleap-related-entities.ts diff --git a/src/db/migrations/1747569536067-add-address-fields-to-customers.ts b/src/db/migrations/1747569536067-add-address-fields-to-customers.ts deleted file mode 100644 index 4d8a8ec..0000000 --- a/src/db/migrations/1747569536067-add-address-fields-to-customers.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; - -export class AddAddressFieldsToCustomers1747569536067 implements MigrationInterface { - name = 'AddAddressFieldsToCustomers1747569536067'; - - public async up(queryRunner: QueryRunner): Promise { - await queryRunner.query(`ALTER TABLE "customers" ADD "country" character varying(255)`); - await queryRunner.query(`ALTER TABLE "customers" ADD "region" character varying(255)`); - await queryRunner.query(`ALTER TABLE "customers" ADD "city" character varying(255)`); - await queryRunner.query(`ALTER TABLE "customers" ADD "neighborhood" character varying(255)`); - await queryRunner.query(`ALTER TABLE "customers" ADD "street" character varying(255)`); - await queryRunner.query(`ALTER TABLE "customers" ADD "building" character varying(255)`); - } - - public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "building"`); - await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "street"`); - await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "neighborhood"`); - await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "city"`); - await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "region"`); - await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "country"`); - } -} diff --git a/src/db/migrations/1749633935436-create-card-entity.ts b/src/db/migrations/1749633935436-create-card-entity.ts deleted file mode 100644 index 5a2ed55..0000000 --- a/src/db/migrations/1749633935436-create-card-entity.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; - -export class CreateCardEntity1749633935436 implements MigrationInterface { - name = 'CreateCardEntity1749633935436'; - - public async up(queryRunner: QueryRunner): Promise { - await queryRunner.query( - `CREATE TABLE "cards" - ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), - "card_reference" character varying NOT NULL, - "first_six_digits" character varying(6) NOT NULL, - "last_four_digits" character varying(4) NOT NULL, - "expiry" character varying NOT NULL, - "customer_type" character varying NOT NULL, - "color" character varying NOT NULL DEFAULT 'BLUE', - "status" character varying NOT NULL DEFAULT 'PENDING', - "scheme" character varying NOT NULL DEFAULT 'VISA', - "issuer" character varying NOT NULL, - "customer_id" uuid NOT NULL, - "parent_id" uuid, - "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), - "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), - CONSTRAINT "PK_9451069b6f1199730791a7f4ae4" PRIMARY KEY ("id"))`, - ); - await queryRunner.query(`CREATE UNIQUE INDEX "IDX_6fb82820c0b1b7ec32a8dbf911" ON "cards" ("card_reference") `); - await queryRunner.query( - `ALTER TABLE "cards" ADD CONSTRAINT "FK_d46837f6ab27271d8125517d0b6" FOREIGN KEY ("parent_id") REFERENCES "customers"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, - ); - await queryRunner.query( - `ALTER TABLE "cards" ADD CONSTRAINT "FK_f5aa0baf4ff1b397b3f946a443e" FOREIGN KEY ("customer_id") REFERENCES "customers"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, - ); - } - - public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`ALTER TABLE "cards" DROP CONSTRAINT "FK_f5aa0baf4ff1b397b3f946a443e"`); - await queryRunner.query(`ALTER TABLE "cards" DROP CONSTRAINT "FK_d46837f6ab27271d8125517d0b6"`); - await queryRunner.query(`DROP INDEX "public"."IDX_6fb82820c0b1b7ec32a8dbf911"`); - await queryRunner.query(`DROP TABLE "cards"`); - } -} diff --git a/src/db/migrations/1751456987627-create-account-entity.ts b/src/db/migrations/1751456987627-create-account-entity.ts deleted file mode 100644 index 40e7a1b..0000000 --- a/src/db/migrations/1751456987627-create-account-entity.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; - -export class CreateAccountEntity1751456987627 implements MigrationInterface { - name = 'CreateAccountEntity1751456987627'; - - public async up(queryRunner: QueryRunner): Promise { - await queryRunner.query(`ALTER TABLE "cards" DROP CONSTRAINT "FK_f5aa0baf4ff1b397b3f946a443e"`); - await queryRunner.query(`ALTER TABLE "cards" DROP CONSTRAINT "FK_d46837f6ab27271d8125517d0b6"`); - await queryRunner.query(`DROP INDEX "public"."IDX_6fb82820c0b1b7ec32a8dbf911"`); - await queryRunner.query( - `CREATE TABLE "accounts" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "account_reference" character varying(255) NOT NULL, "currency" character varying(255) NOT NULL, "balance" numeric(10,2) NOT NULL DEFAULT '0', "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "UQ_829183fe026a0ce5fc8026b2417" UNIQUE ("account_reference"), CONSTRAINT "PK_5a7a02c20412299d198e097a8fe" PRIMARY KEY ("id"))`, - ); - await queryRunner.query( - `CREATE UNIQUE INDEX "IDX_829183fe026a0ce5fc8026b241" ON "accounts" ("account_reference") `, - ); - await queryRunner.query(`ALTER TABLE "cards" ADD "account_id" uuid NOT NULL`); - await queryRunner.query(`CREATE UNIQUE INDEX "IDX_4f7df5c5dc950295dc417a11d8" ON "cards" ("card_reference") `); - await queryRunner.query( - `ALTER TABLE "cards" ADD CONSTRAINT "FK_8ba18e7060c38ddae12e5bdf907" FOREIGN KEY ("parent_id") REFERENCES "customers"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, - ); - await queryRunner.query( - `ALTER TABLE "cards" ADD CONSTRAINT "FK_2fd0ee722ec57594d2e448c73d7" FOREIGN KEY ("customer_id") REFERENCES "customers"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, - ); - await queryRunner.query( - `ALTER TABLE "cards" ADD CONSTRAINT "FK_b2874ef49ff7da2dee49e4bc6d3" FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, - ); - } - - public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`ALTER TABLE "cards" DROP CONSTRAINT "FK_b2874ef49ff7da2dee49e4bc6d3"`); - await queryRunner.query(`ALTER TABLE "cards" DROP CONSTRAINT "FK_2fd0ee722ec57594d2e448c73d7"`); - await queryRunner.query(`ALTER TABLE "cards" DROP CONSTRAINT "FK_8ba18e7060c38ddae12e5bdf907"`); - await queryRunner.query(`DROP INDEX "public"."IDX_4f7df5c5dc950295dc417a11d8"`); - await queryRunner.query(`ALTER TABLE "cards" DROP COLUMN "account_id"`); - await queryRunner.query(`DROP INDEX "public"."IDX_829183fe026a0ce5fc8026b241"`); - await queryRunner.query(`DROP TABLE "accounts"`); - await queryRunner.query(`CREATE UNIQUE INDEX "IDX_6fb82820c0b1b7ec32a8dbf911" ON "cards" ("card_reference") `); - await queryRunner.query( - `ALTER TABLE "cards" ADD CONSTRAINT "FK_d46837f6ab27271d8125517d0b6" FOREIGN KEY ("parent_id") REFERENCES "customers"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, - ); - await queryRunner.query( - `ALTER TABLE "cards" ADD CONSTRAINT "FK_f5aa0baf4ff1b397b3f946a443e" FOREIGN KEY ("customer_id") REFERENCES "customers"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, - ); - } -} diff --git a/src/db/migrations/1751466314709-create-transaction-table.ts b/src/db/migrations/1751466314709-create-transaction-table.ts deleted file mode 100644 index 4c5e773..0000000 --- a/src/db/migrations/1751466314709-create-transaction-table.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { MigrationInterface, QueryRunner } from "typeorm"; - -export class CreateTransactionTable1751466314709 implements MigrationInterface { - name = 'CreateTransactionTable1751466314709' - - public async up(queryRunner: QueryRunner): Promise { - await queryRunner.query(`CREATE TABLE "transactions" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "card_reference" character varying, "transaction_type" character varying NOT NULL DEFAULT 'EXTERNAL', "account_reference" character varying, "transaction_id" character varying, "card_masked_number" character varying, "transaction_date" TIMESTAMP WITH TIME ZONE, "rrn" character varying, "transaction_amount" numeric(12,2) NOT NULL, "transaction_currency" character varying NOT NULL, "billing_amount" numeric(12,2) NOT NULL, "settlement_amount" numeric(12,2) NOT NULL, "fees" numeric(12,2) NOT NULL, "card_id" uuid, "account_id" uuid, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "UQ_9162bf9ab4e31961a8f7932974c" UNIQUE ("transaction_id"), CONSTRAINT "PK_a219afd8dd77ed80f5a862f1db9" PRIMARY KEY ("id"))`); - await queryRunner.query(`ALTER TABLE "transactions" ADD CONSTRAINT "FK_80ad48141be648db2d84ff32f79" FOREIGN KEY ("card_id") REFERENCES "cards"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); - await queryRunner.query(`ALTER TABLE "transactions" ADD CONSTRAINT "FK_49c0d6e8ba4bfb5582000d851f0" FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); - } - - public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`ALTER TABLE "transactions" DROP CONSTRAINT "FK_49c0d6e8ba4bfb5582000d851f0"`); - await queryRunner.query(`ALTER TABLE "transactions" DROP CONSTRAINT "FK_80ad48141be648db2d84ff32f79"`); - await queryRunner.query(`DROP TABLE "transactions"`); - } - -} diff --git a/src/db/migrations/1752056898465-edit-transaction-table.ts b/src/db/migrations/1752056898465-edit-transaction-table.ts deleted file mode 100644 index d4bab41..0000000 --- a/src/db/migrations/1752056898465-edit-transaction-table.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { MigrationInterface, QueryRunner } from "typeorm"; - -export class EditTransactionTable1752056898465 implements MigrationInterface { - name = 'EditTransactionTable1752056898465' - - public async up(queryRunner: QueryRunner): Promise { - await queryRunner.query(`ALTER TABLE "transactions" ADD "transaction_scope" character varying NOT NULL`); - await queryRunner.query(`ALTER TABLE "transactions" ADD "vat_on_fees" numeric(12,2) NOT NULL DEFAULT '0'`); - } - - public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`ALTER TABLE "transactions" DROP COLUMN "vat_on_fees"`); - await queryRunner.query(`ALTER TABLE "transactions" DROP COLUMN "transaction_scope"`); - } - -} diff --git a/src/db/migrations/1753098116701-update-card-table.ts b/src/db/migrations/1753098116701-update-card-table.ts deleted file mode 100644 index 56cb15c..0000000 --- a/src/db/migrations/1753098116701-update-card-table.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { MigrationInterface, QueryRunner } from "typeorm"; - -export class UpdateCardTable1753098116701 implements MigrationInterface { - name = 'UpdateCardTable1753098116701' - - public async up(queryRunner: QueryRunner): Promise { - await queryRunner.query(`ALTER TABLE "cards" ADD "statusDescription" character varying NOT NULL DEFAULT 'PENDING_ACTIVATION'`); - await queryRunner.query(`ALTER TABLE "cards" ADD "limit" numeric(10,2) NOT NULL DEFAULT '0'`); - } - - public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`ALTER TABLE "cards" DROP COLUMN "limit"`); - await queryRunner.query(`ALTER TABLE "cards" DROP COLUMN "statusDescription"`); - } - -} diff --git a/src/db/migrations/1753098326876-edit-customer-table.ts b/src/db/migrations/1753098326876-edit-customer-table.ts deleted file mode 100644 index 2595e2b..0000000 --- a/src/db/migrations/1753098326876-edit-customer-table.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { MigrationInterface, QueryRunner } from "typeorm"; - -export class EditCustomerTable1753098326876 implements MigrationInterface { - name = 'EditCustomerTable1753098326876' - - public async up(queryRunner: QueryRunner): Promise { - await queryRunner.query(`ALTER TABLE "customers" RENAME COLUMN "waiting_number" TO "application_number"`); - await queryRunner.query(`ALTER SEQUENCE "customers_waiting_number_seq" RENAME TO "customers_application_number_seq"`); - } - - public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`ALTER SEQUENCE "customers_application_number_seq" RENAME TO "customers_waiting_number_seq"`); - await queryRunner.query(`ALTER TABLE "customers" RENAME COLUMN "application_number" TO "waiting_number"`); - } - -} diff --git a/src/db/migrations/1753874205042-add-neoleap-related-entities.ts b/src/db/migrations/1753874205042-add-neoleap-related-entities.ts new file mode 100644 index 0000000..ab80885 --- /dev/null +++ b/src/db/migrations/1753874205042-add-neoleap-related-entities.ts @@ -0,0 +1,44 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddNeoleapRelatedEntities1753874205042 implements MigrationInterface { + name = 'AddNeoleapRelatedEntities1753874205042' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "transactions" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "transaction_scope" character varying NOT NULL, "transaction_type" character varying NOT NULL DEFAULT 'EXTERNAL', "card_reference" character varying, "account_reference" character varying, "transaction_id" character varying, "card_masked_number" character varying, "transaction_date" TIMESTAMP WITH TIME ZONE, "rrn" character varying, "transaction_amount" numeric(12,2) NOT NULL, "transaction_currency" character varying NOT NULL, "billing_amount" numeric(12,2) NOT NULL, "settlement_amount" numeric(12,2) NOT NULL, "fees" numeric(12,2) NOT NULL, "vat_on_fees" numeric(12,2) NOT NULL DEFAULT '0', "card_id" uuid, "account_id" uuid, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "UQ_9162bf9ab4e31961a8f7932974c" UNIQUE ("transaction_id"), CONSTRAINT "PK_a219afd8dd77ed80f5a862f1db9" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TABLE "accounts" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "account_reference" character varying(255) NOT NULL, "currency" character varying(255) NOT NULL, "balance" numeric(10,2) NOT NULL DEFAULT '0', "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "UQ_829183fe026a0ce5fc8026b2417" UNIQUE ("account_reference"), CONSTRAINT "PK_5a7a02c20412299d198e097a8fe" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_829183fe026a0ce5fc8026b241" ON "accounts" ("account_reference") `); + await queryRunner.query(`CREATE TABLE "cards" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "card_reference" character varying NOT NULL, "first_six_digits" character varying(6) NOT NULL, "last_four_digits" character varying(4) NOT NULL, "expiry" character varying NOT NULL, "customer_type" character varying NOT NULL, "color" character varying NOT NULL DEFAULT 'BLUE', "status" character varying NOT NULL DEFAULT 'PENDING', "statusDescription" character varying NOT NULL DEFAULT 'PENDING_ACTIVATION', "limit" numeric(10,2) NOT NULL DEFAULT '0', "scheme" character varying NOT NULL DEFAULT 'VISA', "issuer" character varying NOT NULL, "customer_id" uuid NOT NULL, "parent_id" uuid, "account_id" uuid NOT NULL, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_5f3269634705fdff4a9935860fc" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_4f7df5c5dc950295dc417a11d8" ON "cards" ("card_reference") `); + await queryRunner.query(`ALTER TABLE "customers" ADD "country" character varying(255)`); + await queryRunner.query(`ALTER TABLE "customers" ADD "region" character varying(255)`); + await queryRunner.query(`ALTER TABLE "customers" ADD "city" character varying(255)`); + await queryRunner.query(`ALTER TABLE "customers" ADD "neighborhood" character varying(255)`); + await queryRunner.query(`ALTER TABLE "customers" ADD "street" character varying(255)`); + await queryRunner.query(`ALTER TABLE "customers" ADD "building" character varying(255)`); + await queryRunner.query(`ALTER TABLE "transactions" ADD CONSTRAINT "FK_80ad48141be648db2d84ff32f79" FOREIGN KEY ("card_id") REFERENCES "cards"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "transactions" ADD CONSTRAINT "FK_49c0d6e8ba4bfb5582000d851f0" FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "cards" ADD CONSTRAINT "FK_8ba18e7060c38ddae12e5bdf907" FOREIGN KEY ("parent_id") REFERENCES "customers"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "cards" ADD CONSTRAINT "FK_2fd0ee722ec57594d2e448c73d7" FOREIGN KEY ("customer_id") REFERENCES "customers"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "cards" ADD CONSTRAINT "FK_b2874ef49ff7da2dee49e4bc6d3" FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "cards" DROP CONSTRAINT "FK_b2874ef49ff7da2dee49e4bc6d3"`); + await queryRunner.query(`ALTER TABLE "cards" DROP CONSTRAINT "FK_2fd0ee722ec57594d2e448c73d7"`); + await queryRunner.query(`ALTER TABLE "cards" DROP CONSTRAINT "FK_8ba18e7060c38ddae12e5bdf907"`); + await queryRunner.query(`ALTER TABLE "transactions" DROP CONSTRAINT "FK_49c0d6e8ba4bfb5582000d851f0"`); + await queryRunner.query(`ALTER TABLE "transactions" DROP CONSTRAINT "FK_80ad48141be648db2d84ff32f79"`); + await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "building"`); + await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "street"`); + await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "neighborhood"`); + await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "city"`); + await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "region"`); + await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "country"`); + await queryRunner.query(`DROP INDEX "public"."IDX_4f7df5c5dc950295dc417a11d8"`); + await queryRunner.query(`DROP TABLE "cards"`); + await queryRunner.query(`DROP INDEX "public"."IDX_829183fe026a0ce5fc8026b241"`); + await queryRunner.query(`DROP TABLE "accounts"`); + await queryRunner.query(`DROP TABLE "transactions"`); + } + +} diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index e06880b..9996c42 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -2,3 +2,4 @@ export * from './1753869637732-seed-default-avatar'; export * from './1733990253208-seeds-default-tasks-logo'; export * from './1734247702310-seeds-goals-categories'; export * from './1733750228289-initial-migration'; +export * from './1753874205042-add-neoleap-related-entities'; From a245545811f8ddd1bd6728c98c7bed1d11df2795 Mon Sep 17 00:00:00 2001 From: Abdalhamid Alhamad Date: Wed, 30 Jul 2025 15:40:40 +0300 Subject: [PATCH 16/32] feat: add login and forget password and refactor code --- src/auth/auth.module.ts | 4 +- src/auth/controllers/auth.controller.ts | 124 ++---- src/auth/controllers/auth.v2.controller.ts | 27 -- src/auth/controllers/index.ts | 1 - .../create-unverified-user.request.dto.ts | 16 +- .../create-unverified-user.request.v2.dto.ts | 4 - .../request/forget-password.request.dto.ts | 34 +- src/auth/dtos/request/index.ts | 4 - src/auth/dtos/request/login.request.dto.ts | 47 +- .../send-forget-password-otp.request.dto.ts | 2 +- .../request/send-login-otp.request.dto.ts | 9 - .../request/verify-login-otp.request.dto.ts | 20 - .../dtos/request/verify-user.request.dto.ts | 38 +- .../request/verify-user.v2.request.dto.ts | 71 ---- .../send-forget-password.response.dto.ts | 6 +- .../send-register-otp.response.dto.ts | 6 +- src/auth/enums/grant-type.enum.ts | 2 - src/auth/services/auth.service.ts | 401 ++++-------------- src/user/services/user.service.ts | 76 +--- 19 files changed, 198 insertions(+), 694 deletions(-) delete mode 100644 src/auth/controllers/auth.v2.controller.ts delete mode 100644 src/auth/dtos/request/create-unverified-user.request.v2.dto.ts delete mode 100644 src/auth/dtos/request/send-login-otp.request.dto.ts delete mode 100644 src/auth/dtos/request/verify-login-otp.request.dto.ts delete mode 100644 src/auth/dtos/request/verify-user.v2.request.dto.ts diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 60dd928..138e689 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -3,14 +3,14 @@ import { Module } from '@nestjs/common'; import { JwtModule } from '@nestjs/jwt'; import { JuniorModule } from '~/junior/junior.module'; import { UserModule } from '~/user/user.module'; -import { AuthController, AuthV2Controller } from './controllers'; +import { AuthController } from './controllers'; import { AuthService, Oauth2Service } from './services'; import { AccessTokenStrategy } from './strategies'; @Module({ imports: [JwtModule.register({}), UserModule, JuniorModule, HttpModule], providers: [AuthService, AccessTokenStrategy, Oauth2Service], - controllers: [AuthController, AuthV2Controller], + controllers: [AuthController], exports: [], }) export class AuthModule {} diff --git a/src/auth/controllers/auth.controller.ts b/src/auth/controllers/auth.controller.ts index d402ad6..f651e69 100644 --- a/src/auth/controllers/auth.controller.ts +++ b/src/auth/controllers/auth.controller.ts @@ -1,29 +1,20 @@ import { Body, Controller, HttpCode, HttpStatus, Post, Req, UseGuards } from '@nestjs/common'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { Request } from 'express'; -import { AuthenticatedUser, Public } from '~/common/decorators'; +import { Public } from '~/common/decorators'; import { AccessTokenGuard } from '~/common/guards'; -import { ApiDataResponse, ApiLangRequestHeader } from '~/core/decorators'; +import { ApiLangRequestHeader } from '~/core/decorators'; import { ResponseFactory } from '~/core/utils'; import { - AppleLoginRequestDto, CreateUnverifiedUserRequestDto, - DisableBiometricRequestDto, - EnableBiometricRequestDto, ForgetPasswordRequestDto, - GoogleLoginRequestDto, + LoginRequestDto, RefreshTokenRequestDto, SendForgetPasswordOtpRequestDto, - SendLoginOtpRequestDto, - SetEmailRequestDto, - setJuniorPasswordRequestDto, - SetPasscodeRequestDto, - VerifyLoginOtpRequestDto, VerifyUserRequestDto, } from '../dtos/request'; import { SendForgetPasswordOtpResponseDto, SendRegisterOtpResponseDto } from '../dtos/response'; import { LoginResponseDto } from '../dtos/response/login.response.dto'; -import { IJwtPayload } from '../interfaces'; import { AuthService } from '../services'; @Controller('auth') @@ -44,85 +35,16 @@ export class AuthController { return ResponseFactory.data(new LoginResponseDto(res, user)); } - @Post('login/otp') - @HttpCode(HttpStatus.NO_CONTENT) - async sendLoginOtp(@Body() data: SendLoginOtpRequestDto) { - return this.authService.sendLoginOtp(data); - } - - @Post('login/verify') - @HttpCode(HttpStatus.OK) - @ApiDataResponse(LoginResponseDto) - async verifyLoginOtp(@Body() data: VerifyLoginOtpRequestDto) { - const [token, user] = await this.authService.verifyLoginOtp(data); - return ResponseFactory.data(new LoginResponseDto(token, user)); - } - - @Post('login/google') - @HttpCode(HttpStatus.OK) - @ApiDataResponse(LoginResponseDto) - async loginWithGoogle(@Body() data: GoogleLoginRequestDto) { - const [token, user] = await this.authService.loginWithGoogle(data); - return ResponseFactory.data(new LoginResponseDto(token, user)); - } - - @Post('login/apple') - @HttpCode(HttpStatus.OK) - @ApiDataResponse(LoginResponseDto) - async loginWithApple(@Body() data: AppleLoginRequestDto) { - const [token, user] = await this.authService.loginWithApple(data); - return ResponseFactory.data(new LoginResponseDto(token, user)); - } - - @Post('register/set-email') - @HttpCode(HttpStatus.NO_CONTENT) - @UseGuards(AccessTokenGuard) - async setEmail(@AuthenticatedUser() { sub }: IJwtPayload, @Body() setEmailDto: SetEmailRequestDto) { - await this.authService.setEmail(sub, setEmailDto); - } - - @Post('register/set-passcode') - @HttpCode(HttpStatus.NO_CONTENT) - @UseGuards(AccessTokenGuard) - async setPasscode(@AuthenticatedUser() { sub }: IJwtPayload, @Body() { passcode }: SetPasscodeRequestDto) { - await this.authService.setPasscode(sub, passcode); - } - - // @Post('register/set-phone/otp') - // @UseGuards(AccessTokenGuard) - // async setPhoneNumber( - // @AuthenticatedUser() { sub }: IJwtPayload, - // @Body() setPhoneNumberDto: CreateUnverifiedUserRequestDto, - // ) { - // const phoneNumber = await this.authService.setPhoneNumber(sub, setPhoneNumberDto); - // return ResponseFactory.data(new SendRegisterOtpResponseDto(phoneNumber)); - // } - - // @Post('register/set-phone/verify') - // @HttpCode(HttpStatus.NO_CONTENT) - // @UseGuards(AccessTokenGuard) - // async verifyPhoneNumber(@AuthenticatedUser() { sub }: IJwtPayload, @Body() { otp }: VerifyOtpRequestDto) { - // await this.authService.verifyPhoneNumber(sub, otp); - // } - - @Post('biometric/enable') - @HttpCode(HttpStatus.NO_CONTENT) - @UseGuards(AccessTokenGuard) - enableBiometric(@AuthenticatedUser() { sub }: IJwtPayload, @Body() enableBiometricDto: EnableBiometricRequestDto) { - return this.authService.enableBiometric(sub, enableBiometricDto); - } - - @Post('biometric/disable') - @HttpCode(HttpStatus.NO_CONTENT) - @UseGuards(AccessTokenGuard) - disableBiometric(@AuthenticatedUser() { sub }: IJwtPayload, @Body() disableBiometricDto: DisableBiometricRequestDto) { - return this.authService.disableBiometric(sub, disableBiometricDto); + @Post('login') + async login(@Body() verifyUserDto: LoginRequestDto) { + const [res, user] = await this.authService.loginWithPassword(verifyUserDto); + return ResponseFactory.data(new LoginResponseDto(res, user)); } @Post('forget-password/otp') async forgetPassword(@Body() sendForgetPasswordOtpDto: SendForgetPasswordOtpRequestDto) { - const email = await this.authService.sendForgetPasswordOtp(sendForgetPasswordOtpDto); - return ResponseFactory.data(new SendForgetPasswordOtpResponseDto(email)); + const maskedNumber = await this.authService.sendForgetPasswordOtp(sendForgetPasswordOtpDto); + return ResponseFactory.data(new SendForgetPasswordOtpResponseDto(maskedNumber)); } @Post('forget-password/reset') @@ -131,13 +53,6 @@ export class AuthController { return this.authService.verifyForgetPasswordOtp(forgetPasswordDto); } - @Post('junior/set-passcode') - @HttpCode(HttpStatus.NO_CONTENT) - @Public() - setJuniorPasscode(@Body() setPasscodeDto: setJuniorPasswordRequestDto) { - return this.authService.setJuniorPasscode(setPasscodeDto); - } - @Post('refresh-token') @Public() async refreshToken(@Body() { refreshToken }: RefreshTokenRequestDto) { @@ -151,4 +66,25 @@ export class AuthController { async logout(@Req() request: Request) { await this.authService.logout(request); } + + // @Post('biometric/enable') + // @HttpCode(HttpStatus.NO_CONTENT) + // @UseGuards(AccessTokenGuard) + // enableBiometric(@AuthenticatedUser() { sub }: IJwtPayload, @Body() enableBiometricDto: EnableBiometricRequestDto) { + // return this.authService.enableBiometric(sub, enableBiometricDto); + // } + + // @Post('biometric/disable') + // @HttpCode(HttpStatus.NO_CONTENT) + // @UseGuards(AccessTokenGuard) + // disableBiometric(@AuthenticatedUser() { sub }: IJwtPayload, @Body() disableBiometricDto: DisableBiometricRequestDto) { + // return this.authService.disableBiometric(sub, disableBiometricDto); + // } + + // @Post('junior/set-passcode') + // @HttpCode(HttpStatus.NO_CONTENT) + // @Public() + // setJuniorPasscode(@Body() setPasscodeDto: setJuniorPasswordRequestDto) { + // return this.authService.setJuniorPasscode(setPasscodeDto); + // } } diff --git a/src/auth/controllers/auth.v2.controller.ts b/src/auth/controllers/auth.v2.controller.ts deleted file mode 100644 index 3b79641..0000000 --- a/src/auth/controllers/auth.v2.controller.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Body, Controller, Post } from '@nestjs/common'; -import { ApiTags } from '@nestjs/swagger'; -import { ApiDataResponse } from '~/core/decorators'; -import { ResponseFactory } from '~/core/utils'; -import { CreateUnverifiedUserV2RequestDto, VerifyUserV2RequestDto } from '../dtos/request'; -import { LoginResponseDto } from '../dtos/response/login.response.dto'; -import { SendRegisterOtpV2ResponseDto } from '../dtos/response/send-register-otp.v2.response.dto'; -import { AuthService } from '../services'; - -@Controller('auth/v2') -@ApiTags('Auth V2') -export class AuthV2Controller { - constructor(private readonly authService: AuthService) {} - @Post('register/otp') - @ApiDataResponse(SendRegisterOtpV2ResponseDto) - async register(@Body() createUnverifiedUserDto: CreateUnverifiedUserV2RequestDto) { - const phoneNumber = await this.authService.sendPhoneRegisterOtp(createUnverifiedUserDto); - return ResponseFactory.data(new SendRegisterOtpV2ResponseDto(phoneNumber)); - } - - @Post('register/verify') - @ApiDataResponse(LoginResponseDto) - async verifyUser(@Body() verifyUserDto: VerifyUserV2RequestDto) { - const [loginResponse, user] = await this.authService.verifyUserV2(verifyUserDto); - return ResponseFactory.data(new LoginResponseDto(loginResponse, user)); - } -} diff --git a/src/auth/controllers/index.ts b/src/auth/controllers/index.ts index 37a52b8..04d02fa 100644 --- a/src/auth/controllers/index.ts +++ b/src/auth/controllers/index.ts @@ -1,2 +1 @@ export * from './auth.controller'; -export * from './auth.v2.controller'; diff --git a/src/auth/dtos/request/create-unverified-user.request.dto.ts b/src/auth/dtos/request/create-unverified-user.request.dto.ts index 9fdc3bd..520f99e 100644 --- a/src/auth/dtos/request/create-unverified-user.request.dto.ts +++ b/src/auth/dtos/request/create-unverified-user.request.dto.ts @@ -1,14 +1,4 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsEmail } from 'class-validator'; -import { i18nValidationMessage as i18n } from 'nestjs-i18n'; +import { OmitType } from '@nestjs/swagger'; +import { VerifyUserRequestDto } from './verify-user.request.dto'; -export class CreateUnverifiedUserRequestDto { - @ApiProperty({ example: 'test@test.com' }) - @IsEmail( - {}, - { - message: i18n('validation.IsEmail', { path: 'general', property: 'auth.email' }), - }, - ) - email!: string; -} +export class CreateUnverifiedUserRequestDto extends OmitType(VerifyUserRequestDto, ['otp']) {} diff --git a/src/auth/dtos/request/create-unverified-user.request.v2.dto.ts b/src/auth/dtos/request/create-unverified-user.request.v2.dto.ts deleted file mode 100644 index 7df2980..0000000 --- a/src/auth/dtos/request/create-unverified-user.request.v2.dto.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { OmitType } from '@nestjs/swagger'; -import { VerifyUserV2RequestDto } from './verify-user.v2.request.dto'; - -export class CreateUnverifiedUserV2RequestDto extends OmitType(VerifyUserV2RequestDto, ['otp']) {} diff --git a/src/auth/dtos/request/forget-password.request.dto.ts b/src/auth/dtos/request/forget-password.request.dto.ts index 97f7236..4153a96 100644 --- a/src/auth/dtos/request/forget-password.request.dto.ts +++ b/src/auth/dtos/request/forget-password.request.dto.ts @@ -1,20 +1,32 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsEmail, IsNotEmpty, IsNumberString, IsString, MaxLength, MinLength } from 'class-validator'; +import { ApiProperty, PickType } from '@nestjs/swagger'; +import { IsEmail, IsNotEmpty, IsNumberString, IsString, Matches, MaxLength, MinLength } from 'class-validator'; import { i18nValidationMessage as i18n } from 'nestjs-i18n'; +import { COUNTRY_CODE_REGEX, PASSWORD_REGEX } from '~/auth/constants'; import { DEFAULT_OTP_LENGTH } from '~/common/modules/otp/constants'; +import { IsValidPhoneNumber } from '~/core/decorators/validations'; export class ForgetPasswordRequestDto { - @ApiProperty({ example: 'test@test.com' }) - @IsEmail({}, { message: i18n('validation.IsEmail', { path: 'general', property: 'auth.email' }) }) - email!: string; + @ApiProperty({ example: '+962' }) + @Matches(COUNTRY_CODE_REGEX, { + message: i18n('validation.Matches', { path: 'general', property: 'auth.countryCode' }), + }) + countryCode!: string; - @ApiProperty({ example: 'password' }) - @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.password' }) }) - @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'auth.password' }) }) + @ApiProperty({ example: '787259134' }) + @IsValidPhoneNumber({ + message: i18n('validation.IsValidPhoneNumber', { path: 'general', property: 'auth.phoneNumber' }), + }) + phoneNumber!: string; + + @ApiProperty({ example: 'Abcd1234@' }) + @Matches(PASSWORD_REGEX, { + message: i18n('validation.Matches', { path: 'general', property: 'auth.password' }), + }) password!: string; - @ApiProperty({ example: 'password' }) - @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.confirmPassword' }) }) - @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'auth.confirmPassword' }) }) + @ApiProperty({ example: 'Abcd1234@' }) + @Matches(PASSWORD_REGEX, { + message: i18n('validation.Matches', { path: 'general', property: 'auth.confirmPassword' }), + }) confirmPassword!: string; @ApiProperty({ example: '111111' }) diff --git a/src/auth/dtos/request/index.ts b/src/auth/dtos/request/index.ts index ab51125..a329e46 100644 --- a/src/auth/dtos/request/index.ts +++ b/src/auth/dtos/request/index.ts @@ -1,6 +1,5 @@ export * from './apple-login.request.dto'; export * from './create-unverified-user.request.dto'; -export * from './create-unverified-user.request.v2.dto'; export * from './disable-biometric.request.dto'; export * from './enable-biometric.request.dto'; export * from './forget-password.request.dto'; @@ -8,11 +7,8 @@ export * from './google-login.request.dto'; export * from './login.request.dto'; export * from './refresh-token.request.dto'; export * from './send-forget-password-otp.request.dto'; -export * from './send-login-otp.request.dto'; export * from './set-email.request.dto'; export * from './set-junior-password.request.dto'; export * from './set-passcode.request.dto'; -export * from './verify-login-otp.request.dto'; export * from './verify-otp.request.dto'; export * from './verify-user.request.dto'; -export * from './verify-user.v2.request.dto'; diff --git a/src/auth/dtos/request/login.request.dto.ts b/src/auth/dtos/request/login.request.dto.ts index 476961d..ed48ae3 100644 --- a/src/auth/dtos/request/login.request.dto.ts +++ b/src/auth/dtos/request/login.request.dto.ts @@ -1,43 +1,24 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsEmail, IsEnum, IsNotEmpty, IsOptional, IsString, ValidateIf } from 'class-validator'; +import { IsEmail, IsEnum, IsNotEmpty, IsOptional, IsString, Matches, ValidateIf } from 'class-validator'; import { i18nValidationMessage as i18n } from 'nestjs-i18n'; +import { COUNTRY_CODE_REGEX } from '~/auth/constants'; import { GrantType } from '~/auth/enums'; +import { IsValidPhoneNumber } from '~/core/decorators/validations'; export class LoginRequestDto { - @ApiProperty({ example: GrantType.APPLE }) - @IsEnum(GrantType, { message: i18n('validation.IsEnum', { path: 'general', property: 'auth.grantType' }) }) - grantType!: GrantType; + @ApiProperty({ example: '+962' }) + @Matches(COUNTRY_CODE_REGEX, { + message: i18n('validation.Matches', { path: 'general', property: 'auth.countryCode' }), + }) + countryCode!: string; - @ApiProperty({ example: 'test@test.com' }) - @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.email' }) }) - @IsEmail({}, { message: i18n('validation.IsEmail', { path: 'general', property: 'auth.email' }) }) - @ValidateIf((o) => o.grantType !== GrantType.APPLE && o.grantType !== GrantType.GOOGLE) - email!: string; + @ApiProperty({ example: '787259134' }) + @IsValidPhoneNumber({ + message: i18n('validation.IsValidPhoneNumber', { path: 'general', property: 'auth.phoneNumber' }), + }) + phoneNumber!: string; - @ApiProperty({ example: '123456' }) + @ApiProperty({ example: 'Abcd1234@' }) @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.password' }) }) @ValidateIf((o) => o.grantType === GrantType.PASSWORD) password!: string; - - @ApiProperty({ example: 'Login signature' }) - @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.signature' }) }) - @ValidateIf((o) => o.grantType === GrantType.BIOMETRIC) - signature!: string; - - @ApiProperty({ example: 'google_token' }) - @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.googleToken' }) }) - @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'auth.googleToken' }) }) - @ValidateIf((o) => o.grantType === GrantType.GOOGLE) - googleToken!: string; - - @ApiProperty({ example: 'apple_token' }) - @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.appleToken' }) }) - @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'auth.appleToken' }) }) - @ValidateIf((o) => o.grantType === GrantType.APPLE) - appleToken!: string; - - @ApiProperty({ example: 'fcm-device-token' }) - @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.fcmToken' }) }) - @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'auth.fcmToken' }) }) - @IsOptional() - fcmToken?: string; } diff --git a/src/auth/dtos/request/send-forget-password-otp.request.dto.ts b/src/auth/dtos/request/send-forget-password-otp.request.dto.ts index 077b14a..ebb7941 100644 --- a/src/auth/dtos/request/send-forget-password-otp.request.dto.ts +++ b/src/auth/dtos/request/send-forget-password-otp.request.dto.ts @@ -1,4 +1,4 @@ import { PickType } from '@nestjs/swagger'; import { LoginRequestDto } from './login.request.dto'; -export class SendForgetPasswordOtpRequestDto extends PickType(LoginRequestDto, ['email']) {} +export class SendForgetPasswordOtpRequestDto extends PickType(LoginRequestDto, ['countryCode', 'phoneNumber']) {} diff --git a/src/auth/dtos/request/send-login-otp.request.dto.ts b/src/auth/dtos/request/send-login-otp.request.dto.ts deleted file mode 100644 index 45388f3..0000000 --- a/src/auth/dtos/request/send-login-otp.request.dto.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsEmail } from 'class-validator'; -import { i18nValidationMessage as i18n } from 'nestjs-i18n'; - -export class SendLoginOtpRequestDto { - @ApiProperty({ example: 'test@test.com' }) - @IsEmail({}, { message: i18n('validation.IsEmail', { path: 'general', property: 'auth.email' }) }) - email!: string; -} diff --git a/src/auth/dtos/request/verify-login-otp.request.dto.ts b/src/auth/dtos/request/verify-login-otp.request.dto.ts deleted file mode 100644 index 819ead5..0000000 --- a/src/auth/dtos/request/verify-login-otp.request.dto.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsNumberString, MaxLength, MinLength } from 'class-validator'; -import { i18nValidationMessage as i18n } from 'nestjs-i18n'; -import { DEFAULT_OTP_LENGTH } from '~/common/modules/otp/constants'; -import { SendLoginOtpRequestDto } from './send-login-otp.request.dto'; - -export class VerifyLoginOtpRequestDto extends SendLoginOtpRequestDto { - @ApiProperty({ example: '111111' }) - @IsNumberString( - { no_symbols: true }, - { message: i18n('validation.IsNumberString', { path: 'general', property: 'auth.otp' }) }, - ) - @MaxLength(DEFAULT_OTP_LENGTH, { - message: i18n('validation.MaxLength', { path: 'general', property: 'auth.otp', length: DEFAULT_OTP_LENGTH }), - }) - @MinLength(DEFAULT_OTP_LENGTH, { - message: i18n('validation.MinLength', { path: 'general', property: 'auth.otp', length: DEFAULT_OTP_LENGTH }), - }) - otp!: string; -} diff --git a/src/auth/dtos/request/verify-user.request.dto.ts b/src/auth/dtos/request/verify-user.request.dto.ts index c1adc87..f0d7289 100644 --- a/src/auth/dtos/request/verify-user.request.dto.ts +++ b/src/auth/dtos/request/verify-user.request.dto.ts @@ -1,21 +1,34 @@ -import { ApiProperty, PickType } from '@nestjs/swagger'; +import { ApiProperty } from '@nestjs/swagger'; import { IsDateString, + IsEmail, IsEnum, IsNotEmpty, IsNumberString, IsOptional, IsString, + Matches, MaxLength, MinLength, } from 'class-validator'; import { i18nValidationMessage as i18n } from 'nestjs-i18n'; +import { COUNTRY_CODE_REGEX, PASSWORD_REGEX } from '~/auth/constants'; import { CountryIso } from '~/common/enums'; import { DEFAULT_OTP_LENGTH } from '~/common/modules/otp/constants'; -import { IsAbove18 } from '~/core/decorators/validations'; -import { CreateUnverifiedUserRequestDto } from './create-unverified-user.request.dto'; +import { IsAbove18, IsValidPhoneNumber } from '~/core/decorators/validations'; -export class VerifyUserRequestDto extends PickType(CreateUnverifiedUserRequestDto, ['email']) { +export class VerifyUserRequestDto { + @ApiProperty({ example: '+962' }) + @Matches(COUNTRY_CODE_REGEX, { + message: i18n('validation.Matches', { path: 'general', property: 'auth.countryCode' }), + }) + countryCode!: string; + + @ApiProperty({ example: '787259134' }) + @IsValidPhoneNumber({ + message: i18n('validation.IsValidPhoneNumber', { path: 'general', property: 'auth.phoneNumber' }), + }) + phoneNumber!: string; @ApiProperty({ example: 'John' }) @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.firstName' }) }) @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.firstName' }) }) @@ -38,6 +51,23 @@ export class VerifyUserRequestDto extends PickType(CreateUnverifiedUserRequestDt @IsOptional() countryOfResidence: CountryIso = CountryIso.SAUDI_ARABIA; + @ApiProperty({ example: 'test@test.com' }) + @IsEmail({}, { message: i18n('validation.IsEmail', { path: 'general', property: 'auth.email' }) }) + @IsOptional() + email!: string; + + @ApiProperty({ example: 'Abcd1234@' }) + @Matches(PASSWORD_REGEX, { + message: i18n('validation.Matches', { path: 'general', property: 'auth.password' }), + }) + password!: string; + + @ApiProperty({ example: 'Abcd1234@' }) + @Matches(PASSWORD_REGEX, { + message: i18n('validation.Matches', { path: 'general', property: 'auth.confirmPassword' }), + }) + confirmPassword!: string; + @ApiProperty({ example: '111111' }) @IsNumberString( { no_symbols: true }, diff --git a/src/auth/dtos/request/verify-user.v2.request.dto.ts b/src/auth/dtos/request/verify-user.v2.request.dto.ts deleted file mode 100644 index ce0f216..0000000 --- a/src/auth/dtos/request/verify-user.v2.request.dto.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { - IsDateString, - IsEmail, - IsEnum, - IsNotEmpty, - IsNumberString, - IsOptional, - IsString, - Matches, - MaxLength, - MinLength, -} from 'class-validator'; -import { i18nValidationMessage as i18n } from 'nestjs-i18n'; -import { COUNTRY_CODE_REGEX } from '~/auth/constants'; -import { CountryIso } from '~/common/enums'; -import { DEFAULT_OTP_LENGTH } from '~/common/modules/otp/constants'; -import { IsAbove18, IsValidPhoneNumber } from '~/core/decorators/validations'; - -export class VerifyUserV2RequestDto { - @ApiProperty({ example: '+962' }) - @Matches(COUNTRY_CODE_REGEX, { - message: i18n('validation.Matches', { path: 'general', property: 'auth.countryCode' }), - }) - countryCode!: string; - - @ApiProperty({ example: '787259134' }) - @IsValidPhoneNumber({ - message: i18n('validation.IsValidPhoneNumber', { path: 'general', property: 'auth.phoneNumber' }), - }) - phoneNumber!: string; - @ApiProperty({ example: 'John' }) - @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.firstName' }) }) - @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.firstName' }) }) - firstName!: string; - - @ApiProperty({ example: 'Doe' }) - @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.lastName' }) }) - @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.lastName' }) }) - lastName!: string; - - @ApiProperty({ example: '2021-01-01' }) - @IsDateString({}, { message: i18n('validation.IsDateString', { path: 'general', property: 'customer.dateOfBirth' }) }) - @IsAbove18({ message: i18n('validation.IsAbove18', { path: 'general', property: 'customer.dateOfBirth' }) }) - dateOfBirth!: Date; - - @ApiProperty({ example: 'JO' }) - @IsEnum(CountryIso, { - message: i18n('validation.IsEnum', { path: 'general', property: 'customer.countryOfResidence' }), - }) - @IsOptional() - countryOfResidence: CountryIso = CountryIso.SAUDI_ARABIA; - - @ApiProperty({ example: 'test@test.com' }) - @IsEmail({}, { message: i18n('validation.IsEmail', { path: 'general', property: 'auth.email' }) }) - @IsOptional() - email!: string; - - @ApiProperty({ example: '111111' }) - @IsNumberString( - { no_symbols: true }, - { message: i18n('validation.IsNumberString', { path: 'general', property: 'auth.otp' }) }, - ) - @MaxLength(DEFAULT_OTP_LENGTH, { - message: i18n('validation.MaxLength', { path: 'general', property: 'auth.otp', length: DEFAULT_OTP_LENGTH }), - }) - @MinLength(DEFAULT_OTP_LENGTH, { - message: i18n('validation.MinLength', { path: 'general', property: 'auth.otp', length: DEFAULT_OTP_LENGTH }), - }) - otp!: string; -} diff --git a/src/auth/dtos/response/send-forget-password.response.dto.ts b/src/auth/dtos/response/send-forget-password.response.dto.ts index 1bbf6b8..40e338f 100644 --- a/src/auth/dtos/response/send-forget-password.response.dto.ts +++ b/src/auth/dtos/response/send-forget-password.response.dto.ts @@ -1,7 +1,7 @@ export class SendForgetPasswordOtpResponseDto { - email!: string; + maskedNumber!: string; - constructor(email: string) { - this.email = email; + constructor(maskedNumber: string) { + this.maskedNumber = maskedNumber; } } diff --git a/src/auth/dtos/response/send-register-otp.response.dto.ts b/src/auth/dtos/response/send-register-otp.response.dto.ts index 0597b90..ee27ad5 100644 --- a/src/auth/dtos/response/send-register-otp.response.dto.ts +++ b/src/auth/dtos/response/send-register-otp.response.dto.ts @@ -2,9 +2,9 @@ import { ApiProperty } from '@nestjs/swagger'; export class SendRegisterOtpResponseDto { @ApiProperty() - email!: string; + maskedNumber!: string; - constructor(email: string) { - this.email = email; + constructor(maskedNumber: string) { + this.maskedNumber = maskedNumber; } } diff --git a/src/auth/enums/grant-type.enum.ts b/src/auth/enums/grant-type.enum.ts index 952f851..4a16d92 100644 --- a/src/auth/enums/grant-type.enum.ts +++ b/src/auth/enums/grant-type.enum.ts @@ -1,6 +1,4 @@ export enum GrantType { PASSWORD = 'PASSWORD', BIOMETRIC = 'BIOMETRIC', - GOOGLE = 'GOOGLE', - APPLE = 'APPLE', } diff --git a/src/auth/services/auth.service.ts b/src/auth/services/auth.service.ts index 908e6ed..ca5e4dd 100644 --- a/src/auth/services/auth.service.ts +++ b/src/auth/services/auth.service.ts @@ -3,7 +3,6 @@ import { ConfigService } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; import * as bcrypt from 'bcrypt'; import { Request } from 'express'; -import { ArrayContains } from 'typeorm'; import { CacheService } from '~/common/modules/cache/services'; import { OtpScope, OtpType } from '~/common/modules/otp/enums'; import { OtpService } from '~/common/modules/otp/services'; @@ -12,23 +11,15 @@ import { DeviceService, UserService, UserTokenService } from '~/user/services'; import { User } from '../../user/entities'; import { PASSCODE_REGEX } from '../constants'; import { - AppleLoginRequestDto, CreateUnverifiedUserRequestDto, - CreateUnverifiedUserV2RequestDto, DisableBiometricRequestDto, EnableBiometricRequestDto, ForgetPasswordRequestDto, - GoogleLoginRequestDto, LoginRequestDto, SendForgetPasswordOtpRequestDto, - SendLoginOtpRequestDto, - SetEmailRequestDto, setJuniorPasswordRequestDto, - VerifyLoginOtpRequestDto, VerifyUserRequestDto, - VerifyUserV2RequestDto, } from '../dtos/request'; -import { Roles } from '../enums'; import { IJwtPayload, ILoginResponse } from '../interfaces'; import { removePadding, verifySignature } from '../utils'; import { Oauth2Service } from './oauth2.service'; @@ -49,19 +40,8 @@ export class AuthService { private readonly cacheService: CacheService, private readonly oauth2Service: Oauth2Service, ) {} + async sendRegisterOtp(body: CreateUnverifiedUserRequestDto) { - this.logger.log(`Sending OTP to ${body.email}`); - const user = await this.userService.findOrCreateUser(body); - - return this.otpService.generateAndSendOtp({ - userId: user.id, - recipient: user.email, - scope: OtpScope.VERIFY_EMAIL, - otpType: OtpType.EMAIL, - }); - } - - async sendPhoneRegisterOtp(body: CreateUnverifiedUserV2RequestDto) { if (body.email) { const isEmailUsed = await this.userService.findUser({ email: body.email, isEmailVerified: true }); if (isEmailUsed) { @@ -70,8 +50,13 @@ export class AuthService { } } + if (body.password !== body.confirmPassword) { + this.logger.error('Password and confirm password do not match'); + throw new BadRequestException('AUTH.PASSWORD_MISMATCH'); + } + this.logger.log(`Sending OTP to ${body.countryCode + body.phoneNumber}`); - const user = await this.userService.findOrCreateByPhoneNumber(body); + const user = await this.userService.findOrCreateUser(body); return this.otpService.generateAndSendOtp({ userId: user.id, recipient: user.fullPhoneNumber, @@ -81,36 +66,6 @@ export class AuthService { } async verifyUser(verifyUserDto: VerifyUserRequestDto): Promise<[ILoginResponse, User]> { - this.logger.log(`Verifying user with email ${verifyUserDto.email}`); - const user = await this.userService.findUserOrThrow({ email: verifyUserDto.email }); - - if (user.isEmailVerified) { - this.logger.error(`User with email ${verifyUserDto.email} already verified`); - throw new BadRequestException('USER.EMAIL_ALREADY_VERIFIED'); - } - - const isOtpValid = await this.otpService.verifyOtp({ - userId: user.id, - scope: OtpScope.VERIFY_EMAIL, - otpType: OtpType.EMAIL, - value: verifyUserDto.otp, - }); - - if (!isOtpValid) { - this.logger.error(`Invalid OTP for user with email ${verifyUserDto.email}`); - throw new BadRequestException('OTP.INVALID_OTP'); - } - - await this.userService.verifyUser(user.id, verifyUserDto); - - await user.reload(); - - const tokens = await this.generateAuthToken(user); - this.logger.log(`User with email ${verifyUserDto.email} verified successfully`); - return [tokens, user]; - } - - async verifyUserV2(verifyUserDto: VerifyUserV2RequestDto): Promise<[ILoginResponse, User]> { this.logger.log(`Verifying user with phone number ${verifyUserDto.countryCode + verifyUserDto.phoneNumber}`); const user = await this.userService.findUserOrThrow({ phoneNumber: verifyUserDto.phoneNumber, @@ -134,7 +89,7 @@ export class AuthService { throw new BadRequestException('OTP.INVALID_OTP'); } - await this.userService.verifyUserV2(user.id, verifyUserDto); + await this.userService.verifyUser(user.id, verifyUserDto); await user.reload(); @@ -143,81 +98,6 @@ export class AuthService { return [tokens, user]; } - async setEmail(userId: string, { email }: SetEmailRequestDto) { - this.logger.log(`Setting email for user with id ${userId}`); - const user = await this.userService.findUserOrThrow({ id: userId }); - - if (user.email) { - this.logger.error(`Email already set for user with id ${userId}`); - throw new BadRequestException('USER.EMAIL_ALREADY_SET'); - } - - const existingUser = await this.userService.findUser({ email }); - - if (existingUser) { - this.logger.error(`Email ${email} already taken`); - throw new BadRequestException('USER.EMAIL_ALREADY_TAKEN'); - } - - return this.userService.setEmail(userId, email); - } - - async setPasscode(userId: string, passcode: string) { - this.logger.log(`Setting passcode for user with id ${userId}`); - const user = await this.userService.findUserOrThrow({ id: userId }); - - if (user.password) { - this.logger.error(`Passcode already set for user with id ${userId}`); - throw new BadRequestException('AUTH.PASSCODE_ALREADY_SET'); - } - const salt = bcrypt.genSaltSync(SALT_ROUNDS); - const hashedPasscode = bcrypt.hashSync(passcode, salt); - - await this.userService.setPasscode(userId, hashedPasscode, salt); - this.logger.log(`Passcode set successfully for user with id ${userId}`); - } - - // async setPhoneNumber(userId: string, { phoneNumber, countryCode }: CreateUnverifiedUserRequestDto) { - // const user = await this.userService.findUserOrThrow({ id: userId }); - - // if (user.phoneNumber || user.countryCode) { - // this.logger.error(`Phone number already set for user with id ${userId}`); - // throw new BadRequestException('USER.PHONE_NUMBER_ALREADY_SET'); - // } - - // const existingUser = await this.userService.findUser({ phoneNumber, countryCode }); - - // if (existingUser) { - // this.logger.error(`Phone number ${countryCode + phoneNumber} already taken`); - // throw new BadRequestException('USER.PHONE_NUMBER_ALREADY_TAKEN'); - // } - - // await this.userService.setPhoneNumber(userId, phoneNumber, countryCode); - - // return this.otpService.generateAndSendOtp({ - // userId, - // recipient: countryCode + phoneNumber, - // scope: OtpScope.VERIFY_PHONE, - // otpType: OtpType.SMS, - // }); - // } - - // async verifyPhoneNumber(userId: string, otp: string) { - // const isOtpValid = await this.otpService.verifyOtp({ - // otpType: OtpType.SMS, - // scope: OtpScope.VERIFY_PHONE, - // userId, - // value: otp, - // }); - - // if (!isOtpValid) { - // this.logger.error(`Invalid OTP for user with id ${userId}`); - // throw new BadRequestException('OTP.INVALID_OTP'); - // } - - // return this.userService.verifyPhoneNumber(userId); - // } - async enableBiometric(userId: string, { deviceId, publicKey }: EnableBiometricRequestDto) { this.logger.log(`Enabling biometric for user with id ${userId}`); const device = await this.deviceService.findUserDeviceById(deviceId, userId); @@ -255,48 +135,49 @@ export class AuthService { return this.deviceService.updateDevice(deviceId, { publicKey: null }); } - async sendForgetPasswordOtp({ email }: SendForgetPasswordOtpRequestDto) { - this.logger.log(`Sending forget password OTP to ${email}`); - const user = await this.userService.findUserOrThrow({ email }); - - if (!user.isProfileCompleted) { - this.logger.error(`Profile not completed for user with email ${email}`); - throw new BadRequestException('USER.PROFILE_NOT_COMPLETED'); - } + async sendForgetPasswordOtp({ countryCode, phoneNumber }: SendForgetPasswordOtpRequestDto) { + this.logger.log(`Sending forget password OTP to ${countryCode + phoneNumber}`); + const user = await this.userService.findUserOrThrow({ countryCode, phoneNumber }); return this.otpService.generateAndSendOtp({ userId: user.id, - recipient: user.email, + recipient: user.fullPhoneNumber, scope: OtpScope.FORGET_PASSWORD, - otpType: OtpType.EMAIL, + otpType: OtpType.SMS, }); } - async verifyForgetPasswordOtp({ email, otp, password, confirmPassword }: ForgetPasswordRequestDto) { - this.logger.log(`Verifying forget password OTP for ${email}`); - const user = await this.userService.findUserOrThrow({ email }); - if (!user.isProfileCompleted) { - this.logger.error(`Profile not completed for user with email ${email}`); - throw new BadRequestException('USER.PROFILE_NOT_COMPLETED'); - } + async verifyForgetPasswordOtp({ + countryCode, + phoneNumber, + otp, + password, + confirmPassword, + }: ForgetPasswordRequestDto) { + this.logger.log(`Verifying forget password OTP for ${countryCode + phoneNumber}`); + const user = await this.userService.findUserOrThrow({ countryCode, phoneNumber }); + const isOtpValid = await this.otpService.verifyOtp({ userId: user.id, scope: OtpScope.FORGET_PASSWORD, - otpType: OtpType.EMAIL, + otpType: OtpType.SMS, value: otp, }); if (!isOtpValid) { - this.logger.error(`Invalid OTP for user with email ${email}`); + this.logger.error(`Invalid OTP for user with phone number ${user.fullPhoneNumber}`); throw new BadRequestException('OTP.INVALID_OTP'); } - this.validatePassword(password, confirmPassword, user); + if (password !== confirmPassword) { + this.logger.error('Password and confirm password do not match'); + throw new BadRequestException('AUTH.PASSWORD_MISMATCH'); + } const hashedPassword = bcrypt.hashSync(password, user.salt); - await this.userService.setPasscode(user.id, hashedPassword, user.salt); - this.logger.log(`Passcode updated successfully for user with email ${email}`); + await this.userService.setPassword(user.id, hashedPassword, user.salt); + this.logger.log(`Passcode updated successfully for user with phone number ${user.fullPhoneNumber}`); } async setJuniorPasscode(body: setJuniorPasswordRequestDto) { @@ -304,7 +185,7 @@ export class AuthService { const juniorId = await this.userTokenService.validateToken(body.qrToken, UserType.JUNIOR); const salt = bcrypt.genSaltSync(SALT_ROUNDS); const hashedPasscode = bcrypt.hashSync(body.passcode, salt); - await this.userService.setPasscode(juniorId!, hashedPasscode, salt); + await this.userService.setPassword(juniorId!, hashedPasscode, salt); await this.userTokenService.invalidateToken(body.qrToken); this.logger.log(`Passcode set successfully for junior with id ${juniorId}`); } @@ -345,40 +226,6 @@ export class AuthService { } } - async sendLoginOtp({ email }: SendLoginOtpRequestDto) { - const user = await this.userService.findUserOrThrow({ email }); - - this.logger.log(`Sending login OTP to ${email}`); - return this.otpService.generateAndSendOtp({ - recipient: email, - scope: OtpScope.LOGIN, - otpType: OtpType.EMAIL, - userId: user.id, - }); - } - - async verifyLoginOtp({ email, otp }: VerifyLoginOtpRequestDto): Promise<[ILoginResponse, User]> { - const user = await this.userService.findUserOrThrow({ email }); - - this.logger.log(`Verifying login OTP for ${email}`); - const isOtpValid = await this.otpService.verifyOtp({ - otpType: OtpType.EMAIL, - scope: OtpScope.LOGIN, - userId: user.id, - value: otp, - }); - - if (!isOtpValid) { - this.logger.error(`Invalid OTP for user with email ${email}`); - throw new BadRequestException('OTP.INVALID_OTP'); - } - - this.logger.log(`Login OTP verified successfully for ${email}`); - - const token = await this.generateAuthToken(user); - return [token, user]; - } - logout(req: Request) { this.logger.log('Logging out'); const accessToken = req.headers.authorization?.split(' ')[1] as string; @@ -386,147 +233,68 @@ export class AuthService { return this.cacheService.set(accessToken, 'BLACKLISTED', expiryInTtl); } - private async loginWithPassword(loginDto: LoginRequestDto): Promise<[ILoginResponse, User]> { - const user = await this.userService.findUserOrThrow({ email: loginDto.email }); + async loginWithPassword(loginDto: LoginRequestDto): Promise<[ILoginResponse, User]> { + const user = await this.userService.findUser({ + countryCode: loginDto.countryCode, + phoneNumber: loginDto.phoneNumber, + }); - this.logger.log(`validating password for user with email ${loginDto.email}`); + if (!user) { + this.logger.error(`User not found with phone number ${loginDto.countryCode + loginDto.phoneNumber}`); + throw new UnauthorizedException('AUTH.INVALID_CREDENTIALS'); + } + + if (!user.password) { + this.logger.error(`Password not set for user with phone number ${loginDto.countryCode + loginDto.phoneNumber}`); + throw new UnauthorizedException('AUTH.PHONE_NUMBER_NOT_VERIFIED'); + } + + this.logger.log(`validating password for user with phone ${loginDto.countryCode + loginDto.phoneNumber}`); const isPasswordValid = bcrypt.compareSync(loginDto.password, user.password); if (!isPasswordValid) { - this.logger.error(`Invalid password for user with email ${loginDto.email}`); + this.logger.error(`Invalid password for user with phone ${loginDto.countryCode + loginDto.phoneNumber}`); throw new UnauthorizedException('AUTH.INVALID_CREDENTIALS'); } const tokens = await this.generateAuthToken(user); - this.logger.log(`Password validated successfully for user with email ${loginDto.email}`); + this.logger.log(`Password validated successfully for user`); return [tokens, user]; } - private async loginWithBiometric(loginDto: LoginRequestDto, deviceId: string): Promise<[ILoginResponse, User]> { - const user = await this.userService.findUserOrThrow({ email: loginDto.email }); + // private async loginWithBiometric(loginDto: LoginRequestDto, deviceId: string): Promise<[ILoginResponse, User]> { + // const user = await this.userService.findUserOrThrow({ email: loginDto.email }); - this.logger.log(`validating biometric for user with email ${loginDto.email}`); - const device = await this.deviceService.findUserDeviceById(deviceId, user.id); + // this.logger.log(`validating biometric for user with email ${loginDto.email}`); + // const device = await this.deviceService.findUserDeviceById(deviceId, user.id); - if (!device) { - this.logger.error(`Device not found for user with email ${loginDto.email} and device id ${deviceId}`); - throw new UnauthorizedException('AUTH.DEVICE_NOT_FOUND'); - } + // if (!device) { + // this.logger.error(`Device not found for user with email ${loginDto.email} and device id ${deviceId}`); + // throw new UnauthorizedException('AUTH.DEVICE_NOT_FOUND'); + // } - if (!device.publicKey) { - this.logger.error(`Biometric not enabled for user with email ${loginDto.email}`); - throw new UnauthorizedException('AUTH.BIOMETRIC_NOT_ENABLED'); - } + // if (!device.publicKey) { + // this.logger.error(`Biometric not enabled for user with email ${loginDto.email}`); + // throw new UnauthorizedException('AUTH.BIOMETRIC_NOT_ENABLED'); + // } - const cleanToken = removePadding(loginDto.signature); - const isValidToken = await verifySignature( - device.publicKey, - cleanToken, - `${user.email} - ${device.deviceId}`, - 'SHA1', - ); + // const cleanToken = removePadding(loginDto.signature); + // const isValidToken = await verifySignature( + // device.publicKey, + // cleanToken, + // `${user.email} - ${device.deviceId}`, + // 'SHA1', + // ); - if (!isValidToken) { - this.logger.error(`Invalid biometric for user with email ${loginDto.email}`); - throw new UnauthorizedException('AUTH.INVALID_BIOMETRIC'); - } + // if (!isValidToken) { + // this.logger.error(`Invalid biometric for user with email ${loginDto.email}`); + // throw new UnauthorizedException('AUTH.INVALID_BIOMETRIC'); + // } - const tokens = await this.generateAuthToken(user); - this.logger.log(`Biometric validated successfully for user with email ${loginDto.email}`); - return [tokens, user]; - } - - async loginWithGoogle(loginDto: GoogleLoginRequestDto): Promise<[ILoginResponse, User]> { - const { - email, - sub, - given_name: firstName, - family_name: lastName, - } = await this.oauth2Service.verifyGoogleToken(loginDto.googleToken); - const [existingUser, isJunior, existingUserWithEmail] = await Promise.all([ - this.userService.findUser({ googleId: sub }), - this.userService.findUser({ email, roles: ArrayContains([Roles.JUNIOR]) }), - this.userService.findUser({ email }), - ]); - - if (isJunior) { - this.logger.error(`User with email ${email} is an already registered junior`); - throw new BadRequestException('USER.JUNIOR_UPGRADE_NOT_SUPPORTED_YET'); - } - - if (!existingUser && existingUserWithEmail) { - this.logger.error(`User with email ${email} already exists adding google id to existing user`); - await this.userService.updateUser(existingUserWithEmail.id, { googleId: sub }); - const tokens = await this.generateAuthToken(existingUserWithEmail); - return [tokens, existingUserWithEmail]; - } - - if (!existingUser && !existingUserWithEmail) { - this.logger.debug(`User with google id ${sub} or email ${email} not found, creating new user`); - const user = await this.userService.createGoogleUser(sub, email, firstName, lastName); - - const tokens = await this.generateAuthToken(user); - - return [tokens, user]; - } - - const tokens = await this.generateAuthToken(existingUser!); - - return [tokens, existingUser!]; - } - - async loginWithApple(loginDto: AppleLoginRequestDto): Promise<[ILoginResponse, User]> { - const { sub, email } = await this.oauth2Service.verifyAppleToken(loginDto.appleToken); - - const [existingUserWithSub, isJunior] = await Promise.all([ - this.userService.findUser({ appleId: sub }), - this.userService.findUser({ email, roles: ArrayContains([Roles.JUNIOR]) }), - ]); - - if (isJunior) { - this.logger.error(`User with apple id ${sub} is an already registered junior`); - throw new BadRequestException('USER.JUNIOR_UPGRADE_NOT_SUPPORTED_YET'); - } - - if (email) { - const existingUserWithEmail = await this.userService.findUser({ email }); - - if (existingUserWithEmail && !existingUserWithSub) { - { - this.logger.error(`User with email ${email} already exists adding apple id to existing user`); - await this.userService.updateUser(existingUserWithEmail.id, { appleId: sub }); - const tokens = await this.generateAuthToken(existingUserWithEmail); - return [tokens, existingUserWithEmail]; - } - } - } - - if (!existingUserWithSub) { - // Apple only provides email if user authorized zod for the first time - if (!email || !loginDto.additionalData) { - this.logger.error(`User authorized zod before but his email is not stored in the database`); - throw new BadRequestException('AUTH.APPLE_RE-CONSENT_REQUIRED'); - } - - this.logger.debug(`User with apple id ${sub} not found, creating new user`); - const user = await this.userService.createAppleUser( - sub, - email, - loginDto.additionalData.firstName, - loginDto.additionalData.lastName, - ); - - const tokens = await this.generateAuthToken(user); - - return [tokens, user]; - } - - const tokens = await this.generateAuthToken(existingUserWithSub); - - this.logger.log(`User with apple id ${sub} logged in successfully`); - - return [tokens, existingUserWithSub]; - } + // const tokens = await this.generateAuthToken(user); + // this.logger.log(`Biometric validated successfully for user with email ${loginDto.email}`); + // return [tokens, user]; + // } private async generateAuthToken(user: User) { this.logger.log(`Generating auth token for user with id ${user.id}`); @@ -550,19 +318,4 @@ export class AuthService { this.logger.log(`Auth token generated successfully for user with id ${user.id}`); return { accessToken, refreshToken, expiresAt: new Date(this.jwtService.decode(accessToken).exp * ONE_THOUSAND) }; } - - private validatePassword(password: string, confirmPassword: string, user: User) { - this.logger.log(`Validating password for user with id ${user.id}`); - if (password !== confirmPassword) { - this.logger.error(`Password mismatch for user with id ${user.id}`); - throw new BadRequestException('AUTH.PASSWORD_MISMATCH'); - } - - if (!PASSCODE_REGEX.test(password)) { - this.logger.error(`Invalid password for user with id ${user.id}`); - throw new BadRequestException('AUTH.INVALID_PASSCODE'); - } - } - - private validateGoogleToken(googleToken: string) {} } diff --git a/src/user/services/user.service.ts b/src/user/services/user.service.ts index 3279780..2640cbe 100644 --- a/src/user/services/user.service.ts +++ b/src/user/services/user.service.ts @@ -7,12 +7,7 @@ import { Transactional } from 'typeorm-transactional'; import { CountryIso } from '~/common/enums'; import { NotificationsService } from '~/common/modules/notification/services'; import { CustomerService } from '~/customer/services'; -import { - CreateUnverifiedUserRequestDto, - CreateUnverifiedUserV2RequestDto, - VerifyUserRequestDto, - VerifyUserV2RequestDto, -} from '../../auth/dtos/request'; +import { CreateUnverifiedUserRequestDto, VerifyUserRequestDto } from '../../auth/dtos/request'; import { Roles } from '../../auth/enums'; import { CreateCheckerRequestDto, @@ -49,9 +44,9 @@ export class UserService { return this.userRepository.update(userId, { email }); } - setPasscode(userId: string, passcode: string, salt: string) { + setPassword(userId: string, password: string, salt: string) { this.logger.log(`Setting passcode for user ${userId}`); - return this.userRepository.update(userId, { password: passcode, salt, isProfileCompleted: true }); + return this.userRepository.update(userId, { password, salt }); } setPhoneNumber(userId: string, phoneNumber: string, countryCode: string) { @@ -60,23 +55,9 @@ export class UserService { } @Transactional() - async verifyUser(userId: string, body: VerifyUserRequestDto | VerifyUserV2RequestDto) { - this.logger.log(`Verifying user with id ${userId}`); - await Promise.all([ - this.customerService.createGuardianCustomer(userId, { - firstName: body.firstName, - lastName: body.lastName, - dateOfBirth: body.dateOfBirth, - countryOfResidence: body.countryOfResidence, - }), - this.userRepository.update(userId, { - isEmailVerified: true, - }), - ]); - } - - @Transactional() - async verifyUserV2(userId: string, body: VerifyUserV2RequestDto) { + async verifyUser(userId: string, body: VerifyUserRequestDto) { + const salt = bcrypt.genSaltSync(SALT_ROUNDS); + const hashedPassword = bcrypt.hashSync(body.password, salt); this.logger.log(`Verifying user with id ${userId}`); await Promise.all([ this.customerService.createGuardianCustomer(userId, { @@ -87,7 +68,8 @@ export class UserService { }), this.userRepository.update(userId, { isPhoneVerified: true, - + password: hashedPassword, + salt, ...(body.email && { email: body.email }), }), ]); @@ -113,29 +95,6 @@ export class UserService { @Transactional() async findOrCreateUser(body: CreateUnverifiedUserRequestDto) { - this.logger.log(`Finding or creating user with email ${body.email}`); - const user = await this.userRepository.findOne({ email: body.email }); - - if (!user) { - this.logger.log(`User with email ${body.email} not found, creating new user`); - return this.userRepository.createUnverifiedUser({ email: body.email, roles: [Roles.GUARDIAN] }); - } - if (user && user.roles.includes(Roles.GUARDIAN) && user.isEmailVerified) { - this.logger.error(`User with email ${body.email} already exists`); - throw new BadRequestException('USER.EMAIL_ALREADY_TAKEN'); - } - - if (user && user.roles.includes(Roles.JUNIOR)) { - this.logger.error(`User with email ${body.email} is an already registered junior`); - throw new BadRequestException('USER.JUNIOR_UPGRADE_NOT_SUPPORTED_YET'); - //TODO add role Guardian to the existing user and send OTP - } - - this.logger.log(`User with email ${body.email}`); - return user; - } - - async findOrCreateByPhoneNumber(body: CreateUnverifiedUserV2RequestDto) { this.logger.log(`Finding or creating user with phone number ${body.countryCode + body.phoneNumber}`); const user = await this.userRepository.findOne({ phoneNumber: body.phoneNumber, @@ -167,25 +126,6 @@ export class UserService { return user; } - async findOrCreateByEmail(email: string) { - this.logger.log(`Finding or creating user with email ${email} `); - const user = await this.userRepository.findOne({ email }); - - if (!user) { - this.logger.log(`User with email ${email} not found, creating new user`); - return this.userRepository.createUser({ email, roles: [Roles.GUARDIAN] }); - } - - if (user && user.roles.includes(Roles.JUNIOR)) { - this.logger.error(`User with email ${email} is an already registered junior`); - throw new BadRequestException('USER.JUNIOR_UPGRADE_NOT_SUPPORTED_YET'); - //TODO add role Guardian to the existing user and send OTP - } - - this.logger.log(`User with email ${email} found successfully`); - return user; - } - async createUser(data: Partial) { this.logger.log(`Creating user with data ${JSON.stringify(data)}`); const user = await this.userRepository.createUser(data); From 7e63abb2fb71389864cc1a4cbdce5e948dded79f Mon Sep 17 00:00:00 2001 From: Abdalhamid Alhamad Date: Thu, 31 Jul 2025 14:07:01 +0300 Subject: [PATCH 17/32] feat: add-account-details --- src/card/entities/account.entity.ts | 8 +++++ src/card/repositories/account.repository.ts | 14 ++++++-- src/card/repositories/card.repository.ts | 7 ++++ src/card/services/account.service.ts | 13 ++++++-- src/card/services/card.service.ts | 11 ++++++- src/card/services/transaction.service.ts | 2 +- .../create-application.response.dto.ts | 10 ++++++ ...count-number-and-iban-to-account-entity.ts | 32 +++++++++++++++++++ src/db/migrations/index.ts | 5 +-- 9 files changed, 94 insertions(+), 8 deletions(-) create mode 100644 src/db/migrations/1753948642040-add-account-number-and-iban-to-account-entity.ts diff --git a/src/card/entities/account.entity.ts b/src/card/entities/account.entity.ts index cdd7847..4a1ffcb 100644 --- a/src/card/entities/account.entity.ts +++ b/src/card/entities/account.entity.ts @@ -11,6 +11,14 @@ export class Account { @Index({ unique: true }) accountReference!: string; + @Index({ unique: true }) + @Column('varchar', { length: 255, nullable: false, name: 'account_number' }) + accountNumber!: string; + + @Index({ unique: true }) + @Column('varchar', { length: 255, nullable: false, name: 'iban' }) + iban!: string; + @Column('varchar', { length: 255, nullable: false, name: 'currency' }) currency!: string; diff --git a/src/card/repositories/account.repository.ts b/src/card/repositories/account.repository.ts index da15be6..428b109 100644 --- a/src/card/repositories/account.repository.ts +++ b/src/card/repositories/account.repository.ts @@ -1,16 +1,19 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; +import { CreateApplicationResponse } from '~/common/modules/neoleap/dtos/response'; import { Account } from '../entities/account.entity'; @Injectable() export class AccountRepository { constructor(@InjectRepository(Account) private readonly accountRepository: Repository) {} - createAccount(accountId: string): Promise { + createAccount(data: CreateApplicationResponse): Promise { return this.accountRepository.save( this.accountRepository.create({ - accountReference: accountId, + accountReference: data.accountId, + accountNumber: data.accountNumber, + iban: data.iBan, balance: 0, currency: '682', }), @@ -24,6 +27,13 @@ export class AccountRepository { }); } + getAccountByAccountNumber(accountNumber: string): Promise { + return this.accountRepository.findOne({ + where: { accountNumber }, + relations: ['cards'], + }); + } + topUpAccountBalance(accountReference: string, amount: number) { return this.accountRepository.increment({ accountReference }, 'balance', amount); } diff --git a/src/card/repositories/card.repository.ts b/src/card/repositories/card.repository.ts index d747feb..d5bc71d 100644 --- a/src/card/repositories/card.repository.ts +++ b/src/card/repositories/card.repository.ts @@ -34,6 +34,13 @@ export class CardRepository { return this.cardRepository.findOne({ where: { cardReference: referenceNumber }, relations: ['account'] }); } + getCardByAccountNumber(accountNumber: string): Promise { + return this.cardRepository.findOne({ + where: { account: { accountNumber } }, + relations: ['account'], + }); + } + getActiveCardForCustomer(customerId: string): Promise { return this.cardRepository.findOne({ where: { customerId, status: CardStatus.ACTIVE }, diff --git a/src/card/services/account.service.ts b/src/card/services/account.service.ts index cce8e68..ac9e0dd 100644 --- a/src/card/services/account.service.ts +++ b/src/card/services/account.service.ts @@ -1,4 +1,5 @@ import { Injectable, UnprocessableEntityException } from '@nestjs/common'; +import { CreateApplicationResponse } from '~/common/modules/neoleap/dtos/response'; import { Account } from '../entities/account.entity'; import { AccountRepository } from '../repositories/account.repository'; @@ -6,8 +7,8 @@ import { AccountRepository } from '../repositories/account.repository'; export class AccountService { constructor(private readonly accountRepository: AccountRepository) {} - createAccount(accountId: string): Promise { - return this.accountRepository.createAccount(accountId); + createAccount(data: CreateApplicationResponse): Promise { + return this.accountRepository.createAccount(data); } async getAccountByReferenceNumber(accountReference: string): Promise { @@ -18,6 +19,14 @@ export class AccountService { return account; } + async getAccountByAccountNumber(accountNumber: string): Promise { + const account = await this.accountRepository.getAccountByAccountNumber(accountNumber); + if (!account) { + throw new UnprocessableEntityException('ACCOUNT.NOT_FOUND'); + } + return account; + } + async creditAccountBalance(accountReference: string, amount: number) { return this.accountRepository.topUpAccountBalance(accountReference, amount); } diff --git a/src/card/services/card.service.ts b/src/card/services/card.service.ts index 61f785e..0241852 100644 --- a/src/card/services/card.service.ts +++ b/src/card/services/card.service.ts @@ -13,7 +13,7 @@ export class CardService { @Transactional() async createCard(customerId: string, cardData: CreateApplicationResponse): Promise { - const account = await this.accountService.createAccount(cardData.accountId); + const account = await this.accountService.createAccount(cardData); return this.cardRepository.createCard(customerId, account.id, cardData); } @@ -37,6 +37,15 @@ export class CardService { return card; } + async getCardByAccountNumber(accountNumber: string): Promise { + const card = await this.cardRepository.getCardByAccountNumber(accountNumber); + + if (!card) { + throw new BadRequestException('CARD.NOT_FOUND'); + } + return card; + } + async getActiveCardForCustomer(customerId: string): Promise { const card = await this.cardRepository.getActiveCardForCustomer(customerId); if (!card) { diff --git a/src/card/services/transaction.service.ts b/src/card/services/transaction.service.ts index d512299..4db3a80 100644 --- a/src/card/services/transaction.service.ts +++ b/src/card/services/transaction.service.ts @@ -20,7 +20,7 @@ export class TransactionService { @Transactional() async createCardTransaction(body: CardTransactionWebhookRequest) { - const card = await this.cardService.getCardByReferenceNumber(body.cardId); + const card = await this.cardService.getCardByAccountNumber(body.cardId); const existingTransaction = await this.findExistingTransaction(body.transactionId, card.account.accountReference); if (existingTransaction) { diff --git a/src/common/modules/neoleap/dtos/response/create-application.response.dto.ts b/src/common/modules/neoleap/dtos/response/create-application.response.dto.ts index f135b4f..8da0c0a 100644 --- a/src/common/modules/neoleap/dtos/response/create-application.response.dto.ts +++ b/src/common/modules/neoleap/dtos/response/create-application.response.dto.ts @@ -37,4 +37,14 @@ export class CreateApplicationResponse extends InquireApplicationResponse { @Expose() @ApiProperty() accountId!: string; + + @Transform(({ obj }) => obj.AccountDetailsList?.[0]?.AccountNumber) + @Expose() + @ApiProperty() + accountNumber!: string; + + @Transform(({ obj }) => obj.AccountDetailsList?.[0]?.UserData5) + @Expose() + @ApiProperty() + iBan!: string; } diff --git a/src/db/migrations/1753948642040-add-account-number-and-iban-to-account-entity.ts b/src/db/migrations/1753948642040-add-account-number-and-iban-to-account-entity.ts new file mode 100644 index 0000000..165be69 --- /dev/null +++ b/src/db/migrations/1753948642040-add-account-number-and-iban-to-account-entity.ts @@ -0,0 +1,32 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; +export class AddAccountNumberAndIbanToAccountEntity1753948642040 implements MigrationInterface { + name = 'AddAccountNumberAndIbanToAccountEntity1753948642040'; + + public async up(queryRunner: QueryRunner): Promise { + // Step 1: Add columns as nullable + await queryRunner.query(`ALTER TABLE "accounts" ADD "account_number" character varying(255)`); + await queryRunner.query(`ALTER TABLE "accounts" ADD "iban" character varying(255)`); + + // Step 2: Populate dummy values or correct ones + await queryRunner.query(` + UPDATE "accounts" + SET "account_number" = 'TEMP_ACC_' || id, + "iban" = 'TEMP_IBAN_' || id + `); + + // Step 3: Alter columns to be NOT NULL + await queryRunner.query(`ALTER TABLE "accounts" ALTER COLUMN "account_number" SET NOT NULL`); + await queryRunner.query(`ALTER TABLE "accounts" ALTER COLUMN "iban" SET NOT NULL`); + + // Step 4: Add unique indexes + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_ffd1ae96513bfb2c6eada0f7d3" ON "accounts" ("account_number")`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_9a4b004902294416b096e7556e" ON "accounts" ("iban")`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "public"."IDX_9a4b004902294416b096e7556e"`); + await queryRunner.query(`DROP INDEX "public"."IDX_ffd1ae96513bfb2c6eada0f7d3"`); + await queryRunner.query(`ALTER TABLE "accounts" DROP COLUMN "iban"`); + await queryRunner.query(`ALTER TABLE "accounts" DROP COLUMN "account_number"`); + } +} diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index 9996c42..8f22bfe 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -1,5 +1,6 @@ -export * from './1753869637732-seed-default-avatar'; +export * from './1733750228289-initial-migration'; export * from './1733990253208-seeds-default-tasks-logo'; export * from './1734247702310-seeds-goals-categories'; -export * from './1733750228289-initial-migration'; +export * from './1753869637732-seed-default-avatar'; export * from './1753874205042-add-neoleap-related-entities'; +export * from './1753948642040-add-account-number-and-iban-to-account-entity'; From f9776e60cfd7c9667d9e97a0a50c1807330164ca Mon Sep 17 00:00:00 2001 From: Abdalhamid Alhamad Date: Thu, 31 Jul 2025 14:11:53 +0300 Subject: [PATCH 18/32] fix: save transaction file --- src/card/services/transaction.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/card/services/transaction.service.ts b/src/card/services/transaction.service.ts index 4db3a80..17973a3 100644 --- a/src/card/services/transaction.service.ts +++ b/src/card/services/transaction.service.ts @@ -37,7 +37,7 @@ export class TransactionService { @Transactional() async createAccountTransaction(body: AccountTransactionWebhookRequest) { - const account = await this.accountService.getAccountByReferenceNumber(body.accountId); + const account = await this.accountService.getAccountByAccountNumber(body.accountId); const existingTransaction = await this.findExistingTransaction(body.transactionId, account.accountReference); From 5e0a4e6bd1f60be5a75a3f441df1fd9b256aeb0d Mon Sep 17 00:00:00 2001 From: Abdalhamid Alhamad Date: Thu, 31 Jul 2025 14:42:06 +0300 Subject: [PATCH 19/32] feat: fix update card status webhook --- src/card/services/card.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/card/services/card.service.ts b/src/card/services/card.service.ts index 0241852..e2e862b 100644 --- a/src/card/services/card.service.ts +++ b/src/card/services/card.service.ts @@ -55,7 +55,7 @@ export class CardService { } async updateCardStatus(body: AccountCardStatusChangedWebhookRequest) { - const card = await this.getCardByReferenceNumber(body.cardId); + const card = await this.getCardByAccountNumber(body.cardId); const { description, status } = CardStatusMapper[body.newStatus] || CardStatusMapper['99']; return this.cardRepository.updateCardStatus(card.id, status, description); From fce720237f5df89577a7a0f0ca63761b710cf3dc Mon Sep 17 00:00:00 2001 From: Abdalhamid Alhamad Date: Sun, 3 Aug 2025 11:53:16 +0300 Subject: [PATCH 20/32] feat: add vpan to card entity --- src/card/entities/card.entity.ts | 4 ++++ src/card/repositories/card.repository.ts | 5 +++-- src/card/services/card.service.ts | 6 ++--- src/card/services/transaction.service.ts | 2 +- .../neoleap-webhooks.controller.ts | 10 ++++++--- .../1754210729273-add-vpan-to-card.ts | 22 +++++++++++++++++++ src/db/migrations/index.ts | 1 + 7 files changed, 41 insertions(+), 9 deletions(-) create mode 100644 src/db/migrations/1754210729273-add-vpan-to-card.ts diff --git a/src/card/entities/card.entity.ts b/src/card/entities/card.entity.ts index 53ac864..72ce7a0 100644 --- a/src/card/entities/card.entity.ts +++ b/src/card/entities/card.entity.ts @@ -23,6 +23,10 @@ export class Card { @Column({ name: 'card_reference', nullable: false, type: 'varchar' }) cardReference!: string; + @Index({ unique: true }) + @Column({ name: 'vpan', nullable: false, type: 'varchar' }) + vpan!: string; + @Column({ length: 6, name: 'first_six_digits', nullable: false, type: 'varchar' }) firstSixDigits!: string; diff --git a/src/card/repositories/card.repository.ts b/src/card/repositories/card.repository.ts index d5bc71d..a6e8cb6 100644 --- a/src/card/repositories/card.repository.ts +++ b/src/card/repositories/card.repository.ts @@ -22,6 +22,7 @@ export class CardRepository { scheme: CardScheme.VISA, issuer: CardIssuers.NEOLEAP, accountId: accountId, + vpan: card.vpan, }), ); } @@ -34,9 +35,9 @@ export class CardRepository { return this.cardRepository.findOne({ where: { cardReference: referenceNumber }, relations: ['account'] }); } - getCardByAccountNumber(accountNumber: string): Promise { + getCardByVpan(vpan: string): Promise { return this.cardRepository.findOne({ - where: { account: { accountNumber } }, + where: { vpan }, relations: ['account'], }); } diff --git a/src/card/services/card.service.ts b/src/card/services/card.service.ts index e2e862b..6d48d0f 100644 --- a/src/card/services/card.service.ts +++ b/src/card/services/card.service.ts @@ -37,8 +37,8 @@ export class CardService { return card; } - async getCardByAccountNumber(accountNumber: string): Promise { - const card = await this.cardRepository.getCardByAccountNumber(accountNumber); + async getCardByVpan(vpan: string): Promise { + const card = await this.cardRepository.getCardByVpan(vpan); if (!card) { throw new BadRequestException('CARD.NOT_FOUND'); @@ -55,7 +55,7 @@ export class CardService { } async updateCardStatus(body: AccountCardStatusChangedWebhookRequest) { - const card = await this.getCardByAccountNumber(body.cardId); + const card = await this.getCardByVpan(body.cardId); const { description, status } = CardStatusMapper[body.newStatus] || CardStatusMapper['99']; return this.cardRepository.updateCardStatus(card.id, status, description); diff --git a/src/card/services/transaction.service.ts b/src/card/services/transaction.service.ts index 17973a3..ff22aef 100644 --- a/src/card/services/transaction.service.ts +++ b/src/card/services/transaction.service.ts @@ -20,7 +20,7 @@ export class TransactionService { @Transactional() async createCardTransaction(body: CardTransactionWebhookRequest) { - const card = await this.cardService.getCardByAccountNumber(body.cardId); + const card = await this.cardService.getCardByVpan(body.cardId); const existingTransaction = await this.findExistingTransaction(body.transactionId, card.account.accountReference); if (existingTransaction) { diff --git a/src/common/modules/neoleap/controllers/neoleap-webhooks.controller.ts b/src/common/modules/neoleap/controllers/neoleap-webhooks.controller.ts index 1fb8d60..03aaf8a 100644 --- a/src/common/modules/neoleap/controllers/neoleap-webhooks.controller.ts +++ b/src/common/modules/neoleap/controllers/neoleap-webhooks.controller.ts @@ -1,5 +1,6 @@ import { Body, Controller, Post } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; +import { ResponseFactory } from '~/core/utils'; import { AccountCardStatusChangedWebhookRequest, AccountTransactionWebhookRequest, @@ -14,16 +15,19 @@ export class NeoLeapWebhooksController { @Post('card-transaction') async handleCardTransactionWebhook(@Body() body: CardTransactionWebhookRequest) { - return this.neoleapWebhookService.handleCardTransactionWebhook(body); + await this.neoleapWebhookService.handleCardTransactionWebhook(body); + return ResponseFactory.data({ message: 'Card transaction processed successfully', status: 'success' }); } @Post('account-transaction') async handleAccountTransactionWebhook(@Body() body: AccountTransactionWebhookRequest) { - return this.neoleapWebhookService.handleAccountTransactionWebhook(body); + await this.neoleapWebhookService.handleAccountTransactionWebhook(body); + return ResponseFactory.data({ message: 'Account transaction processed successfully', status: 'success' }); } @Post('account-card-status-changed') async handleAccountCardStatusChangedWebhook(@Body() body: AccountCardStatusChangedWebhookRequest) { - return this.neoleapWebhookService.handleAccountCardStatusChangedWebhook(body); + await this.neoleapWebhookService.handleAccountCardStatusChangedWebhook(body); + return ResponseFactory.data({ message: 'Card status updated successfully', status: 'success' }); } } diff --git a/src/db/migrations/1754210729273-add-vpan-to-card.ts b/src/db/migrations/1754210729273-add-vpan-to-card.ts new file mode 100644 index 0000000..03d7beb --- /dev/null +++ b/src/db/migrations/1754210729273-add-vpan-to-card.ts @@ -0,0 +1,22 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddVpanToCard1754210729273 implements MigrationInterface { + name = 'AddVpanToCard1754210729273'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "cards" ADD "vpan" character varying`); + await queryRunner.query(` + UPDATE "cards" + SET "vpan" = 'TEMP_VPAN_' || id + `); + + await queryRunner.query(`ALTER TABLE "cards" ALTER COLUMN "vpan" SET NOT NULL`); + + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_1ec2ef68b0370f26639261e87b" ON "cards" ("vpan") `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "public"."IDX_1ec2ef68b0370f26639261e87b"`); + await queryRunner.query(`ALTER TABLE "cards" DROP COLUMN "vpan"`); + } +} diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index 8f22bfe..63f3865 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -4,3 +4,4 @@ export * from './1734247702310-seeds-goals-categories'; export * from './1753869637732-seed-default-avatar'; export * from './1753874205042-add-neoleap-related-entities'; export * from './1753948642040-add-account-number-and-iban-to-account-entity'; +export * from './1754210729273-add-vpan-to-card'; From f65a7d293383affecbce47ddd28e4fa95002465b Mon Sep 17 00:00:00 2001 From: Abdalhamid Alhamad Date: Sun, 3 Aug 2025 14:21:14 +0300 Subject: [PATCH 21/32] feat: generate upload signed url for oci --- .../controllers/document.controller.ts | 45 +++---------------- .../generate-upload-signed-url.request.dto.ts | 18 ++++++++ src/document/dtos/request/index.ts | 1 + src/document/services/document.service.ts | 9 +++- src/document/services/oci.service.ts | 45 ++++++++++++++++++- 5 files changed, 78 insertions(+), 40 deletions(-) create mode 100644 src/document/dtos/request/generate-upload-signed-url.request.dto.ts diff --git a/src/document/controllers/document.controller.ts b/src/document/controllers/document.controller.ts index 7b3eed1..2fc2a72 100644 --- a/src/document/controllers/document.controller.ts +++ b/src/document/controllers/document.controller.ts @@ -1,15 +1,9 @@ -import { Body, Controller, Post, UploadedFile, UseGuards, UseInterceptors } from '@nestjs/common'; -import { FileInterceptor } from '@nestjs/platform-express'; -import { ApiBearerAuth, ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger'; -import { memoryStorage } from 'multer'; -import { IJwtPayload } from '~/auth/interfaces'; -import { AuthenticatedUser } from '~/common/decorators'; +import { Body, Controller, Post, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { AccessTokenGuard } from '~/common/guards'; import { ApiLangRequestHeader } from '~/core/decorators'; import { ResponseFactory } from '~/core/utils'; -import { UploadDocumentRequestDto } from '../dtos/request'; -import { DocumentMetaResponseDto } from '../dtos/response'; -import { DocumentType } from '../enums'; +import { GenerateUploadSignedUrlRequestDto } from '../dtos/request'; import { DocumentService } from '../services'; @Controller('document') @ApiTags('Document') @@ -19,34 +13,9 @@ import { DocumentService } from '../services'; export class DocumentController { constructor(private readonly documentService: DocumentService) {} - @Post() - @ApiConsumes('multipart/form-data') - @ApiBody({ - schema: { - type: 'object', - properties: { - document: { - type: 'string', - format: 'binary', - }, - documentType: { - type: 'string', - enum: Object.values(DocumentType).filter( - (value) => ![DocumentType.DEFAULT_AVATAR, DocumentType.DEFAULT_TASKS_LOGO].includes(value), - ), - }, - }, - required: ['document', 'documentType'], - }, - }) - @UseInterceptors(FileInterceptor('document', { storage: memoryStorage() })) - async createDocument( - @UploadedFile() file: Express.Multer.File, - @Body() uploadedDocumentRequest: UploadDocumentRequestDto, - @AuthenticatedUser() user: IJwtPayload, - ) { - const document = await this.documentService.createDocument(file, uploadedDocumentRequest, user.sub); - - return ResponseFactory.data(new DocumentMetaResponseDto(document)); + @Post('signed-url') + async generateSignedUrl(@Body() body: GenerateUploadSignedUrlRequestDto) { + const signedUrl = await this.documentService.generateUploadSignedUrl(body); + return ResponseFactory.data(signedUrl); } } diff --git a/src/document/dtos/request/generate-upload-signed-url.request.dto.ts b/src/document/dtos/request/generate-upload-signed-url.request.dto.ts new file mode 100644 index 0000000..38e83b2 --- /dev/null +++ b/src/document/dtos/request/generate-upload-signed-url.request.dto.ts @@ -0,0 +1,18 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsEnum, IsString } from 'class-validator'; +import { i18nValidationMessage as i18n } from 'nestjs-i18n'; +import { DocumentType } from '~/document/enums'; + +export class GenerateUploadSignedUrlRequestDto { + @ApiProperty({ enum: DocumentType }) + @IsEnum(DocumentType, { message: i18n('validation.IsEnum', { path: 'general', property: 'document.documentType' }) }) + documentType!: DocumentType; + + @ApiProperty({ type: String }) + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'document.document.extension' }) }) + extension!: string; + + @ApiProperty({ type: String }) + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'document.originalFileName' }) }) + originalFileName!: string; +} diff --git a/src/document/dtos/request/index.ts b/src/document/dtos/request/index.ts index 4071ca7..2dd4fad 100644 --- a/src/document/dtos/request/index.ts +++ b/src/document/dtos/request/index.ts @@ -1 +1,2 @@ +export * from './generate-upload-signed-url.request.dto'; export * from './upload-document.request.dto'; diff --git a/src/document/services/document.service.ts b/src/document/services/document.service.ts index 5378942..bdc72cd 100644 --- a/src/document/services/document.service.ts +++ b/src/document/services/document.service.ts @@ -1,6 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; import { FindOptionsWhere } from 'typeorm'; -import { UploadDocumentRequestDto } from '../dtos/request'; +import { GenerateUploadSignedUrlRequestDto, UploadDocumentRequestDto } from '../dtos/request'; import { Document } from '../entities'; import { DocumentRepository } from '../repositories'; import { OciService } from './oci.service'; @@ -24,4 +24,11 @@ export class DocumentService { this.logger.log(`finding document with id ${id}`); return this.documentRepository.findDocumentById(id); } + + generateUploadSignedUrl(body: GenerateUploadSignedUrlRequestDto) { + this.logger.log( + `generating signed URL for document type ${body.documentType} with original file name ${body.originalFileName}`, + ); + return this.ociService.generateUploadPreSignedUrl(body); + } } diff --git a/src/document/services/oci.service.ts b/src/document/services/oci.service.ts index 3e65a89..9c6002d 100644 --- a/src/document/services/oci.service.ts +++ b/src/document/services/oci.service.ts @@ -8,7 +8,7 @@ import { CreatePreauthenticatedRequestDetails } from 'oci-objectstorage/lib/mode import path from 'path'; import { CacheService } from '~/common/modules/cache/services'; import { BUCKETS } from '../constants'; -import { UploadDocumentRequestDto } from '../dtos/request'; +import { GenerateUploadSignedUrlRequestDto, UploadDocumentRequestDto } from '../dtos/request'; import { UploadResponseDto } from '../dtos/response'; import { Document } from '../entities'; import { generateNewFileName } from '../utils'; @@ -106,4 +106,47 @@ export class OciService { return document.name; } } + + async generateUploadPreSignedUrl({ + documentType, + originalFileName, + extension, + }: GenerateUploadSignedUrlRequestDto): Promise { + const bucketName = BUCKETS[documentType]; + + if (!bucketName) { + this.logger.error('Could not find bucket name for document type', documentType); + throw new BadRequestException('DOCUMENT.TYPE_NOT_SUPPORTED'); + } + + const objectName = generateNewFileName(originalFileName); + const expiration = moment().add('1', 'hours').toDate(); + + try { + this.logger.debug(`Generating pre-signed upload URL for object ${objectName} in bucket ${bucketName}`); + + const response = await this.ociClient.createPreauthenticatedRequest({ + namespaceName: this.namespace, + bucketName, + createPreauthenticatedRequestDetails: { + name: `upload-${objectName}`, + accessType: CreatePreauthenticatedRequestDetails.AccessType.ObjectWrite, // 🔑 for upload + timeExpires: expiration, + objectName: `${objectName}${extension}`, // Ensure the object name includes the extension + }, + }); + + this.logger.log(`Generated upload URL for ${objectName}`); + + return plainToInstance(UploadResponseDto, { + name: objectName, + extension, + url: response.preauthenticatedRequest.fullPath, + documentType, + }); + } catch (error) { + this.logger.error('Error generating pre-signed upload URL', error); + throw new BadRequestException('UPLOAD.URL_GENERATION_FAILED'); + } + } } From 7461af20dd70b5428cb544760155499532fba824 Mon Sep 17 00:00:00 2001 From: Abdalhamid Alhamad Date: Sun, 3 Aug 2025 14:48:14 +0300 Subject: [PATCH 22/32] feat: edit forget password flow --- src/auth/controllers/auth.controller.ts | 12 +++++- .../request/forget-password.request.dto.ts | 20 +++------- src/auth/dtos/request/index.ts | 1 + .../verify-forget-password-otp.request.dto.ts | 23 +++++++++++ ...verify-forget-password-otp.response.dto.ts | 19 ++++++++++ src/auth/services/auth.service.ts | 38 ++++++++++++++----- src/i18n/ar/app.json | 4 +- src/i18n/en/app.json | 4 +- src/user/enums/user-type.enum.ts | 1 + .../repositories/user-token-repository.ts | 2 +- src/user/services/user-token.service.ts | 4 +- 11 files changed, 97 insertions(+), 31 deletions(-) create mode 100644 src/auth/dtos/request/verify-forget-password-otp.request.dto.ts create mode 100644 src/auth/dtos/response/verify-forget-password-otp.response.dto.ts diff --git a/src/auth/controllers/auth.controller.ts b/src/auth/controllers/auth.controller.ts index f651e69..e3bafcf 100644 --- a/src/auth/controllers/auth.controller.ts +++ b/src/auth/controllers/auth.controller.ts @@ -11,10 +11,12 @@ import { LoginRequestDto, RefreshTokenRequestDto, SendForgetPasswordOtpRequestDto, + VerifyForgetPasswordOtpRequestDto, VerifyUserRequestDto, } from '../dtos/request'; import { SendForgetPasswordOtpResponseDto, SendRegisterOtpResponseDto } from '../dtos/response'; import { LoginResponseDto } from '../dtos/response/login.response.dto'; +import { VerifyForgetPasswordOtpResponseDto } from '../dtos/response/verify-forget-password-otp.response.dto'; import { AuthService } from '../services'; @Controller('auth') @@ -47,10 +49,18 @@ export class AuthController { return ResponseFactory.data(new SendForgetPasswordOtpResponseDto(maskedNumber)); } + @Post('forget-password/verify') + @HttpCode(HttpStatus.OK) + async verifyForgetPasswordOtp(@Body() forgetPasswordDto: VerifyForgetPasswordOtpRequestDto) { + const { token, user } = await this.authService.verifyForgetPasswordOtp(forgetPasswordDto); + + return ResponseFactory.data(new VerifyForgetPasswordOtpResponseDto(token, user)); + } + @Post('forget-password/reset') @HttpCode(HttpStatus.NO_CONTENT) resetPassword(@Body() forgetPasswordDto: ForgetPasswordRequestDto) { - return this.authService.verifyForgetPasswordOtp(forgetPasswordDto); + return this.authService.resetPassword(forgetPasswordDto); } @Post('refresh-token') diff --git a/src/auth/dtos/request/forget-password.request.dto.ts b/src/auth/dtos/request/forget-password.request.dto.ts index 4153a96..4680c71 100644 --- a/src/auth/dtos/request/forget-password.request.dto.ts +++ b/src/auth/dtos/request/forget-password.request.dto.ts @@ -1,8 +1,7 @@ -import { ApiProperty, PickType } from '@nestjs/swagger'; -import { IsEmail, IsNotEmpty, IsNumberString, IsString, Matches, MaxLength, MinLength } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, Matches } from 'class-validator'; import { i18nValidationMessage as i18n } from 'nestjs-i18n'; import { COUNTRY_CODE_REGEX, PASSWORD_REGEX } from '~/auth/constants'; -import { DEFAULT_OTP_LENGTH } from '~/common/modules/otp/constants'; import { IsValidPhoneNumber } from '~/core/decorators/validations'; export class ForgetPasswordRequestDto { @ApiProperty({ example: '+962' }) @@ -29,16 +28,7 @@ export class ForgetPasswordRequestDto { }) confirmPassword!: string; - @ApiProperty({ example: '111111' }) - @IsNumberString( - { no_symbols: true }, - { message: i18n('validation.IsNumberString', { path: 'general', property: 'auth.otp' }) }, - ) - @MaxLength(DEFAULT_OTP_LENGTH, { - message: i18n('validation.MaxLength', { path: 'general', property: 'auth.otp', length: DEFAULT_OTP_LENGTH }), - }) - @MinLength(DEFAULT_OTP_LENGTH, { - message: i18n('validation.MinLength', { path: 'general', property: 'auth.otp', length: DEFAULT_OTP_LENGTH }), - }) - otp!: string; + @ApiProperty({ example: 'reset-token-32423123' }) + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.resetPasswordToken' }) }) + resetPasswordToken!: string; } diff --git a/src/auth/dtos/request/index.ts b/src/auth/dtos/request/index.ts index a329e46..09274d4 100644 --- a/src/auth/dtos/request/index.ts +++ b/src/auth/dtos/request/index.ts @@ -10,5 +10,6 @@ export * from './send-forget-password-otp.request.dto'; export * from './set-email.request.dto'; export * from './set-junior-password.request.dto'; export * from './set-passcode.request.dto'; +export * from './verify-forget-password-otp.request.dto'; export * from './verify-otp.request.dto'; export * from './verify-user.request.dto'; diff --git a/src/auth/dtos/request/verify-forget-password-otp.request.dto.ts b/src/auth/dtos/request/verify-forget-password-otp.request.dto.ts new file mode 100644 index 0000000..03f2100 --- /dev/null +++ b/src/auth/dtos/request/verify-forget-password-otp.request.dto.ts @@ -0,0 +1,23 @@ +import { ApiProperty, PickType } from '@nestjs/swagger'; +import { IsNumberString, MaxLength, MinLength } from 'class-validator'; +import { i18nValidationMessage as i18n } from 'nestjs-i18n'; +import { DEFAULT_OTP_LENGTH } from '~/common/modules/otp/constants'; +import { ForgetPasswordRequestDto } from './forget-password.request.dto'; + +export class VerifyForgetPasswordOtpRequestDto extends PickType(ForgetPasswordRequestDto, [ + 'countryCode', + 'phoneNumber', +]) { + @ApiProperty({ example: '111111' }) + @IsNumberString( + { no_symbols: true }, + { message: i18n('validation.IsNumberString', { path: 'general', property: 'auth.otp' }) }, + ) + @MaxLength(DEFAULT_OTP_LENGTH, { + message: i18n('validation.MaxLength', { path: 'general', property: 'auth.otp', length: DEFAULT_OTP_LENGTH }), + }) + @MinLength(DEFAULT_OTP_LENGTH, { + message: i18n('validation.MinLength', { path: 'general', property: 'auth.otp', length: DEFAULT_OTP_LENGTH }), + }) + otp!: string; +} diff --git a/src/auth/dtos/response/verify-forget-password-otp.response.dto.ts b/src/auth/dtos/response/verify-forget-password-otp.response.dto.ts new file mode 100644 index 0000000..8fc6010 --- /dev/null +++ b/src/auth/dtos/response/verify-forget-password-otp.response.dto.ts @@ -0,0 +1,19 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { User } from '~/user/entities'; + +export class VerifyForgetPasswordOtpResponseDto { + @ApiProperty() + phoneNumber!: string; + + @ApiProperty() + countryCode!: string; + + @ApiProperty() + resetPasswordToken!: string; + + constructor(token: string, user: User) { + this.phoneNumber = user.phoneNumber; + this.countryCode = user.countryCode; + this.resetPasswordToken = token; + } +} diff --git a/src/auth/services/auth.service.ts b/src/auth/services/auth.service.ts index ca5e4dd..e096196 100644 --- a/src/auth/services/auth.service.ts +++ b/src/auth/services/auth.service.ts @@ -3,13 +3,13 @@ import { ConfigService } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; import * as bcrypt from 'bcrypt'; import { Request } from 'express'; +import moment from 'moment'; import { CacheService } from '~/common/modules/cache/services'; import { OtpScope, OtpType } from '~/common/modules/otp/enums'; import { OtpService } from '~/common/modules/otp/services'; import { UserType } from '~/user/enums'; import { DeviceService, UserService, UserTokenService } from '~/user/services'; import { User } from '../../user/entities'; -import { PASSCODE_REGEX } from '../constants'; import { CreateUnverifiedUserRequestDto, DisableBiometricRequestDto, @@ -18,10 +18,11 @@ import { LoginRequestDto, SendForgetPasswordOtpRequestDto, setJuniorPasswordRequestDto, + VerifyForgetPasswordOtpRequestDto, VerifyUserRequestDto, } from '../dtos/request'; +import { Roles } from '../enums'; import { IJwtPayload, ILoginResponse } from '../interfaces'; -import { removePadding, verifySignature } from '../utils'; import { Oauth2Service } from './oauth2.service'; const ONE_THOUSAND = 1000; @@ -147,14 +148,7 @@ export class AuthService { }); } - async verifyForgetPasswordOtp({ - countryCode, - phoneNumber, - otp, - password, - confirmPassword, - }: ForgetPasswordRequestDto) { - this.logger.log(`Verifying forget password OTP for ${countryCode + phoneNumber}`); + async verifyForgetPasswordOtp({ countryCode, phoneNumber, otp }: VerifyForgetPasswordOtpRequestDto) { const user = await this.userService.findUserOrThrow({ countryCode, phoneNumber }); const isOtpValid = await this.otpService.verifyOtp({ @@ -169,6 +163,29 @@ export class AuthService { throw new BadRequestException('OTP.INVALID_OTP'); } + // generate a token for the user to reset password + const token = await this.userTokenService.generateToken( + user.id, + user.roles.includes(Roles.GUARDIAN) ? UserType.GUARDIAN : UserType.JUNIOR, + moment().add(5, 'minutes').toDate(), + ); + + return { token, user }; + } + async resetPassword({ + countryCode, + phoneNumber, + resetPasswordToken, + password, + confirmPassword, + }: ForgetPasswordRequestDto) { + this.logger.log(`Verifying forget password OTP for ${countryCode + phoneNumber}`); + const user = await this.userService.findUserOrThrow({ countryCode, phoneNumber }); + await this.userTokenService.validateToken( + resetPasswordToken, + user.roles.includes(Roles.GUARDIAN) ? UserType.GUARDIAN : UserType.JUNIOR, + ); + if (password !== confirmPassword) { this.logger.error('Password and confirm password do not match'); throw new BadRequestException('AUTH.PASSWORD_MISMATCH'); @@ -177,6 +194,7 @@ export class AuthService { const hashedPassword = bcrypt.hashSync(password, user.salt); await this.userService.setPassword(user.id, hashedPassword, user.salt); + await this.userTokenService.invalidateToken(resetPasswordToken); this.logger.log(`Passcode updated successfully for user with phone number ${user.fullPhoneNumber}`); } diff --git a/src/i18n/ar/app.json b/src/i18n/ar/app.json index 455ddaf..797784d 100644 --- a/src/i18n/ar/app.json +++ b/src/i18n/ar/app.json @@ -11,7 +11,9 @@ "PASSWORD_MISMATCH": "كلمات المرور التي أدخلتها غير متطابقة. يرجى إدخال كلمات المرور مرة أخرى.", "INVALID_PASSCODE": "رمز المرور الذي أدخلته غير صحيح. يرجى المحاولة مرة أخرى.", "PASSCODE_ALREADY_SET": "تم تعيين رمز المرور بالفعل.", - "APPLE_RE-CONSENT_REQUIRED": "إعادة الموافقة على آبل مطلوبة. يرجى إلغاء تطبيقك من إعدادات معرف آبل الخاصة بك والمحاولة مرة أخرى." + "APPLE_RE-CONSENT_REQUIRED": "إعادة الموافقة على آبل مطلوبة. يرجى إلغاء تطبيقك من إعدادات معرف آبل الخاصة بك والمحاولة مرة أخرى.", + "TOKEN_INVALID": "رمز المستخدم غير صالح.", + "TOKEN_EXPIRED": "رمز المستخدم منتهي الصلاحية." }, "USER": { diff --git a/src/i18n/en/app.json b/src/i18n/en/app.json index 105afc9..668b1e8 100644 --- a/src/i18n/en/app.json +++ b/src/i18n/en/app.json @@ -11,7 +11,9 @@ "PASSWORD_MISMATCH": "The passwords you entered do not match. Please re-enter the passwords.", "INVALID_PASSCODE": "The passcode you entered is incorrect. Please try again.", "PASSCODE_ALREADY_SET": "The pass code has already been set.", - "APPLE_RE-CONSENT_REQUIRED": "Apple re-consent is required. Please revoke the app from your Apple ID settings and try again." + "APPLE_RE-CONSENT_REQUIRED": "Apple re-consent is required. Please revoke the app from your Apple ID settings and try again.", + "TOKEN_INVALID": "The user token is invalid.", + "TOKEN_EXPIRED": "The user token has expired." }, "USER": { diff --git a/src/user/enums/user-type.enum.ts b/src/user/enums/user-type.enum.ts index e00c99c..b3a705f 100644 --- a/src/user/enums/user-type.enum.ts +++ b/src/user/enums/user-type.enum.ts @@ -1,4 +1,5 @@ export enum UserType { CHECKER = 'CHECKER', JUNIOR = 'JUNIOR', + GUARDIAN = 'GUARDIAN', } diff --git a/src/user/repositories/user-token-repository.ts b/src/user/repositories/user-token-repository.ts index 3e130db..9eda1bf 100644 --- a/src/user/repositories/user-token-repository.ts +++ b/src/user/repositories/user-token-repository.ts @@ -17,7 +17,7 @@ export class UserTokenRepository { generateToken(userId: string, userType: UserType, expiryDate?: Date) { return this.userTokenRepository.save( this.userTokenRepository.create({ - userId: userType === UserType.CHECKER ? userId : null, + userId, juniorId: userType === UserType.JUNIOR ? userId : null, expiryDate: expiryDate ?? moment().add('15', 'minutes').toDate(), userType, diff --git a/src/user/services/user-token.service.ts b/src/user/services/user-token.service.ts index 3a3ee1b..efcbe00 100644 --- a/src/user/services/user-token.service.ts +++ b/src/user/services/user-token.service.ts @@ -20,12 +20,12 @@ export class UserTokenService { if (!tokenEntity) { this.logger.error(`Token ${token} not found`); - throw new BadRequestException('TOKEN.INVALID'); + throw new BadRequestException('AUTH.TOKEN_INVALID'); } if (tokenEntity.expiryDate < new Date()) { this.logger.error(`Token ${token} expired`); - throw new BadRequestException('TOKEN.EXPIRED'); + throw new BadRequestException('AUTH.TOKEN_EXPIRED'); } this.logger.log(`Token validated successfully`); From 4cc52a1c07b63d6a2d25d238491b3faa88f4e13b Mon Sep 17 00:00:00 2001 From: Abdalhamid Alhamad Date: Sun, 3 Aug 2025 14:50:04 +0300 Subject: [PATCH 23/32] fix: add swagger doc to verify otp api --- src/auth/controllers/auth.controller.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/auth/controllers/auth.controller.ts b/src/auth/controllers/auth.controller.ts index e3bafcf..61a1985 100644 --- a/src/auth/controllers/auth.controller.ts +++ b/src/auth/controllers/auth.controller.ts @@ -3,7 +3,7 @@ import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { Request } from 'express'; import { Public } from '~/common/decorators'; import { AccessTokenGuard } from '~/common/guards'; -import { ApiLangRequestHeader } from '~/core/decorators'; +import { ApiDataResponse, ApiLangRequestHeader } from '~/core/decorators'; import { ResponseFactory } from '~/core/utils'; import { CreateUnverifiedUserRequestDto, @@ -51,6 +51,7 @@ export class AuthController { @Post('forget-password/verify') @HttpCode(HttpStatus.OK) + @ApiDataResponse(VerifyForgetPasswordOtpResponseDto) async verifyForgetPasswordOtp(@Body() forgetPasswordDto: VerifyForgetPasswordOtpRequestDto) { const { token, user } = await this.authService.verifyForgetPasswordOtp(forgetPasswordDto); From 1e2b859b920196947c0ce99f20d8c57f96542882 Mon Sep 17 00:00:00 2001 From: Abdalhamid Alhamad Date: Sun, 3 Aug 2025 16:18:06 +0300 Subject: [PATCH 24/32] feat: finish generating signed url for document upload flow --- .../1733990253208-seeds-default-tasks-logo.ts | 6 +++- .../1753869637732-seed-default-avatar.ts | 11 +++++- ...47-add-upload-status-to-document-entity.ts | 15 ++++++++ src/db/migrations/index.ts | 1 + .../controllers/document.controller.ts | 25 ++++++++++---- ....dto.ts => create-document.request.dto.ts} | 4 +-- src/document/dtos/request/index.ts | 3 +- .../request/upload-document.request.dto.ts | 8 ----- ...generate-upload-signed-url.response.dto.ts | 16 +++++++++ src/document/dtos/response/index.ts | 1 + src/document/entities/document.entity.ts | 5 ++- src/document/enums/index.ts | 1 + src/document/enums/upload-status.enum.ts | 4 +++ .../repositories/document.repository.ts | 4 +++ src/document/services/document.service.ts | 29 +++++++++++----- src/document/services/oci.service.ts | 34 ++----------------- 16 files changed, 105 insertions(+), 62 deletions(-) create mode 100644 src/db/migrations/1754226754947-add-upload-status-to-document-entity.ts rename src/document/dtos/request/{generate-upload-signed-url.request.dto.ts => create-document.request.dto.ts} (88%) delete mode 100644 src/document/dtos/request/upload-document.request.dto.ts create mode 100644 src/document/dtos/response/generate-upload-signed-url.response.dto.ts create mode 100644 src/document/enums/upload-status.enum.ts diff --git a/src/db/migrations/1733990253208-seeds-default-tasks-logo.ts b/src/db/migrations/1733990253208-seeds-default-tasks-logo.ts index ea02a1c..94661aa 100644 --- a/src/db/migrations/1733990253208-seeds-default-tasks-logo.ts +++ b/src/db/migrations/1733990253208-seeds-default-tasks-logo.ts @@ -1,31 +1,35 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; import { v4 as uuid } from 'uuid'; import { Document } from '~/document/entities'; -import { DocumentType } from '~/document/enums'; +import { DocumentType, UploadStatus } from '~/document/enums'; const DEFAULT_TASK_LOGOS = [ { id: uuid(), name: 'bed-furniture', extension: '.jpg', documentType: DocumentType.DEFAULT_TASKS_LOGO, + uploadStatus: UploadStatus.UPLOADED, }, { id: uuid(), name: 'dog', extension: '.jpg', documentType: DocumentType.DEFAULT_TASKS_LOGO, + uploadStatus: UploadStatus.UPLOADED, }, { id: uuid(), name: 'dish-washing', extension: '.jpg', documentType: DocumentType.DEFAULT_TASKS_LOGO, + uploadStatus: UploadStatus.UPLOADED, }, { id: uuid(), name: 'walking-the-dog', extension: '.jpg', documentType: DocumentType.DEFAULT_TASKS_LOGO, + uploadStatus: UploadStatus.UPLOADED, }, ]; export class SeedsDefaultTasksLogo1733990253208 implements MigrationInterface { diff --git a/src/db/migrations/1753869637732-seed-default-avatar.ts b/src/db/migrations/1753869637732-seed-default-avatar.ts index 7b1a5e3..a6c5935 100644 --- a/src/db/migrations/1753869637732-seed-default-avatar.ts +++ b/src/db/migrations/1753869637732-seed-default-avatar.ts @@ -1,61 +1,70 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; import { v4 as uuid } from 'uuid'; import { Document } from '../../document/entities'; -import { DocumentType } from '../../document/enums'; +import { DocumentType, UploadStatus } from '../../document/enums'; const DEFAULT_AVATARS = [ { id: uuid(), name: 'vacation', extension: '.jpg', documentType: DocumentType.DEFAULT_AVATAR, + uploadStatus: UploadStatus.UPLOADED, }, { id: uuid(), name: 'colors', extension: '.jpg', documentType: DocumentType.DEFAULT_AVATAR, + uploadStatus: UploadStatus.UPLOADED, }, { id: uuid(), name: 'astronaut', extension: '.jpg', documentType: DocumentType.DEFAULT_AVATAR, + uploadStatus: UploadStatus.UPLOADED, }, { id: uuid(), name: 'pet', extension: '.jpg', documentType: DocumentType.DEFAULT_AVATAR, + uploadStatus: UploadStatus.UPLOADED, }, { id: uuid(), name: 'disney', extension: '.jpg', documentType: DocumentType.DEFAULT_AVATAR, + uploadStatus: UploadStatus.UPLOADED, }, { id: uuid(), name: 'clothes', extension: '.jpg', documentType: DocumentType.DEFAULT_AVATAR, + uploadStatus: UploadStatus.UPLOADED, }, { id: uuid(), name: 'playstation', extension: '.jpg', documentType: DocumentType.DEFAULT_AVATAR, + uploadStatus: UploadStatus.UPLOADED, }, { id: uuid(), name: 'football', extension: '.jpg', documentType: DocumentType.DEFAULT_AVATAR, + uploadStatus: UploadStatus.UPLOADED, }, { id: uuid(), name: 'cars', extension: '.jpg', documentType: DocumentType.DEFAULT_AVATAR, + uploadStatus: UploadStatus.UPLOADED, }, ]; export class SeedDefaultAvatar1753869637732 implements MigrationInterface { diff --git a/src/db/migrations/1754226754947-add-upload-status-to-document-entity.ts b/src/db/migrations/1754226754947-add-upload-status-to-document-entity.ts new file mode 100644 index 0000000..10a6b67 --- /dev/null +++ b/src/db/migrations/1754226754947-add-upload-status-to-document-entity.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddUploadStatusToDocumentEntity1754226754947 implements MigrationInterface { + name = 'AddUploadStatusToDocumentEntity1754226754947'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "documents" ADD "upload_status" character varying(255) NOT NULL DEFAULT 'NOT_UPLOADED'`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "documents" DROP COLUMN "upload_status"`); + } +} diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index 63f3865..85c5217 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -5,3 +5,4 @@ export * from './1753869637732-seed-default-avatar'; export * from './1753874205042-add-neoleap-related-entities'; export * from './1753948642040-add-account-number-and-iban-to-account-entity'; export * from './1754210729273-add-vpan-to-card'; +export * from './1754226754947-add-upload-status-to-document-entity'; diff --git a/src/document/controllers/document.controller.ts b/src/document/controllers/document.controller.ts index 2fc2a72..4393413 100644 --- a/src/document/controllers/document.controller.ts +++ b/src/document/controllers/document.controller.ts @@ -1,9 +1,12 @@ -import { Body, Controller, Post, UseGuards } from '@nestjs/common'; -import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { Body, Controller, HttpCode, HttpStatus, Param, Post, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiNoContentResponse, ApiTags } from '@nestjs/swagger'; +import { IJwtPayload } from '~/auth/interfaces'; +import { AuthenticatedUser } from '~/common/decorators'; import { AccessTokenGuard } from '~/common/guards'; -import { ApiLangRequestHeader } from '~/core/decorators'; +import { ApiDataResponse, ApiLangRequestHeader } from '~/core/decorators'; import { ResponseFactory } from '~/core/utils'; -import { GenerateUploadSignedUrlRequestDto } from '../dtos/request'; +import { CreateDocumentRequestDto } from '../dtos/request'; +import { GenerateUploadSignedUrlResponseDto } from '../dtos/response'; import { DocumentService } from '../services'; @Controller('document') @ApiTags('Document') @@ -14,8 +17,16 @@ export class DocumentController { constructor(private readonly documentService: DocumentService) {} @Post('signed-url') - async generateSignedUrl(@Body() body: GenerateUploadSignedUrlRequestDto) { - const signedUrl = await this.documentService.generateUploadSignedUrl(body); - return ResponseFactory.data(signedUrl); + @ApiDataResponse(GenerateUploadSignedUrlResponseDto) + async generateSignedUrl(@AuthenticatedUser() { sub }: IJwtPayload, @Body() body: CreateDocumentRequestDto) { + const result = await this.documentService.generateUploadSignedUrl(sub, body); + return ResponseFactory.data(new GenerateUploadSignedUrlResponseDto(result.document, result.uploadUrl)); + } + + @Post(':documentId/confirm-update') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiNoContentResponse({ description: 'Document update confirmed successfully' }) + async confirmDocumentUpdate(@Param('documentId') documentId: string) { + return this.documentService.confirmDocumentUpdate(documentId); } } diff --git a/src/document/dtos/request/generate-upload-signed-url.request.dto.ts b/src/document/dtos/request/create-document.request.dto.ts similarity index 88% rename from src/document/dtos/request/generate-upload-signed-url.request.dto.ts rename to src/document/dtos/request/create-document.request.dto.ts index 38e83b2..2b0bd63 100644 --- a/src/document/dtos/request/generate-upload-signed-url.request.dto.ts +++ b/src/document/dtos/request/create-document.request.dto.ts @@ -3,12 +3,12 @@ import { IsEnum, IsString } from 'class-validator'; import { i18nValidationMessage as i18n } from 'nestjs-i18n'; import { DocumentType } from '~/document/enums'; -export class GenerateUploadSignedUrlRequestDto { +export class CreateDocumentRequestDto { @ApiProperty({ enum: DocumentType }) @IsEnum(DocumentType, { message: i18n('validation.IsEnum', { path: 'general', property: 'document.documentType' }) }) documentType!: DocumentType; - @ApiProperty({ type: String }) + @ApiProperty({ type: String, example: '.jpg' }) @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'document.document.extension' }) }) extension!: string; diff --git a/src/document/dtos/request/index.ts b/src/document/dtos/request/index.ts index 2dd4fad..7d9b39c 100644 --- a/src/document/dtos/request/index.ts +++ b/src/document/dtos/request/index.ts @@ -1,2 +1 @@ -export * from './generate-upload-signed-url.request.dto'; -export * from './upload-document.request.dto'; +export * from './create-document.request.dto'; diff --git a/src/document/dtos/request/upload-document.request.dto.ts b/src/document/dtos/request/upload-document.request.dto.ts deleted file mode 100644 index 89210fe..0000000 --- a/src/document/dtos/request/upload-document.request.dto.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { IsEnum } from 'class-validator'; -import { i18nValidationMessage as i18n } from 'nestjs-i18n'; -import { DocumentType } from '~/document/enums'; - -export class UploadDocumentRequestDto { - @IsEnum(DocumentType, { message: i18n('validation.IsEnum', { path: 'general', property: 'document.documentType' }) }) - documentType!: DocumentType; -} diff --git a/src/document/dtos/response/generate-upload-signed-url.response.dto.ts b/src/document/dtos/response/generate-upload-signed-url.response.dto.ts new file mode 100644 index 0000000..4e2055e --- /dev/null +++ b/src/document/dtos/response/generate-upload-signed-url.response.dto.ts @@ -0,0 +1,16 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Document } from '~/document/entities'; +import { DocumentMetaResponseDto } from './document-meta.response.dto'; + +export class GenerateUploadSignedUrlResponseDto { + @ApiProperty({ type: DocumentMetaResponseDto }) + document!: DocumentMetaResponseDto; + + @ApiProperty() + uploadUrl!: string; + + constructor(document: Document, uploadUrl: string) { + this.document = new DocumentMetaResponseDto(document); + this.uploadUrl = uploadUrl; + } +} diff --git a/src/document/dtos/response/index.ts b/src/document/dtos/response/index.ts index 18a07a3..6422d12 100644 --- a/src/document/dtos/response/index.ts +++ b/src/document/dtos/response/index.ts @@ -1,2 +1,3 @@ export * from './document-meta.response.dto'; +export * from './generate-upload-signed-url.response.dto'; export * from './upload.response.dto'; diff --git a/src/document/entities/document.entity.ts b/src/document/entities/document.entity.ts index 7c327f1..959f63d 100644 --- a/src/document/entities/document.entity.ts +++ b/src/document/entities/document.entity.ts @@ -15,13 +15,16 @@ import { SavingGoal } from '~/saving-goals/entities'; import { Task } from '~/task/entities'; import { TaskSubmission } from '~/task/entities/task-submissions.entity'; import { User } from '~/user/entities'; -import { DocumentType } from '../enums'; +import { DocumentType, UploadStatus } from '../enums'; @Entity('documents') export class Document { @PrimaryGeneratedColumn('uuid') id!: string; + @Column({ type: 'varchar', length: 255, default: UploadStatus.NOT_UPLOADED, name: 'upload_status' }) + uploadStatus!: UploadStatus; + @Column({ type: 'varchar', length: 255 }) name!: string; diff --git a/src/document/enums/index.ts b/src/document/enums/index.ts index 173c116..e725bf4 100644 --- a/src/document/enums/index.ts +++ b/src/document/enums/index.ts @@ -1 +1,2 @@ export * from './document-type.enum'; +export * from './upload-status.enum'; diff --git a/src/document/enums/upload-status.enum.ts b/src/document/enums/upload-status.enum.ts new file mode 100644 index 0000000..c5f049e --- /dev/null +++ b/src/document/enums/upload-status.enum.ts @@ -0,0 +1,4 @@ +export enum UploadStatus { + NOT_UPLOADED = 'NOT_UPLOADED', + UPLOADED = 'UPLOADED', +} diff --git a/src/document/repositories/document.repository.ts b/src/document/repositories/document.repository.ts index 42e383a..bf9f124 100644 --- a/src/document/repositories/document.repository.ts +++ b/src/document/repositories/document.repository.ts @@ -26,4 +26,8 @@ export class DocumentRepository { findDocumentById(id: string) { return this.documentRepository.findOne({ where: { id } }); } + + updateDocument(id: string, updateData: Partial) { + return this.documentRepository.update(id, updateData); + } } diff --git a/src/document/services/document.service.ts b/src/document/services/document.service.ts index bdc72cd..027dd3c 100644 --- a/src/document/services/document.service.ts +++ b/src/document/services/document.service.ts @@ -1,7 +1,8 @@ import { Injectable, Logger } from '@nestjs/common'; import { FindOptionsWhere } from 'typeorm'; -import { GenerateUploadSignedUrlRequestDto, UploadDocumentRequestDto } from '../dtos/request'; +import { CreateDocumentRequestDto } from '../dtos/request'; import { Document } from '../entities'; +import { UploadStatus } from '../enums'; import { DocumentRepository } from '../repositories'; import { OciService } from './oci.service'; @@ -9,11 +10,6 @@ import { OciService } from './oci.service'; export class DocumentService { private readonly logger = new Logger(DocumentService.name); constructor(private readonly ociService: OciService, private readonly documentRepository: DocumentRepository) {} - async createDocument(file: Express.Multer.File, uploadedDocumentRequest: UploadDocumentRequestDto, userId: string) { - this.logger.log(`creating document for with type ${uploadedDocumentRequest.documentType}`); - const uploadedFile = await this.ociService.uploadFile(file, uploadedDocumentRequest); - return this.documentRepository.createDocument(userId, uploadedFile); - } findDocuments(where: FindOptionsWhere) { this.logger.log(`finding documents with where clause ${JSON.stringify(where)}`); @@ -25,10 +21,27 @@ export class DocumentService { return this.documentRepository.findDocumentById(id); } - generateUploadSignedUrl(body: GenerateUploadSignedUrlRequestDto) { + async confirmDocumentUpdate(documentId: string) { + const document = await this.documentRepository.findDocumentById(documentId); + if (!document) { + this.logger.error(`Document with id ${documentId} not found`); + throw new Error(`Document with id ${documentId} not found.`); + } + this.logger.log(`Confirming document update for document id ${documentId}`); + + return this.documentRepository.updateDocument(documentId, { + uploadStatus: UploadStatus.UPLOADED, + }); + } + + async generateUploadSignedUrl(userId: string, body: CreateDocumentRequestDto) { this.logger.log( `generating signed URL for document type ${body.documentType} with original file name ${body.originalFileName}`, ); - return this.ociService.generateUploadPreSignedUrl(body); + const uploadResult = await this.ociService.generateUploadPreSignedUrl(body); + + const document = await this.documentRepository.createDocument(userId, uploadResult); + + return { document, uploadUrl: uploadResult.url }; } } diff --git a/src/document/services/oci.service.ts b/src/document/services/oci.service.ts index 9c6002d..ad2f319 100644 --- a/src/document/services/oci.service.ts +++ b/src/document/services/oci.service.ts @@ -5,10 +5,9 @@ import moment from 'moment'; import { Region, SimpleAuthenticationDetailsProvider } from 'oci-common'; import { ObjectStorageClient } from 'oci-objectstorage'; import { CreatePreauthenticatedRequestDetails } from 'oci-objectstorage/lib/model'; -import path from 'path'; import { CacheService } from '~/common/modules/cache/services'; import { BUCKETS } from '../constants'; -import { GenerateUploadSignedUrlRequestDto, UploadDocumentRequestDto } from '../dtos/request'; +import { CreateDocumentRequestDto } from '../dtos/request'; import { UploadResponseDto } from '../dtos/response'; import { Document } from '../entities'; import { generateNewFileName } from '../utils'; @@ -37,35 +36,6 @@ export class OciService { }); } - async uploadFile(file: Express.Multer.File, { documentType }: UploadDocumentRequestDto): Promise { - this.logger.log(`Uploading file with type ${documentType}`); - const bucketName = BUCKETS[documentType]; - const objectName = generateNewFileName(file.originalname); - - if (!bucketName) { - this.logger.error('Could not find bucket name for document type', documentType); - throw new BadRequestException('DOCUMENT.TYPE_NOT_SUPPORTED'); - } - - this.logger.debug(`Uploading file to bucket ${bucketName} with object name ${objectName}`); - await this.ociClient.putObject({ - namespaceName: this.namespace, - bucketName, - putObjectBody: file.buffer, - contentLength: file.buffer.length, - objectName, - }); - - this.logger.log(`File uploaded successfully to bucket ${bucketName} with object name ${objectName}`); - - return plainToInstance(UploadResponseDto, { - name: objectName, - extension: path.extname(file.originalname), - url: `https://objectstorage.${this.region}.oraclecloud.com/n/${this.namespace}/b/${bucketName}/o/${objectName}`, - documentType, - }); - } - async generatePreSignedUrl(document?: Document): Promise { this.logger.log(`Generating pre-signed url for document ${document?.id}`); if (!document) { @@ -111,7 +81,7 @@ export class OciService { documentType, originalFileName, extension, - }: GenerateUploadSignedUrlRequestDto): Promise { + }: CreateDocumentRequestDto): Promise { const bucketName = BUCKETS[documentType]; if (!bucketName) { From 275984954eea7a6ac1863ab55edc5206fbafbc77 Mon Sep 17 00:00:00 2001 From: Abdalhamid Alhamad Date: Tue, 5 Aug 2025 17:53:38 +0300 Subject: [PATCH 25/32] feat: working on edit profile ticket --- .../allowance-change-request.repository.ts | 11 +- .../repositories/allowances.repository.ts | 4 +- .../allowance-change-requests.service.ts | 2 +- src/allowance/services/allowances.service.ts | 2 +- .../dtos/request/verify-user.request.dto.ts | 2 +- src/auth/dtos/response/user.response.dto.ts | 41 +++-- .../request/create-customer.request.dto.ts | 4 +- .../dtos/response/customer.response.dto.ts | 2 - .../internal.customer-details.response.dto.ts | 1 - src/customer/entities/customer.entity.ts | 7 - .../repositories/customer.repository.ts | 3 +- src/customer/services/customer.service.ts | 15 +- .../1754399872619-add-display-name-to-user.ts | 37 +++++ ...ile-picture-to-user-instead-of-customer.ts | 31 ++++ src/db/migrations/index.ts | 2 + src/document/entities/document.entity.ts | 4 +- src/document/services/oci.service.ts | 2 +- .../dtos/response/junior.response.dto.ts | 4 +- src/junior/repositories/junior.repository.ts | 2 +- src/junior/services/junior.service.ts | 2 +- .../repositories/money-requests.repository.ts | 3 +- .../services/money-requests.service.ts | 2 +- src/task/repositories/task.repository.ts | 6 +- src/task/services/task.service.ts | 6 +- src/user/controllers/admin.user.controller.ts | 50 ------ src/user/controllers/index.ts | 1 - src/user/controllers/user.controller.ts | 49 ++++-- .../request/create-checker.request.dto.ts | 25 --- src/user/dtos/request/index.ts | 4 +- .../set-internal-password.request.dto.ts | 15 -- .../dtos/request/update-email.request.dto.ts | 9 ++ .../dtos/request/update-user.request.dto.ts | 21 +++ .../dtos/request/user-filters.request.dto.ts | 18 --- src/user/entities/user.entity.ts | 16 +- src/user/repositories/user.repository.ts | 23 +-- src/user/services/user.service.ts | 143 +++++++++++------- src/user/user.module.ts | 4 +- 37 files changed, 298 insertions(+), 275 deletions(-) create mode 100644 src/db/migrations/1754399872619-add-display-name-to-user.ts create mode 100644 src/db/migrations/1754401348483-add-profile-picture-to-user-instead-of-customer.ts delete mode 100644 src/user/controllers/admin.user.controller.ts delete mode 100644 src/user/dtos/request/create-checker.request.dto.ts delete mode 100644 src/user/dtos/request/set-internal-password.request.dto.ts create mode 100644 src/user/dtos/request/update-email.request.dto.ts create mode 100644 src/user/dtos/request/update-user.request.dto.ts delete mode 100644 src/user/dtos/request/user-filters.request.dto.ts diff --git a/src/allowance/repositories/allowance-change-request.repository.ts b/src/allowance/repositories/allowance-change-request.repository.ts index de2941f..f0eacf4 100644 --- a/src/allowance/repositories/allowance-change-request.repository.ts +++ b/src/allowance/repositories/allowance-change-request.repository.ts @@ -25,7 +25,13 @@ export class AllowanceChangeRequestsRepository { findAllowanceChangeRequestBy(where: FindOptionsWhere, withRelations = false) { const relations = withRelations - ? ['allowance', 'allowance.junior', 'allowance.junior.customer', 'allowance.junior.customer.profilePicture'] + ? [ + 'allowance', + 'allowance.junior', + 'allowance.junior.customer', + 'allowance.junior.customer.user', + 'allowance.junior.customer.user.profilePicture', + ] : []; return this.allowanceChangeRequestsRepository.findOne({ where, relations }); } @@ -43,7 +49,8 @@ export class AllowanceChangeRequestsRepository { 'allowance', 'allowance.junior', 'allowance.junior.customer', - 'allowance.junior.customer.profilePicture', + 'allowance.junior.customer.user', + 'allowance.junior.customer.user.profilePicture', ], }); } diff --git a/src/allowance/repositories/allowances.repository.ts b/src/allowance/repositories/allowances.repository.ts index 2914ff1..a03b441 100644 --- a/src/allowance/repositories/allowances.repository.ts +++ b/src/allowance/repositories/allowances.repository.ts @@ -28,14 +28,14 @@ export class AllowancesRepository { findAllowanceById(allowanceId: string, guardianId?: string) { return this.allowancesRepository.findOne({ where: { id: allowanceId, guardianId }, - relations: ['junior', 'junior.customer', 'junior.customer.profilePicture'], + relations: ['junior', 'junior.customer', 'junior.customer.user', 'junior.customer.user.profilePicture'], }); } findAllowances(guardianId: string, query: PageOptionsRequestDto) { return this.allowancesRepository.findAndCount({ where: { guardianId }, - relations: ['junior', 'junior.customer', 'junior.customer.profilePicture'], + relations: ['junior', 'junior.customer', 'junior.customer.user', 'junior.customer.user.profilePicture'], take: query.size, skip: query.size * (query.page - ONE), }); diff --git a/src/allowance/services/allowance-change-requests.service.ts b/src/allowance/services/allowance-change-requests.service.ts index 0e343ba..9e000fb 100644 --- a/src/allowance/services/allowance-change-requests.service.ts +++ b/src/allowance/services/allowance-change-requests.service.ts @@ -122,7 +122,7 @@ export class AllowanceChangeRequestsService { this.logger.log(`Preparing allowance change requests images`); return Promise.all( requests.map(async (request) => { - const profilePicture = request.allowance.junior.customer.profilePicture; + const profilePicture = request.allowance.junior.customer.user.profilePicture; if (profilePicture) { profilePicture.url = await this.ociService.generatePreSignedUrl(profilePicture); } diff --git a/src/allowance/services/allowances.service.ts b/src/allowance/services/allowances.service.ts index 9d3547a..1e35b33 100644 --- a/src/allowance/services/allowances.service.ts +++ b/src/allowance/services/allowances.service.ts @@ -100,7 +100,7 @@ export class AllowancesService { this.logger.log(`Preparing document for allowances`); await Promise.all( allowance.map(async (allowance) => { - const profilePicture = allowance.junior.customer.profilePicture; + const profilePicture = allowance.junior.customer.user.profilePicture; if (profilePicture) { profilePicture.url = await this.ociService.generatePreSignedUrl(profilePicture); } diff --git a/src/auth/dtos/request/verify-user.request.dto.ts b/src/auth/dtos/request/verify-user.request.dto.ts index f0d7289..94944a2 100644 --- a/src/auth/dtos/request/verify-user.request.dto.ts +++ b/src/auth/dtos/request/verify-user.request.dto.ts @@ -39,7 +39,7 @@ export class VerifyUserRequestDto { @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.lastName' }) }) lastName!: string; - @ApiProperty({ example: '2021-01-01' }) + @ApiProperty({ example: '2001-01-01' }) @IsDateString({}, { message: i18n('validation.IsDateString', { path: 'general', property: 'customer.dateOfBirth' }) }) @IsAbove18({ message: i18n('validation.IsAbove18', { path: 'general', property: 'customer.dateOfBirth' }) }) dateOfBirth!: Date; diff --git a/src/auth/dtos/response/user.response.dto.ts b/src/auth/dtos/response/user.response.dto.ts index 2d80e6f..f2a9853 100644 --- a/src/auth/dtos/response/user.response.dto.ts +++ b/src/auth/dtos/response/user.response.dto.ts @@ -1,5 +1,5 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { Roles } from '~/auth/enums'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { DocumentMetaResponseDto } from '~/document/dtos/response'; import { User } from '~/user/entities'; export class UserResponseDto { @@ -7,42 +7,39 @@ export class UserResponseDto { id!: string; @ApiProperty() - email!: string; + countryCode!: string; @ApiProperty() phoneNumber!: string; @ApiProperty() - countryCode!: string; + email!: string; @ApiProperty() - isPasswordSet!: boolean; + firstName!: string; @ApiProperty() - isProfileCompleted!: boolean; + lastName!: string; + + @ApiPropertyOptional({ type: DocumentMetaResponseDto, nullable: true }) + profilePicture!: DocumentMetaResponseDto | null; @ApiProperty() - isSmsEnabled!: boolean; + isPhoneVerified!: boolean; @ApiProperty() - isEmailEnabled!: boolean; - - @ApiProperty() - isPushEnabled!: boolean; - - @ApiProperty() - roles!: Roles[]; + isEmailVerified!: boolean; constructor(user: User) { this.id = user.id; - this.email = user.email; - this.phoneNumber = user.phoneNumber; this.countryCode = user.countryCode; - this.isPasswordSet = user.isPasswordSet; - this.isProfileCompleted = user.isProfileCompleted; - this.isSmsEnabled = user.isSmsEnabled; - this.isEmailEnabled = user.isEmailEnabled; - this.isPushEnabled = user.isPushEnabled; - this.roles = user.roles; + this.phoneNumber = user.phoneNumber; + + this.email = user.email; + this.firstName = user.firstName; + this.lastName = user.lastName; + this.profilePicture = user.profilePicture ? new DocumentMetaResponseDto(user.profilePicture) : null; + this.isEmailVerified = user.isEmailVerified; + this.isPhoneVerified = user.isPhoneVerified; } } diff --git a/src/customer/dtos/request/create-customer.request.dto.ts b/src/customer/dtos/request/create-customer.request.dto.ts index 83cc614..f6fc4b3 100644 --- a/src/customer/dtos/request/create-customer.request.dto.ts +++ b/src/customer/dtos/request/create-customer.request.dto.ts @@ -20,13 +20,13 @@ export class CreateCustomerRequestDto { @IsOptional() gender?: Gender; - @ApiProperty({ example: 'JO' }) + @ApiProperty({ enum: CountryIso, example: CountryIso.SAUDI_ARABIA }) @IsEnum(CountryIso, { message: i18n('validation.IsEnum', { path: 'general', property: 'customer.countryOfResidence' }), }) countryOfResidence!: CountryIso; - @ApiProperty({ example: '2021-01-01' }) + @ApiProperty({ example: '2001-01-01' }) @IsDateString({}, { message: i18n('validation.IsDateString', { path: 'general', property: 'customer.dateOfBirth' }) }) @IsAbove18({ message: i18n('validation.IsAbove18', { path: 'general', property: 'customer.dateOfBirth' }) }) dateOfBirth!: Date; diff --git a/src/customer/dtos/response/customer.response.dto.ts b/src/customer/dtos/response/customer.response.dto.ts index f0b46f6..82bfe91 100644 --- a/src/customer/dtos/response/customer.response.dto.ts +++ b/src/customer/dtos/response/customer.response.dto.ts @@ -104,7 +104,5 @@ export class CustomerResponseDto { this.neighborhood = customer.neighborhood; this.street = customer.street; this.building = customer.building; - - this.profilePicture = customer.profilePicture ? new DocumentMetaResponseDto(customer.profilePicture) : null; } } diff --git a/src/customer/dtos/response/internal.customer-details.response.dto.ts b/src/customer/dtos/response/internal.customer-details.response.dto.ts index ee8c985..62c56ed 100644 --- a/src/customer/dtos/response/internal.customer-details.response.dto.ts +++ b/src/customer/dtos/response/internal.customer-details.response.dto.ts @@ -84,6 +84,5 @@ export class InternalCustomerDetailsResponseDto { this.isGuardian = customer.isGuardian; this.civilIdFront = new DocumentMetaResponseDto(customer.civilIdFront); this.civilIdBack = new DocumentMetaResponseDto(customer.civilIdBack); - this.profilePicture = customer.profilePicture ? new DocumentMetaResponseDto(customer.profilePicture) : null; } } diff --git a/src/customer/entities/customer.entity.ts b/src/customer/entities/customer.entity.ts index aa029ef..5bcc221 100644 --- a/src/customer/entities/customer.entity.ts +++ b/src/customer/entities/customer.entity.ts @@ -96,13 +96,6 @@ export class Customer extends BaseEntity { @Column('varchar', { name: 'building', length: 255, nullable: true }) building!: string; - @Column('varchar', { name: 'profile_picture_id', nullable: true }) - profilePictureId!: string; - - @OneToOne(() => Document, (document) => document.customerPicture, { cascade: true, nullable: true }) - @JoinColumn({ name: 'profile_picture_id' }) - profilePicture!: Document; - @OneToOne(() => User, (user) => user.customer, { onDelete: 'CASCADE' }) @JoinColumn({ name: 'user_id' }) user!: User; diff --git a/src/customer/repositories/customer.repository.ts b/src/customer/repositories/customer.repository.ts index 85565a6..b506978 100644 --- a/src/customer/repositories/customer.repository.ts +++ b/src/customer/repositories/customer.repository.ts @@ -15,7 +15,7 @@ export class CustomerRepository { findOne(where: FindOptionsWhere) { return this.customerRepository.findOne({ where, - relations: ['profilePicture', 'user', 'civilIdFront', 'civilIdBack', 'cards'], + relations: ['user', 'civilIdFront', 'civilIdBack', 'cards'], }); } @@ -36,7 +36,6 @@ export class CustomerRepository { findCustomers(filters: CustomerFiltersRequestDto) { const query = this.customerRepository.createQueryBuilder('customer'); - query.leftJoinAndSelect('customer.profilePicture', 'profilePicture'); query.leftJoinAndSelect('customer.user', 'user'); if (filters.name) { diff --git a/src/customer/services/customer.service.ts b/src/customer/services/customer.service.ts index 5690858..4b22d18 100644 --- a/src/customer/services/customer.service.ts +++ b/src/customer/services/customer.service.ts @@ -30,6 +30,9 @@ export class CustomerService { this.logger.log(`Updating customer ${userId}`); await this.validateProfilePictureForCustomer(userId, data.profilePictureId); + if (data.civilIdBackId || data.civilIdFrontId) { + await this.validateCivilIdForCustomer(userId, data.civilIdFrontId!, data.civilIdBackId!); + } await this.customerRepository.updateCustomer(userId, data); this.logger.log(`Customer ${userId} updated successfully`); return this.findCustomerById(userId); @@ -52,9 +55,6 @@ export class CustomerService { throw new BadRequestException('CUSTOMER.NOT_FOUND'); } - if (customer.profilePicture) { - customer.profilePicture.url = await this.ociService.generatePreSignedUrl(customer.profilePicture); - } this.logger.log(`Customer ${id} found successfully`); return customer; } @@ -101,8 +101,6 @@ export class CustomerService { throw new BadRequestException('CUSTOMER.ALREADY_EXISTS'); } - // await this.validateCivilIdForCustomer(userId, body.civilIdFrontId, body.civilIdBackId); - const customer = await this.customerRepository.createCustomer(userId, body, true); this.logger.log(`customer created for user ${userId}`); @@ -215,14 +213,7 @@ export class CustomerService { promises.push(this.ociService.generatePreSignedUrl(customer.civilIdFront)); promises.push(this.ociService.generatePreSignedUrl(customer.civilIdBack)); - if (customer.profilePicture) { - promises.push(this.ociService.generatePreSignedUrl(customer.profilePicture)); - } - const [civilIdFrontUrl, civilIdBackUrl, profilePictureUrl] = await Promise.all(promises); - if (customer.profilePicture) { - customer.profilePicture.url = profilePictureUrl; - } customer.civilIdFront.url = civilIdFrontUrl; customer.civilIdBack.url = civilIdBackUrl; diff --git a/src/db/migrations/1754399872619-add-display-name-to-user.ts b/src/db/migrations/1754399872619-add-display-name-to-user.ts new file mode 100644 index 0000000..8612fbf --- /dev/null +++ b/src/db/migrations/1754399872619-add-display-name-to-user.ts @@ -0,0 +1,37 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddDisplayNameToUser1754399872619 implements MigrationInterface { + name = 'AddDisplayNameToUser1754399872619'; + + public async up(queryRunner: QueryRunner): Promise { + // Step 1: Add columns as nullable + await queryRunner.query(`ALTER TABLE "users" ADD "first_name" character varying(255)`); + await queryRunner.query(`ALTER TABLE "users" ADD "last_name" character varying(255)`); + + // Step 2: Populate the new columns with fallback to test values + await queryRunner.query(` + UPDATE "users" + SET "first_name" = COALESCE(c."first_name", 'TEST_FIRST_NAME'), + "last_name" = COALESCE(c."last_name", 'TEST_LAST_NAME') + FROM "customers" c + WHERE c.user_id = "users"."id" + `); + + // Step 2b: Handle users without a matching customer row + await queryRunner.query(` + UPDATE "users" + SET "first_name" = COALESCE("first_name", 'TEST_FIRST_NAME'), + "last_name" = COALESCE("last_name", 'TEST_LAST_NAME') + WHERE "first_name" IS NULL OR "last_name" IS NULL + `); + + // Step 3: Make the columns NOT NULL + await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "first_name" SET NOT NULL`); + await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "last_name" SET NOT NULL`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "last_name"`); + await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "first_name"`); + } +} diff --git a/src/db/migrations/1754401348483-add-profile-picture-to-user-instead-of-customer.ts b/src/db/migrations/1754401348483-add-profile-picture-to-user-instead-of-customer.ts new file mode 100644 index 0000000..adc44bf --- /dev/null +++ b/src/db/migrations/1754401348483-add-profile-picture-to-user-instead-of-customer.ts @@ -0,0 +1,31 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddProfilePictureToUserInsteadOfCustomer1754401348483 implements MigrationInterface { + name = 'AddProfilePictureToUserInsteadOfCustomer1754401348483'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "customers" DROP CONSTRAINT "FK_e7574892da11dd01de5cfc46499"`); + await queryRunner.query(`ALTER TABLE "customers" DROP CONSTRAINT "REL_e7574892da11dd01de5cfc4649"`); + await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "profile_picture_id"`); + await queryRunner.query(`ALTER TABLE "users" ADD "profile_picture_id" uuid`); + await queryRunner.query( + `ALTER TABLE "users" ADD CONSTRAINT "UQ_02ec15de199e79a0c46869895f4" UNIQUE ("profile_picture_id")`, + ); + await queryRunner.query( + `ALTER TABLE "users" ADD CONSTRAINT "FK_02ec15de199e79a0c46869895f4" FOREIGN KEY ("profile_picture_id") REFERENCES "documents"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "users" DROP CONSTRAINT "FK_02ec15de199e79a0c46869895f4"`); + await queryRunner.query(`ALTER TABLE "users" DROP CONSTRAINT "UQ_02ec15de199e79a0c46869895f4"`); + await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "profile_picture_id"`); + await queryRunner.query(`ALTER TABLE "customers" ADD "profile_picture_id" uuid`); + await queryRunner.query( + `ALTER TABLE "customers" ADD CONSTRAINT "REL_e7574892da11dd01de5cfc4649" UNIQUE ("profile_picture_id")`, + ); + await queryRunner.query( + `ALTER TABLE "customers" ADD CONSTRAINT "FK_e7574892da11dd01de5cfc46499" FOREIGN KEY ("profile_picture_id") REFERENCES "documents"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + } +} diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index 85c5217..9d9ed62 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -6,3 +6,5 @@ export * from './1753874205042-add-neoleap-related-entities'; export * from './1753948642040-add-account-number-and-iban-to-account-entity'; export * from './1754210729273-add-vpan-to-card'; export * from './1754226754947-add-upload-status-to-document-entity'; +export * from './1754399872619-add-display-name-to-user'; +export * from './1754401348483-add-profile-picture-to-user-instead-of-customer'; diff --git a/src/document/entities/document.entity.ts b/src/document/entities/document.entity.ts index 959f63d..b103790 100644 --- a/src/document/entities/document.entity.ts +++ b/src/document/entities/document.entity.ts @@ -37,8 +37,8 @@ export class Document { @Column({ type: 'uuid', nullable: true, name: 'created_by_id' }) createdById!: string; - @OneToOne(() => Customer, (customer) => customer.profilePicture, { onDelete: 'SET NULL' }) - customerPicture?: Customer; + @OneToOne(() => User, (user) => user.profilePicture, { onDelete: 'SET NULL' }) + userPicture?: Customer; @OneToOne(() => Customer, (customer) => customer.civilIdFront, { onDelete: 'SET NULL' }) customerCivilIdFront?: User; diff --git a/src/document/services/oci.service.ts b/src/document/services/oci.service.ts index ad2f319..4749f3b 100644 --- a/src/document/services/oci.service.ts +++ b/src/document/services/oci.service.ts @@ -51,7 +51,7 @@ export class OciService { } const bucketName = BUCKETS[document.documentType]; - const objectName = document.name; + const objectName = document.name + document.extension; const expiration = moment().add(TWO, 'hours').toDate(); try { diff --git a/src/junior/dtos/response/junior.response.dto.ts b/src/junior/dtos/response/junior.response.dto.ts index 5c293d6..b520190 100644 --- a/src/junior/dtos/response/junior.response.dto.ts +++ b/src/junior/dtos/response/junior.response.dto.ts @@ -20,8 +20,8 @@ export class JuniorResponseDto { this.id = junior.id; this.fullName = `${junior.customer.firstName} ${junior.customer.lastName}`; this.relationship = junior.relationship; - this.profilePicture = junior.customer.profilePicture - ? new DocumentMetaResponseDto(junior.customer.profilePicture) + this.profilePicture = junior.customer.user.profilePicture + ? new DocumentMetaResponseDto(junior.customer.user.profilePicture) : null; } } diff --git a/src/junior/repositories/junior.repository.ts b/src/junior/repositories/junior.repository.ts index 316b3d4..cd1e3df 100644 --- a/src/junior/repositories/junior.repository.ts +++ b/src/junior/repositories/junior.repository.ts @@ -13,7 +13,7 @@ export class JuniorRepository { findJuniorsByGuardianId(guardianId: string, pageOptions: PageOptionsRequestDto) { return this.juniorRepository.findAndCount({ where: { guardianId }, - relations: ['customer', 'customer.user', 'customer.profilePicture'], + relations: ['customer', 'customer.user', 'customer.user.profilePicture'], skip: (pageOptions.page - FIRST_PAGE) * pageOptions.size, take: pageOptions.size, }); diff --git a/src/junior/services/junior.service.ts b/src/junior/services/junior.service.ts index 5d2abbd..c8f3e95 100644 --- a/src/junior/services/junior.service.ts +++ b/src/junior/services/junior.service.ts @@ -132,7 +132,7 @@ export class JuniorService { this.logger.log(`Preparing junior images`); await Promise.all( juniors.map(async (junior) => { - const profilePicture = junior.customer.profilePicture; + const profilePicture = junior.customer.user.profilePicture; if (profilePicture) { profilePicture.url = await this.ociService.generatePreSignedUrl(profilePicture); diff --git a/src/money-request/repositories/money-requests.repository.ts b/src/money-request/repositories/money-requests.repository.ts index db93dea..f2a8761 100644 --- a/src/money-request/repositories/money-requests.repository.ts +++ b/src/money-request/repositories/money-requests.repository.ts @@ -34,7 +34,8 @@ export class MoneyRequestsRepository { const query = this.moneyRequestRepository.createQueryBuilder('moneyRequest'); query.leftJoinAndSelect('moneyRequest.requester', 'requester'); query.leftJoinAndSelect('requester.customer', 'customer'); - query.leftJoinAndSelect('customer.profilePicture', 'profilePicture'); + query.leftJoinAndSelect('customer.user', 'user'); + query.leftJoinAndSelect('user.profilePicture', 'profilePicture'); query.orderBy('moneyRequest.createdAt', 'DESC'); query.where('moneyRequest.reviewerId = :reviewerId', { reviewerId }); query.andWhere('moneyRequest.status = :status', { status: filters.status }); diff --git a/src/money-request/services/money-requests.service.ts b/src/money-request/services/money-requests.service.ts index 7e9d7d9..760b594 100644 --- a/src/money-request/services/money-requests.service.ts +++ b/src/money-request/services/money-requests.service.ts @@ -106,7 +106,7 @@ export class MoneyRequestsService { this.logger.log(`Preparing document for money requests`); await Promise.all( moneyRequests.map(async (moneyRequest) => { - const profilePicture = moneyRequest.requester.customer.profilePicture; + const profilePicture = moneyRequest.requester.customer.user.profilePicture; if (profilePicture) { profilePicture.url = await this.ociService.generatePreSignedUrl(profilePicture); diff --git a/src/task/repositories/task.repository.ts b/src/task/repositories/task.repository.ts index e53dc2b..3add476 100644 --- a/src/task/repositories/task.repository.ts +++ b/src/task/repositories/task.repository.ts @@ -36,7 +36,8 @@ export class TaskRepository { 'image', 'assignedTo', 'assignedTo.customer', - 'assignedTo.customer.profilePicture', + 'assignedTo.customer.user', + 'assignedTo.customer.user.profilePicture', 'submission', 'submission.proofOfCompletion', ], @@ -50,7 +51,8 @@ export class TaskRepository { .leftJoinAndSelect('task.image', 'image') .leftJoinAndSelect('task.assignedTo', 'assignedTo') .leftJoinAndSelect('assignedTo.customer', 'customer') - .leftJoinAndSelect('customer.profilePicture', 'profilePicture') + .leftJoinAndSelect('customer.user', 'user') + .leftJoinAndSelect('user.profilePicture', 'profilePicture') .leftJoinAndSelect('task.submission', 'submission') .leftJoinAndSelect('submission.proofOfCompletion', 'proofOfCompletion'); diff --git a/src/task/services/task.service.ts b/src/task/services/task.service.ts index 264c47d..974873c 100644 --- a/src/task/services/task.service.ts +++ b/src/task/services/task.service.ts @@ -132,7 +132,7 @@ export class TaskService { const [imageUrl, submissionUrl, profilePictureUrl] = await Promise.all([ this.ociService.generatePreSignedUrl(task.image), this.ociService.generatePreSignedUrl(task.submission?.proofOfCompletion), - this.ociService.generatePreSignedUrl(task.assignedTo.customer.profilePicture), + this.ociService.generatePreSignedUrl(task.assignedTo.customer.user.profilePicture), ]); task.image.url = imageUrl; @@ -141,8 +141,8 @@ export class TaskService { task.submission.proofOfCompletion.url = submissionUrl; } - if (task.assignedTo.customer.profilePicture) { - task.assignedTo.customer.profilePicture.url = profilePictureUrl; + if (task.assignedTo.customer.user.profilePicture) { + task.assignedTo.customer.user.profilePicture.url = profilePictureUrl; } }), ); diff --git a/src/user/controllers/admin.user.controller.ts b/src/user/controllers/admin.user.controller.ts deleted file mode 100644 index 660a3eb..0000000 --- a/src/user/controllers/admin.user.controller.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common'; -import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; -import { UserResponseDto } from '~/auth/dtos/response'; -import { Roles } from '~/auth/enums'; -import { AllowedRoles } from '~/common/decorators'; -import { RolesGuard } from '~/common/guards'; -import { ApiDataPageResponse, ApiDataResponse } from '~/core/decorators'; -import { CustomParseUUIDPipe } from '~/core/pipes'; -import { ResponseFactory } from '~/core/utils'; -import { CreateCheckerRequestDto, UserFiltersRequestDto } from '../dtos/request'; -import { UserService } from '../services'; - -@Controller('admin/users') -@ApiTags('Users') -@UseGuards(RolesGuard) -@AllowedRoles(Roles.SUPER_ADMIN) -@ApiBearerAuth() -export class AdminUserController { - constructor(private readonly userService: UserService) {} - @Post() - @ApiDataResponse(UserResponseDto) - async createCheckers(@Body() data: CreateCheckerRequestDto) { - const user = await this.userService.createChecker(data); - - return ResponseFactory.data(new UserResponseDto(user)); - } - - @Get() - @ApiDataPageResponse(UserResponseDto) - async findUsers(@Query() filters: UserFiltersRequestDto) { - const [users, count] = await this.userService.findUsers(filters); - - return ResponseFactory.dataPage( - users.map((user) => new UserResponseDto(user)), - { - page: filters.page, - size: filters.size, - itemCount: count, - }, - ); - } - - @Get(':userId') - @ApiDataResponse(UserResponseDto) - async findUserById(@Param('userId', CustomParseUUIDPipe) userId: string) { - const user = await this.userService.findUserOrThrow({ id: userId }); - - return ResponseFactory.data(new UserResponseDto(user)); - } -} diff --git a/src/user/controllers/index.ts b/src/user/controllers/index.ts index 4f30a96..edd3705 100644 --- a/src/user/controllers/index.ts +++ b/src/user/controllers/index.ts @@ -1,2 +1 @@ -export * from './admin.user.controller'; export * from './user.controller'; diff --git a/src/user/controllers/user.controller.ts b/src/user/controllers/user.controller.ts index 004776d..0b7005c 100644 --- a/src/user/controllers/user.controller.ts +++ b/src/user/controllers/user.controller.ts @@ -1,20 +1,52 @@ -import { Body, Controller, Headers, HttpCode, HttpStatus, Patch, Post, UseGuards } from '@nestjs/common'; +import { Body, Controller, Get, Headers, HttpCode, HttpStatus, Patch, UseGuards } from '@nestjs/common'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { VerifyOtpRequestDto } from '~/auth/dtos/request'; +import { UserResponseDto } from '~/auth/dtos/response'; import { IJwtPayload } from '~/auth/interfaces'; import { DEVICE_ID_HEADER } from '~/common/constants'; -import { AuthenticatedUser, Public } from '~/common/decorators'; +import { AuthenticatedUser } from '~/common/decorators'; import { AccessTokenGuard } from '~/common/guards'; -import { SetInternalPasswordRequestDto, UpdateNotificationsSettingsRequestDto } from '../dtos/request'; +import { ApiDataResponse } from '~/core/decorators'; +import { ResponseFactory } from '~/core/utils'; +import { UpdateNotificationsSettingsRequestDto, UpdateUserRequestDto } from '../dtos/request'; +import { UpdateEmailRequestDto } from '../dtos/request/update-email.request.dto'; import { UserService } from '../services'; -@Controller('users') -@ApiTags('Users') +@Controller('profile') +@ApiTags('User - Profile') @UseGuards(AccessTokenGuard) @ApiBearerAuth() export class UserController { constructor(private userService: UserService) {} + @Get() + @HttpCode(HttpStatus.OK) + @ApiDataResponse(UserResponseDto) + async getProfile(@AuthenticatedUser() { sub }: IJwtPayload) { + const user = await this.userService.findUserOrThrow({ id: sub }, true); + + return ResponseFactory.data(new UserResponseDto(user)); + } + @Patch('') + @HttpCode(HttpStatus.NO_CONTENT) + async updateProfile(@AuthenticatedUser() user: IJwtPayload, @Body() data: UpdateUserRequestDto) { + return this.userService.updateUser(user.sub, data); + } + + @Patch('email') + @HttpCode(HttpStatus.NO_CONTENT) + async updateEmail(@AuthenticatedUser() user: IJwtPayload, @Body() data: UpdateEmailRequestDto) { + return this.userService.updateUserEmail(user.sub, data.email); + } + + @Patch('verify-email') + @HttpCode(HttpStatus.NO_CONTENT) + async verifyEmail(@AuthenticatedUser() user: IJwtPayload, @Body() { otp }: VerifyOtpRequestDto) { + return this.userService.verifyEmail(user.sub, otp); + } + @Patch('notifications-settings') + @HttpCode(HttpStatus.NO_CONTENT) async updateNotificationSettings( @AuthenticatedUser() user: IJwtPayload, @Body() data: UpdateNotificationsSettingsRequestDto, @@ -22,11 +54,4 @@ export class UserController { ) { return this.userService.updateNotificationSettings(user.sub, data, deviceId); } - - @Post('internal/set-password') - @Public() - @HttpCode(HttpStatus.NO_CONTENT) - async setPassword(@Body() data: SetInternalPasswordRequestDto) { - return this.userService.setCheckerPassword(data); - } } diff --git a/src/user/dtos/request/create-checker.request.dto.ts b/src/user/dtos/request/create-checker.request.dto.ts deleted file mode 100644 index 1ce8ed1..0000000 --- a/src/user/dtos/request/create-checker.request.dto.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsEmail, IsString, Matches } from 'class-validator'; -import { i18nValidationMessage as i18n } from 'nestjs-i18n'; -import { COUNTRY_CODE_REGEX } from '~/auth/constants'; -import { IsValidPhoneNumber } from '~/core/decorators/validations'; - -export class CreateCheckerRequestDto { - @ApiProperty({ example: 'checker@example.com' }) - @ApiProperty({ example: 'test@test.com' }) - @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.email' }) }) - @IsEmail({}, { message: i18n('validation.IsEmail', { path: 'general', property: 'auth.email' }) }) - email!: string; - - @ApiProperty({ example: '+962' }) - @Matches(COUNTRY_CODE_REGEX, { - message: i18n('validation.Matches', { path: 'general', property: 'auth.countryCode' }), - }) - countryCode!: string; - - @ApiProperty({ example: '797229134' }) - @IsValidPhoneNumber({ - message: i18n('validation.IsValidPhoneNumber', { path: 'general', property: 'auth.phoneNumber' }), - }) - phoneNumber!: string; -} diff --git a/src/user/dtos/request/index.ts b/src/user/dtos/request/index.ts index 30831e1..bc36355 100644 --- a/src/user/dtos/request/index.ts +++ b/src/user/dtos/request/index.ts @@ -1,4 +1,2 @@ -export * from './create-checker.request.dto'; -export * from './set-internal-password.request.dto'; export * from './update-notifications-settings.request.dto'; -export * from './user-filters.request.dto'; +export * from './update-user.request.dto'; diff --git a/src/user/dtos/request/set-internal-password.request.dto.ts b/src/user/dtos/request/set-internal-password.request.dto.ts deleted file mode 100644 index ec49b65..0000000 --- a/src/user/dtos/request/set-internal-password.request.dto.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsNotEmpty, IsString } from 'class-validator'; -import { i18nValidationMessage as i18n } from 'nestjs-i18n'; - -export class SetInternalPasswordRequestDto { - @ApiProperty() - @IsString({ message: i18n('validation.IsNumberString', { path: 'general', property: 'auth.token' }) }) - @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'auth.token' }) }) - token!: string; - - @ApiProperty() - @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.password' }) }) - @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'auth.password' }) }) - password!: string; -} diff --git a/src/user/dtos/request/update-email.request.dto.ts b/src/user/dtos/request/update-email.request.dto.ts new file mode 100644 index 0000000..0548b8e --- /dev/null +++ b/src/user/dtos/request/update-email.request.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsEmail, IsOptional } from 'class-validator'; +import { i18nValidationMessage as i18n } from 'nestjs-i18n'; +export class UpdateEmailRequestDto { + @ApiProperty({ example: 'test@test.com' }) + @IsEmail({}, { message: i18n('validation.IsEmail', { path: 'general', property: 'user.email' }) }) + @IsOptional() + email!: string; +} diff --git a/src/user/dtos/request/update-user.request.dto.ts b/src/user/dtos/request/update-user.request.dto.ts new file mode 100644 index 0000000..5024952 --- /dev/null +++ b/src/user/dtos/request/update-user.request.dto.ts @@ -0,0 +1,21 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator'; +import { i18nValidationMessage as i18n } from 'nestjs-i18n'; +export class UpdateUserRequestDto { + @ApiProperty({ example: 'John' }) + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'user.firstName' }) }) + @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'user.firstName' }) }) + @IsOptional() + firstName!: string; + + @ApiProperty({ example: 'Doe' }) + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'user.lastName' }) }) + @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'user.lastName' }) }) + @IsOptional() + lastName!: string; + + @ApiProperty({ example: '123e4567-e89b-12d3-a456-426614174000' }) + @IsUUID('4', { message: i18n('validation.IsUUID', { path: 'general', property: 'user.profilePictureId' }) }) + @IsOptional() + profilePictureId!: string; +} diff --git a/src/user/dtos/request/user-filters.request.dto.ts b/src/user/dtos/request/user-filters.request.dto.ts deleted file mode 100644 index 3f04cc5..0000000 --- a/src/user/dtos/request/user-filters.request.dto.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { ApiPropertyOptional } from '@nestjs/swagger'; -import { IsEnum, IsNotEmpty, IsOptional, IsString } from 'class-validator'; -import { i18nValidationMessage as i18n } from 'nestjs-i18n'; -import { Roles } from '~/auth/enums'; -import { PageOptionsRequestDto } from '~/core/dtos'; - -export class UserFiltersRequestDto extends PageOptionsRequestDto { - @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'user.search' }) }) - @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'user.search' }) }) - @IsOptional() - @ApiPropertyOptional({ description: 'Search by email or phone number' }) - search?: string; - - @IsEnum(Roles, { message: i18n('validation.IsEnum', { path: 'general', property: 'user.role' }) }) - @IsOptional() - @ApiPropertyOptional({ enum: Roles, enumName: 'Roles', example: Roles.CHECKER, description: 'Role of the user' }) - role?: string; -} diff --git a/src/user/entities/user.entity.ts b/src/user/entities/user.entity.ts index a18173d..ee3db70 100644 --- a/src/user/entities/user.entity.ts +++ b/src/user/entities/user.entity.ts @@ -3,6 +3,7 @@ import { Column, CreateDateColumn, Entity, + JoinColumn, OneToMany, OneToOne, PrimaryGeneratedColumn, @@ -21,7 +22,13 @@ export class User extends BaseEntity { @PrimaryGeneratedColumn('uuid') id!: string; - @Column('varchar', { length: 255, nullable: true, name: 'email' }) + @Column('varchar', { length: 255, name: 'first_name', nullable: false }) + firstName!: string; + + @Column('varchar', { length: 255, name: 'last_name', nullable: false }) + lastName!: string; + + @Column('varchar', { length: 255, name: 'email', nullable: true }) email!: string; @Column('varchar', { length: 255, name: 'phone_number', nullable: true }) @@ -81,6 +88,13 @@ export class User extends BaseEntity { @OneToMany(() => UserRegistrationToken, (token) => token.user) registrationTokens!: UserRegistrationToken[]; + @Column('varchar', { name: 'profile_picture_id', nullable: true }) + profilePictureId!: string; + + @OneToOne(() => Document, (document) => document.userPicture, { cascade: true, nullable: true }) + @JoinColumn({ name: 'profile_picture_id' }) + profilePicture!: Document; + @CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone' }) createdAt!: Date; diff --git a/src/user/repositories/user.repository.ts b/src/user/repositories/user.repository.ts index d740cd8..c55ff1d 100644 --- a/src/user/repositories/user.repository.ts +++ b/src/user/repositories/user.repository.ts @@ -2,7 +2,6 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { FindOptionsWhere, Repository } from 'typeorm'; import { User } from '../../user/entities'; -import { UserFiltersRequestDto } from '../dtos/request'; @Injectable() export class UserRepository { @@ -17,7 +16,7 @@ export class UserRepository { } findOne(where: FindOptionsWhere | FindOptionsWhere[]) { - return this.userRepository.findOne({ where }); + return this.userRepository.findOne({ where, relations: ['profilePicture'] }); } update(userId: string, data: Partial) { @@ -31,24 +30,4 @@ export class UserRepository { return this.userRepository.save(user); } - - findUsers(filters: UserFiltersRequestDto) { - const queryBuilder = this.userRepository.createQueryBuilder('user'); - - if (filters.role) { - queryBuilder.andWhere(`user.roles @> ARRAY[:role]`, { role: filters.role }); - } - - if (filters.search) { - queryBuilder.andWhere(`user.email ILIKE :search OR user.phoneNumber ILIKE :search`, { - search: `%${filters.search}%`, - }); - } - - queryBuilder.orderBy('user.createdAt', 'DESC'); - queryBuilder.take(filters.size); - queryBuilder.skip((filters.page - 1) * filters.size); - - return queryBuilder.getManyAndCount(); - } } diff --git a/src/user/services/user.service.ts b/src/user/services/user.service.ts index 2640cbe..a9088ab 100644 --- a/src/user/services/user.service.ts +++ b/src/user/services/user.service.ts @@ -1,25 +1,20 @@ import { BadRequestException, forwardRef, Inject, Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import * as bcrypt from 'bcrypt'; -import moment from 'moment'; import { FindOptionsWhere } from 'typeorm'; import { Transactional } from 'typeorm-transactional'; import { CountryIso } from '~/common/enums'; import { NotificationsService } from '~/common/modules/notification/services'; +import { OtpScope, OtpType } from '~/common/modules/otp/enums'; +import { OtpService } from '~/common/modules/otp/services'; import { CustomerService } from '~/customer/services'; +import { DocumentService, OciService } from '~/document/services'; import { CreateUnverifiedUserRequestDto, VerifyUserRequestDto } from '../../auth/dtos/request'; import { Roles } from '../../auth/enums'; -import { - CreateCheckerRequestDto, - SetInternalPasswordRequestDto, - UpdateNotificationsSettingsRequestDto, - UserFiltersRequestDto, -} from '../dtos/request'; +import { UpdateNotificationsSettingsRequestDto, UpdateUserRequestDto } from '../dtos/request'; import { User } from '../entities'; -import { UserType } from '../enums'; import { UserRepository } from '../repositories'; import { DeviceService } from './device.service'; -import { UserTokenService } from './user-token.service'; const SALT_ROUNDS = 10; @Injectable() export class UserService { @@ -29,14 +24,21 @@ export class UserService { private readonly userRepository: UserRepository, @Inject(forwardRef(() => NotificationsService)) private readonly notificationsService: NotificationsService, private readonly deviceService: DeviceService, - private readonly userTokenService: UserTokenService, private readonly configService: ConfigService, private customerService: CustomerService, + private readonly documentService: DocumentService, + private readonly otpService: OtpService, + private readonly ociService: OciService, ) {} - findUser(where: FindOptionsWhere | FindOptionsWhere[]) { + async findUser(where: FindOptionsWhere | FindOptionsWhere[], includeSignedUrl = false) { this.logger.log(`finding user with where clause ${JSON.stringify(where)}`); - return this.userRepository.findOne(where); + const user = await this.userRepository.findOne(where); + + if (user?.profilePicture && includeSignedUrl) { + user.profilePicture.url = await this.ociService.generatePreSignedUrl(user.profilePicture); + } + return user; } setEmail(userId: string, email: string) { @@ -75,14 +77,9 @@ export class UserService { ]); } - findUsers(filters: UserFiltersRequestDto) { - this.logger.log(`Getting users with filters ${JSON.stringify(filters)}`); - return this.userRepository.findUsers(filters); - } - - async findUserOrThrow(where: FindOptionsWhere) { + async findUserOrThrow(where: FindOptionsWhere, includeSignedUrl = false) { this.logger.log(`Finding user with where clause ${JSON.stringify(where)}`); - const user = await this.findUser(where); + const user = await this.findUser(where, includeSignedUrl); if (!user) { this.logger.error(`User with not found with where clause ${JSON.stringify(where)}`); @@ -107,6 +104,8 @@ export class UserService { phoneNumber: body.phoneNumber, countryCode: body.countryCode, email: body.email, + firstName: body.firstName, + lastName: body.lastName, roles: [Roles.GUARDIAN], }); } @@ -134,29 +133,6 @@ export class UserService { return user; } - @Transactional() - async createChecker(data: CreateCheckerRequestDto) { - const existingUser = await this.userRepository.findOne([ - { email: data.email }, - { phoneNumber: data.phoneNumber, countryCode: data.countryCode }, - ]); - - if (existingUser) { - throw new BadRequestException('USER.ALREADY_EXISTS'); - } - - const user = await this.createUser({ - ...data, - roles: [Roles.CHECKER], - isProfileCompleted: true, - }); - const ONE_DAY = moment().add(1, 'day').toDate(); - const token = await this.userTokenService.generateToken(user.id, UserType.CHECKER, ONE_DAY); - await this.sendCheckerAccountCreatedEmail(data.email, token); - - return user; - } - async updateNotificationSettings(userId: string, data: UpdateNotificationsSettingsRequestDto, deviceId?: string) { this.logger.log(`Updating notification settings for user ${userId} with data ${JSON.stringify(data)}`); if (data.isPushEnabled && !data.fcmToken) { @@ -213,16 +189,9 @@ export class UserService { return this.findUserOrThrow({ id: user.id }); } - async setCheckerPassword(data: SetInternalPasswordRequestDto) { - const userId = await this.userTokenService.validateToken(data.token, UserType.CHECKER); - this.logger.log(`Setting password for checker ${userId}`); - const salt = bcrypt.genSaltSync(SALT_ROUNDS); - const hashedPasscode = bcrypt.hashSync(data.password, salt); + async updateUser(userId: string, data: UpdateUserRequestDto) { + await this.validateProfilePictureId(data.profilePictureId, userId); - return this.userRepository.update(userId, { password: hashedPasscode, salt, isProfileCompleted: true }); - } - - async updateUser(userId: string, data: Partial) { this.logger.log(`Updating user ${userId} with data ${JSON.stringify(data)}`); const { affected } = await this.userRepository.update(userId, data); if (affected === 0) { @@ -231,12 +200,72 @@ export class UserService { } } - private sendCheckerAccountCreatedEmail(email: string, token: string) { - return this.notificationsService.sendEmailAsync({ - to: email, - template: 'user-invite', - subject: 'Checker Account Created', - data: { inviteLink: `${this.adminPortalUrl}?token=${token}` }, + async updateUserEmail(userId: string, email: string) { + const userWithEmail = await this.findUser({ email, isEmailVerified: true }); + + if (userWithEmail) { + if (userWithEmail.id === userId) { + return; + } + + this.logger.error(`Email ${email} is already taken by another user`); + throw new BadRequestException('USER.EMAIL_ALREADY_TAKEN'); + } + + this.logger.log(`Updating email for user ${userId} to ${email}`); + const { affected } = await this.userRepository.update(userId, { email, isEmailVerified: false }); + + if (affected === 0) { + this.logger.error(`User with id ${userId} not found`); + throw new BadRequestException('USER.NOT_FOUND'); + } + + return this.otpService.generateAndSendOtp({ + userId, + recipient: email, + otpType: OtpType.EMAIL, + scope: OtpScope.VERIFY_EMAIL, }); } + + async verifyEmail(userId: string, otp: string) { + this.logger.log(`Verifying email for user ${userId} with otp ${otp}`); + const user = await this.findUserOrThrow({ id: userId }); + + if (user.isEmailVerified) { + this.logger.error(`User with id ${userId} already has verified email`); + throw new BadRequestException('USER.EMAIL_ALREADY_VERIFIED'); + } + + await this.otpService.verifyOtp({ + userId, + value: otp, + scope: OtpScope.VERIFY_EMAIL, + otpType: OtpType.EMAIL, + }); + + await this.userRepository.update(userId, { isEmailVerified: true }); + this.logger.log(`Email for user ${userId} verified successfully`); + } + + private async validateProfilePictureId(profilePictureId: string, userId: string) { + if (!profilePictureId) { + return; + } + this.logger.log(`Validating profile picture id ${profilePictureId}`); + + const document = await this.documentService.findDocumentById(profilePictureId); + + if (!document) { + this.logger.error(`Document with id ${profilePictureId} not found`); + throw new BadRequestException('DOCUMENT.NOT_FOUND'); + } + + if (document.createdById !== userId) { + this.logger.error(`Document with id ${profilePictureId} does not belong to user ${userId}`); + throw new BadRequestException('DOCUMENT.NOT_BELONG_TO_USER'); + } + + this.logger.log(`Profile picture id ${profilePictureId} validated successfully`); + } } diff --git a/src/user/user.module.ts b/src/user/user.module.ts index 50c740f..1ea07ae 100644 --- a/src/user/user.module.ts +++ b/src/user/user.module.ts @@ -2,7 +2,7 @@ import { forwardRef, Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { NotificationModule } from '~/common/modules/notification/notification.module'; import { CustomerModule } from '~/customer/customer.module'; -import { AdminUserController, UserController } from './controllers'; +import { UserController } from './controllers'; import { Device, User, UserRegistrationToken } from './entities'; import { DeviceRepository, UserRepository, UserTokenRepository } from './repositories'; import { DeviceService, UserService, UserTokenService } from './services'; @@ -15,6 +15,6 @@ import { DeviceService, UserService, UserTokenService } from './services'; ], providers: [UserService, DeviceService, UserRepository, DeviceRepository, UserTokenRepository, UserTokenService], exports: [UserService, DeviceService, UserTokenService], - controllers: [UserController, AdminUserController], + controllers: [UserController], }) export class UserModule {} From ee7b3655277c07b2be2bc6d774331d2894502769 Mon Sep 17 00:00:00 2001 From: Abdalhamid Alhamad Date: Thu, 7 Aug 2025 14:23:33 +0300 Subject: [PATCH 26/32] feat: kyc process --- client/.gitignore | 24 - client/README.md | 50 - client/eslint.config.js | 28 - client/index.html | 13 - client/package-lock.json | 4115 ----------------- client/package.json | 38 - client/public/vite.svg | 1 - client/src/App.css | 42 - client/src/App.tsx | 127 - client/src/api/client.ts | 140 - client/src/assets/react.svg | 1 - client/src/components/auth/AppleLogin.tsx | 69 - client/src/components/auth/GoogleLogin.tsx | 40 - client/src/components/auth/LoginForm.tsx | 149 - client/src/components/auth/RegisterForm.tsx | 254 - client/src/components/dashboard/Dashboard.tsx | 151 - .../components/document/DocumentUpload.tsx | 86 - .../src/components/juniors/AddJuniorForm.tsx | 266 -- client/src/components/juniors/JuniorsList.tsx | 121 - client/src/components/layout/AuthLayout.tsx | 175 - client/src/components/tasks/AddTask.tsx | 245 - client/src/components/tasks/TaskDetails.tsx | 87 - client/src/components/tasks/TasksList.tsx | 200 - client/src/contexts/AuthContext.tsx | 119 - client/src/enums/grantType.enum.ts | 6 - client/src/enums/index.ts | 1 - client/src/index.css | 52 - client/src/main.tsx | 13 - client/src/types/api.ts | 14 - client/src/types/auth.ts | 27 - client/src/types/document.ts | 9 - client/src/types/junior.ts | 41 - client/src/types/task.ts | 42 - client/src/vite-env.d.ts | 1 - client/tsconfig.app.json | 26 - client/tsconfig.json | 7 - client/tsconfig.node.json | 24 - client/vite.config.ts | 16 - src/auth/dtos/response/user.response.dto.ts | 5 +- src/common/mappers/index.ts | 1 + src/common/mappers/numeric-to-iso.mapper.ts | 11 + src/common/modules/neoleap/__mocks__/index.ts | 1 + .../neoleap/__mocks__/initiate-kyc.mock.ts | 21 + .../neoleap/__mocks__/kyc-callback.mock.ts | 23 + .../neoleap-webhooks.controller.ts | 7 + .../modules/neoleap/dtos/requests/index.ts | 1 + .../dtos/requests/kyc-webhook.request.dto.ts | 99 + .../modules/neoleap/dtos/response/index.ts | 1 + .../response/initiate-kyc.response.dto.ts | 28 + src/common/modules/neoleap/neoleap.module.ts | 5 +- .../services/neoleap-webook.service.ts | 12 +- .../neoleap/services/neoleap.service.ts | 65 +- src/core/decorators/validations/index.ts | 1 + .../validations/is-valid-saudi-id.ts | 35 + .../controllers/customer.controller.ts | 28 +- src/customer/controllers/index.ts | 1 - .../internal.customer.controller.ts | 49 - src/customer/customer.module.ts | 12 +- .../request/create-customer.request.dto.ts | 80 - .../request/customer-filters.request.dto.ts | 23 - src/customer/dtos/request/index.ts | 5 +- .../dtos/request/initiate-kyc.request.dto.ts | 8 + .../reject-customer-kyc.request.dto.ts | 11 - .../request/update-customer.request.dto.ts | 10 - src/customer/dtos/response/index.ts | 3 +- .../response/initiate-kyc.response.dto.ts | 10 + .../internal.customer-details.response.dto.ts | 88 - .../internal.customer-list.response.dto.ts | 44 - .../repositories/customer.repository.ts | 28 - src/customer/services/customer.service.ts | 163 +- src/i18n/ar/validation.json | 3 +- src/i18n/en/validation.json | 3 +- src/user/services/user.service.ts | 1 - 73 files changed, 388 insertions(+), 7318 deletions(-) delete mode 100644 client/.gitignore delete mode 100644 client/README.md delete mode 100644 client/eslint.config.js delete mode 100644 client/index.html delete mode 100644 client/package-lock.json delete mode 100644 client/package.json delete mode 100644 client/public/vite.svg delete mode 100644 client/src/App.css delete mode 100644 client/src/App.tsx delete mode 100644 client/src/api/client.ts delete mode 100644 client/src/assets/react.svg delete mode 100644 client/src/components/auth/AppleLogin.tsx delete mode 100644 client/src/components/auth/GoogleLogin.tsx delete mode 100644 client/src/components/auth/LoginForm.tsx delete mode 100644 client/src/components/auth/RegisterForm.tsx delete mode 100644 client/src/components/dashboard/Dashboard.tsx delete mode 100644 client/src/components/document/DocumentUpload.tsx delete mode 100644 client/src/components/juniors/AddJuniorForm.tsx delete mode 100644 client/src/components/juniors/JuniorsList.tsx delete mode 100644 client/src/components/layout/AuthLayout.tsx delete mode 100644 client/src/components/tasks/AddTask.tsx delete mode 100644 client/src/components/tasks/TaskDetails.tsx delete mode 100644 client/src/components/tasks/TasksList.tsx delete mode 100644 client/src/contexts/AuthContext.tsx delete mode 100644 client/src/enums/grantType.enum.ts delete mode 100644 client/src/enums/index.ts delete mode 100644 client/src/index.css delete mode 100644 client/src/main.tsx delete mode 100644 client/src/types/api.ts delete mode 100644 client/src/types/auth.ts delete mode 100644 client/src/types/document.ts delete mode 100644 client/src/types/junior.ts delete mode 100644 client/src/types/task.ts delete mode 100644 client/src/vite-env.d.ts delete mode 100644 client/tsconfig.app.json delete mode 100644 client/tsconfig.json delete mode 100644 client/tsconfig.node.json delete mode 100644 client/vite.config.ts create mode 100644 src/common/mappers/index.ts create mode 100644 src/common/mappers/numeric-to-iso.mapper.ts create mode 100644 src/common/modules/neoleap/__mocks__/initiate-kyc.mock.ts create mode 100644 src/common/modules/neoleap/__mocks__/kyc-callback.mock.ts create mode 100644 src/common/modules/neoleap/dtos/requests/kyc-webhook.request.dto.ts create mode 100644 src/common/modules/neoleap/dtos/response/initiate-kyc.response.dto.ts create mode 100644 src/core/decorators/validations/is-valid-saudi-id.ts delete mode 100644 src/customer/controllers/internal.customer.controller.ts delete mode 100644 src/customer/dtos/request/create-customer.request.dto.ts delete mode 100644 src/customer/dtos/request/customer-filters.request.dto.ts create mode 100644 src/customer/dtos/request/initiate-kyc.request.dto.ts delete mode 100644 src/customer/dtos/request/reject-customer-kyc.request.dto.ts delete mode 100644 src/customer/dtos/request/update-customer.request.dto.ts create mode 100644 src/customer/dtos/response/initiate-kyc.response.dto.ts delete mode 100644 src/customer/dtos/response/internal.customer-details.response.dto.ts delete mode 100644 src/customer/dtos/response/internal.customer-list.response.dto.ts diff --git a/client/.gitignore b/client/.gitignore deleted file mode 100644 index a547bf3..0000000 --- a/client/.gitignore +++ /dev/null @@ -1,24 +0,0 @@ -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* -lerna-debug.log* - -node_modules -dist -dist-ssr -*.local - -# Editor directories and files -.vscode/* -!.vscode/extensions.json -.idea -.DS_Store -*.suo -*.ntvs* -*.njsproj -*.sln -*.sw? diff --git a/client/README.md b/client/README.md deleted file mode 100644 index 74872fd..0000000 --- a/client/README.md +++ /dev/null @@ -1,50 +0,0 @@ -# React + TypeScript + Vite - -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. - -Currently, two official plugins are available: - -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh - -## Expanding the ESLint configuration - -If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: - -- Configure the top-level `parserOptions` property like this: - -```js -export default tseslint.config({ - languageOptions: { - // other options... - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - }, -}) -``` - -- Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked` -- Optionally add `...tseslint.configs.stylisticTypeChecked` -- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config: - -```js -// eslint.config.js -import react from 'eslint-plugin-react' - -export default tseslint.config({ - // Set the react version - settings: { react: { version: '18.3' } }, - plugins: { - // Add the react plugin - react, - }, - rules: { - // other rules... - // Enable its recommended rules - ...react.configs.recommended.rules, - ...react.configs['jsx-runtime'].rules, - }, -}) -``` diff --git a/client/eslint.config.js b/client/eslint.config.js deleted file mode 100644 index 092408a..0000000 --- a/client/eslint.config.js +++ /dev/null @@ -1,28 +0,0 @@ -import js from '@eslint/js' -import globals from 'globals' -import reactHooks from 'eslint-plugin-react-hooks' -import reactRefresh from 'eslint-plugin-react-refresh' -import tseslint from 'typescript-eslint' - -export default tseslint.config( - { ignores: ['dist'] }, - { - extends: [js.configs.recommended, ...tseslint.configs.recommended], - files: ['**/*.{ts,tsx}'], - languageOptions: { - ecmaVersion: 2020, - globals: globals.browser, - }, - plugins: { - 'react-hooks': reactHooks, - 'react-refresh': reactRefresh, - }, - rules: { - ...reactHooks.configs.recommended.rules, - 'react-refresh/only-export-components': [ - 'warn', - { allowConstantExport: true }, - ], - }, - }, -) diff --git a/client/index.html b/client/index.html deleted file mode 100644 index e4b78ea..0000000 --- a/client/index.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - Vite + React + TS - - -
- - - diff --git a/client/package-lock.json b/client/package-lock.json deleted file mode 100644 index 03a0ab0..0000000 --- a/client/package-lock.json +++ /dev/null @@ -1,4115 +0,0 @@ -{ - "name": "client", - "version": "0.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "client", - "version": "0.0.0", - "dependencies": { - "@emotion/react": "^11.14.0", - "@emotion/styled": "^11.14.0", - "@fontsource/roboto": "^5.1.1", - "@mui/icons-material": "^6.3.1", - "@mui/material": "^6.3.1", - "@react-oauth/google": "^0.12.1", - "axios": "^1.7.9", - "react": "^18.3.1", - "react-apple-signin-auth": "^1.1.0", - "react-dom": "^18.3.1", - "react-router-dom": "^7.1.1" - }, - "devDependencies": { - "@eslint/js": "^9.17.0", - "@types/react": "^18.3.18", - "@types/react-dom": "^18.3.5", - "@vitejs/plugin-react": "^4.3.4", - "eslint": "^9.17.0", - "eslint-plugin-react-hooks": "^5.0.0", - "eslint-plugin-react-refresh": "^0.4.16", - "globals": "^15.14.0", - "typescript": "~5.6.2", - "typescript-eslint": "^8.18.2", - "vite": "^6.0.5" - } - }, - "node_modules/@ampproject/remapping": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", - "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/code-frame": { - "version": "7.26.2", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", - "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.25.9", - "js-tokens": "^4.0.0", - "picocolors": "^1.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/compat-data": { - "version": "7.26.3", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.26.3.tgz", - "integrity": "sha512-nHIxvKPniQXpmQLb0vhY3VaFb3S0YrTAwpOWJZh1wn3oJPjJk9Asva204PsBdmAE8vpzfHudT8DB0scYvy9q0g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.0.tgz", - "integrity": "sha512-i1SLeK+DzNnQ3LL/CswPCa/E5u4lh1k6IAEphON8F+cXt0t9euTshDru0q7/IqMa1PMPz5RnHuHscF8/ZJsStg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.26.0", - "@babel/generator": "^7.26.0", - "@babel/helper-compilation-targets": "^7.25.9", - "@babel/helper-module-transforms": "^7.26.0", - "@babel/helpers": "^7.26.0", - "@babel/parser": "^7.26.0", - "@babel/template": "^7.25.9", - "@babel/traverse": "^7.25.9", - "@babel/types": "^7.26.0", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/core/node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@babel/generator": { - "version": "7.26.3", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.3.tgz", - "integrity": "sha512-6FF/urZvD0sTeO7k6/B15pMLC4CHUv1426lzr3N01aHJTl046uCAh9LXW/fzeXXjPNCJ6iABW5XaWOsIZB93aQ==", - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.26.3", - "@babel/types": "^7.26.3", - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.25.9.tgz", - "integrity": "sha512-j9Db8Suy6yV/VHa4qzrj9yZfZxhLWQdVnRlXxmKLYlhWUVB1sB2G5sxuWYXk/whHD9iW76PmNzxZ4UCnTQTVEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.25.9", - "@babel/helper-validator-option": "^7.25.9", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.25.9.tgz", - "integrity": "sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==", - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.25.9", - "@babel/types": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.26.0.tgz", - "integrity": "sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.25.9", - "@babel/helper-validator-identifier": "^7.25.9", - "@babel/traverse": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.25.9.tgz", - "integrity": "sha512-kSMlyUVdWe25rEsRGviIgOWnoT/nfABVWlqt9N19/dIPWViAOW2s9wznP5tURbs/IDuNk4gPy3YdYRgH3uxhBw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", - "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", - "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-option": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.25.9.tgz", - "integrity": "sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.0.tgz", - "integrity": "sha512-tbhNuIxNcVb21pInl3ZSjksLCvgdZy9KwJ8brv993QtIVKJBBkYXz4q4ZbAv31GdnC+R90np23L5FbEBlthAEw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.25.9", - "@babel/types": "^7.26.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.26.3", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.3.tgz", - "integrity": "sha512-WJ/CvmY8Mea8iDXo6a7RK2wbmJITT5fN3BEkRuFlxVyNx8jOKIIhmC4fSkTcPcf8JyavbBwIe6OpiCOBXt/IcA==", - "license": "MIT", - "dependencies": { - "@babel/types": "^7.26.3" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-self": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.25.9.tgz", - "integrity": "sha512-y8quW6p0WHkEhmErnfe58r7x0A70uKphQm8Sp8cV7tjNQwK56sNVK0M73LK3WuYmsuyrftut4xAkjjgU0twaMg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-source": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.25.9.tgz", - "integrity": "sha512-+iqjT8xmXhhYv4/uiYd8FNQsraMFZIfxVSqxxVSZP0WbbSAWvBXAul0m/zu+7Vv4O/3WtApy9pmaTMiumEZgfg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/runtime": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.0.tgz", - "integrity": "sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw==", - "license": "MIT", - "dependencies": { - "regenerator-runtime": "^0.14.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/template": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.9.tgz", - "integrity": "sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.25.9", - "@babel/parser": "^7.25.9", - "@babel/types": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.26.4", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.4.tgz", - "integrity": "sha512-fH+b7Y4p3yqvApJALCPJcwb0/XaOSgtK4pzV6WVjPR5GLFQBRI7pfoX2V2iM48NXvX07NUxxm1Vw98YjqTcU5w==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.26.2", - "@babel/generator": "^7.26.3", - "@babel/parser": "^7.26.3", - "@babel/template": "^7.25.9", - "@babel/types": "^7.26.3", - "debug": "^4.3.1", - "globals": "^11.1.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse/node_modules/globals": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", - "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/types": { - "version": "7.26.3", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.3.tgz", - "integrity": "sha512-vN5p+1kl59GVKMvTHt55NzzmYVxprfJD+ql7U9NFIfKCBkYE55LYtS+WtPlaYOyzydrKI8Nezd+aZextrd+FMA==", - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.25.9", - "@babel/helper-validator-identifier": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@emotion/babel-plugin": { - "version": "11.13.5", - "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", - "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.16.7", - "@babel/runtime": "^7.18.3", - "@emotion/hash": "^0.9.2", - "@emotion/memoize": "^0.9.0", - "@emotion/serialize": "^1.3.3", - "babel-plugin-macros": "^3.1.0", - "convert-source-map": "^1.5.0", - "escape-string-regexp": "^4.0.0", - "find-root": "^1.1.0", - "source-map": "^0.5.7", - "stylis": "4.2.0" - } - }, - "node_modules/@emotion/cache": { - "version": "11.14.0", - "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", - "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", - "license": "MIT", - "dependencies": { - "@emotion/memoize": "^0.9.0", - "@emotion/sheet": "^1.4.0", - "@emotion/utils": "^1.4.2", - "@emotion/weak-memoize": "^0.4.0", - "stylis": "4.2.0" - } - }, - "node_modules/@emotion/hash": { - "version": "0.9.2", - "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", - "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", - "license": "MIT" - }, - "node_modules/@emotion/is-prop-valid": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.3.1.tgz", - "integrity": "sha512-/ACwoqx7XQi9knQs/G0qKvv5teDMhD7bXYns9N/wM8ah8iNb8jZ2uNO0YOgiq2o2poIvVtJS2YALasQuMSQ7Kw==", - "license": "MIT", - "dependencies": { - "@emotion/memoize": "^0.9.0" - } - }, - "node_modules/@emotion/memoize": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", - "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", - "license": "MIT" - }, - "node_modules/@emotion/react": { - "version": "11.14.0", - "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", - "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.18.3", - "@emotion/babel-plugin": "^11.13.5", - "@emotion/cache": "^11.14.0", - "@emotion/serialize": "^1.3.3", - "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", - "@emotion/utils": "^1.4.2", - "@emotion/weak-memoize": "^0.4.0", - "hoist-non-react-statics": "^3.3.1" - }, - "peerDependencies": { - "react": ">=16.8.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@emotion/serialize": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", - "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", - "license": "MIT", - "dependencies": { - "@emotion/hash": "^0.9.2", - "@emotion/memoize": "^0.9.0", - "@emotion/unitless": "^0.10.0", - "@emotion/utils": "^1.4.2", - "csstype": "^3.0.2" - } - }, - "node_modules/@emotion/sheet": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", - "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==", - "license": "MIT" - }, - "node_modules/@emotion/styled": { - "version": "11.14.0", - "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.0.tgz", - "integrity": "sha512-XxfOnXFffatap2IyCeJyNov3kiDQWoR08gPUQxvbL7fxKryGBKUZUkG6Hz48DZwVrJSVh9sJboyV1Ds4OW6SgA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.18.3", - "@emotion/babel-plugin": "^11.13.5", - "@emotion/is-prop-valid": "^1.3.0", - "@emotion/serialize": "^1.3.3", - "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", - "@emotion/utils": "^1.4.2" - }, - "peerDependencies": { - "@emotion/react": "^11.0.0-rc.0", - "react": ">=16.8.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@emotion/unitless": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", - "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==", - "license": "MIT" - }, - "node_modules/@emotion/use-insertion-effect-with-fallbacks": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", - "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==", - "license": "MIT", - "peerDependencies": { - "react": ">=16.8.0" - } - }, - "node_modules/@emotion/utils": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", - "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==", - "license": "MIT" - }, - "node_modules/@emotion/weak-memoize": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", - "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", - "license": "MIT" - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz", - "integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz", - "integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz", - "integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz", - "integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz", - "integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz", - "integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz", - "integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz", - "integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz", - "integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz", - "integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz", - "integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz", - "integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz", - "integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz", - "integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz", - "integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz", - "integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz", - "integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz", - "integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz", - "integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz", - "integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz", - "integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz", - "integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz", - "integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz", - "integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz", - "integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.1.tgz", - "integrity": "sha512-s3O3waFUrMV8P/XaF/+ZTp1X9XBZW1a4B97ZnjQF2KYWaFD2A8KyFBsrsfSjEmjn3RGWAIuvlneuZm3CUK3jbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } - }, - "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.1", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", - "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, - "node_modules/@eslint/config-array": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.19.1.tgz", - "integrity": "sha512-fo6Mtm5mWyKjA/Chy1BYTdn5mGJoDNjC7C64ug20ADsRDGrA85bN3uK3MaKbeRkRuuIEAR5N33Jr1pbm411/PA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/object-schema": "^2.1.5", - "debug": "^4.3.1", - "minimatch": "^3.1.2" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/core": { - "version": "0.9.1", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.9.1.tgz", - "integrity": "sha512-GuUdqkyyzQI5RMIWkHhvTWLCyLo1jNK3vzkSyaExH5kHPDHcuL2VOpHjmMY+y3+NC69qAKToBqldTBgYeLSr9Q==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.2.0.tgz", - "integrity": "sha512-grOjVNN8P3hjJn/eIETF1wwd12DdnwFDoyceUJLYYdkpbwq3nLi+4fqrTAONx7XDALqlL220wC/RHSC/QTI/0w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/eslintrc/node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@eslint/js": { - "version": "9.17.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.17.0.tgz", - "integrity": "sha512-Sxc4hqcs1kTu0iID3kcZDW3JHq2a77HO9P8CP6YEA/FpH3Ll8UXE2r/86Rz9YJLKme39S9vU5OWNjC6Xl0Cr3w==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/object-schema": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.5.tgz", - "integrity": "sha512-o0bhxnL89h5Bae5T318nFoFzGy+YE5i/gGkoPAgkmTVdRKTiv3p8JHevPiPaMwoloKfEiiaHlawCqaZMqRm+XQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/plugin-kit": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.4.tgz", - "integrity": "sha512-zSkKow6H5Kdm0ZUQUB2kV5JIXqoG0+uH5YADhaEHswm664N9Db8dXSi0nMJpacpMf+MyyglF1vnZohpEg5yUtg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "levn": "^0.4.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@fontsource/roboto": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/@fontsource/roboto/-/roboto-5.1.1.tgz", - "integrity": "sha512-XwVVXtERDQIM7HPUIbyDe0FP4SRovpjF7zMI8M7pbqFp3ahLJsJTd18h+E6pkar6UbV3btbwkKjYARr5M+SQow==", - "license": "Apache-2.0" - }, - "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/node": { - "version": "0.16.6", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", - "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/core": "^0.19.1", - "@humanwhocodes/retry": "^0.3.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", - "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.1.tgz", - "integrity": "sha512-c7hNEllBlenFTHBky65mhq8WD2kbN9Q6gk0bTk8lSBvc554jpXSkST1iePudpt7+A/AQvuHs9EMqjHDXMY1lrA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", - "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", - "license": "MIT", - "dependencies": { - "@jridgewell/set-array": "^1.2.1", - "@jridgewell/sourcemap-codec": "^1.4.10", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/set-array": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", - "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.25", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", - "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@mui/core-downloads-tracker": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-6.3.1.tgz", - "integrity": "sha512-2OmnEyoHpj5//dJJpMuxOeLItCCHdf99pjMFfUFdBteCunAK9jW+PwEo4mtdGcLs7P+IgZ+85ypd52eY4AigoQ==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - } - }, - "node_modules/@mui/icons-material": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-6.3.1.tgz", - "integrity": "sha512-nJmWj1PBlwS3t1PnoqcixIsftE+7xrW3Su7f0yrjPw4tVjYrgkhU0hrRp+OlURfZ3ptdSkoBkalee9Bhf1Erfw==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.26.0" - }, - "engines": { - "node": ">=14.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - }, - "peerDependencies": { - "@mui/material": "^6.3.1", - "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react": "^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@mui/material": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/@mui/material/-/material-6.3.1.tgz", - "integrity": "sha512-ynG9ayhxgCsHJ/dtDcT1v78/r2GwQyP3E0hPz3GdPRl0uFJz/uUTtI5KFYwadXmbC+Uv3bfB8laZ6+Cpzh03gA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.26.0", - "@mui/core-downloads-tracker": "^6.3.1", - "@mui/system": "^6.3.1", - "@mui/types": "^7.2.21", - "@mui/utils": "^6.3.1", - "@popperjs/core": "^2.11.8", - "@types/react-transition-group": "^4.4.12", - "clsx": "^2.1.1", - "csstype": "^3.1.3", - "prop-types": "^15.8.1", - "react-is": "^19.0.0", - "react-transition-group": "^4.4.5" - }, - "engines": { - "node": ">=14.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - }, - "peerDependencies": { - "@emotion/react": "^11.5.0", - "@emotion/styled": "^11.3.0", - "@mui/material-pigment-css": "^6.3.1", - "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@emotion/react": { - "optional": true - }, - "@emotion/styled": { - "optional": true - }, - "@mui/material-pigment-css": { - "optional": true - }, - "@types/react": { - "optional": true - } - } - }, - "node_modules/@mui/private-theming": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-6.3.1.tgz", - "integrity": "sha512-g0u7hIUkmXmmrmmf5gdDYv9zdAig0KoxhIQn1JN8IVqApzf/AyRhH3uDGx5mSvs8+a1zb4+0W6LC260SyTTtdQ==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.26.0", - "@mui/utils": "^6.3.1", - "prop-types": "^15.8.1" - }, - "engines": { - "node": ">=14.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - }, - "peerDependencies": { - "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react": "^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@mui/styled-engine": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-6.3.1.tgz", - "integrity": "sha512-/7CC0d2fIeiUxN5kCCwYu4AWUDd9cCTxWCyo0v/Rnv6s8uk6hWgJC3VLZBoDENBHf/KjqDZuYJ2CR+7hD6QYww==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.26.0", - "@emotion/cache": "^11.13.5", - "@emotion/serialize": "^1.3.3", - "@emotion/sheet": "^1.4.0", - "csstype": "^3.1.3", - "prop-types": "^15.8.1" - }, - "engines": { - "node": ">=14.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - }, - "peerDependencies": { - "@emotion/react": "^11.4.1", - "@emotion/styled": "^11.3.0", - "react": "^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@emotion/react": { - "optional": true - }, - "@emotion/styled": { - "optional": true - } - } - }, - "node_modules/@mui/system": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/@mui/system/-/system-6.3.1.tgz", - "integrity": "sha512-AwqQ3EAIT2np85ki+N15fF0lFXX1iFPqenCzVOSl3QXKy2eifZeGd9dGtt7pGMoFw5dzW4dRGGzRpLAq9rkl7A==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.26.0", - "@mui/private-theming": "^6.3.1", - "@mui/styled-engine": "^6.3.1", - "@mui/types": "^7.2.21", - "@mui/utils": "^6.3.1", - "clsx": "^2.1.1", - "csstype": "^3.1.3", - "prop-types": "^15.8.1" - }, - "engines": { - "node": ">=14.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - }, - "peerDependencies": { - "@emotion/react": "^11.5.0", - "@emotion/styled": "^11.3.0", - "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react": "^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@emotion/react": { - "optional": true - }, - "@emotion/styled": { - "optional": true - }, - "@types/react": { - "optional": true - } - } - }, - "node_modules/@mui/types": { - "version": "7.2.21", - "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.21.tgz", - "integrity": "sha512-6HstngiUxNqLU+/DPqlUJDIPbzUBxIVHb1MmXP0eTWDIROiCR2viugXpEif0PPe2mLqqakPzzRClWAnK+8UJww==", - "license": "MIT", - "peerDependencies": { - "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@mui/utils": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.3.1.tgz", - "integrity": "sha512-sjGjXAngoio6lniQZKJ5zGfjm+LD2wvLwco7FbKe1fu8A7VIFmz2SwkLb+MDPLNX1lE7IscvNNyh1pobtZg2tw==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.26.0", - "@mui/types": "^7.2.21", - "@types/prop-types": "^15.7.14", - "clsx": "^2.1.1", - "prop-types": "^15.8.1", - "react-is": "^19.0.0" - }, - "engines": { - "node": ">=14.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - }, - "peerDependencies": { - "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react": "^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@popperjs/core": { - "version": "2.11.8", - "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", - "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/popperjs" - } - }, - "node_modules/@react-oauth/google": { - "version": "0.12.1", - "resolved": "https://registry.npmjs.org/@react-oauth/google/-/google-0.12.1.tgz", - "integrity": "sha512-qagsy22t+7UdkYAiT5ZhfM4StXi9PPNvw0zuwNmabrWyMKddczMtBIOARflbaIj+wHiQjnMAsZmzsUYuXeyoSg==", - "license": "MIT", - "peerDependencies": { - "react": ">=16.8.0", - "react-dom": ">=16.8.0" - } - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.30.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.30.0.tgz", - "integrity": "sha512-qFcFto9figFLz2g25DxJ1WWL9+c91fTxnGuwhToCl8BaqDsDYMl/kOnBXAyAqkkzAWimYMSWNPWEjt+ADAHuoQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.30.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.30.0.tgz", - "integrity": "sha512-vqrQdusvVl7dthqNjWCL043qelBK+gv9v3ZiqdxgaJvmZyIAAXMjeGVSqZynKq69T7062T5VrVTuikKSAAVP6A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.30.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.30.0.tgz", - "integrity": "sha512-617pd92LhdA9+wpixnzsyhVft3szYiN16aNUMzVkf2N+yAk8UXY226Bfp36LvxYTUt7MO/ycqGFjQgJ0wlMaWQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.30.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.30.0.tgz", - "integrity": "sha512-Y3b4oDoaEhCypg8ajPqigKDcpi5ZZovemQl9Edpem0uNv6UUjXv7iySBpGIUTSs2ovWOzYpfw9EbFJXF/fJHWw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.30.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.30.0.tgz", - "integrity": "sha512-3REQJ4f90sFIBfa0BUokiCdrV/E4uIjhkWe1bMgCkhFXbf4D8YN6C4zwJL881GM818qVYE9BO3dGwjKhpo2ABA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.30.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.30.0.tgz", - "integrity": "sha512-ZtY3Y8icbe3Cc+uQicsXG5L+CRGUfLZjW6j2gn5ikpltt3Whqjfo5mkyZ86UiuHF9Q3ZsaQeW7YswlHnN+lAcg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.30.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.30.0.tgz", - "integrity": "sha512-bsPGGzfiHXMhQGuFGpmo2PyTwcrh2otL6ycSZAFTESviUoBOuxF7iBbAL5IJXc/69peXl5rAtbewBFeASZ9O0g==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.30.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.30.0.tgz", - "integrity": "sha512-kvyIECEhs2DrrdfQf++maCWJIQ974EI4txlz1nNSBaCdtf7i5Xf1AQCEJWOC5rEBisdaMFFnOWNLYt7KpFqy5A==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.30.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.30.0.tgz", - "integrity": "sha512-CFE7zDNrokaotXu+shwIrmWrFxllg79vciH4E/zeK7NitVuWEaXRzS0mFfFvyhZfn8WfVOG/1E9u8/DFEgK7WQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.30.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.30.0.tgz", - "integrity": "sha512-MctNTBlvMcIBP0t8lV/NXiUwFg9oK5F79CxLU+a3xgrdJjfBLVIEHSAjQ9+ipofN2GKaMLnFFXLltg1HEEPaGQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loongarch64-gnu": { - "version": "4.30.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.30.0.tgz", - "integrity": "sha512-fBpoYwLEPivL3q368+gwn4qnYnr7GVwM6NnMo8rJ4wb0p/Y5lg88vQRRP077gf+tc25akuqd+1Sxbn9meODhwA==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.30.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.30.0.tgz", - "integrity": "sha512-1hiHPV6dUaqIMXrIjN+vgJqtfkLpqHS1Xsg0oUfUVD98xGp1wX89PIXgDF2DWra1nxAd8dfE0Dk59MyeKaBVAw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.30.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.30.0.tgz", - "integrity": "sha512-U0xcC80SMpEbvvLw92emHrNjlS3OXjAM0aVzlWfar6PR0ODWCTQtKeeB+tlAPGfZQXicv1SpWwRz9Hyzq3Jx3g==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.30.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.30.0.tgz", - "integrity": "sha512-VU/P/IODrNPasgZDLIFJmMiLGez+BN11DQWfTVlViJVabyF3JaeaJkP6teI8760f18BMGCQOW9gOmuzFaI1pUw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.30.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.30.0.tgz", - "integrity": "sha512-laQVRvdbKmjXuFA3ZiZj7+U24FcmoPlXEi2OyLfbpY2MW1oxLt9Au8q9eHd0x6Pw/Kw4oe9gwVXWwIf2PVqblg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.30.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.30.0.tgz", - "integrity": "sha512-3wzKzduS7jzxqcOvy/ocU/gMR3/QrHEFLge5CD7Si9fyHuoXcidyYZ6jyx8OPYmCcGm3uKTUl+9jUSAY74Ln5A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.30.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.30.0.tgz", - "integrity": "sha512-jROwnI1+wPyuv696rAFHp5+6RFhXGGwgmgSfzE8e4xfit6oLRg7GyMArVUoM3ChS045OwWr9aTnU+2c1UdBMyw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.30.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.30.0.tgz", - "integrity": "sha512-duzweyup5WELhcXx5H1jokpr13i3BV9b48FMiikYAwk/MT1LrMYYk2TzenBd0jj4ivQIt58JWSxc19y4SvLP4g==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.30.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.30.0.tgz", - "integrity": "sha512-DYvxS0M07PvgvavMIybCOBYheyrqlui6ZQBHJs6GqduVzHSZ06TPPvlfvnYstjODHQ8UUXFwt5YE+h0jFI8kwg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" - } - }, - "node_modules/@types/babel__generator": { - "version": "7.6.8", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.8.tgz", - "integrity": "sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", - "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__traverse": { - "version": "7.20.6", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.6.tgz", - "integrity": "sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.20.7" - } - }, - "node_modules/@types/cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", - "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/parse-json": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", - "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", - "license": "MIT" - }, - "node_modules/@types/prop-types": { - "version": "15.7.14", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz", - "integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==", - "license": "MIT" - }, - "node_modules/@types/react": { - "version": "18.3.18", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.18.tgz", - "integrity": "sha512-t4yC+vtgnkYjNSKlFx1jkAhH8LgTo2N/7Qvi83kdEaUtMDiwpbLAktKDaAMlRcJ5eSxZkH74eEGt1ky31d7kfQ==", - "license": "MIT", - "dependencies": { - "@types/prop-types": "*", - "csstype": "^3.0.2" - } - }, - "node_modules/@types/react-dom": { - "version": "18.3.5", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.5.tgz", - "integrity": "sha512-P4t6saawp+b/dFrUr2cvkVsfvPguwsxtH6dNIYRllMsefqFzkZk5UIjzyDOv5g1dXIPdG4Sp1yCR4Z6RCUsG/Q==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "^18.0.0" - } - }, - "node_modules/@types/react-transition-group": { - "version": "4.4.12", - "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", - "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*" - } - }, - "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.19.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.19.1.tgz", - "integrity": "sha512-tJzcVyvvb9h/PB96g30MpxACd9IrunT7GF9wfA9/0TJ1LxGOJx1TdPzSbBBnNED7K9Ka8ybJsnEpiXPktolTLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.19.1", - "@typescript-eslint/type-utils": "8.19.1", - "@typescript-eslint/utils": "8.19.1", - "@typescript-eslint/visitor-keys": "8.19.1", - "graphemer": "^1.4.0", - "ignore": "^5.3.1", - "natural-compare": "^1.4.0", - "ts-api-utils": "^2.0.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.8.0" - } - }, - "node_modules/@typescript-eslint/parser": { - "version": "8.19.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.19.1.tgz", - "integrity": "sha512-67gbfv8rAwawjYx3fYArwldTQKoYfezNUT4D5ioWetr/xCrxXxvleo3uuiFuKfejipvq+og7mjz3b0G2bVyUCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/scope-manager": "8.19.1", - "@typescript-eslint/types": "8.19.1", - "@typescript-eslint/typescript-estree": "8.19.1", - "@typescript-eslint/visitor-keys": "8.19.1", - "debug": "^4.3.4" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.8.0" - } - }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "8.19.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.19.1.tgz", - "integrity": "sha512-60L9KIuN/xgmsINzonOcMDSB8p82h95hoBfSBtXuO4jlR1R9L1xSkmVZKgCPVfavDlXihh4ARNjXhh1gGnLC7Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.19.1", - "@typescript-eslint/visitor-keys": "8.19.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/type-utils": { - "version": "8.19.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.19.1.tgz", - "integrity": "sha512-Rp7k9lhDKBMRJB/nM9Ksp1zs4796wVNyihG9/TU9R6KCJDNkQbc2EOKjrBtLYh3396ZdpXLtr/MkaSEmNMtykw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/typescript-estree": "8.19.1", - "@typescript-eslint/utils": "8.19.1", - "debug": "^4.3.4", - "ts-api-utils": "^2.0.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.8.0" - } - }, - "node_modules/@typescript-eslint/types": { - "version": "8.19.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.19.1.tgz", - "integrity": "sha512-JBVHMLj7B1K1v1051ZaMMgLW4Q/jre5qGK0Ew6UgXz1Rqh+/xPzV1aW581OM00X6iOfyr1be+QyW8LOUf19BbA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.19.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.19.1.tgz", - "integrity": "sha512-jk/TZwSMJlxlNnqhy0Eod1PNEvCkpY6MXOXE/WLlblZ6ibb32i2We4uByoKPv1d0OD2xebDv4hbs3fm11SMw8Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.19.1", - "@typescript-eslint/visitor-keys": "8.19.1", - "debug": "^4.3.4", - "fast-glob": "^3.3.2", - "is-glob": "^4.0.3", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "ts-api-utils": "^2.0.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <5.8.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@typescript-eslint/utils": { - "version": "8.19.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.19.1.tgz", - "integrity": "sha512-IxG5gLO0Ne+KaUc8iW1A+XuKLd63o4wlbI1Zp692n1xojCl/THvgIKXJXBZixTh5dd5+yTJ/VXH7GJaaw21qXA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.19.1", - "@typescript-eslint/types": "8.19.1", - "@typescript-eslint/typescript-estree": "8.19.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.8.0" - } - }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.19.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.19.1.tgz", - "integrity": "sha512-fzmjU8CHK853V/avYZAvuVut3ZTfwN5YtMaoi+X9Y9MA9keaWNHC3zEQ9zvyX/7Hj+5JkNyK1l7TOR2hevHB6Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.19.1", - "eslint-visitor-keys": "^4.2.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@vitejs/plugin-react": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.3.4.tgz", - "integrity": "sha512-SCCPBJtYLdE8PX/7ZQAs1QAZ8Jqwih+0VBLum1EGqmCCQal+MIUqLCzj3ZUy8ufbC0cAM4LRlSTm7IQJwWT4ug==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.26.0", - "@babel/plugin-transform-react-jsx-self": "^7.25.9", - "@babel/plugin-transform-react-jsx-source": "^7.25.9", - "@types/babel__core": "^7.20.5", - "react-refresh": "^0.14.2" - }, - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0" - } - }, - "node_modules/acorn": { - "version": "8.14.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", - "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "license": "MIT" - }, - "node_modules/axios": { - "version": "1.7.9", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.9.tgz", - "integrity": "sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==", - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.0", - "proxy-from-env": "^1.1.0" - } - }, - "node_modules/babel-plugin-macros": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", - "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.12.5", - "cosmiconfig": "^7.0.0", - "resolve": "^1.19.0" - }, - "engines": { - "node": ">=10", - "npm": ">=6" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/browserslist": { - "version": "4.24.3", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.3.tgz", - "integrity": "sha512-1CPmv8iobE2fyRMV97dAcMVegvvWKxmq94hkLiAkUGwKVTyDLw33K+ZxiFrREKmmps4rIw6grcCFCnTMSZ/YiA==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "caniuse-lite": "^1.0.30001688", - "electron-to-chromium": "^1.5.73", - "node-releases": "^2.0.19", - "update-browserslist-db": "^1.1.1" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001690", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001690.tgz", - "integrity": "sha512-5ExiE3qQN6oF8Clf8ifIDcMRCRE/dMGcETG/XGMD8/XiXm6HXQgQTh1yZYLXXpSOsEUlJm1Xr7kGULZTuGtP/w==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/clsx": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", - "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, - "node_modules/convert-source-map": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", - "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", - "license": "MIT" - }, - "node_modules/cookie": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", - "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/cosmiconfig": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", - "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", - "license": "MIT", - "dependencies": { - "@types/parse-json": "^4.0.0", - "import-fresh": "^3.2.1", - "parse-json": "^5.0.0", - "path-type": "^4.0.0", - "yaml": "^1.10.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/cosmiconfig/node_modules/yaml": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", - "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", - "license": "ISC", - "engines": { - "node": ">= 6" - } - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/csstype": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "license": "MIT" - }, - "node_modules/debug": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/dom-helpers": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", - "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.8.7", - "csstype": "^3.0.2" - } - }, - "node_modules/electron-to-chromium": { - "version": "1.5.77", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.77.tgz", - "integrity": "sha512-AnJSrt5JpRVgY6dgd5yccguLc5A7oMSF0Kt3fcW+Hp5WTuFbl5upeSFZbMZYy2o7jhmIhU8Ekrd82GhyXUqUUg==", - "dev": true, - "license": "ISC" - }, - "node_modules/error-ex": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", - "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", - "license": "MIT", - "dependencies": { - "is-arrayish": "^0.2.1" - } - }, - "node_modules/esbuild": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz", - "integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.24.2", - "@esbuild/android-arm": "0.24.2", - "@esbuild/android-arm64": "0.24.2", - "@esbuild/android-x64": "0.24.2", - "@esbuild/darwin-arm64": "0.24.2", - "@esbuild/darwin-x64": "0.24.2", - "@esbuild/freebsd-arm64": "0.24.2", - "@esbuild/freebsd-x64": "0.24.2", - "@esbuild/linux-arm": "0.24.2", - "@esbuild/linux-arm64": "0.24.2", - "@esbuild/linux-ia32": "0.24.2", - "@esbuild/linux-loong64": "0.24.2", - "@esbuild/linux-mips64el": "0.24.2", - "@esbuild/linux-ppc64": "0.24.2", - "@esbuild/linux-riscv64": "0.24.2", - "@esbuild/linux-s390x": "0.24.2", - "@esbuild/linux-x64": "0.24.2", - "@esbuild/netbsd-arm64": "0.24.2", - "@esbuild/netbsd-x64": "0.24.2", - "@esbuild/openbsd-arm64": "0.24.2", - "@esbuild/openbsd-x64": "0.24.2", - "@esbuild/sunos-x64": "0.24.2", - "@esbuild/win32-arm64": "0.24.2", - "@esbuild/win32-ia32": "0.24.2", - "@esbuild/win32-x64": "0.24.2" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint": { - "version": "9.17.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.17.0.tgz", - "integrity": "sha512-evtlNcpJg+cZLcnVKwsai8fExnqjGPicK7gnUtlNuzu+Fv9bI0aLpND5T44VLQtoMEnI57LoXO9XAkIXwohKrA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.19.0", - "@eslint/core": "^0.9.0", - "@eslint/eslintrc": "^3.2.0", - "@eslint/js": "9.17.0", - "@eslint/plugin-kit": "^0.2.3", - "@humanfs/node": "^0.16.6", - "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.1", - "@types/estree": "^1.0.6", - "@types/json-schema": "^7.0.15", - "ajv": "^6.12.4", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.6", - "debug": "^4.3.2", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.2.0", - "eslint-visitor-keys": "^4.2.0", - "espree": "^10.3.0", - "esquery": "^1.5.0", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^8.0.0", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "jiti": "*" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - } - } - }, - "node_modules/eslint-plugin-react-hooks": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.1.0.tgz", - "integrity": "sha512-mpJRtPgHN2tNAvZ35AMfqeB3Xqeo273QxrHJsbBEPWODRM4r0yB6jfoROqKEYrOn27UtRPpcpHc2UqyBSuUNTw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" - } - }, - "node_modules/eslint-plugin-react-refresh": { - "version": "0.4.16", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.16.tgz", - "integrity": "sha512-slterMlxAhov/DZO8NScf6mEeMBBXodFUolijDvrtTxyezyLoTQaa73FyYus/VbTdftd8wBgBxPMRk3poleXNQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "eslint": ">=8.40" - } - }, - "node_modules/eslint-scope": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.2.0.tgz", - "integrity": "sha512-PHlWUfG6lvPc3yvP5A4PNyBL1W8fkDUccmI21JUu/+GKZBoH/W5u6usENXUrWFRsyoW5ACUjFGgAFQp5gUlb/A==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-visitor-keys": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", - "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/espree": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz", - "integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.14.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fastq": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.18.0.tgz", - "integrity": "sha512-QKHXPW0hD8g4UET03SdOdunzSouc9N4AuHdsX8XNcTsuz+yYFILVNIX4l9yHABMhiEI9Db0JTTIpu0wB+Y1QQw==", - "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, - "node_modules/file-entry-cache": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", - "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "flat-cache": "^4.0.0" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/find-root": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", - "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", - "license": "MIT" - }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/flat-cache": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", - "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.4" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/flatted": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.2.tgz", - "integrity": "sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA==", - "dev": true, - "license": "ISC" - }, - "node_modules/follow-redirects": { - "version": "1.15.9", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", - "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, - "node_modules/form-data": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz", - "integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==", - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/globals": { - "version": "15.14.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-15.14.0.tgz", - "integrity": "sha512-OkToC372DtlQeje9/zHIo5CT8lRP/FUgEOKBEhU4e0abL7J7CD24fD9ohiLN5hagG/kWCYj4K5oaxxtj2Z0Dig==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true, - "license": "MIT" - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/hoist-non-react-statics": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", - "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", - "license": "BSD-3-Clause", - "dependencies": { - "react-is": "^16.7.0" - } - }, - "node_modules/hoist-non-react-statics/node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT" - }, - "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/import-fresh": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", - "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "license": "MIT" - }, - "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", - "license": "MIT", - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC" - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "license": "MIT" - }, - "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "license": "MIT" - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" - } - }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "license": "MIT" - }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "license": "MIT", - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" - } - }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" - } - }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.8", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", - "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true, - "license": "MIT" - }, - "node_modules/node-releases": { - "version": "2.0.19", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", - "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", - "dev": true, - "license": "MIT" - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.5" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/parse-json": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.0.0", - "error-ex": "^1.3.1", - "json-parse-even-better-errors": "^2.3.0", - "lines-and-columns": "^1.1.6" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "license": "MIT" - }, - "node_modules/path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/postcss": { - "version": "8.4.49", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz", - "integrity": "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.7", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" - } - }, - "node_modules/prop-types/node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT" - }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/react": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", - "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-apple-signin-auth": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/react-apple-signin-auth/-/react-apple-signin-auth-1.1.0.tgz", - "integrity": "sha512-cEFj5kVBa0R7K2Ah/F0kVtttVX19YZ0Fm6tSAICxEj9SmP6kwYHUysZ8N558cHHG09/cK+NTZ9pUxGVNXlG2Lg==", - "license": "MIT", - "peerDependencies": { - "react": ">= 16.8.0", - "react-dom": ">= 16.8.0" - } - }, - "node_modules/react-dom": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", - "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0", - "scheduler": "^0.23.2" - }, - "peerDependencies": { - "react": "^18.3.1" - } - }, - "node_modules/react-is": { - "version": "19.0.0", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.0.0.tgz", - "integrity": "sha512-H91OHcwjZsbq3ClIDHMzBShc1rotbfACdWENsmEf0IFvZ3FgGPtdHMcsv45bQ1hAbgdfiA8SnxTKfDS+x/8m2g==", - "license": "MIT" - }, - "node_modules/react-refresh": { - "version": "0.14.2", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", - "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-router": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.1.1.tgz", - "integrity": "sha512-39sXJkftkKWRZ2oJtHhCxmoCrBCULr/HAH4IT5DHlgu/Q0FCPV0S4Lx+abjDTx/74xoZzNYDYbOZWlJjruyuDQ==", - "license": "MIT", - "dependencies": { - "@types/cookie": "^0.6.0", - "cookie": "^1.0.1", - "set-cookie-parser": "^2.6.0", - "turbo-stream": "2.4.0" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "react": ">=18", - "react-dom": ">=18" - }, - "peerDependenciesMeta": { - "react-dom": { - "optional": true - } - } - }, - "node_modules/react-router-dom": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.1.1.tgz", - "integrity": "sha512-vSrQHWlJ5DCfyrhgo0k6zViOe9ToK8uT5XGSmnuC2R3/g261IdIMpZVqfjD6vWSXdnf5Czs4VA/V60oVR6/jnA==", - "license": "MIT", - "dependencies": { - "react-router": "7.1.1" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "react": ">=18", - "react-dom": ">=18" - } - }, - "node_modules/react-transition-group": { - "version": "4.4.5", - "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", - "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", - "license": "BSD-3-Clause", - "dependencies": { - "@babel/runtime": "^7.5.5", - "dom-helpers": "^5.0.1", - "loose-envify": "^1.4.0", - "prop-types": "^15.6.2" - }, - "peerDependencies": { - "react": ">=16.6.0", - "react-dom": ">=16.6.0" - } - }, - "node_modules/regenerator-runtime": { - "version": "0.14.1", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", - "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", - "license": "MIT" - }, - "node_modules/resolve": { - "version": "1.22.10", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", - "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", - "license": "MIT", - "dependencies": { - "is-core-module": "^2.16.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/reusify": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", - "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", - "dev": true, - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, - "node_modules/rollup": { - "version": "4.30.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.30.0.tgz", - "integrity": "sha512-sDnr1pcjTgUT69qBksNF1N1anwfbyYG6TBQ22b03bII8EdiUQ7J0TlozVaTMjT/eEJAO49e1ndV7t+UZfL1+vA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.6" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.30.0", - "@rollup/rollup-android-arm64": "4.30.0", - "@rollup/rollup-darwin-arm64": "4.30.0", - "@rollup/rollup-darwin-x64": "4.30.0", - "@rollup/rollup-freebsd-arm64": "4.30.0", - "@rollup/rollup-freebsd-x64": "4.30.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.30.0", - "@rollup/rollup-linux-arm-musleabihf": "4.30.0", - "@rollup/rollup-linux-arm64-gnu": "4.30.0", - "@rollup/rollup-linux-arm64-musl": "4.30.0", - "@rollup/rollup-linux-loongarch64-gnu": "4.30.0", - "@rollup/rollup-linux-powerpc64le-gnu": "4.30.0", - "@rollup/rollup-linux-riscv64-gnu": "4.30.0", - "@rollup/rollup-linux-s390x-gnu": "4.30.0", - "@rollup/rollup-linux-x64-gnu": "4.30.0", - "@rollup/rollup-linux-x64-musl": "4.30.0", - "@rollup/rollup-win32-arm64-msvc": "4.30.0", - "@rollup/rollup-win32-ia32-msvc": "4.30.0", - "@rollup/rollup-win32-x64-msvc": "4.30.0", - "fsevents": "~2.3.2" - } - }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, - "node_modules/scheduler": { - "version": "0.23.2", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", - "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - } - }, - "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/set-cookie-parser": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", - "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==", - "license": "MIT" - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/source-map": { - "version": "0.5.7", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", - "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/stylis": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", - "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", - "license": "MIT" - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/ts-api-utils": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.0.0.tgz", - "integrity": "sha512-xCt/TOAc+EOHS1XPnijD3/yzpH6qg2xppZO1YDqGoVsNXfQfzHpOdNuXwrwOU8u4ITXJyDCTyt8w5g1sZv9ynQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.12" - }, - "peerDependencies": { - "typescript": ">=4.8.4" - } - }, - "node_modules/turbo-stream": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/turbo-stream/-/turbo-stream-2.4.0.tgz", - "integrity": "sha512-FHncC10WpBd2eOmGwpmQsWLDoK4cqsA/UT/GqNoaKOQnT8uzhtCbg3EoUDMvqpOSAI0S26mr0rkjzbOO6S3v1g==", - "license": "ISC" - }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/typescript": { - "version": "5.6.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", - "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/typescript-eslint": { - "version": "8.19.1", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.19.1.tgz", - "integrity": "sha512-LKPUQpdEMVOeKluHi8md7rwLcoXHhwvWp3x+sJkMuq3gGm9yaYJtPo8sRZSblMFJ5pcOGCAak/scKf1mvZDlQw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/eslint-plugin": "8.19.1", - "@typescript-eslint/parser": "8.19.1", - "@typescript-eslint/utils": "8.19.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.8.0" - } - }, - "node_modules/update-browserslist-db": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.1.tgz", - "integrity": "sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.0" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/vite": { - "version": "6.0.7", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.0.7.tgz", - "integrity": "sha512-RDt8r/7qx9940f8FcOIAH9PTViRrghKaK2K1jY3RaAURrEUbm9Du1mJ72G+jlhtG3WwodnfzY8ORQZbBavZEAQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.24.2", - "postcss": "^8.4.49", - "rollup": "^4.23.0" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "jiti": ">=1.21.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/word-wrap": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true, - "license": "ISC" - }, - "node_modules/yaml": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.0.tgz", - "integrity": "sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA==", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - } - } -} diff --git a/client/package.json b/client/package.json deleted file mode 100644 index 4901c48..0000000 --- a/client/package.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "name": "client", - "private": true, - "version": "0.0.0", - "type": "module", - "scripts": { - "dev": "vite", - "build": "tsc -b && vite build", - "lint": "eslint .", - "preview": "vite preview" - }, - "dependencies": { - "@emotion/react": "^11.14.0", - "@emotion/styled": "^11.14.0", - "@fontsource/roboto": "^5.1.1", - "@mui/icons-material": "^6.3.1", - "@mui/material": "^6.3.1", - "@react-oauth/google": "^0.12.1", - "axios": "^1.7.9", - "react": "^18.3.1", - "react-apple-signin-auth": "^1.1.0", - "react-dom": "^18.3.1", - "react-router-dom": "^7.1.1" - }, - "devDependencies": { - "@eslint/js": "^9.17.0", - "@types/react": "^18.3.18", - "@types/react-dom": "^18.3.5", - "@vitejs/plugin-react": "^4.3.4", - "eslint": "^9.17.0", - "eslint-plugin-react-hooks": "^5.0.0", - "eslint-plugin-react-refresh": "^0.4.16", - "globals": "^15.14.0", - "typescript": "~5.6.2", - "typescript-eslint": "^8.18.2", - "vite": "^6.0.5" - } -} diff --git a/client/public/vite.svg b/client/public/vite.svg deleted file mode 100644 index e7b8dfb..0000000 --- a/client/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/client/src/App.css b/client/src/App.css deleted file mode 100644 index b9d355d..0000000 --- a/client/src/App.css +++ /dev/null @@ -1,42 +0,0 @@ -#root { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; - text-align: center; -} - -.logo { - height: 6em; - padding: 1.5em; - will-change: filter; - transition: filter 300ms; -} -.logo:hover { - filter: drop-shadow(0 0 2em #646cffaa); -} -.logo.react:hover { - filter: drop-shadow(0 0 2em #61dafbaa); -} - -@keyframes logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - -@media (prefers-reduced-motion: no-preference) { - a:nth-of-type(2) .logo { - animation: logo-spin infinite 20s linear; - } -} - -.card { - padding: 2em; -} - -.read-the-docs { - color: #888; -} diff --git a/client/src/App.tsx b/client/src/App.tsx deleted file mode 100644 index 5894b95..0000000 --- a/client/src/App.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import { CssBaseline, ThemeProvider, createTheme } from '@mui/material'; -import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'; -import { LoginForm } from './components/auth/LoginForm'; -import { RegisterForm } from './components/auth/RegisterForm'; -import { Dashboard } from './components/dashboard/Dashboard'; -import { AddJuniorForm } from './components/juniors/AddJuniorForm'; -import { JuniorsList } from './components/juniors/JuniorsList'; -import { AuthLayout } from './components/layout/AuthLayout'; -import { AddTaskForm } from './components/tasks/AddTask'; -import { TaskDetails } from './components/tasks/TaskDetails'; -import { TasksList } from './components/tasks/TasksList'; -import { AuthProvider } from './contexts/AuthContext'; - -// Create theme -const theme = createTheme({ - palette: { - primary: { - main: '#00A7E1', // Bright blue like Zod Wallet - light: '#33B7E7', - dark: '#0074B2', - }, - secondary: { - main: '#FF6B6B', // Coral red for accents - light: '#FF8E8E', - dark: '#FF4848', - }, - background: { - default: '#F8F9FA', - paper: '#FFFFFF', - }, - text: { - primary: '#2D3748', // Dark gray for main text - secondary: '#718096', // Medium gray for secondary text - }, - }, - typography: { - fontFamily: '"Inter", "Helvetica", "Arial", sans-serif', - h1: { - fontWeight: 700, - fontSize: '2.5rem', - }, - h2: { - fontWeight: 600, - fontSize: '2rem', - }, - h3: { - fontWeight: 600, - fontSize: '1.75rem', - }, - h4: { - fontWeight: 600, - fontSize: '1.5rem', - }, - h5: { - fontWeight: 600, - fontSize: '1.25rem', - }, - h6: { - fontWeight: 600, - fontSize: '1rem', - }, - button: { - textTransform: 'none', - fontWeight: 500, - }, - }, - shape: { - borderRadius: 12, - }, - components: { - MuiButton: { - styleOverrides: { - root: { - borderRadius: '8px', - padding: '8px 16px', - fontWeight: 500, - }, - contained: { - boxShadow: 'none', - '&:hover': { - boxShadow: 'none', - }, - }, - }, - }, - MuiCard: { - styleOverrides: { - root: { - borderRadius: '16px', - boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)', - }, - }, - }, - }, -}); - -function App() { - return ( - - - - - - {/* Public routes */} - } /> - } /> - - {/* Protected routes */} - }> - } /> - } /> - } /> - } /> - } /> - } /> - - - {/* Redirect root to dashboard or login */} - } /> - - - - - ); -} - -export default App; diff --git a/client/src/api/client.ts b/client/src/api/client.ts deleted file mode 100644 index 165a7cb..0000000 --- a/client/src/api/client.ts +++ /dev/null @@ -1,140 +0,0 @@ -import axios from 'axios'; -import { LoginRequest } from '../types/auth'; -import { CreateJuniorRequest, JuniorTheme } from '../types/junior'; -import { CreateTaskRequest, TaskStatus, TaskSubmission } from '../types/task'; - -const API_BASE_URL = 'https://zod.life'; -const AUTH_TOKEN = btoa('zod-digital:Zod2025'); // Base64 encode credentials - -// Helper function to get auth header -const getAuthHeader = () => { - const token = localStorage.getItem('accessToken'); - return token ? `Bearer ${token}` : `Basic ${AUTH_TOKEN}`; -}; - -export const apiClient = axios.create({ - baseURL: API_BASE_URL, - headers: { - 'Content-Type': 'application/json', - 'x-client-id': 'web-client', - }, -}); - -// Add request interceptor to include current auth header -apiClient.interceptors.request.use((config) => { - config.headers.Authorization = getAuthHeader(); - return config; -}); - -// Add response interceptor to handle errors -apiClient.interceptors.response.use( - (response) => response, - (error) => { - const errorMessage = - error.response?.data?.message || error.response?.data?.error || error.message || 'An unexpected error occurred'; - - console.error('API Error:', { - status: error.response?.status, - message: errorMessage, - data: error.response?.data, - }); - - // Throw error with meaningful message - throw new Error(errorMessage); - }, -); - -// Auth API -export const authApi = { - register: (countryCode: string, phoneNumber: string) => { - // Ensure phone number is in the correct format (remove any non-digit characters) - const cleanPhoneNumber = phoneNumber.replace(/\D/g, ''); - return apiClient.post('/api/auth/register/otp', { - countryCode: countryCode.startsWith('+') ? countryCode : `+${countryCode}`, - phoneNumber: cleanPhoneNumber, - }); - }, - - verifyOtp: (countryCode: string, phoneNumber: string, otp: string) => - apiClient.post('/api/auth/register/verify', { countryCode, phoneNumber, otp }), - - setEmail: (email: string) => { - // Use the stored token from localStorage - const storedToken = localStorage.getItem('accessToken'); - if (!storedToken) { - throw new Error('No access token found'); - } - return apiClient.post('/api/auth/register/set-email', { email }); - }, - - setPasscode: (passcode: string) => { - // Use the stored token from localStorage - const storedToken = localStorage.getItem('accessToken'); - if (!storedToken) { - throw new Error('No access token found'); - } - return apiClient.post('/api/auth/register/set-passcode', { passcode }); - }, - - login: ({ grantType, email, password, appleToken, googleToken }: LoginRequest) => - apiClient.post('/api/auth/login', { - grantType, - email, - password, - appleToken, - googleToken, - fcmToken: 'web-client-token', // Required by API - signature: 'web-login', // Required by API - }), -}; - -// Juniors API -export const juniorsApi = { - createJunior: (data: CreateJuniorRequest) => apiClient.post('/api/juniors', data), - - getJuniors: (page = 1, size = 10) => apiClient.get(`/api/juniors?page=${page}&size=${size}`), - - getJunior: (juniorId: string) => apiClient.get(`/api/juniors/${juniorId}`), - - setTheme: (data: JuniorTheme) => apiClient.post('/api/juniors/set-theme', data), - - getQrCode: (juniorId: string) => apiClient.get(`/api/juniors/${juniorId}/qr-code`), - - validateQrCode: (token: string) => apiClient.get(`/api/juniors/qr-code/${token}/validate`), -}; - -// Document API -export const documentApi = { - upload: (file: File, documentType: string) => { - const formData = new FormData(); - formData.append('document', file); - formData.append('documentType', documentType); - return apiClient.post('/api/document', formData, { - headers: { - 'Content-Type': 'multipart/form-data', - }, - }); - }, -}; - -// Tasks API -export const tasksApi = { - createTask: (data: CreateTaskRequest) => apiClient.post('/api/tasks', data), - - getTasks: (status: TaskStatus, page = 1, size = 10, juniorId?: string) => { - const url = new URL('/api/tasks', API_BASE_URL); - url.searchParams.append('status', status); - url.searchParams.append('page', page.toString()); - url.searchParams.append('size', size.toString()); - if (juniorId) url.searchParams.append('juniorId', juniorId); - return apiClient.get(url.pathname + url.search); - }, - - getTaskById: (taskId: string) => apiClient.get(`/api/tasks/${taskId}`), - - submitTask: (taskId: string, data: TaskSubmission) => apiClient.patch(`/api/tasks/${taskId}/submit`, data), - - approveTask: (taskId: string) => apiClient.patch(`/api/tasks/${taskId}/approve`), - - rejectTask: (taskId: string) => apiClient.patch(`/api/tasks/${taskId}/reject`), -}; diff --git a/client/src/assets/react.svg b/client/src/assets/react.svg deleted file mode 100644 index 6c87de9..0000000 --- a/client/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/client/src/components/auth/AppleLogin.tsx b/client/src/components/auth/AppleLogin.tsx deleted file mode 100644 index 4abaca8..0000000 --- a/client/src/components/auth/AppleLogin.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import AppleSignInButton from 'react-apple-signin-auth'; -import { useNavigate } from 'react-router-dom'; -import { useAuth } from '../../contexts/AuthContext'; -import { GrantType } from '../../enums'; - -interface LoginProps { - setError: (error: string) => void; - setLoading: (loading: boolean) => void; -} -export const AppleLogin = ({ setError, setLoading }: LoginProps) => { - const { login } = useAuth(); - const navigate = useNavigate(); - - const onError = (err: any) => { - setError(err instanceof Error ? err.message : 'Login failed. Please check your credentials.'); - }; - - const onSuccess = async (response: any) => { - try { - setLoading(true); - await login({ grantType: GrantType.APPLE, appleToken: response.authorization.id_token }); - navigate('/dashboard'); - } catch (error) { - setError(error instanceof Error ? error.message : 'Login failed. Please check your credentials.'); - } finally { - setLoading(false); - } - }; - - return ( - { - onSuccess(response); - }} // default = undefined - /** Called upon signin error */ - onError={(error: any) => onError(error)} // default = undefined - /** Skips loading the apple script if true */ - skipScript={false} // default = undefined - /** Apple image props */ - - /** render function - called with all props - can be used to fully customize the UI by rendering your own component */ - /> - ); -}; diff --git a/client/src/components/auth/GoogleLogin.tsx b/client/src/components/auth/GoogleLogin.tsx deleted file mode 100644 index f7c6ab9..0000000 --- a/client/src/components/auth/GoogleLogin.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { GoogleLogin as GoogleApiLogin, GoogleOAuthProvider } from '@react-oauth/google'; -import { useNavigate } from 'react-router-dom'; -import { useAuth } from '../../contexts/AuthContext'; -import { GrantType } from '../../enums'; -interface LoginProps { - setError: (error: string) => void; - setLoading: (loading: boolean) => void; -} -export const GoogleLogin = ({ setError, setLoading }: LoginProps) => { - const { login } = useAuth(); - const navigate = useNavigate(); - - const onError = (err: any) => { - setError(err instanceof Error ? err.message : 'Login failed. Please check your credentials.'); - }; - - const onSuccess = async (response: any) => { - try { - setLoading(true); - await login({ grantType: GrantType.GOOGLE, googleToken: response.credential }); - navigate('/dashboard'); - } catch (error) { - setError(error instanceof Error ? error.message : 'Login failed. Please check your credentials.'); - } finally { - setLoading(false); - } - }; - return ( - - { - onSuccess(credentialResponse); - }} - onError={() => { - onError('Login failed. Please check your credentials.'); - }} - /> - - ); -}; diff --git a/client/src/components/auth/LoginForm.tsx b/client/src/components/auth/LoginForm.tsx deleted file mode 100644 index 193c7f7..0000000 --- a/client/src/components/auth/LoginForm.tsx +++ /dev/null @@ -1,149 +0,0 @@ -import { Alert, Box, Button, Container, Paper, TextField, Typography } from '@mui/material'; -import React, { useState } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { useAuth } from '../../contexts/AuthContext'; -import { GrantType } from '../../enums'; -import { AppleLogin } from './AppleLogin'; -import { GoogleLogin } from './GoogleLogin'; -export const LoginForm = () => { - const { login } = useAuth(); - const navigate = useNavigate(); - const [formData, setFormData] = useState({ - email: '', - password: '', - }); - const [error, setError] = useState(''); - const [loading, setLoading] = useState(false); - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setError(''); - setLoading(true); - - try { - await login({ email: formData.email, password: formData.password, grantType: GrantType.PASSWORD }); - navigate('/dashboard'); - } catch (err) { - setError(err instanceof Error ? err.message : 'Login failed. Please check your credentials.'); - } finally { - setLoading(false); - } - }; - - const handleInputChange = (e: React.ChangeEvent) => { - const { name, value } = e.target; - setFormData((prev) => ({ - ...prev, - [name]: value, - })); - }; - return ( - - - - - Zod Alkhair | API TEST - - - login to your account. - - - - - {error && ( - - {error} - - )} - - - - - - - - - - - - - - - - ); -}; diff --git a/client/src/components/auth/RegisterForm.tsx b/client/src/components/auth/RegisterForm.tsx deleted file mode 100644 index 6278aea..0000000 --- a/client/src/components/auth/RegisterForm.tsx +++ /dev/null @@ -1,254 +0,0 @@ -import { Alert, Box, Button, Container, Paper, Step, StepLabel, Stepper, TextField, Typography } from '@mui/material'; -import React, { useState } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { useAuth } from '../../contexts/AuthContext'; - -const steps = ['Phone Verification', 'Email', 'Set Passcode']; - -export const RegisterForm = () => { - const { register, verifyOtp, setEmail, setPasscode } = useAuth(); - const navigate = useNavigate(); - const [activeStep, setActiveStep] = useState(0); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(''); - const [formData, setFormData] = useState({ - countryCode: '+962', - phoneNumber: '', - otp: '', - email: '', - passcode: '', - confirmPasscode: '', - otpRequested: false - }); - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setError(''); - setLoading(true); - - try { - switch (activeStep) { - case 0: - if (!formData.otpRequested) { - // Request OTP - await register(formData.countryCode, formData.phoneNumber); - setFormData(prev => ({ ...prev, otpRequested: true })); - } else { - // Verify OTP - await verifyOtp(formData.countryCode, formData.phoneNumber, formData.otp); - setActiveStep(1); - } - break; - case 1: - await setEmail(formData.email); - setActiveStep(2); - break; - case 2: - if (formData.passcode !== formData.confirmPasscode) { - throw new Error('Passcodes do not match'); - } - await setPasscode(formData.passcode); - navigate('/dashboard'); - break; - } - } catch (err) { - setError(err instanceof Error ? err.message : 'Registration failed'); - } finally { - setLoading(false); - } - }; - - const handleInputChange = (e: React.ChangeEvent) => { - const { name, value } = e.target; - setFormData((prev) => ({ - ...prev, - [name]: value, - })); - }; - - const renderStepContent = () => { - switch (activeStep) { - case 0: - return ( - <> - - {formData.otpRequested && ( - - )} - - ); - case 1: - return ( - - ); - case 2: - return ( - <> - - - - ); - default: - return null; - } - }; - - return ( - - - - - Zod Alkhair | API TEST - - - signup - - - - - - {steps.map((label) => ( - - {label} - - ))} - - - {error && ( - - {error} - - )} - - - {renderStepContent()} - - - - - - - - - ); -}; diff --git a/client/src/components/dashboard/Dashboard.tsx b/client/src/components/dashboard/Dashboard.tsx deleted file mode 100644 index c1627ec..0000000 --- a/client/src/components/dashboard/Dashboard.tsx +++ /dev/null @@ -1,151 +0,0 @@ -import { - People as PeopleIcon, - Assignment as TaskIcon, - TrendingUp as TrendingUpIcon, - AccountBalance as WalletIcon -} from '@mui/icons-material'; -import { - Box, - Button, - Card, - CardContent, - Grid, - Paper, - Typography, - useTheme -} from '@mui/material'; -import { useNavigate } from 'react-router-dom'; - -export const Dashboard = () => { - const theme = useTheme(); - const navigate = useNavigate(); - - const stats = [ - { - title: 'Total Juniors', - value: '3', - icon: , - action: () => navigate('/juniors') - }, - { - title: 'Active Tasks', - value: '5', - icon: , - action: () => navigate('/tasks') - }, - { - title: 'Total Balance', - value: 'SAR 500', - icon: , - action: () => { } - }, - { - title: 'Monthly Growth', - value: '+15%', - icon: , - action: () => { } - } - ]; - - return ( - - - - Welcome to Zod Alkhair, - - - This is the API Testing client - - - - - {stats.map((stat, index) => ( - - - - - {stat.icon} - - - {stat.value} - - - {stat.title} - - - - - ))} - - - - - - - Quick Actions - - - - - - - - - - - - - - - Recent Activity - - - No recent activity - - - - - - ); -}; diff --git a/client/src/components/document/DocumentUpload.tsx b/client/src/components/document/DocumentUpload.tsx deleted file mode 100644 index 1eebc29..0000000 --- a/client/src/components/document/DocumentUpload.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { CloudUpload as CloudUploadIcon } from '@mui/icons-material'; -import { Alert, Box, Button, CircularProgress } from '@mui/material'; -import { AxiosError } from 'axios'; -import React, { useState } from 'react'; -import { documentApi } from '../../api/client'; -import { ApiError } from '../../types/api'; -import { DocumentType } from '../../types/document'; - -interface DocumentUploadProps { - onUploadSuccess: (documentId: string) => void; - documentType: DocumentType; - label: string; -} - -export const DocumentUpload = ({ onUploadSuccess, documentType, label }: DocumentUploadProps) => { - const [loading, setLoading] = useState(false); - const [error, setError] = useState(''); - const [success, setSuccess] = useState(false); - - const handleFileChange = async (event: React.ChangeEvent) => { - const file = event.target.files?.[0]; - if (!file) return; - - setLoading(true); - setError(''); - setSuccess(false); - - try { - const response = await documentApi.upload(file, documentType); - console.log('Document upload response:', response.data); - const documentId = response.data.data.id; - console.log('Extracted document ID:', documentId); - onUploadSuccess(documentId); - setSuccess(true); - } catch (err) { - if (err instanceof AxiosError && err.response?.data) { - const apiError = err.response.data as ApiError; - const messages = Array.isArray(apiError.message) - ? apiError.message.map((m) => `${m.field}: ${m.message}`).join('\n') - : apiError.message; - setError(messages); - } else { - setError(err instanceof Error ? err.message : 'Failed to upload document'); - } - } finally { - setLoading(false); - } - }; - - const now = new Date(); - return ( - - - - - {error && ( - - {error} - - )} - - {success && ( - - Document uploaded successfully - - )} - - ); -}; diff --git a/client/src/components/juniors/AddJuniorForm.tsx b/client/src/components/juniors/AddJuniorForm.tsx deleted file mode 100644 index cb8e344..0000000 --- a/client/src/components/juniors/AddJuniorForm.tsx +++ /dev/null @@ -1,266 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { - Box, - TextField, - Button, - Typography, - Paper, - FormControl, - InputLabel, - Select, - MenuItem, - Grid, - Alert, - SelectChangeEvent, - Divider -} from '@mui/material'; -import { useNavigate } from 'react-router-dom'; -import { juniorsApi } from '../../api/client'; -import { CreateJuniorRequest } from '../../types/junior'; -import { DocumentUpload } from '../document/DocumentUpload'; -import { DocumentType } from '../../types/document'; -import { ApiError } from '../../types/api'; -import { AxiosError } from 'axios'; - -export const AddJuniorForm = () => { - const navigate = useNavigate(); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(''); - const [formData, setFormData] = useState({ - countryCode: '+962', - phoneNumber: '', - firstName: '', - lastName: '', - dateOfBirth: '', - email: '', - relationship: 'PARENT', - civilIdFrontId: '', - civilIdBackId: '' - }); - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - console.log('Form data:', formData); - - setError(''); - setLoading(true); - - try { - if (!formData.civilIdFrontId || !formData.civilIdBackId) { - console.log('Missing documents - Front:', formData.civilIdFrontId, 'Back:', formData.civilIdBackId); - throw new Error('Please upload both front and back civil ID documents'); - } - - console.log('Submitting data:', formData); - const dataToSubmit = { - ...formData, - civilIdFrontId: formData.civilIdFrontId.trim(), - civilIdBackId: formData.civilIdBackId.trim() - }; - await juniorsApi.createJunior(dataToSubmit); - navigate('/juniors'); - } catch (err) { - console.error('Create junior error:', err); - if (err instanceof AxiosError && err.response?.data) { - const apiError = err.response.data as ApiError; - const messages = Array.isArray(apiError.message) - ? apiError.message.map(m => `${m.field}: ${m.message}`).join('\n') - : apiError.message; - setError(messages); - } else { - setError(err instanceof Error ? err.message : 'Failed to create junior'); - } - } finally { - setLoading(false); - } - }; - - const handleInputChange = (e: React.ChangeEvent) => { - const { name, value } = e.target; - setFormData(prev => ({ - ...prev, - [name]: value - })); - }; - - const handleSelectChange = (e: SelectChangeEvent) => { - const { name, value } = e.target; - setFormData(prev => ({ - ...prev, - [name as string]: value - })); - }; - - useEffect(() => { - console.log('Form data updated:', formData); - }, [formData]); - - const handleCivilIdFrontUpload = (documentId: string) => { - console.log('Front ID uploaded:', documentId); - setFormData(prev => ({ - ...prev, - civilIdFrontId: documentId - })); - }; - - const handleCivilIdBackUpload = (documentId: string) => { - console.log('Back ID uploaded:', documentId); - setFormData(prev => ({ - ...prev, - civilIdBackId: documentId - })); - }; - - return ( - - - Add New Junior - - - - {error && ( - - {error} - - )} - - - - - - Country Code - - - - - - - - - - - - - - - - - - - - - Relationship - - - - - - - - Civil ID Documents - - - - - - - {formData.civilIdFrontId && ( - - Civil ID Front uploaded (ID: {formData.civilIdFrontId}) - - )} - - - - {formData.civilIdBackId && ( - - Civil ID Back uploaded (ID: {formData.civilIdBackId}) - - )} - - - - - - - - - - - ); -}; diff --git a/client/src/components/juniors/JuniorsList.tsx b/client/src/components/juniors/JuniorsList.tsx deleted file mode 100644 index 9694d86..0000000 --- a/client/src/components/juniors/JuniorsList.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { - Box, - Typography, - Grid, - Card, - CardContent, - CardMedia, - Button, - CircularProgress, - Pagination -} from '@mui/material'; -import { juniorsApi } from '../../api/client'; -import { Junior, PaginatedResponse } from '../../types/junior'; -import { useNavigate } from 'react-router-dom'; - -export const JuniorsList = () => { - const [loading, setLoading] = useState(true); - const [error, setError] = useState(''); - const [juniors, setJuniors] = useState([]); - const [page, setPage] = useState(1); - const [totalPages, setTotalPages] = useState(1); - const navigate = useNavigate(); - - const fetchJuniors = async (pageNum: number) => { - try { - setLoading(true); - const response = await juniorsApi.getJuniors(pageNum); - const data = response.data as PaginatedResponse; - setJuniors(data.data); - setTotalPages(data.meta.pageCount); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load juniors'); - } finally { - setLoading(false); - } - }; - - useEffect(() => { - fetchJuniors(page); - }, [page]); - - const handlePageChange = (event: React.ChangeEvent, value: number) => { - setPage(value); - }; - - if (loading) { - return ( - - - - ); - } - - if (error) { - return ( - - {error} - - ); - } - - return ( - - - Juniors - - - - - {juniors.map((junior) => ( - - - - - - {junior.fullName} - - - {junior.relationship} - - - - - - - - ))} - - - {totalPages > 1 && ( - - - - )} - - ); -}; diff --git a/client/src/components/layout/AuthLayout.tsx b/client/src/components/layout/AuthLayout.tsx deleted file mode 100644 index 33850ed..0000000 --- a/client/src/components/layout/AuthLayout.tsx +++ /dev/null @@ -1,175 +0,0 @@ -import React from 'react'; -import { Navigate, Outlet, useNavigate } from 'react-router-dom'; -import { - AppBar, - Toolbar, - Typography, - Button, - Box, - Container, - List, - ListItem, - Drawer, - Divider -} from '@mui/material'; -import { - Dashboard as DashboardIcon, - People as PeopleIcon, - Assignment as TasksIcon, - Person as ProfileIcon -} from '@mui/icons-material'; -import { useAuth } from '../../contexts/AuthContext'; - -export const AuthLayout = () => { - const { isAuthenticated, user, logout } = useAuth(); - const navigate = useNavigate(); - - if (!isAuthenticated) { - return ; - } - - return ( - - theme.zIndex.drawer + 1, - backgroundColor: 'background.paper', - boxShadow: 'none', - borderBottom: '1px solid', - borderColor: 'divider' - }} - > - - - Zod Alkhair | API Testting client - - - {user && ( - - {user.firstName} {user.lastName} - - )} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ); -}; diff --git a/client/src/components/tasks/AddTask.tsx b/client/src/components/tasks/AddTask.tsx deleted file mode 100644 index 057ff38..0000000 --- a/client/src/components/tasks/AddTask.tsx +++ /dev/null @@ -1,245 +0,0 @@ -import { - Alert, - Box, - Button, - Checkbox, - FormControl, - FormControlLabel, - Grid, - InputLabel, - MenuItem, - Paper, - Select, - SelectChangeEvent, - TextField, - Typography, -} from '@mui/material'; -import { AxiosError } from 'axios'; -import React, { useEffect, useState } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { juniorsApi, tasksApi } from '../../api/client'; -import { ApiError } from '../../types/api'; -import { DocumentType } from '../../types/document'; -import { Junior, PaginatedResponse } from '../../types/junior'; -import { CreateTaskRequest } from '../../types/task'; -import { DocumentUpload } from '../document/DocumentUpload'; - -export const AddTaskForm = () => { - const navigate = useNavigate(); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(''); - const [formData, setFormData] = useState({ - title: '', - description: '', - dueDate: '', - rewardAmount: 0, - isProofRequired: false, - juniorId: '', - imageId: '', - }); - - const [juniors, setJuniors] = useState([]); - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - console.log('Form data:', formData); - - setError(''); - setLoading(true); - - try { - if (!formData.imageId) { - console.log('Proof is required but no image uploaded'); - } - - console.log('Submitting data:', formData); - const dataToSubmit = { - ...formData, - rewardAmount: Number(formData.rewardAmount), - imageId: formData.imageId, - }; - await tasksApi.createTask(dataToSubmit); - navigate('/tasks'); - } catch (err) { - console.error('Create junior error:', err); - if (err instanceof AxiosError && err.response?.data) { - const apiError = err.response.data as ApiError; - const messages = Array.isArray(apiError.message) - ? apiError.message.map((m) => `${m.field}: ${m.message}`).join('\n') - : apiError.message; - setError(messages); - } else { - setError(err instanceof Error ? err.message : 'Failed to create Task'); - } - } finally { - setLoading(false); - } - }; - - const handleInputChange = (e: React.ChangeEvent) => { - const { name, value } = e.target; - console.log(name, value); - setFormData((prev) => ({ - ...prev, - [name]: value, - })); - }; - - const fetchJuniors = async () => { - try { - const response = await juniorsApi.getJuniors(1, 50); - const data = response.data as PaginatedResponse; - setJuniors(data.data); - } catch (err) { - console.error('Failed to load juniors:', err); - } - }; - - const handleSelectChange = (e: SelectChangeEvent) => { - const { name, value } = e.target; - setFormData((prev) => ({ - ...prev, - [name as string]: value, - })); - }; - - useEffect(() => { - console.log('Form data updated:', formData); - }, [formData]); - - useEffect(() => { - fetchJuniors(); - }, []); - - const handleTaskImageUpload = (documentId: string) => { - console.log('task image ID uploaded:', documentId); - setFormData((prev) => ({ - ...prev, - imageId: documentId, - })); - }; - - const handleCheckedInputChange = (e: React.ChangeEvent) => { - setFormData((prev) => ({ - ...prev, - isProofRequired: e.target.checked, - })); - }; - - return ( - - - Add New Task - - - - {error && ( - - {error} - - )} - - - - - - - - - - - - - - - - - - - - - Junior - - - - - - - {formData.imageId && ( - - Task Image uploaded (ID: {formData.imageId}) - - )} - - - - - - } - label="Proof Required" - /> - - - - - - - - - - - - ); -}; diff --git a/client/src/components/tasks/TaskDetails.tsx b/client/src/components/tasks/TaskDetails.tsx deleted file mode 100644 index a6ae7ba..0000000 --- a/client/src/components/tasks/TaskDetails.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import { Box, Card, CardContent, Chip, CircularProgress, Typography } from '@mui/material'; -import { useEffect, useState } from 'react'; -import { useNavigate, useParams } from 'react-router-dom'; -import { tasksApi } from '../../api/client'; -import { Task } from '../../types/task'; - -export const TaskDetails = () => { - useNavigate(); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(''); - const statusColors = { - PENDING: 'warning', - IN_PROGRESS: 'info', - COMPLETED: 'success', - } as const; - - const { taskId } = useParams(); - if (!taskId) { - throw new Error('Task ID is required'); - } - const [task, setTask] = useState(); - const fetchTask = async () => { - try { - setLoading(true); - const response = await tasksApi.getTaskById(taskId); - setTask(response.data.data); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load task'); - } finally { - setLoading(false); - } - }; - - useEffect(() => { - fetchTask(); - }, []); - - if (loading) { - return ( - - - - ); - } - - if (error) { - return ( - - {error} - - ); - } - - if (!task) { - return ( - - Task not found - - ); - } - console.log(task); - - return ( - - - - - {task.title} - - - - - Due: {new Date(task.dueDate).toLocaleDateString()} - - - {task.description} - - - Reward: ${task.rewardAmount} - - - Assigned to: {task.junior.fullName} - - - - ); -}; diff --git a/client/src/components/tasks/TasksList.tsx b/client/src/components/tasks/TasksList.tsx deleted file mode 100644 index 7703498..0000000 --- a/client/src/components/tasks/TasksList.tsx +++ /dev/null @@ -1,200 +0,0 @@ -import { - Box, - Button, - Card, - CardContent, - Chip, - CircularProgress, - FormControl, - Grid, - InputLabel, - MenuItem, - Pagination, - Select, - SelectChangeEvent, - Typography -} from '@mui/material'; -import React, { useEffect, useState } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { juniorsApi, tasksApi } from '../../api/client'; -import { Junior, PaginatedResponse } from '../../types/junior'; -import { Task, TaskStatus } from '../../types/task'; - -const statusColors = { - PENDING: 'warning', - IN_PROGRESS: 'info', - COMPLETED: 'success' -} as const; - -export const TasksList = () => { - const [loading, setLoading] = useState(true); - const [error, setError] = useState(''); - const [tasks, setTasks] = useState([]); - const [juniors, setJuniors] = useState([]); - const [page, setPage] = useState(1); - const [totalPages, setTotalPages] = useState(1); - const [status, setStatus] = useState('PENDING'); - const [selectedJuniorId, setSelectedJuniorId] = useState(''); - const navigate = useNavigate(); - - const fetchJuniors = async () => { - try { - const response = await juniorsApi.getJuniors(1, 50); - const data = response.data as PaginatedResponse; - setJuniors(data.data); - } catch (err) { - console.error('Failed to load juniors:', err); - } - }; - - const fetchTasks = async (pageNum: number) => { - try { - setLoading(true); - const response = await tasksApi.getTasks(status, pageNum, 10, selectedJuniorId || undefined); - const data = response.data as PaginatedResponse; - setTasks(data.data); - setTotalPages(data.meta.pageCount); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load tasks'); - } finally { - setLoading(false); - } - }; - - useEffect(() => { - fetchJuniors(); - }, []); - - useEffect(() => { - fetchTasks(page); - }, [page, status, selectedJuniorId]); - - const handlePageChange = (event: React.ChangeEvent, value: number) => { - setPage(value); - }; - - const handleStatusChange = (event: SelectChangeEvent) => { - setStatus(event.target.value as TaskStatus); - setPage(1); - }; - - const handleJuniorChange = (event: SelectChangeEvent) => { - setSelectedJuniorId(event.target.value); - setPage(1); - }; - - if (loading && page === 1) { - return ( - - - - ); - } - - if (error) { - return ( - - {error} - - ); - } - - return ( - - - Tasks - - - - - - Status - - - - - Junior - - - - - - {tasks.map((task) => ( - - - - - - {task.title} - - - - - Due: {new Date(task.dueDate).toLocaleDateString()} - - - {task.description} - - - Reward: ${task.rewardAmount} - - - Assigned to: {task.junior.fullName} - - - - - - - - ))} - - - {totalPages > 1 && ( - - - - )} - - ); -}; diff --git a/client/src/contexts/AuthContext.tsx b/client/src/contexts/AuthContext.tsx deleted file mode 100644 index a19aa46..0000000 --- a/client/src/contexts/AuthContext.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import React, { createContext, useCallback, useContext, useState } from 'react'; -import { authApi } from '../api/client'; -import { LoginRequest, LoginResponse, User } from '../types/auth'; - -interface AuthContextType { - isAuthenticated: boolean; - user: User | null; - login: (loginRequest: LoginRequest) => Promise; - logout: () => void; - register: (countryCode: string, phoneNumber: string) => Promise; - verifyOtp: (countryCode: string, phoneNumber: string, otp: string) => Promise; - setEmail: (email: string) => Promise; - setPasscode: (passcode: string) => Promise; -} - -const AuthContext = createContext(null); - -export const useAuth = () => { - const context = useContext(AuthContext); - if (!context) { - throw new Error('useAuth must be used within an AuthProvider'); - } - return context; -}; - -export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { - const [isAuthenticated, setIsAuthenticated] = useState(false); - const [user, setUser] = useState(null); - - const login = useCallback(async (loginRequest: LoginRequest) => { - try { - const response = await authApi.login(loginRequest); - const loginData = response.data.data as LoginResponse; - setUser(loginData.user); - // Store tokens - localStorage.setItem('accessToken', loginData.accessToken); - localStorage.setItem('refreshToken', loginData.refreshToken); - setIsAuthenticated(true); - // Store tokens or other auth data in localStorage if needed - } catch (error) { - console.error('Login failed:', error); - throw error; - } - }, []); - - const logout = useCallback(() => { - setUser(null); - setIsAuthenticated(false); - // Clear any stored auth data - localStorage.clear(); - }, []); - - // Registration state - const [registrationData, setRegistrationData] = useState<{ - countryCode?: string; - phoneNumber?: string; - email?: string; - token?: string; - }>({}); - - const register = useCallback(async (countryCode: string, phoneNumber: string) => { - try { - await authApi.register(countryCode, phoneNumber); - setRegistrationData({ countryCode, phoneNumber }); - } catch (error) { - console.error('Registration failed:', error); - throw error; - } - }, []); - - const verifyOtp = useCallback(async (countryCode: string, phoneNumber: string, otp: string) => { - try { - const response = await authApi.verifyOtp(countryCode, phoneNumber, otp); - console.log('OTP verification response:', response.data); - const { accessToken } = response.data.data; - console.log('Access token:', accessToken); - // Store token in localStorage immediately - localStorage.setItem('accessToken', accessToken); - setRegistrationData((prev) => ({ ...prev, token: accessToken })); - return accessToken; - } catch (error) { - console.error('OTP verification failed:', error); - throw error; - } - }, []); - - const setEmail = useCallback(async (email: string) => { - try { - await authApi.setEmail(email); - setRegistrationData((prev) => ({ ...prev, email })); - } catch (error) { - console.error('Setting email failed:', error); - throw error; - } - }, []); - - const setPasscode = useCallback(async (passcode: string) => { - try { - await authApi.setPasscode(passcode); - setIsAuthenticated(true); - } catch (error) { - console.error('Setting passcode failed:', error); - throw error; - } - }, []); - - const value = { - isAuthenticated, - user, - login, - logout, - register, - verifyOtp, - setEmail, - setPasscode, - }; - - return {children}; -}; diff --git a/client/src/enums/grantType.enum.ts b/client/src/enums/grantType.enum.ts deleted file mode 100644 index f4d19d3..0000000 --- a/client/src/enums/grantType.enum.ts +++ /dev/null @@ -1,6 +0,0 @@ -export enum GrantType { - PASSWORD = 'PASSWORD', - APPLE = 'APPLE', - GOOGLE = 'GOOGLE', - BIOMETRIC = 'BIOMETRIC', -} diff --git a/client/src/enums/index.ts b/client/src/enums/index.ts deleted file mode 100644 index 69dc8be..0000000 --- a/client/src/enums/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './grantType.enum'; diff --git a/client/src/index.css b/client/src/index.css deleted file mode 100644 index f5b7ffb..0000000 --- a/client/src/index.css +++ /dev/null @@ -1,52 +0,0 @@ -@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'); - -* { - margin: 0; - padding: 0; - box-sizing: border-box; -} - -body { - font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', - 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', - sans-serif; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - background-color: #F8F9FA; - color: #2D3748; -} - -/* Custom scrollbar */ -::-webkit-scrollbar { - width: 8px; - height: 8px; -} - -::-webkit-scrollbar-track { - background: #F8F9FA; -} - -::-webkit-scrollbar-thumb { - background: #CBD5E0; - border-radius: 4px; -} - -::-webkit-scrollbar-thumb:hover { - background: #A0AEC0; -} - -/* Smooth transitions */ -a, button { - transition: all 0.2s ease-in-out; -} - -/* Remove focus outline for mouse users, keep for keyboard users */ -:focus:not(:focus-visible) { - outline: none; -} - -/* Keep focus outline for keyboard users */ -:focus-visible { - outline: 2px solid #00A7E1; - outline-offset: 2px; -} diff --git a/client/src/main.tsx b/client/src/main.tsx deleted file mode 100644 index c072d42..0000000 --- a/client/src/main.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import React from 'react'; -import ReactDOM from 'react-dom/client'; -import App from './App'; -import '@fontsource/roboto/300.css'; -import '@fontsource/roboto/400.css'; -import '@fontsource/roboto/500.css'; -import '@fontsource/roboto/700.css'; - -ReactDOM.createRoot(document.getElementById('root')!).render( - - - -); diff --git a/client/src/types/api.ts b/client/src/types/api.ts deleted file mode 100644 index b820ba3..0000000 --- a/client/src/types/api.ts +++ /dev/null @@ -1,14 +0,0 @@ -interface ApiErrorField { - field: string; - message: string; -} - -export interface ApiError { - statusCode: number; - message: string | ApiErrorField[]; - error: string; -} - -export interface ApiResponse { - data: T; -} diff --git a/client/src/types/auth.ts b/client/src/types/auth.ts deleted file mode 100644 index 8c2b38e..0000000 --- a/client/src/types/auth.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { GrantType } from '../enums'; - -export interface User { - id: string; - email: string; - customerStatus?: string; - firstName?: string; - lastName?: string; - dateOfBirth?: string; - countryOfResidence?: string; - isJunior?: boolean; - isGuardian?: boolean; -} - -export interface LoginResponse { - accessToken: string; - refreshToken: string; - user: User; -} - -export interface LoginRequest { - email?: string; - password?: string; - grantType: GrantType; - googleToken?: string; - appleToken?: string; -} diff --git a/client/src/types/document.ts b/client/src/types/document.ts deleted file mode 100644 index 30f434b..0000000 --- a/client/src/types/document.ts +++ /dev/null @@ -1,9 +0,0 @@ -export enum DocumentType { - PROFILE_PICTURE = 'PROFILE_PICTURE', - PASSPORT = 'PASSPORT', - DEFAULT_AVATAR = 'DEFAULT_AVATAR', - DEFAULT_TASKS_LOGO = 'DEFAULT_TASKS_LOGO', - CUSTOM_AVATAR = 'CUSTOM_AVATAR', - CUSTOM_TASKS_LOGO = 'CUSTOM_TASKS_LOGO', - GOALS = 'GOALS' -} diff --git a/client/src/types/junior.ts b/client/src/types/junior.ts deleted file mode 100644 index fbe3c89..0000000 --- a/client/src/types/junior.ts +++ /dev/null @@ -1,41 +0,0 @@ -export interface Junior { - id: string; - fullName: string; - relationship: string; - profilePicture?: { - id: string; - name: string; - extension: string; - documentType: string; - url: string; - }; -} - -export interface CreateJuniorRequest { - countryCode: string; - phoneNumber: string; - firstName: string; - lastName: string; - dateOfBirth: string; - email: string; - relationship: string; - civilIdFrontId: string; - civilIdBackId: string; -} - -export interface JuniorTheme { - color: string; - avatarId: string; -} - -export interface PaginatedResponse { - data: T[]; - meta: { - page: number; - size: number; - itemCount: number; - pageCount: number; - hasPreviousPage: boolean; - hasNextPage: boolean; - }; -} diff --git a/client/src/types/task.ts b/client/src/types/task.ts deleted file mode 100644 index 35f72f7..0000000 --- a/client/src/types/task.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Junior } from './junior'; - -export interface Task { - id: string; - title: string; - description: string; - status: 'PENDING' | 'IN_PROGRESS' | 'COMPLETED'; - dueDate: string; - rewardAmount: number; - isProofRequired: boolean; - submission?: { - imageId?: string; - submittedAt?: string; - status?: 'PENDING' | 'APPROVED' | 'REJECTED'; - }; - junior: Junior; - image?: { - id: string; - name: string; - extension: string; - documentType: string; - url: string; - }; - createdAt: string; - updatedAt: string; -} - -export interface CreateTaskRequest { - title: string; - description: string; - dueDate: string; - rewardAmount: number; - isProofRequired: boolean; - imageId?: string; - juniorId: string; -} - -export interface TaskSubmission { - imageId: string; -} - -export type TaskStatus = 'PENDING' | 'IN_PROGRESS' | 'COMPLETED'; diff --git a/client/src/vite-env.d.ts b/client/src/vite-env.d.ts deleted file mode 100644 index 11f02fe..0000000 --- a/client/src/vite-env.d.ts +++ /dev/null @@ -1 +0,0 @@ -/// diff --git a/client/tsconfig.app.json b/client/tsconfig.app.json deleted file mode 100644 index a67d962..0000000 --- a/client/tsconfig.app.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "compilerOptions": { - "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", - "target": "ES2020", - "useDefineForClassFields": true, - "lib": ["ES2020", "DOM", "DOM.Iterable"], - "module": "ESNext", - "skipLibCheck": true, - - /* Bundler mode */ - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "isolatedModules": true, - "moduleDetection": "force", - "noEmit": true, - "jsx": "react-jsx", - - /* Linting */ - "strict": true, - "noUnusedLocals": false, - "noUnusedParameters": false, - "noFallthroughCasesInSwitch": true, - "noUncheckedSideEffectImports": true - }, - "include": ["src"] -} diff --git a/client/tsconfig.json b/client/tsconfig.json deleted file mode 100644 index 1ffef60..0000000 --- a/client/tsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "files": [], - "references": [ - { "path": "./tsconfig.app.json" }, - { "path": "./tsconfig.node.json" } - ] -} diff --git a/client/tsconfig.node.json b/client/tsconfig.node.json deleted file mode 100644 index db0becc..0000000 --- a/client/tsconfig.node.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "compilerOptions": { - "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", - "target": "ES2022", - "lib": ["ES2023"], - "module": "ESNext", - "skipLibCheck": true, - - /* Bundler mode */ - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "isolatedModules": true, - "moduleDetection": "force", - "noEmit": true, - - /* Linting */ - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true, - "noUncheckedSideEffectImports": true - }, - "include": ["vite.config.ts"] -} diff --git a/client/vite.config.ts b/client/vite.config.ts deleted file mode 100644 index 43e4018..0000000 --- a/client/vite.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import react from '@vitejs/plugin-react'; -import path from 'path'; -import { defineConfig, loadEnv } from 'vite'; - -// https://vitejs.dev/config/ -export default defineConfig(({ mode }) => { - const env = loadEnv(mode, path.join(process.cwd(), '..'), ''); - return { - define: { - 'process.env.REACT_APP_APPLE_CLIENT_ID': JSON.stringify(env.REACT_APP_APPLE_CLIENT_ID), - 'process.env.REACT_APP_APPLE_REDIRECT_URI': JSON.stringify(env.REACT_APP_APPLE_REDIRECT_URI), - 'process.env.GOOGLE_WEB_CLIENT_ID': JSON.stringify(env.GOOGLE_WEB_CLIENT_ID), - }, - plugins: [react()], - }; -}); diff --git a/src/auth/dtos/response/user.response.dto.ts b/src/auth/dtos/response/user.response.dto.ts index f2a9853..1c2c50f 100644 --- a/src/auth/dtos/response/user.response.dto.ts +++ b/src/auth/dtos/response/user.response.dto.ts @@ -21,6 +21,9 @@ export class UserResponseDto { @ApiProperty() lastName!: string; + @ApiProperty() + dateOfBirth!: Date; + @ApiPropertyOptional({ type: DocumentMetaResponseDto, nullable: true }) profilePicture!: DocumentMetaResponseDto | null; @@ -34,7 +37,7 @@ export class UserResponseDto { this.id = user.id; this.countryCode = user.countryCode; this.phoneNumber = user.phoneNumber; - + this.dateOfBirth = user.customer?.dateOfBirth; this.email = user.email; this.firstName = user.firstName; this.lastName = user.lastName; diff --git a/src/common/mappers/index.ts b/src/common/mappers/index.ts new file mode 100644 index 0000000..438273e --- /dev/null +++ b/src/common/mappers/index.ts @@ -0,0 +1 @@ +export * from './numeric-to-iso.mapper'; diff --git a/src/common/mappers/numeric-to-iso.mapper.ts b/src/common/mappers/numeric-to-iso.mapper.ts new file mode 100644 index 0000000..f01a6d6 --- /dev/null +++ b/src/common/mappers/numeric-to-iso.mapper.ts @@ -0,0 +1,11 @@ +import { CountriesNumericISO } from '../constants'; +import { CountryIso } from '../enums'; + +// At module top-level +export const NumericToCountryIso: Record = Object.entries(CountriesNumericISO).reduce( + (acc, [isoKey, numeric]) => { + acc[numeric] = isoKey as CountryIso; + return acc; + }, + {} as Record, +); diff --git a/src/common/modules/neoleap/__mocks__/index.ts b/src/common/modules/neoleap/__mocks__/index.ts index db8964a..98ef2c9 100644 --- a/src/common/modules/neoleap/__mocks__/index.ts +++ b/src/common/modules/neoleap/__mocks__/index.ts @@ -1,2 +1,3 @@ export * from './create-application.mock'; +export * from './initiate-kyc.mock'; export * from './inquire-application.mock'; diff --git a/src/common/modules/neoleap/__mocks__/initiate-kyc.mock.ts b/src/common/modules/neoleap/__mocks__/initiate-kyc.mock.ts new file mode 100644 index 0000000..7c9c1b1 --- /dev/null +++ b/src/common/modules/neoleap/__mocks__/initiate-kyc.mock.ts @@ -0,0 +1,21 @@ +export const INITIATE_KYC_MOCK = { + ResponseHeader: { + Version: '1.0.0', + MsgUid: 'f3a9d4b2-5c7a-4e2f-8121-9c4e5a6b7d8f', + Source: 'ZOD', + ServiceId: 'InitiateKyc', + ReqDateTime: '2025-08-07T14:20:00.000Z', + RspDateTime: '2025-08-07T14:20:00.123Z', + ResponseCode: '000', + ResponseType: 'Success', + ProcessingTime: 123, + ResponseDescription: 'KYC initiation successful', + }, + InitiateKycResponseDetails: { + InstitutionCode: '1100', + TransId: '3136fd60-3f89-4d24-a92f-b9c63a53807f', + RandomNumber: '38', + Status: 'WAITING', + ExpiryDateTime: '2025-08-07T14:30:00.000Z', + }, +}; diff --git a/src/common/modules/neoleap/__mocks__/kyc-callback.mock.ts b/src/common/modules/neoleap/__mocks__/kyc-callback.mock.ts new file mode 100644 index 0000000..5ce59e0 --- /dev/null +++ b/src/common/modules/neoleap/__mocks__/kyc-callback.mock.ts @@ -0,0 +1,23 @@ +export const getKycCallbackMock = (nationalId: string) => { + return { + InstId: '1100', + transId: '3136fd60-3f89-4d24-a92f-b9c63a53807f', + date: '20250807', + time: '150000', + status: 'SUCCESS', + firstName: 'John', + lastName: 'Doe', + dob: '19990107', + nationality: '682', + gender: 'M', + nationalIdExpiry: '20310917', + nationalId, + mobile: '+962798765432', + salaryMin: '500', + salaryMax: '1000', + incomeSource: 'Salary', + professionTitle: 'Software Engineer', + professionType: 'Full-Time', + isPep: 'N', + }; +}; diff --git a/src/common/modules/neoleap/controllers/neoleap-webhooks.controller.ts b/src/common/modules/neoleap/controllers/neoleap-webhooks.controller.ts index 03aaf8a..1fda1ac 100644 --- a/src/common/modules/neoleap/controllers/neoleap-webhooks.controller.ts +++ b/src/common/modules/neoleap/controllers/neoleap-webhooks.controller.ts @@ -5,6 +5,7 @@ import { AccountCardStatusChangedWebhookRequest, AccountTransactionWebhookRequest, CardTransactionWebhookRequest, + KycWebhookRequest, } from '../dtos/requests'; import { NeoLeapWebhookService } from '../services'; @@ -30,4 +31,10 @@ export class NeoLeapWebhooksController { await this.neoleapWebhookService.handleAccountCardStatusChangedWebhook(body); return ResponseFactory.data({ message: 'Card status updated successfully', status: 'success' }); } + + @Post('kyc') + async handleKycWebhook(@Body() body: KycWebhookRequest) { + await this.neoleapWebhookService.handleKycWebhook(body); + return ResponseFactory.data({ message: 'KYC processed successfully', status: 'success' }); + } } diff --git a/src/common/modules/neoleap/dtos/requests/index.ts b/src/common/modules/neoleap/dtos/requests/index.ts index 9360a77..cecc499 100644 --- a/src/common/modules/neoleap/dtos/requests/index.ts +++ b/src/common/modules/neoleap/dtos/requests/index.ts @@ -1,4 +1,5 @@ export * from './account-card-status-changed-webhook.request.dto'; export * from './account-transaction-webhook.request.dto'; export * from './card-transaction-webhook.request.dto'; +export * from './kyc-webhook.request.dto'; export * from './update-card-controls.request.dto'; diff --git a/src/common/modules/neoleap/dtos/requests/kyc-webhook.request.dto.ts b/src/common/modules/neoleap/dtos/requests/kyc-webhook.request.dto.ts new file mode 100644 index 0000000..427af17 --- /dev/null +++ b/src/common/modules/neoleap/dtos/requests/kyc-webhook.request.dto.ts @@ -0,0 +1,99 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Expose } from 'class-transformer'; +import { IsString } from 'class-validator'; +export class KycWebhookRequest { + @Expose({ name: 'InstId' }) + @IsString() + @ApiProperty({ name: 'InstId', example: '1100' }) + instId!: string; + + @Expose() + @IsString() + @ApiProperty({ example: '3136fd60-3f89-4d24-a92f-b9c63a53807f' }) + transId!: string; + + @Expose() + @IsString() + @ApiProperty({ example: '20250807' }) + date!: string; + + @Expose() + @IsString() + @ApiProperty({ example: '150000' }) + time!: string; + + @Expose() + @IsString() + @ApiProperty({ example: 'SUCCESS' }) + status!: string; + + @Expose() + @IsString() + @ApiProperty({ example: 'John' }) + firstName!: string; + + @Expose() + @IsString() + @ApiProperty({ example: 'Doe' }) + lastName!: string; + + @Expose() + @IsString() + @ApiProperty({ example: '19990107' }) + dob!: string; + + @Expose() + @IsString() + @ApiProperty({ example: '682' }) + nationality!: string; + + @Expose() + @IsString() + @ApiProperty({ example: 'M' }) + gender!: string; + + @Expose() + @IsString() + @ApiProperty({ example: '20310917' }) + nationalIdExpiry!: string; + + @Expose() + @IsString() + @ApiProperty({ example: '1250820840' }) + nationalId!: string; + + @Expose() + @IsString() + @ApiProperty({ example: '+962798765432' }) + mobile!: string; + + @Expose() + @IsString() + @ApiProperty({ example: '500' }) + salaryMin!: string; + + @Expose() + @IsString() + @ApiProperty({ example: '1000' }) + salaryMax!: string; + + @Expose() + @IsString() + @ApiProperty({ example: 'Salary' }) + incomeSource!: string; + + @Expose() + @IsString() + @ApiProperty({ example: 'Software Engineer' }) + professionTitle!: string; + + @Expose() + @IsString() + @ApiProperty({ example: 'Full-Time' }) + professionType!: string; + + @Expose() + @IsString() + @ApiProperty({ example: 'N' }) + isPep!: string; +} diff --git a/src/common/modules/neoleap/dtos/response/index.ts b/src/common/modules/neoleap/dtos/response/index.ts index 457e4bc..5b616e5 100644 --- a/src/common/modules/neoleap/dtos/response/index.ts +++ b/src/common/modules/neoleap/dtos/response/index.ts @@ -1,3 +1,4 @@ export * from './create-application.response.dto'; +export * from './initiate-kyc.response.dto'; export * from './inquire-application.response'; export * from './update-card-controls.response.dto'; diff --git a/src/common/modules/neoleap/dtos/response/initiate-kyc.response.dto.ts b/src/common/modules/neoleap/dtos/response/initiate-kyc.response.dto.ts new file mode 100644 index 0000000..cd3066a --- /dev/null +++ b/src/common/modules/neoleap/dtos/response/initiate-kyc.response.dto.ts @@ -0,0 +1,28 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Expose, Transform } from 'class-transformer'; +export class InitiateKycResponseDto { + @Transform(({ obj }) => obj?.InstitutionCode) + @Expose() + @ApiProperty() + institutionCode!: string; + + @Transform(({ obj }) => obj?.TransId) + @Expose() + @ApiProperty() + transId!: string; + + @Transform(({ obj }) => obj?.RandomNumber) + @Expose() + @ApiProperty() + randomNumber!: string; + + @Transform(({ obj }) => obj?.Status) + @Expose() + @ApiProperty() + status!: string; + + @Transform(({ obj }) => obj?.ExpiryDateTime) + @Expose() + @ApiProperty() + expiryDateTime!: string; +} diff --git a/src/common/modules/neoleap/neoleap.module.ts b/src/common/modules/neoleap/neoleap.module.ts index 39951f0..5b1043a 100644 --- a/src/common/modules/neoleap/neoleap.module.ts +++ b/src/common/modules/neoleap/neoleap.module.ts @@ -1,5 +1,5 @@ import { HttpModule } from '@nestjs/axios'; -import { Module } from '@nestjs/common'; +import { forwardRef, Module } from '@nestjs/common'; import { CardModule } from '~/card/card.module'; import { CustomerModule } from '~/customer/customer.module'; import { NeoLeapWebhooksController } from './controllers/neoleap-webhooks.controller'; @@ -8,8 +8,9 @@ import { NeoLeapWebhookService } from './services'; import { NeoLeapService } from './services/neoleap.service'; @Module({ - imports: [HttpModule, CustomerModule, CardModule], + imports: [HttpModule, CardModule, forwardRef(() => CustomerModule)], controllers: [NeoTestController, NeoLeapWebhooksController], providers: [NeoLeapService, NeoLeapWebhookService], + exports: [NeoLeapService], }) export class NeoLeapModule {} diff --git a/src/common/modules/neoleap/services/neoleap-webook.service.ts b/src/common/modules/neoleap/services/neoleap-webook.service.ts index 83b68c4..5c605b8 100644 --- a/src/common/modules/neoleap/services/neoleap-webook.service.ts +++ b/src/common/modules/neoleap/services/neoleap-webook.service.ts @@ -1,15 +1,21 @@ import { Injectable } from '@nestjs/common'; import { CardService } from '~/card/services'; import { TransactionService } from '~/card/services/transaction.service'; +import { CustomerService } from '~/customer/services'; import { AccountCardStatusChangedWebhookRequest, AccountTransactionWebhookRequest, CardTransactionWebhookRequest, + KycWebhookRequest, } from '../dtos/requests'; @Injectable() export class NeoLeapWebhookService { - constructor(private readonly transactionService: TransactionService, private readonly cardService: CardService) {} + constructor( + private readonly transactionService: TransactionService, + private readonly cardService: CardService, + private customerService: CustomerService, + ) {} handleCardTransactionWebhook(body: CardTransactionWebhookRequest) { return this.transactionService.createCardTransaction(body); @@ -22,4 +28,8 @@ export class NeoLeapWebhookService { handleAccountCardStatusChangedWebhook(body: AccountCardStatusChangedWebhookRequest) { return this.cardService.updateCardStatus(body); } + + handleKycWebhook(body: KycWebhookRequest) { + return this.customerService.updateCustomerKyc(body); + } } diff --git a/src/common/modules/neoleap/services/neoleap.service.ts b/src/common/modules/neoleap/services/neoleap.service.ts index 3abd065..2d2eee7 100644 --- a/src/common/modules/neoleap/services/neoleap.service.ts +++ b/src/common/modules/neoleap/services/neoleap.service.ts @@ -1,14 +1,21 @@ import { HttpService } from '@nestjs/axios'; -import { BadRequestException, Injectable, InternalServerErrorException } from '@nestjs/common'; +import { BadRequestException, Injectable, InternalServerErrorException, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { ClassConstructor, plainToInstance } from 'class-transformer'; import moment from 'moment'; import { v4 as uuid } from 'uuid'; import { CountriesNumericISO } from '~/common/constants'; +import { InitiateKycRequestDto } from '~/customer/dtos/request'; import { Customer } from '~/customer/entities'; import { Gender, KycStatus } from '~/customer/enums'; -import { CREATE_APPLICATION_MOCK, INQUIRE_APPLICATION_MOCK } from '../__mocks__/'; -import { CreateApplicationResponse, InquireApplicationResponse, UpdateCardControlsResponseDto } from '../dtos/response'; +import { CREATE_APPLICATION_MOCK, INITIATE_KYC_MOCK, INQUIRE_APPLICATION_MOCK } from '../__mocks__/'; +import { getKycCallbackMock } from '../__mocks__/kyc-callback.mock'; +import { + CreateApplicationResponse, + InitiateKycResponseDto, + InquireApplicationResponse, + UpdateCardControlsResponseDto, +} from '../dtos/response'; import { ICreateApplicationRequest, IInquireApplicationRequest, @@ -17,16 +24,62 @@ import { } from '../interfaces'; @Injectable() export class NeoLeapService { - private readonly baseUrl: string; + private readonly logger = new Logger(NeoLeapService.name); + private readonly gatewayBaseUrl: string; + private readonly zodApiUrl: string; private readonly apiKey: string; private readonly useGateway: boolean; private readonly institutionCode = '1100'; useLocalCert: boolean; constructor(private readonly httpService: HttpService, private readonly configService: ConfigService) { - this.baseUrl = this.configService.getOrThrow('GATEWAY_URL'); + this.gatewayBaseUrl = this.configService.getOrThrow('GATEWAY_URL'); this.apiKey = this.configService.getOrThrow('GATEWAY_API_KEY'); this.useGateway = [true, 'true'].includes(this.configService.get('USE_GATEWAY', false)); this.useLocalCert = this.configService.get('USE_LOCAL_CERT', false); + this.zodApiUrl = this.configService.getOrThrow('ZOD_API_URL'); + } + + async initiateKyc(customerId: string, body: InitiateKycRequestDto) { + const responseKey = 'InitiateKycResponseDetails'; + + if (!this.useGateway) { + const responseDto = plainToInstance(InitiateKycResponseDto, INITIATE_KYC_MOCK[responseKey], { + excludeExtraneousValues: true, + }); + + setTimeout(() => { + this.httpService + .post(`${this.zodApiUrl}/neoleap-webhooks/kyc`, getKycCallbackMock(body.nationalId), { + headers: { + 'Content-Type': 'application/json', + }, + }) + .subscribe({ + next: () => this.logger.log('Mock KYC webhook sent'), + error: (err) => console.error(err), + }); + }, 7000); + + return responseDto; + } + + const payload = { + InitiateKycRequestDetails: { + CustomerIdentifier: { + InstitutionCode: this.institutionCode, + Id: customerId, + NationalId: body.nationalId, + }, + }, + RequestHeader: this.prepareHeaders('InitiateKyc'), + }; + + return this.sendRequestToNeoLeap( + 'kyc/InitiateKyc', + payload, + responseKey, + InitiateKycResponseDto, + ); } async createApplication(customer: Customer) { @@ -189,7 +242,7 @@ export class NeoLeapService { responseClass: ClassConstructor, ): Promise { try { - const { data } = await this.httpService.axiosRef.post(`${this.baseUrl}/${endpoint}`, payload, { + const { data } = await this.httpService.axiosRef.post(`${this.gatewayBaseUrl}/${endpoint}`, payload, { headers: { 'Content-Type': 'application/json', Authorization: `${this.apiKey}`, diff --git a/src/core/decorators/validations/index.ts b/src/core/decorators/validations/index.ts index be49db6..a3070a2 100644 --- a/src/core/decorators/validations/index.ts +++ b/src/core/decorators/validations/index.ts @@ -1,3 +1,4 @@ // placeholder export * from './is-above-18'; export * from './is-valid-phone-number'; +export * from './is-valid-saudi-id'; diff --git a/src/core/decorators/validations/is-valid-saudi-id.ts b/src/core/decorators/validations/is-valid-saudi-id.ts new file mode 100644 index 0000000..c5dbee4 --- /dev/null +++ b/src/core/decorators/validations/is-valid-saudi-id.ts @@ -0,0 +1,35 @@ +import { registerDecorator, ValidationArguments, ValidationOptions } from 'class-validator'; + +export function isValidSaudiId(validationOptions?: ValidationOptions) { + return function (object: any, propertyName: string) { + registerDecorator({ + name: 'isValidSaudiId', + target: object.constructor, + propertyName, + options: { + message: `${propertyName} must be a valid Saudi ID`, + ...validationOptions, + }, + constraints: [], + validator: { + validate(value: any, args: ValidationArguments) { + if (typeof value !== 'string') return false; + if (!/^[12]\d{9}$/.test(value)) return false; + + // Luhn algorithm + let sum = 0; + for (let i = 0; i < 10; i++) { + let digit = parseInt(value[i]); + if (i % 2 === 0) { + digit *= 2; + if (digit > 9) digit -= 9; + } + sum += digit; + } + + return sum % 10 === 0; + }, + }, + }); + }; +} diff --git a/src/customer/controllers/customer.controller.ts b/src/customer/controllers/customer.controller.ts index 6703210..66aba7e 100644 --- a/src/customer/controllers/customer.controller.ts +++ b/src/customer/controllers/customer.controller.ts @@ -1,12 +1,12 @@ -import { Body, Controller, Get, Patch, Post, UseGuards } from '@nestjs/common'; +import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { IJwtPayload } from '~/auth/interfaces'; import { AuthenticatedUser } from '~/common/decorators'; import { AccessTokenGuard } from '~/common/guards'; import { ApiDataResponse, ApiLangRequestHeader } from '~/core/decorators'; import { ResponseFactory } from '~/core/utils'; -import { CreateCustomerRequestDto, UpdateCustomerRequestDto } from '../dtos/request'; -import { CustomerResponseDto } from '../dtos/response'; +import { InitiateKycRequestDto } from '../dtos/request'; +import { CustomerResponseDto, InitiateKycResponseDto } from '../dtos/response'; import { CustomerService } from '../services'; @Controller('customers') @@ -15,8 +15,7 @@ import { CustomerService } from '../services'; @ApiLangRequestHeader() export class CustomerController { constructor(private readonly customerService: CustomerService) {} - - @Get('/profile') + @Get('/kyc') @UseGuards(AccessTokenGuard) @ApiDataResponse(CustomerResponseDto) async getCustomerProfile(@AuthenticatedUser() { sub }: IJwtPayload) { @@ -25,21 +24,12 @@ export class CustomerController { return ResponseFactory.data(new CustomerResponseDto(customer)); } - @Patch() + @Post('/kyc/initiate') @UseGuards(AccessTokenGuard) - @ApiDataResponse(CustomerResponseDto) - async updateCustomer(@AuthenticatedUser() { sub }: IJwtPayload, @Body() body: UpdateCustomerRequestDto) { - const customer = await this.customerService.updateCustomer(sub, body); + @ApiDataResponse(InitiateKycResponseDto) + async initiateKyc(@AuthenticatedUser() { sub }: IJwtPayload, @Body() body: InitiateKycRequestDto) { + const res = await this.customerService.initiateKycRequest(sub, body); - return ResponseFactory.data(new CustomerResponseDto(customer)); - } - - @Post('') - @UseGuards(AccessTokenGuard) - @ApiDataResponse(CustomerResponseDto) - async createCustomer(@AuthenticatedUser() { sub }: IJwtPayload, @Body() body: CreateCustomerRequestDto) { - const customer = await this.customerService.createGuardianCustomer(sub, body); - - return ResponseFactory.data(new CustomerResponseDto(customer)); + return ResponseFactory.data(new InitiateKycResponseDto(res.randomNumber)); } } diff --git a/src/customer/controllers/index.ts b/src/customer/controllers/index.ts index 64166b0..26207a4 100644 --- a/src/customer/controllers/index.ts +++ b/src/customer/controllers/index.ts @@ -1,2 +1 @@ export * from './customer.controller'; -export * from './internal.customer.controller'; diff --git a/src/customer/controllers/internal.customer.controller.ts b/src/customer/controllers/internal.customer.controller.ts deleted file mode 100644 index a30e1f4..0000000 --- a/src/customer/controllers/internal.customer.controller.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { Body, Controller, Get, HttpCode, HttpStatus, Param, Patch, Query } from '@nestjs/common'; -import { ApiTags } from '@nestjs/swagger'; -import { CustomParseUUIDPipe } from '~/core/pipes'; -import { ResponseFactory } from '~/core/utils'; -import { CustomerFiltersRequestDto, RejectCustomerKycRequestDto } from '../dtos/request'; -import { InternalCustomerDetailsResponseDto } from '../dtos/response'; -import { InternalCustomerListResponse } from '../dtos/response/internal.customer-list.response.dto'; -import { CustomerService } from '../services'; - -@ApiTags('Customers') -@Controller('internal/customers') -export class InternalCustomerController { - constructor(private readonly customerService: CustomerService) {} - @Get() - async findCustomers(@Query() filters: CustomerFiltersRequestDto) { - const [customers, count] = await this.customerService.findCustomers(filters); - - return ResponseFactory.dataPage( - customers.map((customer) => new InternalCustomerListResponse(customer)), - { - page: filters.page, - size: filters.size, - itemCount: count, - }, - ); - } - - @Get(':customerId') - async findCustomerById(@Param('customerId', CustomParseUUIDPipe) customerId: string) { - const customer = await this.customerService.findInternalCustomerById(customerId); - - return ResponseFactory.data(new InternalCustomerDetailsResponseDto(customer)); - } - - @Patch(':customerId/approve') - @HttpCode(HttpStatus.NO_CONTENT) - async approveKycForCustomer(@Param('customerId', CustomParseUUIDPipe) customerId: string) { - await this.customerService.approveKycForCustomer(customerId); - } - - @Patch(':customerId/reject') - @HttpCode(HttpStatus.NO_CONTENT) - async rejectKycForCustomer( - @Param('customerId', CustomParseUUIDPipe) customerId: string, - @Body() body: RejectCustomerKycRequestDto, - ) { - await this.customerService.rejectKycForCustomer(customerId, body); - } -} diff --git a/src/customer/customer.module.ts b/src/customer/customer.module.ts index a37bae9..4f11d0a 100644 --- a/src/customer/customer.module.ts +++ b/src/customer/customer.module.ts @@ -1,15 +1,21 @@ import { forwardRef, Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { NeoLeapModule } from '~/common/modules/neoleap/neoleap.module'; import { GuardianModule } from '~/guardian/guardian.module'; import { UserModule } from '~/user/user.module'; -import { CustomerController, InternalCustomerController } from './controllers'; +import { CustomerController } from './controllers'; import { Customer } from './entities'; import { CustomerRepository } from './repositories/customer.repository'; import { CustomerService } from './services'; @Module({ - imports: [TypeOrmModule.forFeature([Customer]), forwardRef(() => UserModule), GuardianModule], - controllers: [CustomerController, InternalCustomerController], + imports: [ + TypeOrmModule.forFeature([Customer]), + forwardRef(() => UserModule), + GuardianModule, + forwardRef(() => NeoLeapModule), + ], + controllers: [CustomerController], providers: [CustomerService, CustomerRepository], exports: [CustomerService], }) diff --git a/src/customer/dtos/request/create-customer.request.dto.ts b/src/customer/dtos/request/create-customer.request.dto.ts deleted file mode 100644 index f6fc4b3..0000000 --- a/src/customer/dtos/request/create-customer.request.dto.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsBoolean, IsDateString, IsEnum, IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator'; -import { i18nValidationMessage as i18n } from 'nestjs-i18n'; -import { CountryIso } from '~/common/enums'; -import { IsAbove18 } from '~/core/decorators/validations'; -import { Gender } from '~/customer/enums'; -export class CreateCustomerRequestDto { - @ApiProperty({ example: 'John' }) - @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.firstName' }) }) - @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.firstName' }) }) - firstName!: string; - - @ApiProperty({ example: 'Doe' }) - @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.lastName' }) }) - @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.lastName' }) }) - lastName!: string; - - @ApiProperty({ example: 'MALE' }) - @IsEnum(Gender, { message: i18n('validation.IsEnum', { path: 'general', property: 'customer.gender' }) }) - @IsOptional() - gender?: Gender; - - @ApiProperty({ enum: CountryIso, example: CountryIso.SAUDI_ARABIA }) - @IsEnum(CountryIso, { - message: i18n('validation.IsEnum', { path: 'general', property: 'customer.countryOfResidence' }), - }) - countryOfResidence!: CountryIso; - - @ApiProperty({ example: '2001-01-01' }) - @IsDateString({}, { message: i18n('validation.IsDateString', { path: 'general', property: 'customer.dateOfBirth' }) }) - @IsAbove18({ message: i18n('validation.IsAbove18', { path: 'general', property: 'customer.dateOfBirth' }) }) - dateOfBirth!: Date; - - @ApiProperty({ example: '999300024' }) - @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.nationalId' }) }) - @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.nationalId' }) }) - @IsOptional() - nationalId?: string; - - @ApiProperty({ example: '2021-01-01' }) - @IsDateString( - {}, - { message: i18n('validation.IsDateString', { path: 'general', property: 'junior.nationalIdExpiry' }) }, - ) - @IsOptional() - nationalIdExpiry?: Date; - - @ApiProperty({ example: 'Employee' }) - @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.sourceOfIncome' }) }) - @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.sourceOfIncome' }) }) - @IsOptional() - sourceOfIncome?: string; - - @ApiProperty({ example: 'Accountant' }) - @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.profession' }) }) - @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.profession' }) }) - @IsOptional() - profession?: string; - - @ApiProperty({ example: 'Finance' }) - @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.professionType' }) }) - @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.professionType' }) }) - @IsOptional() - professionType?: string; - - @ApiProperty({ example: false }) - @IsBoolean({ message: i18n('validation.IsBoolean', { path: 'general', property: 'junior.isPep' }) }) - @IsOptional() - isPep?: boolean; - - @ApiProperty({ example: 'bf342-3f3f-3f3f-3f3f' }) - @IsUUID('4', { message: i18n('validation.IsUUID', { path: 'general', property: 'junior.civilIdFrontId' }) }) - @IsOptional() - civilIdFrontId?: string; - - @ApiProperty({ example: 'bf342-3f3f-3f3f-3f3f' }) - @IsUUID('4', { message: i18n('validation.IsUUID', { path: 'general', property: 'junior.civilIdBackId' }) }) - @IsOptional() - civilIdBackId?: string; -} diff --git a/src/customer/dtos/request/customer-filters.request.dto.ts b/src/customer/dtos/request/customer-filters.request.dto.ts deleted file mode 100644 index c2135ab..0000000 --- a/src/customer/dtos/request/customer-filters.request.dto.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { ApiPropertyOptional } from '@nestjs/swagger'; -import { IsEnum, IsNotEmpty, IsOptional, IsString } from 'class-validator'; -import { i18nValidationMessage as i18n } from 'nestjs-i18n'; -import { PageOptionsRequestDto } from '~/core/dtos'; -import { KycStatus } from '~/customer/enums'; - -export class CustomerFiltersRequestDto extends PageOptionsRequestDto { - @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.name' }) }) - @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.name' }) }) - @IsOptional() - @ApiPropertyOptional({ description: 'search by name' }) - name?: string; - - @IsEnum(KycStatus, { message: i18n('validation.IsEnum', { path: 'general', property: 'customer.kycStatus' }) }) - @IsOptional() - @ApiPropertyOptional({ - enum: KycStatus, - enumName: 'KycStatus', - example: KycStatus.PENDING, - description: 'kyc status of the customer', - }) - kycStatus?: string; -} diff --git a/src/customer/dtos/request/index.ts b/src/customer/dtos/request/index.ts index 1678547..900b859 100644 --- a/src/customer/dtos/request/index.ts +++ b/src/customer/dtos/request/index.ts @@ -1,4 +1 @@ -export * from './create-customer.request.dto'; -export * from './customer-filters.request.dto'; -export * from './reject-customer-kyc.request.dto'; -export * from './update-customer.request.dto'; +export * from './initiate-kyc.request.dto'; diff --git a/src/customer/dtos/request/initiate-kyc.request.dto.ts b/src/customer/dtos/request/initiate-kyc.request.dto.ts new file mode 100644 index 0000000..9ea2357 --- /dev/null +++ b/src/customer/dtos/request/initiate-kyc.request.dto.ts @@ -0,0 +1,8 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { i18nValidationMessage as i18n } from 'nestjs-i18n'; +import { isValidSaudiId } from '~/core/decorators/validations'; +export class InitiateKycRequestDto { + @ApiProperty({ example: '999300024' }) + @isValidSaudiId({ message: i18n('validation.isValidSaudiId', { path: 'general', property: 'customer.nationalId' }) }) + nationalId!: string; +} diff --git a/src/customer/dtos/request/reject-customer-kyc.request.dto.ts b/src/customer/dtos/request/reject-customer-kyc.request.dto.ts deleted file mode 100644 index bec17b5..0000000 --- a/src/customer/dtos/request/reject-customer-kyc.request.dto.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { ApiPropertyOptional } from '@nestjs/swagger'; -import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; -import { i18nValidationMessage as i18n } from 'nestjs-i18n'; - -export class RejectCustomerKycRequestDto { - @ApiPropertyOptional({ description: 'reason for rejecting the customer kyc' }) - @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.rejectionReason' }) }) - @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.rejectionReason' }) }) - @IsOptional({ message: i18n('validation.IsOptional', { path: 'general', property: 'customer.rejectionReason' }) }) - reason?: string; -} diff --git a/src/customer/dtos/request/update-customer.request.dto.ts b/src/customer/dtos/request/update-customer.request.dto.ts deleted file mode 100644 index a9ef346..0000000 --- a/src/customer/dtos/request/update-customer.request.dto.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { ApiProperty, PartialType } from '@nestjs/swagger'; -import { IsOptional, IsUUID } from 'class-validator'; -import { i18nValidationMessage as i18n } from 'nestjs-i18n'; -import { CreateCustomerRequestDto } from './create-customer.request.dto'; -export class UpdateCustomerRequestDto extends PartialType(CreateCustomerRequestDto) { - @ApiProperty({ example: '123e4567-e89b-12d3-a456-426614174000' }) - @IsUUID('4', { message: i18n('validation.IsUUID', { path: 'general', property: 'customer.profilePictureId' }) }) - @IsOptional() - profilePictureId!: string; -} diff --git a/src/customer/dtos/response/index.ts b/src/customer/dtos/response/index.ts index 16eaeba..9d2a535 100644 --- a/src/customer/dtos/response/index.ts +++ b/src/customer/dtos/response/index.ts @@ -1,3 +1,2 @@ export * from './customer.response.dto'; -export * from './internal.customer-details.response.dto'; -export * from './internal.customer-list.response.dto'; +export * from './initiate-kyc.response.dto'; diff --git a/src/customer/dtos/response/initiate-kyc.response.dto.ts b/src/customer/dtos/response/initiate-kyc.response.dto.ts new file mode 100644 index 0000000..54dd40a --- /dev/null +++ b/src/customer/dtos/response/initiate-kyc.response.dto.ts @@ -0,0 +1,10 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class InitiateKycResponseDto { + @ApiProperty() + randomNumber!: string; + + constructor(randomNumber: string) { + this.randomNumber = randomNumber; + } +} diff --git a/src/customer/dtos/response/internal.customer-details.response.dto.ts b/src/customer/dtos/response/internal.customer-details.response.dto.ts deleted file mode 100644 index 62c56ed..0000000 --- a/src/customer/dtos/response/internal.customer-details.response.dto.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { Customer } from '~/customer/entities'; -import { CustomerStatus, KycStatus } from '~/customer/enums'; -import { DocumentMetaResponseDto } from '~/document/dtos/response'; - -export class InternalCustomerDetailsResponseDto { - @ApiProperty() - id!: string; - - @ApiProperty() - customerStatus!: CustomerStatus; - - @ApiProperty() - kycStatus!: KycStatus; - - @ApiProperty() - rejectionReason!: string | null; - - @ApiProperty() - fullName!: string; - - @ApiProperty() - phoneNumber!: string; - - @ApiProperty() - dateOfBirth!: Date; - - @ApiProperty() - nationalId!: string; - - @ApiProperty() - nationalIdExpiry!: Date; - - @ApiProperty() - countryOfResidence!: string; - - @ApiProperty() - sourceOfIncome!: string; - - @ApiProperty() - profession!: string; - - @ApiProperty() - professionType!: string; - - @ApiProperty() - isPep!: boolean; - - @ApiProperty() - gender!: string; - - @ApiProperty() - isJunior!: boolean; - - @ApiProperty() - isGuardian!: boolean; - - @ApiProperty({ type: DocumentMetaResponseDto }) - civilIdFront!: DocumentMetaResponseDto; - - @ApiProperty({ type: DocumentMetaResponseDto }) - civilIdBack!: DocumentMetaResponseDto; - - @ApiPropertyOptional({ type: DocumentMetaResponseDto }) - profilePicture!: DocumentMetaResponseDto | null; - - constructor(customer: Customer) { - this.id = customer.id; - this.customerStatus = customer.customerStatus; - this.kycStatus = customer.kycStatus; - this.rejectionReason = customer.rejectionReason; - this.fullName = `${customer.firstName} ${customer.lastName}`; - this.phoneNumber = customer.user.fullPhoneNumber; - this.dateOfBirth = customer.dateOfBirth; - this.nationalId = customer.nationalId; - this.nationalIdExpiry = customer.nationalIdExpiry; - this.countryOfResidence = customer.countryOfResidence; - this.sourceOfIncome = customer.sourceOfIncome; - this.profession = customer.profession; - this.professionType = customer.professionType; - this.isPep = customer.isPep; - this.gender = customer.gender; - this.isJunior = customer.isJunior; - this.isGuardian = customer.isGuardian; - this.civilIdFront = new DocumentMetaResponseDto(customer.civilIdFront); - this.civilIdBack = new DocumentMetaResponseDto(customer.civilIdBack); - } -} diff --git a/src/customer/dtos/response/internal.customer-list.response.dto.ts b/src/customer/dtos/response/internal.customer-list.response.dto.ts deleted file mode 100644 index 20a55cf..0000000 --- a/src/customer/dtos/response/internal.customer-list.response.dto.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { Customer } from '~/customer/entities'; -import { CustomerStatus, KycStatus } from '~/customer/enums'; - -export class InternalCustomerListResponse { - @ApiProperty() - id!: string; - - @ApiProperty() - fullName!: string; - - @ApiProperty() - phoneNumber!: string; - - @ApiProperty() - customerStatus!: CustomerStatus; - - @ApiProperty() - kycStatus!: KycStatus; - - @ApiProperty() - dateOfBirth!: Date; - - @ApiProperty() - gender!: string; - - @ApiProperty() - isJunior!: boolean; - - @ApiProperty() - isGuardian!: boolean; - - constructor(customer: Customer) { - this.id = customer.id; - this.fullName = `${customer.firstName} ${customer.lastName}`; - this.phoneNumber = customer.user?.fullPhoneNumber; - this.customerStatus = customer.customerStatus; - this.kycStatus = customer.kycStatus; - this.dateOfBirth = customer.dateOfBirth; - this.gender = customer.gender; - this.isGuardian = customer.isGuardian; - this.isJunior = customer.isJunior; - } -} diff --git a/src/customer/repositories/customer.repository.ts b/src/customer/repositories/customer.repository.ts index b506978..84feedb 100644 --- a/src/customer/repositories/customer.repository.ts +++ b/src/customer/repositories/customer.repository.ts @@ -1,7 +1,6 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { FindOptionsWhere, Repository } from 'typeorm'; -import { CustomerFiltersRequestDto } from '../dtos/request'; import { Customer } from '../entities'; @Injectable() @@ -34,33 +33,6 @@ export class CustomerRepository { ); } - findCustomers(filters: CustomerFiltersRequestDto) { - const query = this.customerRepository.createQueryBuilder('customer'); - query.leftJoinAndSelect('customer.user', 'user'); - - if (filters.name) { - const nameParts = filters.name.trim().split(/\s+/); - nameParts.length > 1 - ? query.andWhere('customer.firstName LIKE :firstName AND customer.lastName LIKE :lastName', { - firstName: `%${nameParts[0]}%`, - lastName: `%${nameParts[1]}%`, - }) - : query.andWhere('customer.firstName LIKE :name OR customer.lastName LIKE :name', { - name: `%${filters.name.trim()}%`, - }); - } - - if (filters.kycStatus) { - query.andWhere('customer.kycStatus = :kycStatus', { kycStatus: filters.kycStatus }); - } - - query.orderBy('customer.createdAt', 'DESC'); - query.take(filters.size); - query.skip((filters.page - 1) * filters.size); - - return query.getManyAndCount(); - } - findCustomerByCivilId(civilIdFrontId: string, civilIdBackId: string) { return this.customerRepository.findOne({ where: [{ civilIdFrontId, civilIdBackId }, { civilIdFrontId }, { civilIdBackId }], diff --git a/src/customer/services/customer.service.ts b/src/customer/services/customer.service.ts index 4b22d18..b1aef8d 100644 --- a/src/customer/services/customer.service.ts +++ b/src/customer/services/customer.service.ts @@ -1,17 +1,14 @@ -import { BadRequestException, Injectable, Logger } from '@nestjs/common'; +import { BadRequestException, forwardRef, Inject, Injectable, Logger } from '@nestjs/common'; import moment from 'moment'; import { Transactional } from 'typeorm-transactional'; import { CountryIso } from '~/common/enums'; -import { DocumentService, OciService } from '~/document/services'; +import { NumericToCountryIso } from '~/common/mappers'; +import { KycWebhookRequest } from '~/common/modules/neoleap/dtos/requests'; +import { NeoLeapService } from '~/common/modules/neoleap/services'; import { GuardianService } from '~/guardian/services'; import { CreateJuniorRequestDto } from '~/junior/dtos/request'; import { User } from '~/user/entities'; -import { - CreateCustomerRequestDto, - CustomerFiltersRequestDto, - RejectCustomerKycRequestDto, - UpdateCustomerRequestDto, -} from '../dtos/request'; +import { InitiateKycRequestDto } from '../dtos/request'; import { Customer } from '../entities'; import { Gender, KycStatus } from '../enums'; import { CustomerRepository } from '../repositories/customer.repository'; @@ -21,18 +18,13 @@ export class CustomerService { private readonly logger = new Logger(CustomerService.name); constructor( private readonly customerRepository: CustomerRepository, - private readonly ociService: OciService, - private readonly documentService: DocumentService, private readonly guardianService: GuardianService, + @Inject(forwardRef(() => NeoLeapService)) private readonly neoleapService: NeoLeapService, ) {} - async updateCustomer(userId: string, data: UpdateCustomerRequestDto): Promise { + async updateCustomer(userId: string, data: Partial): Promise { this.logger.log(`Updating customer ${userId}`); - await this.validateProfilePictureForCustomer(userId, data.profilePictureId); - if (data.civilIdBackId || data.civilIdFrontId) { - await this.validateCivilIdForCustomer(userId, data.civilIdFrontId!, data.civilIdBackId!); - } await this.customerRepository.updateCustomer(userId, data); this.logger.log(`Customer ${userId} updated successfully`); return this.findCustomerById(userId); @@ -41,8 +33,6 @@ export class CustomerService { async createJuniorCustomer(guardianId: string, juniorId: string, body: CreateJuniorRequestDto) { this.logger.log(`Creating junior customer for user ${juniorId}`); - await this.validateCivilIdForCustomer(guardianId, body.civilIdFrontId, body.civilIdBackId); - return this.customerRepository.createCustomer(juniorId, body, false); } @@ -59,40 +49,28 @@ export class CustomerService { return customer; } - async findInternalCustomerById(id: string) { - this.logger.log(`Finding internal customer ${id}`); - const customer = await this.customerRepository.findOne({ id }); + async initiateKycRequest(customerId: string, body: InitiateKycRequestDto) { + this.logger.log(`Initiating KYC request for user ${customerId}`); - if (!customer) { - this.logger.error(`Internal customer ${id} not found`); - throw new BadRequestException('CUSTOMER.NOT_FOUND'); - } - - await this.prepareCustomerDocuments(customer); - this.logger.log(`Internal customer ${id} found successfully`); - return customer; - } - - async approveKycForCustomer(customerId: string) { const customer = await this.findCustomerById(customerId); if (customer.kycStatus === KycStatus.APPROVED) { - this.logger.error(`Customer ${customerId} is already approved`); - throw new BadRequestException('CUSTOMER.ALREADY_APPROVED'); + this.logger.error(`KYC for customer ${customerId} is already approved`); + throw new BadRequestException('CUSTOMER.KYC_ALREADY_APPROVED'); } - this.logger.debug(`Approving KYC for customer ${customerId}`); - await this.customerRepository.updateCustomer(customerId, { kycStatus: KycStatus.APPROVED, rejectionReason: null }); - this.logger.log(`KYC approved for customer ${customerId}`); - } + // I will assume the api for initiating KYC is not allowing me to send customerId as correlationId so I will store the nationalId in the customer entity - findCustomers(filters: CustomerFiltersRequestDto) { - this.logger.log(`Finding customers with filters ${JSON.stringify(filters)}`); - return this.customerRepository.findCustomers(filters); + await this.customerRepository.updateCustomer(customerId, { + nationalId: body.nationalId, + kycStatus: KycStatus.PENDING, + }); + + return this.neoleapService.initiateKyc(customerId, body); } @Transactional() - async createGuardianCustomer(userId: string, body: Partial) { + async createGuardianCustomer(userId: string, body: Partial) { this.logger.log(`Creating guardian customer for user ${userId}`); const existingCustomer = await this.customerRepository.findOne({ id: userId }); @@ -110,23 +88,33 @@ export class CustomerService { return customer; } - async rejectKycForCustomer(customerId: string, { reason }: RejectCustomerKycRequestDto) { - const customer = await this.findCustomerById(customerId); + async updateCustomerKyc(body: KycWebhookRequest) { + this.logger.log(`Updating KYC for customer with national ID ${body.nationalId}`); - if (customer.kycStatus === KycStatus.REJECTED) { - this.logger.error(`Customer ${customerId} is already rejected`); - throw new BadRequestException('CUSTOMER.ALREADY_REJECTED'); + const customer = await this.customerRepository.findOne({ nationalId: body.nationalId }); + + if (!customer) { + throw new BadRequestException('CUSTOMER.NOT_FOUND'); } - this.logger.debug(`Rejecting KYC for customer ${customerId}`); - await this.customerRepository.updateCustomer(customerId, { - kycStatus: KycStatus.REJECTED, - rejectionReason: reason, + await this.customerRepository.updateCustomer(customer.id, { + kycStatus: body.status === 'SUCCESS' ? KycStatus.APPROVED : KycStatus.REJECTED, + firstName: body.firstName, + lastName: body.lastName, + dateOfBirth: moment(body.dob, 'YYYYMMDD').toDate(), + nationalId: body.nationalId, + nationalIdExpiry: moment(body.nationalIdExpiry, 'YYYYMMDD').toDate(), + countryOfResidence: NumericToCountryIso[body.nationality], + country: NumericToCountryIso[body.nationality], + gender: body.gender === 'M' ? Gender.MALE : Gender.FEMALE, + sourceOfIncome: body.incomeSource, + profession: body.professionTitle, + professionType: body.professionType, + isPep: body.isPep === 'Y', }); - this.logger.log(`KYC rejected for customer ${customerId}`); } - // this function is for testing only and will be removed + // TO BE REMOVED: This function is for testing only and will be removed @Transactional() async updateKyc(userId: string) { this.logger.log(`Updating KYC for customer ${userId}`); @@ -153,77 +141,6 @@ export class CustomerService { return this.findCustomerById(userId); } - private async validateProfilePictureForCustomer(userId: string, profilePictureId?: string) { - if (!profilePictureId) return; - - this.logger.log(`Validating profile picture ${profilePictureId}`); - - const profilePicture = await this.documentService.findDocumentById(profilePictureId); - - if (!profilePicture) { - this.logger.error(`Profile picture ${profilePictureId} not found`); - throw new BadRequestException('DOCUMENT.NOT_FOUND'); - } - - if (profilePicture.createdById && profilePicture.createdById !== userId) { - this.logger.error(`Profile picture ${profilePictureId} does not belong to user ${userId}`); - throw new BadRequestException('DOCUMENT.NOT_CREATED_BY_USER'); - } - } - - private async validateCivilIdForCustomer(userId: string, civilIdFrontId: string, civilIdBackId: string) { - this.logger.log(`Validating customer documents`); - - if (!civilIdFrontId || !civilIdBackId) { - this.logger.error('Civil id front and back are required'); - throw new BadRequestException('CUSTOMER.CIVIL_ID_REQUIRED'); - } - - const [civilIdFront, civilIdBack] = await Promise.all([ - this.documentService.findDocumentById(civilIdFrontId), - this.documentService.findDocumentById(civilIdBackId), - ]); - - if (!civilIdFront || !civilIdBack) { - this.logger.error('Civil id front or back not found'); - throw new BadRequestException('CUSTOMER.CIVIL_ID_REQUIRED'); - } - - if (civilIdFront.createdById !== userId || civilIdBack.createdById !== userId) { - this.logger.error(`Civil id front or back not created by user with id ${userId}`); - throw new BadRequestException('CUSTOMER.CIVIL_ID_NOT_CREATED_BY_USER'); - } - - const customerWithTheSameCivilId = await this.customerRepository.findCustomerByCivilId( - civilIdFrontId, - civilIdBackId, - ); - - if (customerWithTheSameCivilId) { - this.logger.error( - `Customer with civil id front ${civilIdFrontId} and civil id back ${civilIdBackId} already exists`, - ); - throw new BadRequestException('CUSTOMER.CIVIL_ID_ALREADY_EXISTS'); - } - } - - private async prepareCustomerDocuments(customer: Customer) { - const promises = []; - - promises.push(this.ociService.generatePreSignedUrl(customer.civilIdFront)); - promises.push(this.ociService.generatePreSignedUrl(customer.civilIdBack)); - - const [civilIdFrontUrl, civilIdBackUrl, profilePictureUrl] = await Promise.all(promises); - - customer.civilIdFront.url = civilIdFrontUrl; - customer.civilIdBack.url = civilIdBackUrl; - return customer; - } - - async findAnyCustomer() { - return this.customerRepository.findOne({ isGuardian: true }); - } - // TO BE REMOVED: This function is for testing only and will be removed private generateSaudiPhoneNumber(): string { // Saudi mobile numbers are 9 digits, always starting with '5' diff --git a/src/i18n/ar/validation.json b/src/i18n/ar/validation.json index 4c2ffea..fc70e11 100644 --- a/src/i18n/ar/validation.json +++ b/src/i18n/ar/validation.json @@ -24,5 +24,6 @@ "IsValidExpiryDate": "$t({path}.PROPERTY_MAPPINGS.{property}) يجب ان يكون تاريخ انتهاء بطاقه صحيح", "IsEnglishOnly": "$t({path}.PROPERTY_MAPPINGS.{property}) يجب أن يكون باللغة الانجليزية", "IsAbove18": "$t({path}.PROPERTY_MAPPINGS.{property}) يجب ان يكون فوق 18 سنة", - "IsValidPhoneNumber": "$t({path}.PROPERTY_MAPPINGS.{property}) يجب ان يكون رقم هاتف صحيح" + "IsValidPhoneNumber": "$t({path}.PROPERTY_MAPPINGS.{property}) يجب ان يكون رقم هاتف صحيح", + "isValidSaudiId": "$t({path}.PROPERTY_MAPPINGS.{property}) يجب أن يكون رقم هوية سعودية صحيح" } diff --git a/src/i18n/en/validation.json b/src/i18n/en/validation.json index 22b9714..96b5dfd 100644 --- a/src/i18n/en/validation.json +++ b/src/i18n/en/validation.json @@ -25,5 +25,6 @@ "IsEnglishOnly": "$t({path}.PROPERTY_MAPPINGS.{property}) must be in English", "IsAbove18": "$t({path}.PROPERTY_MAPPINGS.{property}) must be above 18 years", "IsValidPhoneNumber": "$t({path}.PROPERTY_MAPPINGS.{property}) must be a valid phone number", - "IsPositive": "$t({path}.PROPERTY_MAPPINGS.{property}) must be a positive number" + "IsPositive": "$t({path}.PROPERTY_MAPPINGS.{property}) must be a positive number", + "isValidSaudiId": "$t({path}.PROPERTY_MAPPINGS.{property}) must be a valid Saudi ID" } diff --git a/src/user/services/user.service.ts b/src/user/services/user.service.ts index a9088ab..5f2f70a 100644 --- a/src/user/services/user.service.ts +++ b/src/user/services/user.service.ts @@ -19,7 +19,6 @@ const SALT_ROUNDS = 10; @Injectable() export class UserService { private readonly logger = new Logger(UserService.name); - private adminPortalUrl = this.configService.getOrThrow('ADMIN_PORTAL_URL'); constructor( private readonly userRepository: UserRepository, @Inject(forwardRef(() => NotificationsService)) private readonly notificationsService: NotificationsService, From 99ad17f0f9528d93ccfab169d75236b0d9bc8c27 Mon Sep 17 00:00:00 2001 From: Abdalhamid Alhamad Date: Thu, 7 Aug 2025 15:25:45 +0300 Subject: [PATCH 27/32] feat: add change password api --- src/auth/controllers/auth.controller.ts | 11 ++++- .../request/change-password.request.dto.ts | 23 ++++++++++ src/auth/dtos/request/index.ts | 1 + src/auth/services/auth.service.ts | 43 +++++++++++++++++++ src/i18n/ar/app.json | 3 ++ src/i18n/en/app.json | 3 ++ 6 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 src/auth/dtos/request/change-password.request.dto.ts diff --git a/src/auth/controllers/auth.controller.ts b/src/auth/controllers/auth.controller.ts index 61a1985..122d34f 100644 --- a/src/auth/controllers/auth.controller.ts +++ b/src/auth/controllers/auth.controller.ts @@ -1,11 +1,12 @@ import { Body, Controller, HttpCode, HttpStatus, Post, Req, UseGuards } from '@nestjs/common'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { Request } from 'express'; -import { Public } from '~/common/decorators'; +import { AuthenticatedUser, Public } from '~/common/decorators'; import { AccessTokenGuard } from '~/common/guards'; import { ApiDataResponse, ApiLangRequestHeader } from '~/core/decorators'; import { ResponseFactory } from '~/core/utils'; import { + ChangePasswordRequestDto, CreateUnverifiedUserRequestDto, ForgetPasswordRequestDto, LoginRequestDto, @@ -17,6 +18,7 @@ import { import { SendForgetPasswordOtpResponseDto, SendRegisterOtpResponseDto } from '../dtos/response'; import { LoginResponseDto } from '../dtos/response/login.response.dto'; import { VerifyForgetPasswordOtpResponseDto } from '../dtos/response/verify-forget-password-otp.response.dto'; +import { IJwtPayload } from '../interfaces'; import { AuthService } from '../services'; @Controller('auth') @@ -64,6 +66,13 @@ export class AuthController { return this.authService.resetPassword(forgetPasswordDto); } + @Post('change-password') + @HttpCode(HttpStatus.NO_CONTENT) + @UseGuards(AccessTokenGuard) + async changePassword(@AuthenticatedUser() { sub }: IJwtPayload, @Body() forgetPasswordDto: ChangePasswordRequestDto) { + return this.authService.changePassword(sub, forgetPasswordDto); + } + @Post('refresh-token') @Public() async refreshToken(@Body() { refreshToken }: RefreshTokenRequestDto) { diff --git a/src/auth/dtos/request/change-password.request.dto.ts b/src/auth/dtos/request/change-password.request.dto.ts new file mode 100644 index 0000000..58bed32 --- /dev/null +++ b/src/auth/dtos/request/change-password.request.dto.ts @@ -0,0 +1,23 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString, Matches } from 'class-validator'; +import { i18nValidationMessage as i18n } from 'nestjs-i18n'; +import { PASSWORD_REGEX } from '~/auth/constants'; + +export class ChangePasswordRequestDto { + @ApiProperty({ example: 'currentPassword@123' }) + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.currentPassword' }) }) + @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'auth.currentPassword' }) }) + currentPassword!: string; + + @ApiProperty({ example: 'Abcd1234@' }) + @Matches(PASSWORD_REGEX, { + message: i18n('validation.Matches', { path: 'general', property: 'auth.newPassword' }), + }) + newPassword!: string; + + @ApiProperty({ example: 'Abcd1234@' }) + @Matches(PASSWORD_REGEX, { + message: i18n('validation.Matches', { path: 'general', property: 'auth.confirmNewPassword' }), + }) + confirmNewPassword!: string; +} diff --git a/src/auth/dtos/request/index.ts b/src/auth/dtos/request/index.ts index 09274d4..767d1f4 100644 --- a/src/auth/dtos/request/index.ts +++ b/src/auth/dtos/request/index.ts @@ -1,4 +1,5 @@ export * from './apple-login.request.dto'; +export * from './change-password.request.dto'; export * from './create-unverified-user.request.dto'; export * from './disable-biometric.request.dto'; export * from './enable-biometric.request.dto'; diff --git a/src/auth/services/auth.service.ts b/src/auth/services/auth.service.ts index e096196..e6570ac 100644 --- a/src/auth/services/auth.service.ts +++ b/src/auth/services/auth.service.ts @@ -11,6 +11,7 @@ import { UserType } from '~/user/enums'; import { DeviceService, UserService, UserTokenService } from '~/user/services'; import { User } from '../../user/entities'; import { + ChangePasswordRequestDto, CreateUnverifiedUserRequestDto, DisableBiometricRequestDto, EnableBiometricRequestDto, @@ -172,6 +173,7 @@ export class AuthService { return { token, user }; } + async resetPassword({ countryCode, phoneNumber, @@ -191,6 +193,15 @@ export class AuthService { throw new BadRequestException('AUTH.PASSWORD_MISMATCH'); } + const isOldPassword = bcrypt.compareSync(password, user.password); + + if (isOldPassword) { + this.logger.error( + `New password cannot be the same as the current password for user with phone number ${user.fullPhoneNumber}`, + ); + throw new BadRequestException('AUTH.PASSWORD_SAME_AS_CURRENT'); + } + const hashedPassword = bcrypt.hashSync(password, user.salt); await this.userService.setPassword(user.id, hashedPassword, user.salt); @@ -198,6 +209,38 @@ export class AuthService { this.logger.log(`Passcode updated successfully for user with phone number ${user.fullPhoneNumber}`); } + async changePassword(userId: string, { currentPassword, newPassword, confirmNewPassword }: ChangePasswordRequestDto) { + const user = await this.userService.findUserOrThrow({ id: userId }); + + if (!user.isPasswordSet) { + this.logger.error(`Password not set for user with id ${userId}`); + throw new BadRequestException('AUTH.PASSWORD_NOT_SET'); + } + + if (currentPassword === newPassword) { + this.logger.error('New password cannot be the same as current password'); + throw new BadRequestException('AUTH.PASSWORD_SAME_AS_CURRENT'); + } + + if (newPassword !== confirmNewPassword) { + this.logger.error('New password and confirm new password do not match'); + throw new BadRequestException('AUTH.PASSWORD_MISMATCH'); + } + + this.logger.log(`Validating current password for user with id ${userId}`); + const isCurrentPasswordValid = bcrypt.compareSync(currentPassword, user.password); + + if (!isCurrentPasswordValid) { + this.logger.error(`Invalid current password for user with id ${userId}`); + throw new UnauthorizedException('AUTH.INVALID_CURRENT_PASSWORD'); + } + + const salt = bcrypt.genSaltSync(SALT_ROUNDS); + const hashedNewPassword = bcrypt.hashSync(newPassword, salt); + await this.userService.setPassword(user.id, hashedNewPassword, salt); + this.logger.log(`Password changed successfully for user with id ${userId}`); + } + async setJuniorPasscode(body: setJuniorPasswordRequestDto) { this.logger.log(`Setting passcode for junior with qrToken ${body.qrToken}`); const juniorId = await this.userTokenService.validateToken(body.qrToken, UserType.JUNIOR); diff --git a/src/i18n/ar/app.json b/src/i18n/ar/app.json index 797784d..2b8c4cf 100644 --- a/src/i18n/ar/app.json +++ b/src/i18n/ar/app.json @@ -9,6 +9,9 @@ "BIOMETRIC_NOT_ENABLED": "المصادقة البيومترية لم يتم تفعيلها على حسابك. يرجى تفعيلها للمتابعة.", "INVALID_BIOMETRIC": "البيانات البيومترية المقدمة غير صالحة. يرجى المحاولة مرة أخرى أو إعادة إعداد المصادقة البيومترية.", "PASSWORD_MISMATCH": "كلمات المرور التي أدخلتها غير متطابقة. يرجى إدخال كلمات المرور مرة أخرى.", + "INVALID_CURRENT_PASSWORD": "كلمة المرور الحالية التي أدخلتها غير صحيحة. يرجى المحاولة مرة أخرى.", + "PASSWORD_NOT_SET": "لم يتم تعيين كلمة مرور لهذا الحساب. يرجى تعيين كلمة مرور للمتابعة.", + "PASSWORD_SAME_AS_CURRENT": "كلمة المرور الجديدة لا يمكن أن تكون نفس كلمة المرور الحالية.", "INVALID_PASSCODE": "رمز المرور الذي أدخلته غير صحيح. يرجى المحاولة مرة أخرى.", "PASSCODE_ALREADY_SET": "تم تعيين رمز المرور بالفعل.", "APPLE_RE-CONSENT_REQUIRED": "إعادة الموافقة على آبل مطلوبة. يرجى إلغاء تطبيقك من إعدادات معرف آبل الخاصة بك والمحاولة مرة أخرى.", diff --git a/src/i18n/en/app.json b/src/i18n/en/app.json index 668b1e8..1bee066 100644 --- a/src/i18n/en/app.json +++ b/src/i18n/en/app.json @@ -9,6 +9,9 @@ "BIOMETRIC_NOT_ENABLED": "Biometric authentication has not been activated for your account. Please enable it to proceed.", "INVALID_BIOMETRIC": "The biometric data provided is invalid. Please try again or reconfigure your biometric settings.", "PASSWORD_MISMATCH": "The passwords you entered do not match. Please re-enter the passwords.", + "INVALID_CURRENT_PASSWORD": "The current password you entered is incorrect. Please try again.", + "PASSWORD_NOT_SET": "A password has not been set for this account. Please set a password to proceed.", + "PASSWORD_SAME_AS_CURRENT": "The new password cannot be the same as the current password.", "INVALID_PASSCODE": "The passcode you entered is incorrect. Please try again.", "PASSCODE_ALREADY_SET": "The pass code has already been set.", "APPLE_RE-CONSENT_REQUIRED": "Apple re-consent is required. Please revoke the app from your Apple ID settings and try again.", From ac63d4cdc7463a3c8521ec9d84952be680b3ab00 Mon Sep 17 00:00:00 2001 From: Abdalhamid Alhamad Date: Mon, 11 Aug 2025 15:33:32 +0300 Subject: [PATCH 28/32] refactor: refactor the code --- src/auth/auth.module.ts | 4 +- src/auth/controllers/auth.controller.ts | 21 ---- .../apple-additional-data.request.dto.ts | 14 --- .../dtos/request/apple-login.request.dto.ts | 21 ---- .../request/disable-biometric.request.dto.ts | 4 - .../request/enable-biometric.request.dto.ts | 14 --- .../dtos/request/google-login.request.dto.ts | 10 -- .../dtos/request/set-email.request.dto.ts | 8 -- .../interfaces/apple-payload.interface.ts | 11 --- src/auth/services/auth.service.ts | 75 --------------- src/auth/services/index.ts | 1 - src/auth/services/oauth2.service.ts | 83 ---------------- ...5164809-create-neoleap-related-entities.ts | 95 +++++++++++++++++++ src/db/migrations/index.ts | 1 + 14 files changed, 98 insertions(+), 264 deletions(-) delete mode 100644 src/auth/dtos/request/apple-additional-data.request.dto.ts delete mode 100644 src/auth/dtos/request/apple-login.request.dto.ts delete mode 100644 src/auth/dtos/request/disable-biometric.request.dto.ts delete mode 100644 src/auth/dtos/request/enable-biometric.request.dto.ts delete mode 100644 src/auth/dtos/request/google-login.request.dto.ts delete mode 100644 src/auth/dtos/request/set-email.request.dto.ts delete mode 100644 src/auth/interfaces/apple-payload.interface.ts delete mode 100644 src/auth/services/oauth2.service.ts create mode 100644 src/db/migrations/1754915164809-create-neoleap-related-entities.ts diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 138e689..74360e2 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -4,12 +4,12 @@ import { JwtModule } from '@nestjs/jwt'; import { JuniorModule } from '~/junior/junior.module'; import { UserModule } from '~/user/user.module'; import { AuthController } from './controllers'; -import { AuthService, Oauth2Service } from './services'; +import { AuthService } from './services'; import { AccessTokenStrategy } from './strategies'; @Module({ imports: [JwtModule.register({}), UserModule, JuniorModule, HttpModule], - providers: [AuthService, AccessTokenStrategy, Oauth2Service], + providers: [AuthService, AccessTokenStrategy], controllers: [AuthController], exports: [], }) diff --git a/src/auth/controllers/auth.controller.ts b/src/auth/controllers/auth.controller.ts index 122d34f..3094142 100644 --- a/src/auth/controllers/auth.controller.ts +++ b/src/auth/controllers/auth.controller.ts @@ -86,25 +86,4 @@ export class AuthController { async logout(@Req() request: Request) { await this.authService.logout(request); } - - // @Post('biometric/enable') - // @HttpCode(HttpStatus.NO_CONTENT) - // @UseGuards(AccessTokenGuard) - // enableBiometric(@AuthenticatedUser() { sub }: IJwtPayload, @Body() enableBiometricDto: EnableBiometricRequestDto) { - // return this.authService.enableBiometric(sub, enableBiometricDto); - // } - - // @Post('biometric/disable') - // @HttpCode(HttpStatus.NO_CONTENT) - // @UseGuards(AccessTokenGuard) - // disableBiometric(@AuthenticatedUser() { sub }: IJwtPayload, @Body() disableBiometricDto: DisableBiometricRequestDto) { - // return this.authService.disableBiometric(sub, disableBiometricDto); - // } - - // @Post('junior/set-passcode') - // @HttpCode(HttpStatus.NO_CONTENT) - // @Public() - // setJuniorPasscode(@Body() setPasscodeDto: setJuniorPasswordRequestDto) { - // return this.authService.setJuniorPasscode(setPasscodeDto); - // } } diff --git a/src/auth/dtos/request/apple-additional-data.request.dto.ts b/src/auth/dtos/request/apple-additional-data.request.dto.ts deleted file mode 100644 index fae4a53..0000000 --- a/src/auth/dtos/request/apple-additional-data.request.dto.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsNotEmpty, IsString } from 'class-validator'; -import { i18nValidationMessage as i18n } from 'nestjs-i18n'; -export class AppleAdditionalData { - @ApiProperty({ example: 'Ahmad' }) - @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.firstName' }) }) - @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.firstName' }) }) - firstName!: string; - - @ApiProperty({ example: 'Khan' }) - @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.lastName' }) }) - @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.lastName' }) }) - lastName!: string; -} diff --git a/src/auth/dtos/request/apple-login.request.dto.ts b/src/auth/dtos/request/apple-login.request.dto.ts deleted file mode 100644 index 34e89da..0000000 --- a/src/auth/dtos/request/apple-login.request.dto.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { Type } from 'class-transformer'; -import { IsNotEmpty, IsOptional, IsString, ValidateNested } from 'class-validator'; -import { i18nValidationMessage as i18n } from 'nestjs-i18n'; -import { AppleAdditionalData } from './apple-additional-data.request.dto'; - -export class AppleLoginRequestDto { - @ApiProperty({ example: 'apple_token' }) - @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.appleToken' }) }) - @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'auth.appleToken' }) }) - appleToken!: string; - - @ApiProperty({ type: AppleAdditionalData }) - @ValidateNested({ - each: true, - message: i18n('validation.ValidateNested', { path: 'general', property: 'auth.apple.additionalData' }), - }) - @IsOptional() - @Type(() => AppleAdditionalData) - additionalData?: AppleAdditionalData; -} diff --git a/src/auth/dtos/request/disable-biometric.request.dto.ts b/src/auth/dtos/request/disable-biometric.request.dto.ts deleted file mode 100644 index f30df1e..0000000 --- a/src/auth/dtos/request/disable-biometric.request.dto.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { PickType } from '@nestjs/swagger'; -import { EnableBiometricRequestDto } from './enable-biometric.request.dto'; - -export class DisableBiometricRequestDto extends PickType(EnableBiometricRequestDto, ['deviceId']) {} diff --git a/src/auth/dtos/request/enable-biometric.request.dto.ts b/src/auth/dtos/request/enable-biometric.request.dto.ts deleted file mode 100644 index b582391..0000000 --- a/src/auth/dtos/request/enable-biometric.request.dto.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsNotEmpty, IsString } from 'class-validator'; -import { i18nValidationMessage as i18n } from 'nestjs-i18n'; -export class EnableBiometricRequestDto { - @ApiProperty({ example: 'device-id' }) - @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.deviceId' }) }) - @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'auth.deviceId' }) }) - deviceId!: string; - - @ApiProperty({ example: 'publicKey' }) - @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.publicKey' }) }) - @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'auth.publicKey' }) }) - publicKey!: string; -} diff --git a/src/auth/dtos/request/google-login.request.dto.ts b/src/auth/dtos/request/google-login.request.dto.ts deleted file mode 100644 index 0ca1ca4..0000000 --- a/src/auth/dtos/request/google-login.request.dto.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsNotEmpty, IsString } from 'class-validator'; -import { i18nValidationMessage as i18n } from 'nestjs-i18n'; - -export class GoogleLoginRequestDto { - @ApiProperty({ example: 'google_token' }) - @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.googleToken' }) }) - @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'auth.googleToken' }) }) - googleToken!: string; -} diff --git a/src/auth/dtos/request/set-email.request.dto.ts b/src/auth/dtos/request/set-email.request.dto.ts deleted file mode 100644 index 489c08f..0000000 --- a/src/auth/dtos/request/set-email.request.dto.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsEmail } from 'class-validator'; -import { i18nValidationMessage as i18n } from 'nestjs-i18n'; -export class SetEmailRequestDto { - @ApiProperty({ example: 'test@test.com' }) - @IsEmail({}, { message: i18n('validation.IsEmail', { path: 'general', property: 'auth.email' }) }) - email!: string; -} diff --git a/src/auth/interfaces/apple-payload.interface.ts b/src/auth/interfaces/apple-payload.interface.ts deleted file mode 100644 index 06a5d63..0000000 --- a/src/auth/interfaces/apple-payload.interface.ts +++ /dev/null @@ -1,11 +0,0 @@ -export interface ApplePayload { - iss: string; - aud: string; - exp: number; - iat: number; - sub: string; - c_hash: string; - auth_time: number; - nonce_supported: boolean; - email?: string; -} diff --git a/src/auth/services/auth.service.ts b/src/auth/services/auth.service.ts index acfd6bc..3ce8b63 100644 --- a/src/auth/services/auth.service.ts +++ b/src/auth/services/auth.service.ts @@ -13,8 +13,6 @@ import { User } from '../../user/entities'; import { ChangePasswordRequestDto, CreateUnverifiedUserRequestDto, - DisableBiometricRequestDto, - EnableBiometricRequestDto, ForgetPasswordRequestDto, LoginRequestDto, SendForgetPasswordOtpRequestDto, @@ -24,7 +22,6 @@ import { } from '../dtos/request'; import { Roles } from '../enums'; import { IJwtPayload, ILoginResponse } from '../interfaces'; -import { Oauth2Service } from './oauth2.service'; const ONE_THOUSAND = 1000; const SALT_ROUNDS = 10; @@ -40,7 +37,6 @@ export class AuthService { private readonly deviceService: DeviceService, private readonly userTokenService: UserTokenService, private readonly cacheService: CacheService, - private readonly oauth2Service: Oauth2Service, ) {} async sendRegisterOtp(body: CreateUnverifiedUserRequestDto) { @@ -100,43 +96,6 @@ export class AuthService { return [tokens, user]; } - async enableBiometric(userId: string, { deviceId, publicKey }: EnableBiometricRequestDto) { - this.logger.log(`Enabling biometric for user with id ${userId}`); - const device = await this.deviceService.findUserDeviceById(deviceId, userId); - - if (!device) { - this.logger.log(`Device not found, creating new device for user with id ${userId}`); - return this.deviceService.createDevice({ - deviceId, - userId, - publicKey, - }); - } - - if (device.publicKey) { - this.logger.error(`Biometric already enabled for user with id ${userId}`); - throw new BadRequestException('AUTH.BIOMETRIC_ALREADY_ENABLED'); - } - - return this.deviceService.updateDevice(deviceId, { publicKey }); - } - - async disableBiometric(userId: string, { deviceId }: DisableBiometricRequestDto) { - const device = await this.deviceService.findUserDeviceById(deviceId, userId); - - if (!device) { - this.logger.error(`Device not found for user with id ${userId} and device id ${deviceId}`); - throw new BadRequestException('AUTH.DEVICE_NOT_FOUND'); - } - - if (!device.publicKey) { - this.logger.error(`Biometric already disabled for user with id ${userId}`); - throw new BadRequestException('AUTH.BIOMETRIC_ALREADY_DISABLED'); - } - - return this.deviceService.updateDevice(deviceId, { publicKey: null }); - } - async sendForgetPasswordOtp({ countryCode, phoneNumber }: SendForgetPasswordOtpRequestDto) { this.logger.log(`Sending forget password OTP to ${countryCode + phoneNumber}`); const user = await this.userService.findUserOrThrow({ countryCode, phoneNumber }); @@ -319,40 +278,6 @@ export class AuthService { return [tokens, user]; } - // private async loginWithBiometric(loginDto: LoginRequestDto, deviceId: string): Promise<[ILoginResponse, User]> { - // const user = await this.userService.findUserOrThrow({ email: loginDto.email }); - - // this.logger.log(`validating biometric for user with email ${loginDto.email}`); - // const device = await this.deviceService.findUserDeviceById(deviceId, user.id); - - // if (!device) { - // this.logger.error(`Device not found for user with email ${loginDto.email} and device id ${deviceId}`); - // throw new UnauthorizedException('AUTH.DEVICE_NOT_FOUND'); - // } - - // if (!device.publicKey) { - // this.logger.error(`Biometric not enabled for user with email ${loginDto.email}`); - // throw new UnauthorizedException('AUTH.BIOMETRIC_NOT_ENABLED'); - // } - - // const cleanToken = removePadding(loginDto.signature); - // const isValidToken = await verifySignature( - // device.publicKey, - // cleanToken, - // `${user.email} - ${device.deviceId}`, - // 'SHA1', - // ); - - // if (!isValidToken) { - // this.logger.error(`Invalid biometric for user with email ${loginDto.email}`); - // throw new UnauthorizedException('AUTH.INVALID_BIOMETRIC'); - // } - - // const tokens = await this.generateAuthToken(user); - // this.logger.log(`Biometric validated successfully for user with email ${loginDto.email}`); - // return [tokens, user]; - // } - private async generateAuthToken(user: User) { this.logger.log(`Generating auth token for user with id ${user.id}`); const [accessToken, refreshToken] = await Promise.all([ diff --git a/src/auth/services/index.ts b/src/auth/services/index.ts index 69c39b4..2a719d1 100644 --- a/src/auth/services/index.ts +++ b/src/auth/services/index.ts @@ -1,2 +1 @@ export * from './auth.service'; -export * from './oauth2.service'; diff --git a/src/auth/services/oauth2.service.ts b/src/auth/services/oauth2.service.ts deleted file mode 100644 index a3577bb..0000000 --- a/src/auth/services/oauth2.service.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { HttpService } from '@nestjs/axios'; -import { Injectable, Logger, UnauthorizedException } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { JwtService } from '@nestjs/jwt'; -import { OAuth2Client } from 'google-auth-library'; -import jwkToPem from 'jwk-to-pem'; -import { lastValueFrom } from 'rxjs'; -import { ApplePayload } from '../interfaces'; - -@Injectable() -export class Oauth2Service { - private readonly logger = new Logger(Oauth2Service.name); - private appleKeysEndpoint = 'https://appleid.apple.com/auth/keys'; - private appleIssuer = 'https://appleid.apple.com'; - private readonly googleWebClientId = this.configService.getOrThrow('GOOGLE_WEB_CLIENT_ID'); - private readonly googleAndroidClientId = this.configService.getOrThrow('GOOGLE_ANDROID_CLIENT_ID'); - private readonly googleIosClientId = this.configService.getOrThrow('GOOGLE_IOS_CLIENT_ID'); - private readonly client = new OAuth2Client(); - constructor( - private readonly httpService: HttpService, - private readonly jwtService: JwtService, - private readonly configService: ConfigService, - ) {} - - async verifyAppleToken(appleToken: string): Promise { - try { - const response = await lastValueFrom(this.httpService.get(this.appleKeysEndpoint)); - - const keys = response.data.keys; - - const decodedHeader = this.jwtService.decode(appleToken, { complete: true })?.header; - - if (!decodedHeader) { - this.logger.error(`Invalid apple token`); - throw new UnauthorizedException(); - } - - const keyId = decodedHeader.kid; - - const appleKey = keys.find((key: any) => key.kid === keyId); - - if (!appleKey) { - this.logger.error(`Invalid apple token`); - throw new UnauthorizedException(); - } - - const publicKey = jwkToPem(appleKey); - - const payload = this.jwtService.verify(appleToken, { - publicKey, - algorithms: ['RS256'], - audience: this.configService.getOrThrow('APPLE_CLIENT_ID').split(','), - issuer: this.appleIssuer, - }); - - return payload; - } catch (error) { - this.logger.error(`Error verifying apple token: ${error} `); - throw new UnauthorizedException(error); - } - } - - async verifyGoogleToken(googleToken: string): Promise { - try { - const ticket = await this.client.verifyIdToken({ - idToken: googleToken, - audience: [this.googleWebClientId, this.googleAndroidClientId, this.googleIosClientId], - }); - - const payload = ticket.getPayload(); - - if (!payload) { - this.logger.error(`payload not found in google token`); - throw new UnauthorizedException(); - } - - return payload; - } catch (error) { - this.logger.error(`Invalid google token`, error); - throw new UnauthorizedException(); - } - } -} diff --git a/src/db/migrations/1754915164809-create-neoleap-related-entities.ts b/src/db/migrations/1754915164809-create-neoleap-related-entities.ts new file mode 100644 index 0000000..105415d --- /dev/null +++ b/src/db/migrations/1754915164809-create-neoleap-related-entities.ts @@ -0,0 +1,95 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateNeoleapRelatedEntities1754915164809 implements MigrationInterface { + name = 'CreateNeoleapRelatedEntities1754915164809'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "customers" DROP CONSTRAINT "FK_e7574892da11dd01de5cfc46499"`); + await queryRunner.query( + `CREATE TABLE "transactions" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "transaction_scope" character varying NOT NULL, "transaction_type" character varying NOT NULL DEFAULT 'EXTERNAL', "card_reference" character varying, "account_reference" character varying, "transaction_id" character varying, "card_masked_number" character varying, "transaction_date" TIMESTAMP WITH TIME ZONE, "rrn" character varying, "transaction_amount" numeric(12,2) NOT NULL, "transaction_currency" character varying NOT NULL, "billing_amount" numeric(12,2) NOT NULL, "settlement_amount" numeric(12,2) NOT NULL, "fees" numeric(12,2) NOT NULL, "vat_on_fees" numeric(12,2) NOT NULL DEFAULT '0', "card_id" uuid, "account_id" uuid, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "UQ_9162bf9ab4e31961a8f7932974c" UNIQUE ("transaction_id"), CONSTRAINT "PK_a219afd8dd77ed80f5a862f1db9" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE TABLE "accounts" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "account_reference" character varying(255) NOT NULL, "account_number" character varying(255) NOT NULL, "iban" character varying(255) NOT NULL, "currency" character varying(255) NOT NULL, "balance" numeric(10,2) NOT NULL DEFAULT '0', "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "UQ_829183fe026a0ce5fc8026b2417" UNIQUE ("account_reference"), CONSTRAINT "PK_5a7a02c20412299d198e097a8fe" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_829183fe026a0ce5fc8026b241" ON "accounts" ("account_reference") `, + ); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_ffd1ae96513bfb2c6eada0f7d3" ON "accounts" ("account_number") `); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_9a4b004902294416b096e7556e" ON "accounts" ("iban") `); + await queryRunner.query( + `CREATE TABLE "cards" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "card_reference" character varying NOT NULL, "vpan" character varying NOT NULL, "first_six_digits" character varying(6) NOT NULL, "last_four_digits" character varying(4) NOT NULL, "expiry" character varying NOT NULL, "customer_type" character varying NOT NULL, "color" character varying NOT NULL DEFAULT 'BLUE', "status" character varying NOT NULL DEFAULT 'PENDING', "statusDescription" character varying NOT NULL DEFAULT 'PENDING_ACTIVATION', "limit" numeric(10,2) NOT NULL DEFAULT '0', "scheme" character varying NOT NULL DEFAULT 'VISA', "issuer" character varying NOT NULL, "customer_id" uuid NOT NULL, "parent_id" uuid, "account_id" uuid NOT NULL, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_5f3269634705fdff4a9935860fc" PRIMARY KEY ("id"))`, + ); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_4f7df5c5dc950295dc417a11d8" ON "cards" ("card_reference") `); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_1ec2ef68b0370f26639261e87b" ON "cards" ("vpan") `); + await queryRunner.query(`ALTER TABLE "customers" DROP CONSTRAINT "REL_e7574892da11dd01de5cfc4649"`); + await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "profile_picture_id"`); + await queryRunner.query(`ALTER TABLE "users" ADD "first_name" character varying(255) NOT NULL`); + await queryRunner.query(`ALTER TABLE "users" ADD "last_name" character varying(255) NOT NULL`); + await queryRunner.query(`ALTER TABLE "users" ADD "profile_picture_id" uuid`); + await queryRunner.query( + `ALTER TABLE "users" ADD CONSTRAINT "UQ_02ec15de199e79a0c46869895f4" UNIQUE ("profile_picture_id")`, + ); + await queryRunner.query( + `ALTER TABLE "documents" ADD "upload_status" character varying(255) NOT NULL DEFAULT 'NOT_UPLOADED'`, + ); + await queryRunner.query(`ALTER TABLE "customers" ADD "country" character varying(255)`); + await queryRunner.query(`ALTER TABLE "customers" ADD "region" character varying(255)`); + await queryRunner.query(`ALTER TABLE "customers" ADD "city" character varying(255)`); + await queryRunner.query(`ALTER TABLE "customers" ADD "neighborhood" character varying(255)`); + await queryRunner.query(`ALTER TABLE "customers" ADD "street" character varying(255)`); + await queryRunner.query(`ALTER TABLE "customers" ADD "building" character varying(255)`); + await queryRunner.query( + `ALTER TABLE "users" ADD CONSTRAINT "FK_02ec15de199e79a0c46869895f4" FOREIGN KEY ("profile_picture_id") REFERENCES "documents"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "transactions" ADD CONSTRAINT "FK_80ad48141be648db2d84ff32f79" FOREIGN KEY ("card_id") REFERENCES "cards"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "transactions" ADD CONSTRAINT "FK_49c0d6e8ba4bfb5582000d851f0" FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "cards" ADD CONSTRAINT "FK_8ba18e7060c38ddae12e5bdf907" FOREIGN KEY ("parent_id") REFERENCES "customers"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "cards" ADD CONSTRAINT "FK_2fd0ee722ec57594d2e448c73d7" FOREIGN KEY ("customer_id") REFERENCES "customers"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "cards" ADD CONSTRAINT "FK_b2874ef49ff7da2dee49e4bc6d3" FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "cards" DROP CONSTRAINT "FK_b2874ef49ff7da2dee49e4bc6d3"`); + await queryRunner.query(`ALTER TABLE "cards" DROP CONSTRAINT "FK_2fd0ee722ec57594d2e448c73d7"`); + await queryRunner.query(`ALTER TABLE "cards" DROP CONSTRAINT "FK_8ba18e7060c38ddae12e5bdf907"`); + await queryRunner.query(`ALTER TABLE "transactions" DROP CONSTRAINT "FK_49c0d6e8ba4bfb5582000d851f0"`); + await queryRunner.query(`ALTER TABLE "transactions" DROP CONSTRAINT "FK_80ad48141be648db2d84ff32f79"`); + await queryRunner.query(`ALTER TABLE "users" DROP CONSTRAINT "FK_02ec15de199e79a0c46869895f4"`); + await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "building"`); + await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "street"`); + await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "neighborhood"`); + await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "city"`); + await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "region"`); + await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "country"`); + await queryRunner.query(`ALTER TABLE "documents" DROP COLUMN "upload_status"`); + await queryRunner.query(`ALTER TABLE "users" DROP CONSTRAINT "UQ_02ec15de199e79a0c46869895f4"`); + await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "profile_picture_id"`); + await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "last_name"`); + await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "first_name"`); + await queryRunner.query(`ALTER TABLE "customers" ADD "profile_picture_id" uuid`); + await queryRunner.query( + `ALTER TABLE "customers" ADD CONSTRAINT "REL_e7574892da11dd01de5cfc4649" UNIQUE ("profile_picture_id")`, + ); + await queryRunner.query(`DROP INDEX "public"."IDX_1ec2ef68b0370f26639261e87b"`); + await queryRunner.query(`DROP INDEX "public"."IDX_4f7df5c5dc950295dc417a11d8"`); + await queryRunner.query(`DROP TABLE "cards"`); + await queryRunner.query(`DROP INDEX "public"."IDX_9a4b004902294416b096e7556e"`); + await queryRunner.query(`DROP INDEX "public"."IDX_ffd1ae96513bfb2c6eada0f7d3"`); + await queryRunner.query(`DROP INDEX "public"."IDX_829183fe026a0ce5fc8026b241"`); + await queryRunner.query(`DROP TABLE "accounts"`); + await queryRunner.query(`DROP TABLE "transactions"`); + await queryRunner.query( + `ALTER TABLE "customers" ADD CONSTRAINT "FK_e7574892da11dd01de5cfc46499" FOREIGN KEY ("profile_picture_id") REFERENCES "documents"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + } +} diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index 462234a..43207c9 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -1,2 +1,3 @@ export * from './1754913378460-initial-migration'; export * from './1754913378461-seed-default-avatar'; +export * from './1754915164809-create-neoleap-related-entities'; From bf505a65bf1ba445bca6f52db723da14d1860b61 Mon Sep 17 00:00:00 2001 From: Abdalhamid Alhamad Date: Mon, 11 Aug 2025 16:13:53 +0300 Subject: [PATCH 29/32] fix: fix invalid imports --- src/auth/dtos/request/index.ts | 5 ----- src/auth/interfaces/index.ts | 1 - 2 files changed, 6 deletions(-) diff --git a/src/auth/dtos/request/index.ts b/src/auth/dtos/request/index.ts index 767d1f4..fac6c67 100644 --- a/src/auth/dtos/request/index.ts +++ b/src/auth/dtos/request/index.ts @@ -1,14 +1,9 @@ -export * from './apple-login.request.dto'; export * from './change-password.request.dto'; export * from './create-unverified-user.request.dto'; -export * from './disable-biometric.request.dto'; -export * from './enable-biometric.request.dto'; export * from './forget-password.request.dto'; -export * from './google-login.request.dto'; export * from './login.request.dto'; export * from './refresh-token.request.dto'; export * from './send-forget-password-otp.request.dto'; -export * from './set-email.request.dto'; export * from './set-junior-password.request.dto'; export * from './set-passcode.request.dto'; export * from './verify-forget-password-otp.request.dto'; diff --git a/src/auth/interfaces/index.ts b/src/auth/interfaces/index.ts index 6598ce3..a27228e 100644 --- a/src/auth/interfaces/index.ts +++ b/src/auth/interfaces/index.ts @@ -1,3 +1,2 @@ -export * from './apple-payload.interface'; export * from './jwt-payload.interface'; export * from './login-response.interface'; From 681d1e579138586aeb519658fd26cc98fcbcf4fe Mon Sep 17 00:00:00 2001 From: Abdalhamid Alhamad Date: Mon, 11 Aug 2025 16:16:26 +0300 Subject: [PATCH 30/32] fix: fix seed default avatar migration --- .../migrations/1754913378461-seed-default-avatar.ts | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/src/db/migrations/1754913378461-seed-default-avatar.ts b/src/db/migrations/1754913378461-seed-default-avatar.ts index 8bebf46..297c684 100644 --- a/src/db/migrations/1754913378461-seed-default-avatar.ts +++ b/src/db/migrations/1754913378461-seed-default-avatar.ts @@ -1,70 +1,61 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; import { v4 as uuid } from 'uuid'; import { Document } from '../../document/entities'; -import { DocumentType, UploadStatus } from '../../document/enums'; +import { DocumentType } from '../../document/enums'; const DEFAULT_AVATARS = [ { id: uuid(), name: 'vacation', extension: '.jpg', documentType: DocumentType.DEFAULT_AVATAR, - uploadStatus: UploadStatus.UPLOADED, }, { id: uuid(), name: 'colors', extension: '.jpg', documentType: DocumentType.DEFAULT_AVATAR, - uploadStatus: UploadStatus.UPLOADED, }, { id: uuid(), name: 'astronaut', extension: '.jpg', documentType: DocumentType.DEFAULT_AVATAR, - uploadStatus: UploadStatus.UPLOADED, }, { id: uuid(), name: 'pet', extension: '.jpg', documentType: DocumentType.DEFAULT_AVATAR, - uploadStatus: UploadStatus.UPLOADED, }, { id: uuid(), name: 'disney', extension: '.jpg', documentType: DocumentType.DEFAULT_AVATAR, - uploadStatus: UploadStatus.UPLOADED, }, { id: uuid(), name: 'clothes', extension: '.jpg', documentType: DocumentType.DEFAULT_AVATAR, - uploadStatus: UploadStatus.UPLOADED, }, { id: uuid(), name: 'playstation', extension: '.jpg', documentType: DocumentType.DEFAULT_AVATAR, - uploadStatus: UploadStatus.UPLOADED, }, { id: uuid(), name: 'football', extension: '.jpg', documentType: DocumentType.DEFAULT_AVATAR, - uploadStatus: UploadStatus.UPLOADED, }, { id: uuid(), name: 'cars', extension: '.jpg', documentType: DocumentType.DEFAULT_AVATAR, - uploadStatus: UploadStatus.UPLOADED, }, ]; export class SeedDefaultAvatar1754913378460 implements MigrationInterface { From dcc9077392400c9f88a4bc28c50b2a38fc058fe0 Mon Sep 17 00:00:00 2001 From: Abdalhamid Alhamad Date: Mon, 11 Aug 2025 16:22:20 +0300 Subject: [PATCH 31/32] fix: rename migration timestamp --- ...d-default-avatar.ts => 1754915164810-seed-default-avatar.ts} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename src/db/migrations/{1754913378461-seed-default-avatar.ts => 1754915164810-seed-default-avatar.ts} (96%) diff --git a/src/db/migrations/1754913378461-seed-default-avatar.ts b/src/db/migrations/1754915164810-seed-default-avatar.ts similarity index 96% rename from src/db/migrations/1754913378461-seed-default-avatar.ts rename to src/db/migrations/1754915164810-seed-default-avatar.ts index 297c684..e694566 100644 --- a/src/db/migrations/1754913378461-seed-default-avatar.ts +++ b/src/db/migrations/1754915164810-seed-default-avatar.ts @@ -58,7 +58,7 @@ const DEFAULT_AVATARS = [ documentType: DocumentType.DEFAULT_AVATAR, }, ]; -export class SeedDefaultAvatar1754913378460 implements MigrationInterface { +export class SeedDefaultAvatar1754915164810 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.manager.getRepository(Document).save(DEFAULT_AVATARS); } From 05a9f04ac8766622dd95287be9ad229c44312242 Mon Sep 17 00:00:00 2001 From: Abdalhamid Alhamad Date: Mon, 11 Aug 2025 16:23:19 +0300 Subject: [PATCH 32/32] fix: fix import in migration index --- src/db/migrations/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index 43207c9..2ed92f6 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -1,3 +1,3 @@ export * from './1754913378460-initial-migration'; -export * from './1754913378461-seed-default-avatar'; export * from './1754915164809-create-neoleap-related-entities'; +export * from './1754915164810-seed-default-avatar';