diff --git a/.vscode/settings.json b/.vscode/settings.json index 1a21801..2a44e2c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -28,7 +28,11 @@ }, "[markdown]": { "editor.wordWrap": "on", - "editor.quickSuggestions": false + "editor.quickSuggestions": { + "comments": "off", + "strings": "off", + "other": "off" + } }, "[jsonc]": { "editor.defaultFormatter": "esbenp.prettier-vscode" diff --git a/src/app.module.ts b/src/app.module.ts index 71c920a..70b8b47 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -13,6 +13,7 @@ import { UserDBService } from './user/user-db/user-db.service'; import { UserModule } from './user/user.module'; import { AdminModule } from './admin/admin.module'; import { DstModule } from './dst/dst.module'; +import { UCIModule } from './uci/uci.module'; import { AuthModule } from './auth/auth.module'; import got from 'got/dist/source'; @@ -47,6 +48,7 @@ const otpServiceFactory = { }), AdminModule, DstModule, + UCIModule, AuthModule, ], controllers: [AppController], diff --git a/src/uci/fusionauth/fusionauth.service.spec.ts b/src/uci/fusionauth/fusionauth.service.spec.ts new file mode 100644 index 0000000..a44a6a7 --- /dev/null +++ b/src/uci/fusionauth/fusionauth.service.spec.ts @@ -0,0 +1,19 @@ +import { Test, TestingModule } from '@nestjs/testing'; + +import { FusionauthService } from './fusionauth.service'; + +describe('FusionauthService', () => { + let service: FusionauthService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [FusionauthService], + }).compile(); + + service = module.get(FusionauthService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/src/uci/fusionauth/fusionauth.service.ts b/src/uci/fusionauth/fusionauth.service.ts new file mode 100644 index 0000000..04f0ea3 --- /dev/null +++ b/src/uci/fusionauth/fusionauth.service.ts @@ -0,0 +1,432 @@ +import FusionAuthClient, { + LoginRequest, + LoginResponse, + RegistrationRequest, + RegistrationResponse, + SearchRequest, + SearchResponse, + Sort, + UUID, + User, + UserRegistration, + UserRequest, + UserResponse, +} from '@fusionauth/typescript-client'; + +import ClientResponse from '@fusionauth/typescript-client/build/src/ClientResponse'; +import { Injectable } from '@nestjs/common'; +import { response } from 'express'; + +export enum FAStatus { + SUCCESS = 'SUCCESS', + USER_EXISTS = 'USER_EXISTS', + ERROR = 'ERROR', +} + +@Injectable() +export class FusionauthService { + fusionauthClient: FusionAuthClient; + + constructor() { + this.fusionauthClient = new FusionAuthClient( + process.env.FUSIONAUTH_OLD_API_KEY, + process.env.FUSIONAUTH_OLD_BASE_URL, + ); + } + + getUser( + username: string, + ): Promise<{ statusFA: FAStatus; userId: UUID; user: User }> { + return this.fusionauthClient + .retrieveUserByUsername(username) + .then( + ( + response: ClientResponse, + ): { statusFA: FAStatus; userId: UUID; user: User } => { + console.log('Found user'); + return { + statusFA: FAStatus.USER_EXISTS, + userId: response.response.user.id, + user: response.response.user, + }; + }, + ) + .catch((e): { statusFA: FAStatus; userId: UUID; user: User } => { + console.log( + `Could not fetch user with username ${username}`, + JSON.stringify(e), + ); + return { + statusFA: FAStatus.ERROR, + userId: null, + user: null, + }; + }); + } + + getUsers( + applicationId: string, + startRow: number, + numberOfResults: number, + ): Promise<{ total: number; users: Array }> { + const searchRequest = { + search: { + numberOfResults: numberOfResults, + query: `{"bool":{"must":[{"nested":{"path":"registrations","query":{"bool":{"must":[{"match":{"registrations.applicationId":"${applicationId}"}}]}}}}]}}`, + sortFields: [ + { + missing: 'username', + name: 'fullName', + order: Sort.asc, + }, + ], + startRow: startRow, + }, + }; + return this.fusionauthClient + .searchUsersByQuery(searchRequest) + .then( + ( + response: ClientResponse, + ): { total: number; users: Array } => { + console.log('Found users'); + return { + total: response.response.total, + users: response.response.users, + }; + }, + ) + .catch((e): { total: number; users: Array } => { + console.log( + `Could not fetch users for applicationId ${applicationId}`, + JSON.stringify(e), + ); + return { + total: 0, + users: null, + }; + }); + } + + getUsersByString( + queryString: string, + startRow: number, + numberOfResults: number, + ): Promise<{ total: number; users: Array }> { + const searchRequest = { + search: { + numberOfResults: numberOfResults, + query: `{"bool":{"must":[{"bool":{"must":[[{"nested":{"path":"registrations","query":{"bool":{"should":[{"match":{"registrations.applicationId":"${process.env.FUSIONAUTH_APPLICATION_ID}"}},{"match":{"registrations.applicationId":"${process.env.FUSIONAUTH_SAMARTH_HP_APPLICATION_ID}"}}]}}}}]]}},{"query_string":{"query":"${queryString}"}}]}}`, + sortFields: [ + { + missing: 'username', + name: 'fullName', + order: Sort.asc, + }, + ], + startRow: startRow, + }, + }; + console.log(searchRequest); + return this.fusionauthClient + .searchUsersByQuery(searchRequest) + .then( + ( + response: ClientResponse, + ): { total: number; users: Array } => { + console.log('Found users'); + return { + total: response.response.total, + users: response.response.users, + }; + }, + ) + .catch((e): { total: number; users: Array } => { + console.log(`Could not fetch users`, JSON.stringify(e)); + return { + total: 0, + users: null, + }; + }); + } + + updatePassword( + userId: UUID, + password: string, + ): Promise<{ statusFA: FAStatus; userId: UUID }> { + return this.fusionauthClient + .patchUser(userId, { + user: { + password: password, + }, + }) + .then((response) => { + return { + statusFA: FAStatus.SUCCESS, + userId: response.response.user.id, + }; + }) + .catch((response) => { + console.log(JSON.stringify(response)); + return { + statusFA: FAStatus.ERROR, + userId: null, + }; + }); + } + + delete(userId: UUID): Promise { + return this.fusionauthClient + .deleteUser(userId) + .then((response) => { + console.log(response); + }) + .catch((e) => { + console.log(e); + }); + } + + persist(authObj: any): Promise<{ statusFA: FAStatus; userId: UUID }> { + console.log(authObj); + let resp; + let resp1; + const responses: Array<{ statusFA: FAStatus; userId: UUID }> = []; + const registrations: Array = []; + const currentRegistration: UserRegistration = { + username: authObj.username, + applicationId: process.env.FUSIONAUTH_APPLICATION_ID, + roles: authObj.role, + }; + const currentRegistration_samarth_hp: UserRegistration = { + username: authObj.username, + applicationId: process.env.FUSIONAUTH_SAMARTH_HP_APPLICATION_ID, + roles: authObj.role, + }; + registrations.push(currentRegistration); + const userRequest: RegistrationRequest = { + user: { + active: true, + data: { + school: authObj.school, + education: authObj.education, + address: authObj.address, + gender: authObj.gender, + dateOfRetirement: authObj.dateOfRetirement, + phoneVerified: false, + udise: authObj.udise, + }, + email: authObj.email, + firstName: authObj.firstName, + lastName: authObj.lastName, + username: authObj.username, + password: authObj.password, + imageUrl: authObj.avatar, + mobilePhone: authObj.phone, + }, + registration: currentRegistration, + }; + const userRequest_samarth_hp: RegistrationRequest = { + registration: currentRegistration_samarth_hp, + }; + resp = this.fusionauthClient + .register(undefined, userRequest) + .then( + ( + response: ClientResponse, + ): { statusFA: FAStatus; userId: UUID } => { + this.fusionauthClient + .register(response.response.user.id, userRequest_samarth_hp) + .then((res: ClientResponse): any => { + console.log({ res }); + }) + .catch((e): Promise<{ statusFA: FAStatus; userId: UUID }> => { + console.log('Could not create a user in', JSON.stringify(e)); + console.log('Trying to fetch an existing user in'); + return this.fusionauthClient + .retrieveUserByUsername(authObj.username) + .then((response: ClientResponse): any => { + console.log('Found user in'); + }) + .catch((e): any => { + console.log( + `Could not fetch user with username in ${authObj.username}`, + JSON.stringify(e), + ); + }); + }); + return { + statusFA: FAStatus.SUCCESS, + userId: response.response.user.id, + }; + }, + ) + .catch((e): Promise<{ statusFA: FAStatus; userId: UUID }> => { + console.log('Could not create a user', JSON.stringify(e)); + console.log('Trying to fetch an existing user'); + return this.fusionauthClient + .retrieveUserByUsername(authObj.username) + .then( + ( + response: ClientResponse, + ): { statusFA: FAStatus; userId: UUID } => { + console.log('Found user'); + return { + statusFA: FAStatus.USER_EXISTS, + userId: response.response.user.id, + }; + }, + ) + .catch((e): { statusFA: FAStatus; userId: UUID } => { + console.log( + `Could not fetch user with username ${authObj.username}`, + JSON.stringify(e), + ); + return { + statusFA: FAStatus.ERROR, + userId: null, + }; + }); + }); + return resp; + } + + login(user: LoginRequest): Promise> { + console.log(user); + return this.fusionauthClient + .login(user) + .then((response: ClientResponse): any => { + return response; + }) + .catch((e) => { + throw e; + }); + } + + update( + userID: UUID, + authObj: any, + isSimpleUpdate = false, + ): Promise<{ statusFA: FAStatus; userId: UUID; fusionAuthUser: User }> { + let userRequest: UserRequest; + if (!isSimpleUpdate) { + const registrations: Array = []; + const currentRegistration: UserRegistration = { + username: authObj.username, + applicationId: process.env.FUSIONAUTH_APPLICATION_ID, + roles: authObj.role, + }; + registrations.push(currentRegistration); + + userRequest = { + user: { + active: true, + data: { + school: authObj.school, + education: authObj.education, + address: authObj.address, + gender: authObj.gender, + dateOfRetirement: authObj.dateOfRetirement, + phoneVerified: false, + udise: authObj.udise, + phone: authObj.phone, + accountName: authObj.firstName, + }, + email: authObj.email, + firstName: authObj.firstName, + lastName: authObj.lastName, + fullName: authObj.fullName, + username: authObj.username, + password: authObj.password, + imageUrl: authObj.avatar, + mobilePhone: authObj.phone, + }, + }; + } else { + userRequest = { + user: authObj, + }; + } + + return this.fusionauthClient + .patchUser(userID, userRequest) + .then( + ( + response: ClientResponse, + ): { statusFA: FAStatus; userId: UUID; fusionAuthUser: User } => { + console.log({ response }); + return { + statusFA: FAStatus.SUCCESS, + userId: response.response.user.id, + fusionAuthUser: response.response.user, + }; + }, + ) + .catch( + (e): { statusFA: FAStatus; userId: UUID; fusionAuthUser: User } => { + console.log('Unable to update user', JSON.stringify(e)); + return { + statusFA: FAStatus.ERROR, + userId: null, + fusionAuthUser: null, + }; + }, + ); + } + + verifyUsernamePhoneCombination(): Promise { + return Promise.resolve(true); + } + + //One time Task + async updateAllEmptyRolesToSchool(): Promise { + let allDone = false; + const searchRequest: SearchRequest = { + search: { + numberOfResults: 15, + startRow: 0, + sortFields: [ + { + missing: '_first', + name: 'id', + order: Sort.asc, + }, + ], + query: + '{"bool":{"must":[{"nested":{"path":"registrations","query":{"bool":{"must":[{"match":{"registrations.applicationId":"f0ddb3f6-091b-45e4-8c0f-889f89d4f5da"}}],"must_not":[{"match":{"registrations.roles":"school"}}]}}}}]}}', + }, + }; + let iteration = 0; + let invalidUsersCount = 0; + while (!allDone) { + iteration += 1; + searchRequest.search.startRow = invalidUsersCount; + const resp: ClientResponse = + await this.fusionauthClient.searchUsersByQuery(searchRequest); + const total = resp.response.total; + console.log(iteration, total); + if (total === 0) allDone = true; + else { + const users: Array = resp.response.users; + for (const user of users) { + if (user.registrations[0].roles === undefined) { + user.registrations[0].roles = ['school']; + console.log('Here', user); + await this.fusionauthClient + .updateRegistration(user.id, { + registration: user.registrations[0], + }) + .then((resp) => { + console.log('response', JSON.stringify(resp)); + }) + .catch((e) => { + console.log('error', JSON.stringify(e)); + }); + } else { + console.log('Invalid User', user.id); + invalidUsersCount += 1; + } + } + } + } + } +} diff --git a/src/uci/otp/otp.service.spec.ts b/src/uci/otp/otp.service.spec.ts new file mode 100644 index 0000000..4206390 --- /dev/null +++ b/src/uci/otp/otp.service.spec.ts @@ -0,0 +1,43 @@ +import { Test, TestingModule } from '@nestjs/testing'; + +import { GupshupService } from '../sms/gupshup/gupshup.service'; +import { OtpService } from './otp.service'; + +import got, { Got } from 'got/dist/source'; + +const gupshupFactory = { + provide: GupshupService, + useFactory: () => { + return new GupshupService( + process.env.GUPSHUP_USERNAME, + process.env.GUPSHUP_PASSWORD, + process.env.GUPSHUP_BASEURL, + got, + ); + }, + inject: [], +}; + +const otpServiceFactory = { + provide: OtpService, + useFactory: () => { + return new OtpService(gupshupFactory.useFactory()); + }, + inject: [], +}; + +describe('OtpService', () => { + let service: OtpService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [otpServiceFactory], + }).compile(); + + service = module.get(OtpService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/src/uci/otp/otp.service.ts b/src/uci/otp/otp.service.ts new file mode 100644 index 0000000..a4cfee3 --- /dev/null +++ b/src/uci/otp/otp.service.ts @@ -0,0 +1,50 @@ +import { + SMSData, + SMSResponse, + SMSResponseStatus, + SMSType, + TrackResponse, + TrackStatus, +} from '../sms/sms.interface'; + +import { Injectable } from '@nestjs/common'; +import { SmsService } from '../sms/sms.service'; +import { ResponseStatus, SignupResponse } from '../uci.interface'; +import { v4 as uuidv4 } from 'uuid'; +import { UCIService } from '../uci.service'; + +@Injectable() +export class OtpService { + expiry: number = parseInt(process.env.OTP_EXPIRY); + + constructor(private readonly smsService: SmsService) {} + + verifyOTP({ phone, otp }): Promise { + const smsData: SMSData = { + phone, + template: null, + type: SMSType.otp, + params: { + otp, + expiry: this.expiry, + }, + }; + return this.smsService.track(smsData); + } + + sendOTP(phone): Promise { + const smsData: SMSData = { + phone, + template: null, + type: SMSType.otp, + params: { + expiry: this.expiry, + }, + }; + return this.smsService.send(smsData); + } + + static generateOtp() { + return Math.floor(1000 + Math.random() * 9000); + } +} diff --git a/src/uci/sms/gupshup/gupshup.service.spec.ts b/src/uci/sms/gupshup/gupshup.service.spec.ts new file mode 100644 index 0000000..952ddd6 --- /dev/null +++ b/src/uci/sms/gupshup/gupshup.service.spec.ts @@ -0,0 +1,172 @@ +import { Test, TestingModule } from '@nestjs/testing'; + +import { GupshupService } from './gupshup.service'; +import got, { Got } from 'got/dist/source'; +import { SMSResponseStatus, SMSType } from '../sms.interface'; +jest.mock('got/dist/source'); + +const getGupsupuFactory = (mockedGot: Got) => { + const gupshupFactory = { + provide: GupshupService, + useFactory: () => { + return new GupshupService( + 'dummyUsername', + 'dummyGSPass', + 'dummyGSBaseURL', + mockedGot, + ); + }, + inject: [], + }; + return gupshupFactory; +}; + +describe('GupshupService Wiring', () => { + let service: GupshupService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [getGupsupuFactory(null)], + }).compile(); + + service = module.get(GupshupService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + expect(service.httpClient).toBeDefined(); + expect(service.auth).toBeDefined(); + expect(service.baseURL).toBeDefined(); + }); +}); + +describe('GupshupService Negative Use Cases', () => { + let service: GupshupService; + + beforeEach(async () => { + const mockedGot = got as jest.Mocked; + const module: TestingModule = await Test.createTestingModule({ + providers: [getGupsupuFactory(mockedGot)], + }).compile(); + service = module.get(GupshupService); + }); + + it('send with null data throws error', () => { + expect(service.send).toThrowError('Data cannot be null'); + }); + + it('send method for hsm not implemented', () => { + expect(() => + service.send({ + phone: '8004472230', + template: 'abc', + params: 'abc', + type: SMSType.hsm, + }), + ).toThrowError('Method not implemented.'); + }); +}); + +describe('Gupshup service Success Response', () => { + let service: GupshupService; + + beforeEach(async () => { + const mockedGot = got as jest.Mocked; + mockedGot.get.mockResolvedValueOnce({ + body: 'success | 919812345678 | 728014710863298817-1234567890', + }); + const module: TestingModule = await Test.createTestingModule({ + providers: [getGupsupuFactory(mockedGot)], + }).compile(); + service = module.get(GupshupService); + }); + + it('Success Response', async () => { + const resp1 = await service + .send({ + phone: '8004472230', + template: 'abc', + params: 'abc', + type: SMSType.otp, + }) + .then((response) => + expect(response).toEqual({ + error: null, + messageID: '728014710863298817-1234567890', + networkResponseCode: 200, + phone: '8004472230', + provider: 'Gupshup', + providerResponseCode: null, + providerSuccessResponse: undefined, + status: 'success', + }), + ); + }); +}); + +describe('Gupshup service Error Response', () => { + let service: GupshupService; + + beforeEach(async () => { + const mockedGot = got as jest.Mocked; + mockedGot.get.mockResolvedValueOnce({ + body: 'error | 107 | The specified version "1.0" is invalid. Please specify version as "1.1"', + }); + const module: TestingModule = await Test.createTestingModule({ + providers: [getGupsupuFactory(mockedGot)], + }).compile(); + service = module.get(GupshupService); + }); + it('Error Response', async () => { + const resp1 = await service + .send({ + phone: '8004472230', + template: 'abc', + params: 'abc', + type: SMSType.otp, + }) + .then((response) => + expect(response).toEqual({ + error: { + errorCode: '107', + errorText: + 'The specified version "1.0" is invalid. Please specify version as "1.1"', + }, + messageID: null, + networkResponseCode: 200, + phone: '8004472230', + provider: 'Gupshup', + providerResponseCode: '107', + providerSuccessResponse: null, + status: 'failure', + }), + ); + }); +}); + +describe('Gupshup service Success With Incorrect response', () => { + let service: GupshupService; + + beforeEach(async () => { + const mockedGot = got as jest.Mocked; + mockedGot.get.mockResolvedValueOnce({ + body: undefined, + }); + const module: TestingModule = await Test.createTestingModule({ + providers: [getGupsupuFactory(mockedGot)], + }).compile(); + service = module.get(GupshupService); + }); + + it('Should not parse empty response', () => { + expect(service.parseResponse(undefined)).toHaveProperty('messageID', null); + expect(service.parseResponse(undefined)).toHaveProperty( + 'providerSuccessResponse', + null, + ); + expect(service.parseResponse(undefined)).toHaveProperty( + 'providerResponseCode', + null, + ); + }); +}); diff --git a/src/uci/sms/gupshup/gupshup.service.ts b/src/uci/sms/gupshup/gupshup.service.ts new file mode 100644 index 0000000..cf7c95e --- /dev/null +++ b/src/uci/sms/gupshup/gupshup.service.ts @@ -0,0 +1,204 @@ +import { + OTPResponse, + SMS, + SMSData, + SMSError, + SMSProvider, + SMSResponse, + SMSResponseStatus, + SMSType, + TrackResponse, +} from '../sms.interface'; + +import { Injectable } from '@nestjs/common'; +import { SmsService } from '../sms.service'; +import { Got } from 'got/dist/source'; + +@Injectable() +export class GupshupService extends SmsService implements SMS { + apiConstants: any = { + format: 'text', + v: '1.1', + }; + + otpAuthMethod = 'TWO_FACTOR_AUTH'; + getMethod = 'get'; + postMethod = 'get'; + + otpApiConstants: any = { + otpCodeType: 'NUMERIC', + otpCodeLength: 4, + ...this.apiConstants, + }; + + auth: any = { + userid: '', + password: '', + }; + + httpClient: Got; + + baseURL: string; + path = ''; + data: SMSData; + + constructor(username: string, password: string, baseURL: string, got: Got) { + super(); + this.auth.userid = username; + this.auth.password = password; + this.baseURL = baseURL; + this.httpClient = got; + } + + send(data: SMSData): Promise { + if (!data) { + throw new Error('Data cannot be null'); + } + this.data = data; + if (this.data.type === SMSType.otp) return this.doOTPRequest(data); + else return this.doRequest(); + } + + doRequest(): Promise { + throw new Error('Method not implemented.'); + } + + track(data: SMSData): Promise { + if (!data) { + throw new Error('Data cannot be null'); + } + this.data = data; + if (this.data.type === SMSType.otp) return this.verifyOTP(data); + else return this.doRequest(); + } + + private doOTPRequest(data: SMSData): Promise { + const options = { + searchParams: { + ...this.otpApiConstants, + ...this.auth, + method: this.otpAuthMethod, + msg: this.getOTPTemplate(), + phone_no: data.phone, + }, + }; + const url = this.baseURL + '' + this.path; + const status: OTPResponse = {} as OTPResponse; + status.provider = SMSProvider.gupshup; + status.phone = data.phone; + + return this.httpClient + .get(url, options) + .then((response): OTPResponse => { + status.networkResponseCode = 200; + const r = this.parseResponse(response.body); + status.messageID = r.messageID; + status.error = r.error; + status.providerResponseCode = r.providerResponseCode; + status.providerSuccessResponse = r.providerSuccessResponse; + status.status = r.status; + return status; + }) + .catch((e: Error): OTPResponse => { + const error: SMSError = { + errorText: `Uncaught Exception :: ${e.message}`, + errorCode: 'CUSTOM ERROR', + }; + status.networkResponseCode = 200; + status.messageID = null; + status.error = error; + status.providerResponseCode = null; + status.providerSuccessResponse = null; + status.status = SMSResponseStatus.failure; + return status; + }); + } + + verifyOTP(data: SMSData): Promise { + console.log({ data }); + const options = { + searchParams: { + ...this.otpApiConstants, + ...this.auth, + method: this.otpAuthMethod, + msg: this.getOTPTemplate(), + phone_no: data.phone, + otp_code: data.params.otp, + }, + }; + const url = this.baseURL + '' + this.path; + const status: TrackResponse = {} as TrackResponse; + status.provider = SMSProvider.gupshup; + status.phone = data.phone; + + return this.httpClient + .get(url, options) + .then((response): OTPResponse => { + status.networkResponseCode = 200; + const r = this.parseResponse(response.body); + status.messageID = r.messageID; + status.error = r.error; + status.providerResponseCode = r.providerResponseCode; + status.providerSuccessResponse = r.providerSuccessResponse; + status.status = r.status; + return status; + }) + .catch((e: Error): OTPResponse => { + const error: SMSError = { + errorText: `Uncaught Exception :: ${e.message}`, + errorCode: 'CUSTOM ERROR', + }; + status.networkResponseCode = 200; + status.messageID = null; + status.error = error; + status.providerResponseCode = null; + status.providerSuccessResponse = null; + status.status = SMSResponseStatus.failure; + return status; + }); + } + + getOTPTemplate() { + return process.env.GUPSHUP_OTP_TEMPLATE; + } + + parseResponse(response: string) { + // console.log({ response }); + try { + const responseData: string[] = response.split('|').map((s) => s.trim()); + if (responseData[0] === 'success') { + return { + providerResponseCode: null, + status: SMSResponseStatus.success, + messageID: responseData[2], + error: null, + providerSuccessResponse: responseData[3], + }; + } else { + const error: SMSError = { + errorText: responseData[2], + errorCode: responseData[1], + }; + return { + providerResponseCode: responseData[1], + status: SMSResponseStatus.failure, + messageID: null, + error, + providerSuccessResponse: null, + }; + } + } catch (e) { + const error: SMSError = { + errorText: `Gupshup response could not be parsed :: ${e.message}; Provider Response - ${response}`, + errorCode: 'CUSTOM ERROR', + }; + return { + providerResponseCode: null, + status: SMSResponseStatus.failure, + messageID: null, + error, + providerSuccessResponse: null, + }; + } + } +} diff --git a/src/uci/sms/sms.interface.ts b/src/uci/sms/sms.interface.ts new file mode 100644 index 0000000..4cd25f4 --- /dev/null +++ b/src/uci/sms/sms.interface.ts @@ -0,0 +1,64 @@ +export enum TrackStatus { + enqueued = 'enqueued', + sent = 'send', + delivered = 'delivered', + error = 'error', +} + +export enum SMSType { + otp = 'otp', + hsm = 'hsm', +} + +export enum SMSResponseStatus { + success = 'success', + failure = 'failure', +} + +export enum SMSProvider { + gupshup = 'Gupshup', + cdac = 'CDAC', +} + +export type SMSError = { + errorText: string; + errorCode: string; +}; + +export type SMSData = { + phone: string; + template: any; + params: any; + type: SMSType; +}; + +export interface SMS { + send(data: SMSData): Promise; + track(data: SMSData): Promise; +} + +/** + * SMSResponse is the base generic response which can be extended to get OTPResponse, 2WayCommunication etc. + * @interface SMSResponse + */ +export interface SMSResponse { + /** @messageID {string} ID which can be used as a trackingID */ + messageID: string; //ProviderID + + /** @phone {string} Phone number to which the SMS was sent */ + phone: string; + networkResponseCode: number; + providerResponseCode: string; + providerSuccessResponse: string; + provider: SMSProvider; + status: SMSResponseStatus; + error: SMSError; +} + +export interface OTPResponse extends SMSResponse { + test: string; +} + +export interface TrackResponse extends SMSResponse { + test: string; +} diff --git a/src/uci/sms/sms.service.spec.ts b/src/uci/sms/sms.service.spec.ts new file mode 100644 index 0000000..4085265 --- /dev/null +++ b/src/uci/sms/sms.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { SmsService } from './sms.service'; + +describe('SmsService', () => { + let service: SmsService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [SmsService], + }).compile(); + + service = module.get(SmsService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/src/uci/sms/sms.service.ts b/src/uci/sms/sms.service.ts new file mode 100644 index 0000000..218bccf --- /dev/null +++ b/src/uci/sms/sms.service.ts @@ -0,0 +1,16 @@ +import { SMS, SMSData, SMSResponse, TrackStatus } from './sms.interface'; + +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class SmsService implements SMS { + send(data: SMSData): Promise { + console.error(data); + throw new Error('Placeholder:: Method not implemented.'); + } + + track(data: any): Promise { + console.error(data); + throw new Error('Placeholder:: Method not implemented.'); + } +} diff --git a/src/uci/uci.controller.spec.ts b/src/uci/uci.controller.spec.ts new file mode 100644 index 0000000..8d6fcff --- /dev/null +++ b/src/uci/uci.controller.spec.ts @@ -0,0 +1,60 @@ +import { HttpModule } from '@nestjs/axios'; +import { Test, TestingModule } from '@nestjs/testing'; +import got from 'got/dist/source'; +import { AuthModule } from '../auth/auth.module'; +import { UCIController } from './uci.controller'; +import { FusionauthService } from './fusionauth/fusionauth.service'; +import { OtpService } from './otp/otp.service'; +import { GupshupService } from './sms/gupshup/gupshup.service'; +import { SmsService } from './sms/sms.service'; +import { UCIService } from './uci.service'; + +describe('UCIController', () => { + let controller: UCIController; + let fusionauthService: FusionauthService; + let otpService: OtpService; + let smsService: SmsService; + let uciService: UCIService; + + beforeEach(async () => { + const gupshupFactory = { + provide: 'OtpService', + useFactory: () => { + return new GupshupService( + 'testUsername', + 'testPassword', + 'testBaseUrl', + got, + ); + }, + inject: [], + }; + + const otpServiceFactory = { + provide: OtpService, + useFactory: () => { + return new OtpService(gupshupFactory.useFactory()); + }, + inject: [], + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [UCIController], + imports: [HttpModule, AuthModule], + providers: [FusionauthService, otpServiceFactory, SmsService, UCIService], + }).compile(); + + controller = module.get(UCIController); + fusionauthService = module.get(FusionauthService); + otpService = module.get(OtpService); + smsService = module.get(SmsService); + otpService = module.get(OtpService); + uciService = module.get(UCIService); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + // expect(controller.sendOTP("")).toBeDefined(); + // expect(controller.loginOrRegister("", "", "", "", "")).toBeDefined(); + }); +}); diff --git a/src/uci/uci.controller.ts b/src/uci/uci.controller.ts new file mode 100644 index 0000000..a0e7914 --- /dev/null +++ b/src/uci/uci.controller.ts @@ -0,0 +1,75 @@ +import { + Controller, + Get, + HttpException, + HttpStatus, + Param, + Query, +} from '@nestjs/common'; +import { SkipThrottle, Throttle } from '@nestjs/throttler'; +import { SignupResponse } from './uci.interface'; +import { UCIService } from './uci.service'; +import { OtpService } from './otp/otp.service'; +import { SMSResponse } from './sms/sms.interface'; + +@Controller('uci') +export class UCIController { + constructor( + private readonly otpService: OtpService, + private readonly UCIService: UCIService, + ) {} + + @Get('/sendOTP') + @SkipThrottle() + async sendOTP(@Query('phone') phone): Promise { + const status: SMSResponse = await this.otpService.sendOTP(phone); + const resp: SignupResponse = await this.UCIService.transformOtpResponse( + status, + ); + return { resp }; + } + + // @Throttle(3, 60) + // @Get('/verifyOTP') + // async verifyOTP(@Query('phone') phone, @Query('otp') otp): Promise { + // const otpStatus: SMSResponse = await this.otpService.verifyOTP({ phone, otp }); + // const status = otpStatus.status; + // const resp: SignupResponse = await this.dstService.verifyAndLoginOTP({ phone, status }); + // return { resp }; + // } + + // @Throttle(3, 60) + // @Get('/test') + // async loginTrainee(@Query('id') id): Promise { + // const resp = await this.dstService.checkUserInDb(id); + // return { resp }; + // } + + @Throttle( + parseInt(process.env.DST_API_LIMIT), + parseInt(process.env.DST_API_TTL), + ) + @Get('/loginOrRegister') + async loginOrRegister( + @Query('phone') phone, + @Query('otp') otp, + ): Promise { + let resp: SignupResponse; + if (phone == null || otp == null) { + throw new HttpException( + `Error loggin in: Param: ${ + phone == null ? (otp == null ? 'phone and otp' : 'phone') : '' + } missing`, + HttpStatus.BAD_REQUEST, + ); + } else { + const otpStatus: SMSResponse = await this.otpService.verifyOTP({ + phone, + otp, + }); + const status = otpStatus.status; + resp = await this.UCIService.verifyAndLoginOTP({ phone, status }); + } + return { resp }; + } +} diff --git a/src/uci/uci.interface.ts b/src/uci/uci.interface.ts new file mode 100644 index 0000000..48b4608 --- /dev/null +++ b/src/uci/uci.interface.ts @@ -0,0 +1,78 @@ +import { User, UUID } from '@fusionauth/typescript-client'; +import { v4 as uuidv4 } from 'uuid'; + +export enum ResponseStatus { + success = 'Success', + failure = 'Failure', +} + +export enum ResponseCode { + OK = 'OK', + FAILURE = 'FAILURE', +} + +export enum AccountStatus { + PENDING = 'PENDING', + ACTIVE = 'ACTIVE', + DEACTIVATED = 'DEACTIVATED', + REJECTED = 'REJECTED', +} + +export interface ResponseParams { + responseMsgId: UUID; + msgId: UUID; + err: string; + status: ResponseStatus; + errMsg: string; + customMsg?: string; +} + +export interface SignupResult { + responseMsg?: string; + accountStatus?: AccountStatus; + data?: any; +} + +export interface IGenericResponse { + id: string; + ver: string; + ts: Date; + params: ResponseParams; + responseCode: ResponseCode; + + init(msgId: UUID): any; + + getSuccess(): any; + getFailure(): any; +} + +export class SignupResponse implements IGenericResponse { + id: string; + ver: string; + ts: Date; + params: ResponseParams; + responseCode: ResponseCode; + result: SignupResult; + + init(msgId: UUID): SignupResponse { + this.responseCode = ResponseCode.OK; + this.params = { + responseMsgId: uuidv4(), + msgId: msgId, + err: '', + status: ResponseStatus.success, + errMsg: '', + }; + this.ts = new Date(); + this.id = uuidv4(); + this.result = null; + return this; + } + + getSuccess() { + throw new Error('Method not implemented.'); + } + getFailure() { + throw new Error('Method not implemented.'); + } +} diff --git a/src/uci/uci.module.ts b/src/uci/uci.module.ts new file mode 100644 index 0000000..bf62b44 --- /dev/null +++ b/src/uci/uci.module.ts @@ -0,0 +1,37 @@ +import { Module } from '@nestjs/common'; +import { UCIController } from './uci.controller'; +import { UCIService } from './uci.service'; +import { FusionauthService } from './fusionauth/fusionauth.service'; +import { OtpService } from './otp/otp.service'; +import { GupshupService } from './sms/gupshup/gupshup.service'; +import got from 'got/dist/source'; +import { HttpModule } from '@nestjs/axios'; +import { AuthModule } from 'src/auth/auth.module'; + +const gupshupFactory = { + provide: 'OtpService', + useFactory: () => { + return new GupshupService( + process.env.GUPSHUP_USERNAME, + process.env.GUPSHUP_PASSWORD, + process.env.GUPSHUP_BASEURL, + got, + ); + }, + inject: [], +}; + +const otpServiceFactory = { + provide: OtpService, + useFactory: () => { + return new OtpService(gupshupFactory.useFactory()); + }, + inject: [], +}; + +@Module({ + imports: [HttpModule], + controllers: [UCIController], + providers: [UCIService, FusionauthService, otpServiceFactory], +}) +export class UCIModule {} diff --git a/src/uci/uci.service.spec.ts b/src/uci/uci.service.spec.ts new file mode 100644 index 0000000..6a9206b --- /dev/null +++ b/src/uci/uci.service.spec.ts @@ -0,0 +1,49 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { UCIService } from './uci.service'; +import { FusionauthService } from './fusionauth/fusionauth.service'; +import { OtpService } from './otp/otp.service'; +import { GupshupService } from './sms/gupshup/gupshup.service'; +import got from 'got/dist/source'; +import { HttpModule } from '@nestjs/axios'; +import { AuthModule } from '../auth/auth.module'; + +describe('UCIService', () => { + let service: UCIService; + let fusionauthService: FusionauthService; + let otpService: OtpService; + + const gupshupFactory = { + provide: 'OtpService', + useFactory: () => { + return new GupshupService( + process.env.GUPSHUP_USERNAME, + process.env.GUPSHUP_PASSWORD, + process.env.GUPSHUP_BASEURL, + got, + ); + }, + inject: [], + }; + + const otpServiceFactory = { + provide: OtpService, + useFactory: () => { + return new OtpService(gupshupFactory.useFactory()); + }, + inject: [], + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [HttpModule, AuthModule], + providers: [FusionauthService, otpServiceFactory, UCIService], + }).compile(); + fusionauthService = module.get(FusionauthService); + service = module.get(UCIService); + }); + + it('should be defined', () => { + expect(fusionauthService).toBeDefined(); + expect(service).toBeDefined(); + }); +}); diff --git a/src/uci/uci.service.ts b/src/uci/uci.service.ts new file mode 100644 index 0000000..3de5b56 --- /dev/null +++ b/src/uci/uci.service.ts @@ -0,0 +1,283 @@ +import { HttpService } from '@nestjs/axios'; +import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; +import { ResponseCode, ResponseStatus, SignupResponse } from './uci.interface'; +import { FAStatus, FusionauthService } from './fusionauth/fusionauth.service'; +import { v4 as uuidv4 } from 'uuid'; +import { firstValueFrom, map } from 'rxjs'; +import { SMSResponse, SMSResponseStatus } from './sms/sms.interface'; +import { UsersResponse } from 'src/user/user.interface'; +import { User, UUID } from '@fusionauth/typescript-client'; + +@Injectable() +export class UCIService { + expiry: number = parseInt(process.env.OTP_EXPIRY); + constructor( + private readonly fusionAuthService: FusionauthService, + private readonly httpService: HttpService, + ) {} + + async createUser(data: any): Promise { + // console.log('entered createUser'); + const url = process.env.FUSIONAUTH_OLD_BASE_URL + '/api/user/registration'; + return firstValueFrom( + this.httpService + .post(url, data, { + headers: { + Authorization: process.env.FUSIONAUTH_OLD_API_KEY, + 'Content-Type': 'application/json', + }, + }) + .pipe(map((response) => response.data)), + ); + } + + async updatePassword(data: any): Promise { + return firstValueFrom( + this.httpService + .post( + process.env.FUSIONAUTH_OLD_BASE_URL + '/api/user/change-password', + { + loginId: data.loginId, + password: data.password, + }, + { + headers: { + Authorization: process.env.FUSIONAUTH_OLD_API_KEY, + 'Content-Type': 'application/json', + }, + }, + ) + .pipe(map((response) => (response.status === 200 ? true : false))), + ); + } + + async login(user: any): Promise { + const fusionAuthUser = (await this.fusionAuthService.login(user)).response; + // if (fusionAuthUser.user === undefined) { + // console.log("Here") + // fusionAuthUser = fusionAuthUser.loginResponse.successResponse; + // } + const response: SignupResponse = new SignupResponse().init(uuidv4()); + response.responseCode = ResponseCode.OK; + response.result = { + responseMsg: 'Successful Logged In', + accountStatus: null, + data: { + user: fusionAuthUser, + schoolResponse: null, + }, + }; + return response; + } + + async verifyAndLoginOTP({ phone, status }): Promise { + const response: SignupResponse = new SignupResponse().init(uuidv4()); + const password = uuidv4(); + const data = { + registration: { + applicationId: process.env.FUSIONAUTH_DST_APPLICATION_ID, + username: phone, + }, + user: { + username: phone, + password: password, + usernameStatus: 'ACTIVE', + }, + }; + if (status === SMSResponseStatus.success) { + const url = process.env.FUSIONAUTH_OLD_BASE_URL + '/api/user'; + const { + statusFA, + userId, + user, + }: { statusFA: FAStatus; userId: UUID; user: User } = + await this.fusionAuthService.getUser(phone); + if (statusFA === FAStatus.ERROR) { + // throw new HttpException( + // `Error loggin in: ${phone} is not a valid user.`, + // HttpStatus.BAD_REQUEST, + // ); + // console.log('entered inside exception code') + const newUser = await this.createUser(data); + console.log('registering new user'); + console.log(newUser); + return this.login({ + loginId: phone, + password: password, + applicationId: process.env.FUSIONAUTH_DST_APPLICATION_ID, + }); + } else { + const passwordStatus = await this.updatePassword({ + loginId: phone, + password: password, + }).catch((e) => { + console.log(e.response.data); + response.params.err = 'ERROR_PASSWORD_RESET'; + response.params.errMsg = 'Error Logging In. Please try again later.'; + response.params.status = ResponseStatus.failure; + }); + if (passwordStatus) { + const loginResponse: SignupResponse = await this.login({ + loginId: phone, + password: password, + applicationId: process.env.FUSIONAUTH_DST_APPLICATION_ID, + }); + return loginResponse; + } else { + response.params.err = 'INVALID_LOGIN'; + response.params.errMsg = 'Error Logging In. Please try again later.'; + response.params.status = ResponseStatus.failure; + } + } + } else { + response.params.err = 'INVALID_OTP_ERROR'; + response.params.errMsg = 'OTP incorrect'; + response.params.status = ResponseStatus.failure; + } + return response; + } + + async transformOtpResponse(status: SMSResponse): Promise { + console.log({ status }); + + const response: SignupResponse = new SignupResponse().init(uuidv4()); + response.responseCode = ResponseCode.OK; + response.params.status = + status.status === SMSResponseStatus.failure + ? ResponseStatus.failure + : ResponseStatus.success; + response.params.errMsg = status.error == null ? '' : status.error.errorText; + response.params.err = status.error == null ? '' : status.error.errorCode; + const result = { + responseMsg: status.status, + accountStatus: null, + data: { + phone: status.phone, + networkResponseCode: status.networkResponseCode, + providerResponseCode: status.providerResponseCode, + provider: status.provider, + }, + }; + response.result = result; + return response; + } + + async loginTrainee({ id, dob, role }): Promise { + const response: SignupResponse = new SignupResponse().init(uuidv4()); + const password = uuidv4(); + const data = { + registration: { + applicationId: process.env.FUSIONAUTH_DST_APPLICATION_ID, + preferredLanguages: ['en'], + roles: [role], + timezone: 'Asia/Kolkata', + username: id, + usernameStatus: 'ACTIVE', + }, + user: { + birthDate: dob, + preferredLanguages: ['en'], + timezone: 'Asia/Kolkata', + usernameStatus: 'ACTIVE', + username: id, + password: password, + }, + }; + const { + statusFA, + userId, + user, + }: { statusFA: FAStatus; userId: UUID; user: User } = + await this.fusionAuthService.getUser(id); + if (statusFA === FAStatus.ERROR) { + const userDb: string[] = await this.checkUserInDb(id); + if (userDb.length > 0) { + if (userDb[0]['DOB'] === dob) { + const trainee = await this.createUser(data).catch((e) => { + console.log(e.response.data); + return e.response.data.fieldErrors['user.username']['code']; + }); + return this.login({ + loginId: id, + password: password, + applicationId: process.env.FUSIONAUTH_DST_APPLICATION_ID, + }); + } else { + throw new HttpException( + `Error loggin in: dob mismatch for ${id}`, + HttpStatus.BAD_REQUEST, + ); + } + } else { + throw new HttpException( + `Error loggin in: ${id} not found. Please verify again`, + HttpStatus.BAD_REQUEST, + ); + } + } else { + const birthDate = user.birthDate; + const userRoles = user.registrations[0].roles; + if (birthDate === dob && userRoles.indexOf(role) > -1) { + } else { + throw new HttpException( + birthDate === dob + ? 'Error in logging in: Role Mismatch' + : userRoles.indexOf(role) > -1 + ? 'Error in logging in: DOB Mismatch' + : 'Error logging in: Role and DOB Mismatch', + HttpStatus.BAD_REQUEST, + ); + } + const passwordStatus = await this.updatePassword({ + loginId: id, + password: password, + }).catch((e) => { + console.log(e.response.data); + response.params.err = 'ERROR_PASSWORD_RESET'; + response.params.errMsg = 'Error Logging In. Please try again later.'; + response.params.status = ResponseStatus.failure; + }); + if (passwordStatus) { + const loginResponse: SignupResponse = await this.login({ + loginId: id, + password: password, + applicationId: process.env.FUSIONAUTH_DST_APPLICATION_ID, + }); + const loginRole: string[] = + loginResponse.result.data.user.user.registrations[0].roles; + if (loginRole.indexOf(role) > -1) { + return loginResponse; + } else { + throw new HttpException( + `Error in logging in: Role Mismatch`, + HttpStatus.BAD_REQUEST, + ); + } + } else { + response.params.err = 'INVALID_LOGIN'; + response.params.errMsg = 'Error Logging In. Please try again later.'; + response.params.status = ResponseStatus.failure; + } + } + return response; + } + + async checkUserInDb(id: number): Promise { + const url = process.env.DST_HASURA_HOST; + const body = `query MyQuery { + trainee(where: {registrationNumber: {_eq: ${id}}}){ + DOB + } + }`; + return firstValueFrom( + this.httpService + .post(url, JSON.stringify({ query: body }), { + headers: { + 'x-hasura-admin-secret': process.env.DST_HASURA_SECRET, + 'Content-Type': 'application/json', + }, + }) + .pipe(map((response) => response.data.data.trainee)), + ); + } +}