From 3d47bb7752be5db618bd0e20f1e1ec8e49e80bcf Mon Sep 17 00:00:00 2001 From: apurvaubade Date: Fri, 8 Aug 2025 15:15:16 +0530 Subject: [PATCH 1/4] changes to send whatsapp otp --- src/adapters/postgres/user-adapter.ts | 261 +++++++++++++++++++++++-- src/common/utils/notification.axios.ts | 210 +++++++++++++------- src/common/utils/response.messages.ts | 38 ++-- src/user/dto/otpSend.dto.ts | 93 +++++---- src/user/dto/otpVerify.dto.ts | 136 ++++++++----- 5 files changed, 547 insertions(+), 191 deletions(-) diff --git a/src/adapters/postgres/user-adapter.ts b/src/adapters/postgres/user-adapter.ts index 46ffa531..c3447274 100644 --- a/src/adapters/postgres/user-adapter.ts +++ b/src/adapters/postgres/user-adapter.ts @@ -878,7 +878,7 @@ export class PostgresUserService implements IServicelocator { if (username && userId === null) { delete whereClause.userId; whereClause.username = username; - } + } const userDetails = await this.usersRepository.findOne({ where: whereClause, select: [ @@ -910,9 +910,9 @@ export class PostgresUserService implements IServicelocator { userDetails["tenantData"] = tenantData; return userDetails; - } + } - async userTenantRoleData(userId: string) { + async userTenantRoleData(userId: string) { const query = ` SELECT DISTINCT ON (T."tenantId") @@ -932,7 +932,7 @@ export class PostgresUserService implements IServicelocator { WHERE UTM."userId" = $1 ORDER BY - T."tenantId", UTM."Id";`; + T."tenantId", UTM."Id";`; const result = await this.usersRepository.query(query, [userId]); const combinedResult = []; @@ -1932,7 +1932,7 @@ export class PostgresUserService implements IServicelocator { } async assignUserToTenantAndRoll(tenantsData, createdBy) { - try { + try { const orgId = tenantsData?.tenantRoleMapping?.orgnizationId; const userId = tenantsData?.userId; const roleId = tenantsData?.tenantRoleMapping?.roleId; @@ -1947,13 +1947,13 @@ export class PostgresUserService implements IServicelocator { }); } - if (orgId) { - const data = await this.userOrgMappingRepository.save({ - userId: userId, - orgId: orgId, - createdBy: createdBy, - updatedBy: createdBy, - }); + if (orgId) { + const data = await this.userOrgMappingRepository.save({ + userId: userId, + orgId: orgId, + createdBy: createdBy, + updatedBy: createdBy, + }); } LoggerUtil.log(API_RESPONSES.USER_TENANT); @@ -2401,7 +2401,7 @@ export class PostgresUserService implements IServicelocator { } } private formatMobileNumber(mobile: string): string { - return `+91${mobile}`; + return `91${mobile}`; } //Generate Has code as per username or mobile Number @@ -2422,9 +2422,10 @@ export class PostgresUserService implements IServicelocator { async sendOtp(body: OtpSendDTO, response: Response) { const apiId = APIID.SEND_OTP; try { - const { mobile, reason, email, firstName, replacements, key } = body; + const { mobile, reason, email, firstName, replacements, key, whatsapp } = + body; let notificationPayload, hash, expires, sentTo, otp; - if (mobile || email) { + if (mobile || email || whatsapp) { otp = this.authUtils.generateOtp(this.otpDigits).toString(); } if (mobile) { @@ -2459,13 +2460,36 @@ export class PostgresUserService implements IServicelocator { hash = result.hash; expires = result.expires; sentTo = email; + } else if (whatsapp) { + // Send OTP on WhatsApp with SMS fallback + try { + const result = await this.sendOtpOnWhatsApp(whatsapp, otp, reason); + notificationPayload = result.notificationPayload; + hash = result.hash; + expires = result.expires; + sentTo = whatsapp; + } catch (error) { + // If WhatsApp fails, try SMS as fallback + LoggerUtil.error( + "WhatsApp OTP failed, falling back to SMS", + error.message, + apiId + ); + + // Use the same number for SMS + const result = await this.sendOTPOnMobile(whatsapp, otp, reason); + notificationPayload = result.notificationPayload; + hash = result.hash; + expires = result.expires; + sentTo = whatsapp; // Still show as sent to WhatsApp + } } else { - // Neither mobile nor email provided + // Neither mobile nor email nor whatsapp provided return APIResponse.error( response, apiId, API_RESPONSES.BAD_REQUEST, - "Either mobile or email must be provided", + "Either mobile, email, or whatsapp must be provided", HttpStatus.BAD_REQUEST ); } @@ -2476,7 +2500,9 @@ export class PostgresUserService implements IServicelocator { message: `OTP sent to ${sentTo}`, hash: `${hash}.${expires}`, sendStatus: - notificationPayload?.result?.sms?.data?.[0] || notificationPayload, + notificationPayload?.result?.sms?.data?.[0] || + notificationPayload?.result?.whatsapp?.data?.[0] || + notificationPayload, }, }; @@ -2574,7 +2600,7 @@ export class PostgresUserService implements IServicelocator { async verifyOtp(body: OtpVerifyDTO, response: Response) { const apiId = APIID.VERIFY_OTP; try { - const { mobile, email, otp, hash, reason, username } = body; + const { mobile, email, whatsapp, otp, hash, reason, username } = body; // Validate required fields for all requests if (!otp || !hash || !reason) { @@ -2615,7 +2641,7 @@ export class PostgresUserService implements IServicelocator { // Process based on reason if (reason === "signup") { - if (!mobile && !email) { + if (!mobile && !email && !whatsapp) { return APIResponse.error( response, apiId, @@ -2628,6 +2654,8 @@ export class PostgresUserService implements IServicelocator { identifier = this.formatMobileNumber(mobile); } else if (email) { identifier = email; + } else if (whatsapp) { + identifier = whatsapp; } } else if (reason === "forgot") { if (!username) { @@ -2644,6 +2672,8 @@ export class PostgresUserService implements IServicelocator { identifier = this.formatMobileNumber(mobile); } else if (email) { identifier = email; + } else if (whatsapp) { + identifier = whatsapp; } // identifier = this.formatMobileNumber(mobile); @@ -2690,6 +2720,98 @@ export class PostgresUserService implements IServicelocator { responseData["token"] = resetToken; } + // For signup flow, generate access token for the username + if (reason === "signup") { + // Find user by the identifier (mobile, email, or whatsapp) + let userData = null; + if (mobile) { + // Search by mobile number + const users = await this.usersRepository.find({ + where: { mobile: parseInt(this.formatMobileNumber(mobile)) }, + }); + if (users.length > 0) { + userData = users[0]; + } + } else if (email) { + const users = await this.usersRepository.find({ + where: { email: email }, + }); + if (users.length > 0) { + userData = users[0]; + } + } else if (whatsapp) { + // Search by WhatsApp number using custom fields + try { + // First try: Search by custom fields + let userIds = null; + try { + userIds = await this.fieldsService.filterUserUsingCustomFields( + "USERS", + { + whatsapp: whatsapp, + } + ); + } catch (customFieldError) { + // Custom field search failed, continue with fallback + } + + if (userIds && userIds.length > 0) { + const user = await this.usersRepository.findOne({ + where: { userId: userIds[0] }, + }); + if (user) { + userData = user; + } + } else { + // Fallback: Try to find user by mobile number (since WhatsApp numbers are often the same as mobile) + try { + const fallbackUsers = await this.usersRepository.find({ + where: { + mobile: parseInt(this.formatMobileNumber(whatsapp)), + }, + }); + + if (fallbackUsers.length > 0) { + userData = fallbackUsers[0]; + } + } catch (fallbackError) { + // Fallback search failed, continue without user data + } + } + } catch (error) { + // WhatsApp user search failed, continue without user data + } + } + + if (userData) { + // Generate access token for the user + const accessTokenPayload = { + sub: userData.userId, + email: userData.email, + username: userData.username, + name: `${userData.firstName || ""} ${ + userData.lastName || "" + }`.trim(), + }; + + const accessToken = + await this.jwtUtil.generateTokenForForgotPassword( + accessTokenPayload, + this.configService.get("RBAC_JWT_EXPIRES_IN") || "24h", + this.jwt_secret + ); + + responseData["accessToken"] = accessToken; + responseData["user"] = { + userId: userData.userId, + username: userData.username, + email: userData.email, + firstName: userData.firstName, + lastName: userData.lastName, + }; + } + } + return APIResponse.success( response, apiId, @@ -3160,4 +3282,103 @@ export class PostgresUserService implements IServicelocator { // Don't throw the error to avoid affecting the main operation } } + + //send WhatsApp Notification + async sendOtpOnWhatsApp(whatsapp: string, otp: string, reason: string) { + try { + // Step 1: Generate OTP hash + const { hash, expires, expiresInMinutes } = this.generateOtpHash( + whatsapp, + otp, + reason + ); + + // Step 2: Prepare WhatsApp notification payload + const notificationPayload = await this.whatsappNotification( + whatsapp, + otp, + reason + ); + + return { notificationPayload, hash, expires, expiresInMinutes }; + } catch (error) { + throw new Error(`Failed to send OTP via WhatsApp: ${error.message}`); + } + } + + //send WhatsApp Notification + async whatsappNotification(whatsapp: string, otp: string, reason: string) { + try { + // Format mobile number with country code for gupshupSource + const formattedWhatsapp = this.formatMobileNumber(whatsapp); + + // Get environment variables + const templateId = this.configService.get("WHATSAPP_TEMPLATE_ID"); + const apiKey = this.configService.get("WHATSAPP_GUPSHUP_API_KEY"); + const gupshupSource = this.configService.get( + "WHATSAPP_GUPSHUP_SOURCE" + ); + + // Check if environment variables are set + if (!templateId || !apiKey || !gupshupSource) { + // Log warning instead of throwing error + LoggerUtil.error( + "WhatsApp environment variables not configured", + "WhatsApp OTP sending is disabled. Please configure WHATSAPP_TEMPLATE_ID, WHATSAPP_GUPSHUP_API_KEY, and WHATSAPP_GUPSHUP_SOURCE", + "WHATSAPP_CONFIG" + ); + + // Return a mock response for testing + return { + result: { + whatsapp: { + data: [{ status: "skipped", message: "WhatsApp not configured" }], + }, + }, + }; + } + + // WhatsApp notification Body + const notificationPayload = { + whatsapp: { + to: [whatsapp], + templateId: templateId, + templateParams: [otp], + gupshupSource: gupshupSource, // Use environment variable for sender number + gupshupApiKey: apiKey, + }, + }; + + // Send Axios request to raw notification endpoint + const mailSend = await this.notificationRequest.sendRawNotification( + notificationPayload + ); + + // Check for errors in the response + if ( + mailSend?.result?.whatsapp?.errors && + mailSend.result.whatsapp.errors.length > 0 + ) { + const errorMessages = mailSend.result.whatsapp.errors.map( + (error: { error: string }) => error.error + ); + const combinedErrorMessage = errorMessages.join(", "); + throw new Error( + `${API_RESPONSES.WHATSAPP_ERROR} :${combinedErrorMessage}` + ); + } + + // Check if the response indicates success + if (!mailSend || !mailSend.result || !mailSend.result.whatsapp) { + throw new Error("Invalid response from notification service"); + } + + return mailSend; + } catch (error) { + LoggerUtil.error(API_RESPONSES.WHATSAPP_ERROR, error.message); + throw new Error( + `${API_RESPONSES.WHATSAPP_NOTIFICATION_ERROR}: ${error.message}` + ); + } + } } diff --git a/src/common/utils/notification.axios.ts b/src/common/utils/notification.axios.ts index 789b427c..da36eb75 100644 --- a/src/common/utils/notification.axios.ts +++ b/src/common/utils/notification.axios.ts @@ -2,80 +2,150 @@ import { HttpService } from "@nestjs/axios"; import { HttpException, HttpStatus, Injectable } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; + import axios, { AxiosRequestConfig } from "axios"; import { API_RESPONSES } from "./response.messages"; + @Injectable() export class NotificationRequest { - private readonly url: string; - constructor( - private readonly configService: ConfigService, - private readonly httpService: HttpService - ) { - this.url = this.configService.get("NOTIFICATION_URL"); - } + private readonly url: string; + constructor( + private readonly configService: ConfigService, + private readonly httpService: HttpService + ) { + this.url = this.configService.get("NOTIFICATION_URL"); + } + + + async sendNotification(body) { + const data = JSON.stringify(body); + const config: AxiosRequestConfig = { + method: "POST", + maxBodyLength: Infinity, + url: `${this.url}/notification/send`, + headers: { + "Content-Type": "application/json", + }, + data: data, + }; + try { + const response = await axios.request(config); + return response.data; + } catch (error) { + if (error.code === "ECONNREFUSED") { + throw new HttpException( + API_RESPONSES.SERVICE_UNAVAILABLE, + HttpStatus.SERVICE_UNAVAILABLE + ); + } + if (error.response) { + const statusCode = error.response.status; + const errorDetails = error.response.data || API_RESPONSES.ERROR; + + + switch (statusCode) { + case 400: + throw new HttpException( + `Bad Request: ${ + errorDetails.params?.errmsg || API_RESPONSES.BAD_REQUEST + }`, + HttpStatus.BAD_REQUEST + ); + case 404: + throw new HttpException( + `Not Found: ${ + errorDetails.params?.errmsg || API_RESPONSES.NOT_FOUND + }`, + HttpStatus.NOT_FOUND + ); + case 500: + throw new HttpException( + `Internal Server Error: ${ + errorDetails.params?.errmsg || + API_RESPONSES.INTERNAL_SERVER_ERROR + }`, + HttpStatus.INTERNAL_SERVER_ERROR + ); + default: + throw new HttpException( + `Unexpected Error: ${ + errorDetails.params?.errmsg || API_RESPONSES.UNEXPECTED_ERROR + }`, + HttpStatus.INTERNAL_SERVER_ERROR + ); + } + } + throw new HttpException( + API_RESPONSES.INTERNAL_SERVER_ERROR, + HttpStatus.INTERNAL_SERVER_ERROR + ); + } + } + + + async sendRawNotification(body) { + const data = JSON.stringify(body); + const config: AxiosRequestConfig = { + method: "POST", + maxBodyLength: Infinity, + url: `${this.url}/notification/send-raw`, + headers: { + "Content-Type": "application/json", + }, + data: data, + }; + try { + const response = await axios.request(config); + return response.data; + } catch (error) { + if (error.code === "ECONNREFUSED") { + throw new HttpException( + API_RESPONSES.SERVICE_UNAVAILABLE, + HttpStatus.SERVICE_UNAVAILABLE + ); + } + if (error.response) { + const statusCode = error.response.status; + const errorDetails = error.response.data || API_RESPONSES.ERROR; - async sendNotification(body) { - const data = JSON.stringify(body); - const config: AxiosRequestConfig = { - method: "POST", - maxBodyLength: Infinity, - url: `${this.url}/notification/send`, - headers: { - "Content-Type": "application/json", - }, - data: data, - }; - try { - const response = await axios.request(config); - return response.data; - } catch (error) { - if (error.code === "ECONNREFUSED") { - throw new HttpException( - API_RESPONSES.SERVICE_UNAVAILABLE, - HttpStatus.SERVICE_UNAVAILABLE - ); - } - if (error.response) { - const statusCode = error.response.status; - const errorDetails = error.response.data || API_RESPONSES.ERROR; - switch (statusCode) { - case 400: - throw new HttpException( - `Bad Request: ${ - errorDetails.params?.errmsg || API_RESPONSES.BAD_REQUEST - }`, - HttpStatus.BAD_REQUEST - ); - case 404: - throw new HttpException( - `Not Found: ${ - errorDetails.params?.errmsg || API_RESPONSES.NOT_FOUND - }`, - HttpStatus.NOT_FOUND - ); - case 500: - throw new HttpException( - `Internal Server Error: ${ - errorDetails.params?.errmsg || - API_RESPONSES.INTERNAL_SERVER_ERROR - }`, - HttpStatus.INTERNAL_SERVER_ERROR - ); - default: - throw new HttpException( - `Unexpected Error: ${ - errorDetails.params?.errmsg || API_RESPONSES.UNEXPECTED_ERROR - }`, - HttpStatus.INTERNAL_SERVER_ERROR - ); - } - } - throw new HttpException( - API_RESPONSES.INTERNAL_SERVER_ERROR, - HttpStatus.INTERNAL_SERVER_ERROR - ); - } - } -} + switch (statusCode) { + case 400: + throw new HttpException( + `Bad Request: ${ + errorDetails.params?.errmsg || API_RESPONSES.BAD_REQUEST + }`, + HttpStatus.BAD_REQUEST + ); + case 404: + throw new HttpException( + `Not Found: ${ + errorDetails.params?.errmsg || API_RESPONSES.NOT_FOUND + }`, + HttpStatus.NOT_FOUND + ); + case 500: + throw new HttpException( + `Internal Server Error: ${ + errorDetails.params?.errmsg || + API_RESPONSES.INTERNAL_SERVER_ERROR + }`, + HttpStatus.INTERNAL_SERVER_ERROR + ); + default: + throw new HttpException( + `Unexpected Error: ${ + errorDetails.params?.errmsg || API_RESPONSES.UNEXPECTED_ERROR + }`, + HttpStatus.INTERNAL_SERVER_ERROR + ); + } + } + throw new HttpException( + API_RESPONSES.INTERNAL_SERVER_ERROR, + HttpStatus.INTERNAL_SERVER_ERROR + ); + } + } +} \ No newline at end of file diff --git a/src/common/utils/response.messages.ts b/src/common/utils/response.messages.ts index af6ef46b..b3087ae0 100644 --- a/src/common/utils/response.messages.ts +++ b/src/common/utils/response.messages.ts @@ -81,7 +81,8 @@ export const API_RESPONSES = { UNAUTHORIZED: "Unauthorized", INVALID_TOKEN: "Token Invalid", INVALID_OPTION: 'Invalid Option Selected', - + + //User Api messages USER_UPDATED_SUCCESSFULLY: "User updated successfully.", USER_NOT_EXISTS: "User does not exist.", @@ -90,10 +91,12 @@ export const API_RESPONSES = { USER_ALREADY_EXISTS: "User already exists.", SERVER_ERROR: "Internal server error", SERVICE_NAME: "User service", - + + USER_NOT_FOUND_FOR_DELETE: "User not found for delete.", USER_NOT_FOUND_FOR_PASSWORD_RESET: "User not found for password reset.", - + + //get User Details USER_GET_SUCCESSFULLY: "User details fetched successfully.", USER_GET_BY_EMAIL_SUCCESSFULLY: "User details fetched successfully by email", @@ -112,7 +115,8 @@ export const API_RESPONSES = { USERNAME_EXISTS_KEYCLOAK: 'Username is already exists in keycloak', UPDATE_USER_KEYCLOAK_ERROR:'Failed to update username details in Keycloak.', USERNAME_SUGGEST_SUCCESSFULLY:'Username is already taken. Suggested a new unique username.', - + + //Create user USER_CREATE_SUCCESSFULLY: `User created successfully`, USER_CREATE_IN_DB: "User created in user table successfully", @@ -127,7 +131,8 @@ export const API_RESPONSES = { `User creation failed with error: ${error}. Username: ${username}`, USERID_NOT_FOUND: (userId) => `User Id '${userId}' does not exist.`, TENANTID_NOT_FOUND: (tenantId) => `Tenant Id '${tenantId}' does not exist.`, - + + //UUID constants UUID_VALIDATION: "Please enter valid UUID", INVALID_EMAIL: (emailId) => `Invalid email address: ${emailId}`, @@ -136,7 +141,8 @@ export const API_RESPONSES = { DOB_FORMAT: (dob) => `Date of birth must be in the format yyyy-mm-dd: ${dob}`, INVALID_USERNAME_EMAIL: `Invalid Username Or Email`, USER_RELATEDENTITY_DELETE: `User and related entries deleted Successfully.`, - + + ACADEMIC_YEAR_NOT_FOUND: "Academic year not found for tenant", DUPLICAT_TENANTID: "Duplicate tenantId detected. Please ensure each tenantId is unique and correct your data.", @@ -144,7 +150,8 @@ export const API_RESPONSES = { "Invalid parameters provided. Please ensure that tenantId, roleId, and cohortId (if applicable) are correctly provided.", COHORT_NOT_FOUND_IN_TENANT_ID: (cohortId, TenantId) => `Cohort Id '${cohortId}' does not exist for this tenant '${TenantId}'.`, - + + ROLE_NOT_FOUND_IN_TENANT: (roleId, tenantId) => `Role Id '${roleId}' does not exist for this tenant '${tenantId}'.`, USER_EXISTS_SEND_MAIL: "User Exists. Proceed with Sending Email.", @@ -154,14 +161,16 @@ export const API_RESPONSES = { `Duplicate fieldId detected: ${duplicateFieldKeys}`, FIELD_NOT_FOUND: "Field not found", PASSWORD_RESET: "Password reset successful!", - + + SOMETHING_WRONG: "Something went wrong", USER_PASSWORD_UPDATE: "User Password Updated Successfully", USER_BASIC_DETAILS_UPDATE: "User basic details updated successfully", USER_TENANT: "User tenant mapping successfully", USER_COHORT: "User cohort mapping successfully", COHORT_NAME_EXIST: "Cohort name already exist.Please provide another name.", - + + COHORT_LIST: "Cohort list fetched successfully", COHORT_HIERARCHY: "Cohort hierarchy fetched successfully", COHORT_EXISTS: "Cohort already exists", @@ -172,7 +181,8 @@ export const API_RESPONSES = { COHORT_UPDATED_SUCCESSFULLY: "Cohort updated successfully.", TENANT_NOTFOUND: "Tenant not found", COHORTMEMBER_UPDATE_SUCCESSFULLY: "Cohort Member updated Successfully", - + + //Tenant TENANT_GET: "Tenant fetched successfully.", TENANT_NOT_FOUND: "No tenants found matching the specified criteria.", @@ -183,7 +193,8 @@ export const API_RESPONSES = { TENANT_SEARCH_SUCCESS: "Tenant search successfully", TENANT_CREATE_FAILED: "Failed to create tenant, please try again.", REQUIRED_AND_UUID: "tenantId is required and it's must be a valid UUID.", - + + //OTP NOTIFICATION_FAIL_DURING_OTP_SEND: "Send SMS notification failed duing OTP send", @@ -211,9 +222,10 @@ export const API_RESPONSES = { SEND_OTP: "OTP sent successfully", EMAIL_NOTIFICATION_ERROR: "Failed to send Email notification:", EMAIL_ERROR: "Email notification failed", + WHATSAPP_ERROR: "WhatsApp notification failed", + WHATSAPP_NOTIFICATION_ERROR: "Failed to send WhatsApp notification:", SIGNED_URL_SUCCESS: "Signed URL generated successfully", SIGNED_URL_FAILED: "Error while generating signed URL", INVALID_FILE_TYPE: "Invalid file type. Allowed file types are: '.jpg','.jpeg','.png','.webp','.pdf','.doc','.docx','.mp4','.mov','.txt','.csv'", FILE_SIZE_ERROR: "File too large. Maximum allowed file size is 10MB." -}; - + }; \ No newline at end of file diff --git a/src/user/dto/otpSend.dto.ts b/src/user/dto/otpSend.dto.ts index bb08fd56..44722b8d 100644 --- a/src/user/dto/otpSend.dto.ts +++ b/src/user/dto/otpSend.dto.ts @@ -1,41 +1,62 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsEmail, IsIn, IsMobilePhone, IsObject, IsOptional, IsString, Matches, ValidateIf } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { + IsEmail, + IsIn, + IsMobilePhone, + IsObject, + IsOptional, + IsString, + Matches, + ValidateIf, +} from "class-validator"; + export class OtpSendDTO { + @ApiPropertyOptional() + @ValidateIf((o) => o.mobile !== undefined && o.mobile !== "") + @IsString({ message: "Mobile number must be a string." }) + @IsMobilePhone(null, { message: "Invalid mobile phone number format." }) + @Matches(/^\d{10}$/, { message: "Mobile number must be exactly 10 digits." }) + mobile?: string; + + + @ApiProperty() + @IsString({ message: "Reason must be a string." }) + @IsIn(["signup", "login", "forgot"], { + message: 'Reason must be "signup,login or forgot".', + }) + reason: string; + + + @ApiPropertyOptional() + @IsOptional() + @IsEmail() + email?: string; + + + @ApiPropertyOptional() + @IsOptional() + @IsString() + firstName?: string; + + + @ApiPropertyOptional() + @IsOptional() + @IsString() + key?: string; + + + @ApiPropertyOptional() + @IsOptional() + @IsString() + whatsapp?: string; - @ApiPropertyOptional() - @ValidateIf(o => o.mobile !== undefined && o.mobile !== '') - @IsString({ message: 'Mobile number must be a string.' }) - @IsMobilePhone(null, { message: 'Invalid mobile phone number format.' }) - @Matches(/^\d{10}$/, { message: 'Mobile number must be exactly 10 digits.' }) - mobile?: string; - - @ApiProperty() - @IsString({ message: 'Reason must be a string.' }) - @IsIn(['signup', 'login', 'forgot'], { message: 'Reason must be "signup,login or forgot".' }) - reason: string - - @ApiPropertyOptional() - @IsOptional() - @IsEmail() - email?: string; - - @ApiPropertyOptional() - @IsOptional() - @IsString() - firstName?: string; - - @ApiPropertyOptional() - @IsOptional() - @IsString() - key?: string; - - @ApiPropertyOptional({ - type: 'object', - description: 'Key-value pairs for dynamic replacements in templates', - }) - @IsOptional() - @IsObject() - replacements?: Record; + @ApiPropertyOptional({ + type: "object", + description: "Key-value pairs for dynamic replacements in templates", + }) + @IsOptional() + @IsObject() + replacements?: Record; } \ No newline at end of file diff --git a/src/user/dto/otpVerify.dto.ts b/src/user/dto/otpVerify.dto.ts index 5013e794..865f6974 100644 --- a/src/user/dto/otpVerify.dto.ts +++ b/src/user/dto/otpVerify.dto.ts @@ -1,59 +1,91 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsIn, IsString, Length, Matches, ValidateIf, registerDecorator, ValidationOptions, ValidationArguments, IsOptional, IsEmail } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { + IsIn, + IsString, + Length, + Matches, + ValidateIf, + registerDecorator, + ValidationOptions, + ValidationArguments, + IsOptional, + IsEmail, +} from "class-validator"; + // Custom validator function function IsValidOtp(validationOptions?: ValidationOptions) { - return function (object: Object, propertyName: string) { - registerDecorator({ - name: 'isValidOtp', - target: object.constructor, - propertyName: propertyName, - options: validationOptions, - validator: { - validate(value: any, args: ValidationArguments) { - const otpDigits = process.env.OTP_DIGITS ? parseInt(process.env.OTP_DIGITS) : 6; - const regex = new RegExp(`^\\d{${otpDigits}}$`); - return typeof value === 'string' && regex.test(value); - }, - defaultMessage(args: ValidationArguments) { - const otpDigits = process.env.OTP_DIGITS ? parseInt(process.env.OTP_DIGITS) : 6; - return `OTP must be exactly ${otpDigits} digits.`; - } - } - }); - }; + return function (object: Object, propertyName: string) { + registerDecorator({ + name: "isValidOtp", + target: object.constructor, + propertyName: propertyName, + options: validationOptions, + validator: { + validate(value: any, args: ValidationArguments) { + const otpDigits = process.env.OTP_DIGITS + ? parseInt(process.env.OTP_DIGITS) + : 6; + const regex = new RegExp(`^\\d{${otpDigits}}$`); + return typeof value === "string" && regex.test(value); + }, + defaultMessage(args: ValidationArguments) { + const otpDigits = process.env.OTP_DIGITS + ? parseInt(process.env.OTP_DIGITS) + : 6; + return `OTP must be exactly ${otpDigits} digits.`; + }, + }, + }); + }; } + export class OtpVerifyDTO { - @ApiPropertyOptional() - @IsOptional() - @ValidateIf(o => o.reason === 'signup') - @IsString({ message: 'Mobile number must be a string.' }) - @Matches(/^\d{10}$/, { message: 'Mobile number must be exactly 10 digits.' }) - mobile?: string; - - @ApiPropertyOptional() - @IsOptional() - @IsEmail() - email?: string; - - @ApiProperty() - @IsString({ message: 'OTP must be a string.' }) - @IsValidOtp() // Use the custom validator - otp: string; - - @ApiProperty() - @IsString({ message: 'Hash must be a string.' }) - @Length(10, 256, { message: 'Hash must be between 10 and 256 characters long.' }) - hash: string; - - @ApiProperty() - @IsString({ message: 'Reason must be a string.' }) - @IsIn(['signup', 'forgot'], { message: 'Reason must be either "signup" or "forgot".' }) - reason: string; - - @ApiProperty() - @ValidateIf(o => o.reason === 'forgot') - @IsString({ message: 'Username must be a string.' }) - username: string; + @ApiPropertyOptional() + @IsOptional() + @ValidateIf((o) => o.reason === "signup") + @IsString({ message: "Mobile number must be a string." }) + @Matches(/^\d{10}$/, { message: "Mobile number must be exactly 10 digits." }) + mobile?: string; + + + @ApiPropertyOptional() + @IsOptional() + @IsEmail() + email?: string; + + + @ApiPropertyOptional() + @IsOptional() + @IsString({ message: "WhatsApp number must be a string." }) + whatsapp?: string; + + + @ApiProperty() + @IsString({ message: "OTP must be a string." }) + @IsValidOtp() // Use the custom validator + otp: string; + + + @ApiProperty() + @IsString({ message: "Hash must be a string." }) + @Length(10, 256, { + message: "Hash must be between 10 and 256 characters long.", + }) + hash: string; + + + @ApiProperty() + @IsString({ message: "Reason must be a string." }) + @IsIn(["signup", "forgot"], { + message: 'Reason must be either "signup" or "forgot".', + }) + reason: string; + + + @ApiProperty() + @ValidateIf((o) => o.reason === "forgot") + @IsString({ message: "Username must be a string." }) + username: string; } \ No newline at end of file From ab0fdded39a5c36c7a709657919be8000b3bec67 Mon Sep 17 00:00:00 2001 From: apurvaubade Date: Fri, 8 Aug 2025 15:42:22 +0530 Subject: [PATCH 2/4] address SonarCloud-reported issues --- src/adapters/postgres/user-adapter.ts | 64 ++++++++++----------------- 1 file changed, 24 insertions(+), 40 deletions(-) diff --git a/src/adapters/postgres/user-adapter.ts b/src/adapters/postgres/user-adapter.ts index c3447274..475f128d 100644 --- a/src/adapters/postgres/user-adapter.ts +++ b/src/adapters/postgres/user-adapter.ts @@ -2741,45 +2741,32 @@ export class PostgresUserService implements IServicelocator { } } else if (whatsapp) { // Search by WhatsApp number using custom fields - try { - // First try: Search by custom fields - let userIds = null; - try { - userIds = await this.fieldsService.filterUserUsingCustomFields( - "USERS", - { - whatsapp: whatsapp, - } - ); - } catch (customFieldError) { - // Custom field search failed, continue with fallback + // First try: Search by custom fields + let userIds = await this.fieldsService.filterUserUsingCustomFields( + "USERS", + { + whatsapp: whatsapp, } + ); - if (userIds && userIds.length > 0) { - const user = await this.usersRepository.findOne({ - where: { userId: userIds[0] }, - }); - if (user) { - userData = user; - } - } else { - // Fallback: Try to find user by mobile number (since WhatsApp numbers are often the same as mobile) - try { - const fallbackUsers = await this.usersRepository.find({ - where: { - mobile: parseInt(this.formatMobileNumber(whatsapp)), - }, - }); - - if (fallbackUsers.length > 0) { - userData = fallbackUsers[0]; - } - } catch (fallbackError) { - // Fallback search failed, continue without user data - } + if (userIds && userIds.length > 0) { + const user = await this.usersRepository.findOne({ + where: { userId: userIds[0] }, + }); + if (user) { + userData = user; + } + } else { + // Fallback: Try to find user by mobile number (since WhatsApp numbers are often the same as mobile) + const fallbackUsers = await this.usersRepository.find({ + where: { + mobile: parseInt(this.formatMobileNumber(whatsapp)), + }, + }); + + if (fallbackUsers.length > 0) { + userData = fallbackUsers[0]; } - } catch (error) { - // WhatsApp user search failed, continue without user data } } @@ -3309,9 +3296,6 @@ export class PostgresUserService implements IServicelocator { //send WhatsApp Notification async whatsappNotification(whatsapp: string, otp: string, reason: string) { try { - // Format mobile number with country code for gupshupSource - const formattedWhatsapp = this.formatMobileNumber(whatsapp); - // Get environment variables const templateId = this.configService.get("WHATSAPP_TEMPLATE_ID"); const apiKey = this.configService.get("WHATSAPP_GUPSHUP_API_KEY"); @@ -3369,7 +3353,7 @@ export class PostgresUserService implements IServicelocator { } // Check if the response indicates success - if (!mailSend || !mailSend.result || !mailSend.result.whatsapp) { + if (!mailSend?.result?.whatsapp) { throw new Error("Invalid response from notification service"); } From f4ebe6fbec90bfdae9921f0be066ecbc899003dc Mon Sep 17 00:00:00 2001 From: apurvaubade Date: Wed, 13 Aug 2025 17:45:37 +0530 Subject: [PATCH 3/4] changes for whatsapp notification --- src/adapters/postgres/user-adapter.ts | 92 ++------------------------- src/user/dto/otpVerify.dto.ts | 4 +- 2 files changed, 9 insertions(+), 87 deletions(-) diff --git a/src/adapters/postgres/user-adapter.ts b/src/adapters/postgres/user-adapter.ts index 475f128d..8c5fb9f8 100644 --- a/src/adapters/postgres/user-adapter.ts +++ b/src/adapters/postgres/user-adapter.ts @@ -2401,7 +2401,7 @@ export class PostgresUserService implements IServicelocator { } } private formatMobileNumber(mobile: string): string { - return `91${mobile}`; + return `+91${mobile}`; } //Generate Has code as per username or mobile Number @@ -2640,7 +2640,7 @@ export class PostgresUserService implements IServicelocator { let resetToken: string | null = null; // Process based on reason - if (reason === "signup") { + if (reason === "signup" || reason === "login") { if (!mobile && !email && !whatsapp) { return APIResponse.error( response, @@ -2655,7 +2655,7 @@ export class PostgresUserService implements IServicelocator { } else if (email) { identifier = email; } else if (whatsapp) { - identifier = whatsapp; + identifier = this.formatMobileNumber(whatsapp); } } else if (reason === "forgot") { if (!username) { @@ -2673,7 +2673,7 @@ export class PostgresUserService implements IServicelocator { } else if (email) { identifier = email; } else if (whatsapp) { - identifier = whatsapp; + identifier = this.formatMobileNumber(whatsapp); } // identifier = this.formatMobileNumber(mobile); @@ -2720,85 +2720,6 @@ export class PostgresUserService implements IServicelocator { responseData["token"] = resetToken; } - // For signup flow, generate access token for the username - if (reason === "signup") { - // Find user by the identifier (mobile, email, or whatsapp) - let userData = null; - if (mobile) { - // Search by mobile number - const users = await this.usersRepository.find({ - where: { mobile: parseInt(this.formatMobileNumber(mobile)) }, - }); - if (users.length > 0) { - userData = users[0]; - } - } else if (email) { - const users = await this.usersRepository.find({ - where: { email: email }, - }); - if (users.length > 0) { - userData = users[0]; - } - } else if (whatsapp) { - // Search by WhatsApp number using custom fields - // First try: Search by custom fields - let userIds = await this.fieldsService.filterUserUsingCustomFields( - "USERS", - { - whatsapp: whatsapp, - } - ); - - if (userIds && userIds.length > 0) { - const user = await this.usersRepository.findOne({ - where: { userId: userIds[0] }, - }); - if (user) { - userData = user; - } - } else { - // Fallback: Try to find user by mobile number (since WhatsApp numbers are often the same as mobile) - const fallbackUsers = await this.usersRepository.find({ - where: { - mobile: parseInt(this.formatMobileNumber(whatsapp)), - }, - }); - - if (fallbackUsers.length > 0) { - userData = fallbackUsers[0]; - } - } - } - - if (userData) { - // Generate access token for the user - const accessTokenPayload = { - sub: userData.userId, - email: userData.email, - username: userData.username, - name: `${userData.firstName || ""} ${ - userData.lastName || "" - }`.trim(), - }; - - const accessToken = - await this.jwtUtil.generateTokenForForgotPassword( - accessTokenPayload, - this.configService.get("RBAC_JWT_EXPIRES_IN") || "24h", - this.jwt_secret - ); - - responseData["accessToken"] = accessToken; - responseData["user"] = { - userId: userData.userId, - username: userData.username, - email: userData.email, - firstName: userData.firstName, - lastName: userData.lastName, - }; - } - } - return APIResponse.success( response, apiId, @@ -3273,9 +3194,10 @@ export class PostgresUserService implements IServicelocator { //send WhatsApp Notification async sendOtpOnWhatsApp(whatsapp: string, otp: string, reason: string) { try { - // Step 1: Generate OTP hash + // Step 1: Generate OTP hash with formatted number (consistent with verification) + const whatsappWithCode = this.formatMobileNumber(whatsapp); const { hash, expires, expiresInMinutes } = this.generateOtpHash( - whatsapp, + whatsappWithCode, otp, reason ); diff --git a/src/user/dto/otpVerify.dto.ts b/src/user/dto/otpVerify.dto.ts index 865f6974..6ae91e9a 100644 --- a/src/user/dto/otpVerify.dto.ts +++ b/src/user/dto/otpVerify.dto.ts @@ -78,8 +78,8 @@ export class OtpVerifyDTO { @ApiProperty() @IsString({ message: "Reason must be a string." }) - @IsIn(["signup", "forgot"], { - message: 'Reason must be either "signup" or "forgot".', + @IsIn(["signup", "login", "forgot"], { + message: 'Reason must be either "signup", "login" or "forgot".', }) reason: string; From 39b23bcb8cde194d57fd53aced9b7c7423cc5c68 Mon Sep 17 00:00:00 2001 From: apurvaubade Date: Mon, 18 Aug 2025 12:56:35 +0530 Subject: [PATCH 4/4] magic link changes:send email --- src/adapters/postgres/user-adapter.ts | 78 +++++++ src/adapters/userservicelocator.ts | 1 + src/auth/auth.controller.ts | 53 ++++- src/auth/auth.module.ts | 8 + src/auth/auth.service.ts | 289 ++++++++++++++++++++++++- src/auth/dto/magic-link.dto.ts | 60 +++++ src/auth/entities/magic-link.entity.ts | 37 ++++ src/common/utils/api-id.config.ts | 4 +- src/common/utils/notification.axios.ts | 55 +++++ src/user/user.module.ts | 1 + src/user/useradapter.ts | 6 + 11 files changed, 589 insertions(+), 3 deletions(-) create mode 100644 src/auth/dto/magic-link.dto.ts create mode 100644 src/auth/entities/magic-link.entity.ts diff --git a/src/adapters/postgres/user-adapter.ts b/src/adapters/postgres/user-adapter.ts index 8c5fb9f8..071b1b70 100644 --- a/src/adapters/postgres/user-adapter.ts +++ b/src/adapters/postgres/user-adapter.ts @@ -3287,4 +3287,82 @@ export class PostgresUserService implements IServicelocator { ); } } + + async findUserByIdentifier(identifier: string): Promise { + try { + console.log(`[DEBUG] Searching for user with identifier: ${identifier}`); + + // Try to find user by email + let user = await this.usersRepository.findOne({ + where: { email: identifier } + }); + console.log(`[DEBUG] Email search result:`, user ? 'User found' : 'No user found'); + + if (!user) { + // Try to find user by username + user = await this.usersRepository.findOne({ + where: { username: identifier } + }); + console.log(`[DEBUG] Username search result:`, user ? 'User found' : 'No user found'); + } + + if (!user) { + // Try to find user by mobile (if identifier is numeric) + if (/^\d+$/.test(identifier)) { + user = await this.usersRepository.findOne({ + where: { mobile: parseInt(identifier) } + }); + console.log(`[DEBUG] Mobile search result:`, user ? 'User found' : 'No user found'); + } + } + + // If still no user found, try case-insensitive search + if (!user) { + console.log(`[DEBUG] Trying case-insensitive search...`); + const caseInsensitiveUser = await this.usersRepository + .createQueryBuilder('user') + .where('LOWER(user.email) = LOWER(:email)', { email: identifier }) + .getOne(); + + if (caseInsensitiveUser) { + console.log(`[DEBUG] Found user with case-insensitive search:`, caseInsensitiveUser); + user = caseInsensitiveUser; + } + } + + // Final fallback: direct database query + if (!user) { + console.log(`[DEBUG] Trying direct database query...`); + try { + const rawUser = await this.dataSource.query( + 'SELECT * FROM public."Users" WHERE "email" = $1 OR "username" = $1', + [identifier] + ); + console.log(`[DEBUG] Raw query result:`, rawUser); + + if (rawUser && rawUser.length > 0) { + const rawUserData = rawUser[0]; + user = { + userId: rawUserData.userId || rawUserData.id, + email: rawUserData.email, + username: rawUserData.username, + mobile: rawUserData.mobile, + firstName: rawUserData.firstName, + lastName: rawUserData.lastName + } as any; + console.log(`[DEBUG] Converted raw user:`, user); + } + } catch (rawError) { + console.error(`[DEBUG] Raw query error:`, rawError); + } + } + + console.log(`[DEBUG] Final result:`, user ? `User found: ${user.userId}` : 'No user found'); + return user; + } catch (error) { + console.error('[DEBUG] Error in findUserByIdentifier:', error); + LoggerUtil.error('Error finding user by identifier', error.message); + return null; + } + } } diff --git a/src/adapters/userservicelocator.ts b/src/adapters/userservicelocator.ts index 11e8cd16..bd51dabf 100644 --- a/src/adapters/userservicelocator.ts +++ b/src/adapters/userservicelocator.ts @@ -62,4 +62,5 @@ export interface IServicelocator { body: SendPasswordResetOTPDto, response: Response ): Promise; + findUserByIdentifier(identifier: string): Promise; } diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index 3db39e88..017738eb 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -5,6 +5,8 @@ import { ApiHeader, ApiBasicAuth, ApiOkResponse, + ApiParam, + ApiQuery, } from "@nestjs/swagger"; import { Controller, @@ -20,12 +22,20 @@ import { ValidationPipe, UseGuards, UseFilters, + Param, + Query, + Redirect, } from "@nestjs/common"; import { AuthDto, RefreshTokenRequestBody, LogoutRequestBody, } from "./dto/auth-dto"; +import { + RequestMagicLinkDto, + MagicLinkResponseDto, + MagicLinkValidationDto +} from "./dto/magic-link.dto"; import { AuthService } from "./auth.service"; import { JwtAuthGuard } from "src/common/guards/keycloak.guard"; import { APIID } from "src/common/utils/api-id.config"; @@ -35,7 +45,9 @@ import { Response } from "express"; @ApiTags("Auth") @Controller("auth") export class AuthController { - constructor(private authService: AuthService) {} + constructor( + private authService: AuthService + ) {} @UseFilters(new AllExceptionsFilter(APIID.LOGIN)) @Post("/login") @@ -86,4 +98,43 @@ export class AuthController { await this.authService.logout(refreshToken, response); } + + @UseFilters(new AllExceptionsFilter(APIID.REQUEST_MAGIC_LINK)) + @Post("/request-magic-link") + @ApiBody({ type: RequestMagicLinkDto }) + @UsePipes(ValidationPipe) + @HttpCode(HttpStatus.OK) + @ApiOkResponse({ + description: "Magic link request processed", + type: MagicLinkResponseDto + }) + async requestMagicLink( + @Body() requestDto: RequestMagicLinkDto, + @Res() response: Response + ): Promise { + return this.authService.requestMagicLink(requestDto, response); + } + + @Get("/magic-link/:token") + @ApiParam({ name: 'token', description: 'Magic link token' }) + @ApiQuery({ name: 'redirect', description: 'Encoded redirect URL', required: false }) + @Redirect() + async validateMagicLink( + @Param('token') token: string, + @Query('redirect') redirect?: string + ) { + try { + const result = await this.authService.validateMagicLink(token, redirect); + + // Redirect to frontend with tokens + const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000'; + const redirectUrl = `${frontendUrl}/auth/success#access_token=${result.access_token}&refresh_token=${result.refresh_token}&expires_in=${result.expires_in}`; + + return { url: redirectUrl }; + } catch (error) { + // Redirect to error page + const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000'; + return { url: `${frontendUrl}/auth/error?message=${encodeURIComponent(error.message)}` }; + } + } } diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 1d27c001..37f77cc8 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -1,5 +1,6 @@ import { Module } from "@nestjs/common"; import { HttpModule } from "@nestjs/axios"; +import { JwtModule } from "@nestjs/jwt"; import { AuthController } from "./auth.controller"; import { AuthService } from "./auth.service"; import { JwtStrategy } from "src/common/guards/keycloak.strategy"; @@ -15,6 +16,8 @@ import { PostgresModule } from "src/adapters/postgres/postgres-module"; import { RolePermissionModule } from "src/permissionRbac/rolePermissionMapping/role-permission.module"; import { RolePermissionService } from "src/permissionRbac/rolePermissionMapping/role-permission-mapping.service"; import { RolePermission } from "src/permissionRbac/rolePermissionMapping/entities/rolePermissionMapping"; +// import { MagicLink } from "./entities/magic-link.entity"; +import { MagicLink } from "src/auth/entities/magic-link.entity"; @Module({ imports: [ @@ -24,8 +27,13 @@ import { RolePermission } from "src/permissionRbac/rolePermissionMapping/entitie Fields, CohortMembers, RolePermission, + MagicLink, ]), HttpModule, + JwtModule.register({ + secret: process.env.JWT_SECRET || 'your-secret-key', + signOptions: { expiresIn: '1h' }, + }), PostgresModule, RolePermissionModule, ], diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 4c5d7a20..e266da17 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -11,6 +11,12 @@ import APIResponse from "src/common/responses/response"; import { KeycloakService } from "src/common/utils/keycloak.service"; import { APIID } from "src/common/utils/api-id.config"; import { Response } from "express"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; +import { MagicLink } from "./entities/magic-link.entity"; +import { RequestMagicLinkDto } from "./dto/magic-link.dto"; +import { JwtService } from "@nestjs/jwt"; +import { NotificationRequest } from "src/common/utils/notification.axios"; type LoginResponse = { access_token: string; @@ -22,7 +28,11 @@ type LoginResponse = { export class AuthService { constructor( private readonly useradapter: UserAdapter, - private readonly keycloakService: KeycloakService + private readonly keycloakService: KeycloakService, + @InjectRepository(MagicLink) + private magicLinkRepository: Repository, + private jwtService: JwtService, + private notificationService: NotificationRequest, ) { } async login(authDto, response: Response) { @@ -147,4 +157,281 @@ export class AuthService { } } } + + private generateToken(): string { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + let token = ''; + for (let i = 0; i < 16; i++) { + token += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return token; + } + + async requestMagicLink(requestDto: RequestMagicLinkDto, response: Response) { + const apiId = APIID.REQUEST_MAGIC_LINK; + try { + console.log(`[DEBUG] AuthService: Requesting magic link for identifier: ${requestDto.identifier}`); + + // First check if user exists - if not, throw error immediately + const user = await this.useradapter.findUserByIdentifier(requestDto.identifier); + console.log(`[DEBUG] AuthService: User search result:`, user ? `User found: ${user.userId}` : 'No user found'); + + if (!user) { + console.log(`[DEBUG] AuthService: User not found, returning 404 error`); + return APIResponse.error( + response, + apiId, + "User Not Found", + "Invalid identifier. User does not exist in the system.", + HttpStatus.NOT_FOUND + ); + } + + // Generate unique token + let token: string; + let isUnique = false; + while (!isUnique) { + token = this.generateToken(); + const existingToken = await this.magicLinkRepository.findOne({ where: { token } }); + if (!existingToken) { + isUnique = true; + } + } + + // Determine identifier type + let identifierType = 'username'; + if (requestDto.identifier.includes('@')) { + identifierType = 'email'; + } else if (/^\d+$/.test(requestDto.identifier)) { + identifierType = 'phone'; + } + + // Create magic link record + const magicLink = this.magicLinkRepository.create({ + token, + identifier: requestDto.identifier, + identifier_type: identifierType, + redirect_url: requestDto.redirectUrl, + notification_channel: requestDto.notificationChannel, + expires_at: new Date(Date.now() + 15 * 60 * 1000), // 15 minutes expiry + is_used: false, + is_expired: false, + }); + + await this.magicLinkRepository.save(magicLink); + + // Send notification based on channel using notification microservice + try { + await this.sendMagicLinkNotification(requestDto.identifier, token, requestDto.notificationChannel, requestDto.redirectUrl); + } catch (notificationError) { + // Log the notification error but don't fail the request + console.error('Failed to send magic link notification:', notificationError); + + // Still return success since the magic link was created + return APIResponse.success( + response, + apiId, + { + success: true, + message: 'Magic link created successfully but notification failed. Please contact support.', + token: token // Include token for debugging if needed + }, + HttpStatus.OK, + 'Magic link created but notification failed' + ); + } + + return APIResponse.success( + response, + apiId, + { success: true, message: 'Magic link sent successfully' }, + HttpStatus.OK, + 'Magic link request processed successfully' + ); + } catch (error) { + const errorMessage = error?.message || "Something went wrong"; + return APIResponse.error( + response, + apiId, + "Internal Server Error", + `Error : ${errorMessage}`, + HttpStatus.INTERNAL_SERVER_ERROR + ); + } + } + + async validateMagicLink(token: string, redirect?: string) { + try { + const magicLink = await this.magicLinkRepository.findOne({ where: { token } }); + + if (!magicLink) { + throw new NotFoundException('Invalid magic link'); + } + + if (magicLink.is_used) { + throw new UnauthorizedException('Magic link has already been used'); + } + + if (magicLink.is_expired || new Date() > magicLink.expires_at) { + // Mark as expired + magicLink.is_expired = true; + await this.magicLinkRepository.save(magicLink); + throw new UnauthorizedException('Magic link has expired'); + } + + // Find user + const user = await this.useradapter.findUserByIdentifier(magicLink.identifier); + if (!user) { + throw new NotFoundException('User not found'); + } + + // Mark magic link as used + magicLink.is_used = true; + await this.magicLinkRepository.save(magicLink); + + // Generate JWT tokens + const payload = { + sub: user.userId, + email: user.email, + username: user.username, + tenantId: null // User entity doesn't have tenant_id, it's in userTenantMapping + }; + + const access_token = this.jwtService.sign(payload, { expiresIn: '1h' }); + const refresh_token = this.jwtService.sign(payload, { expiresIn: '7d' }); + + return { + access_token, + refresh_token, + expires_in: 3600 + }; + } catch (error) { + throw error; + } + } + + private async sendMagicLinkNotification( + identifier: string, + token: string, + channel: string, + redirectUrl?: string + ): Promise { + const magicLinkUrl = `${process.env.FRONTEND_URL || 'http://localhost:3000'}/auth/magic-link/${token}`; + + let finalUrl = magicLinkUrl; + if (redirectUrl) { + const encodedRedirect = encodeURIComponent(redirectUrl); + finalUrl = `${magicLinkUrl}?redirect=${encodedRedirect}`; + } + + const message = `Magic Link: ${finalUrl}`; + + try { + switch (channel) { + case 'email': + // Send email notification using notification microservice + await this.notificationService.sendEmail(identifier, 'Magic Link', message); + break; + case 'sms': + // Send SMS notification using the same approach as OTP method + // Format phone number with +91 prefix like the OTP method does + const formattedSmsPhone = `+91${identifier}`; + console.log(`[DEBUG] Magic Link SMS - Original identifier: ${identifier}, Formatted phone: ${formattedSmsPhone}`); + + // Use the same SMS payload format as your working OTP method + // Note: OTP method passes original number, smsNotification handles formatting + const smsPayload = { + isQueue: false, + context: "MAGIC_LINK", + key: "Magic_Link_SMS", + replacements: { + "{magic_link}": finalUrl, + "{expiry_time}": "15 minutes" + }, + sms: { + receipients: [identifier], // Use original number like OTP method + }, + }; + + console.log(`[DEBUG] Magic Link SMS payload:`, JSON.stringify(smsPayload, null, 2)); + + try { + const result = await this.notificationService.sendNotification(smsPayload); + console.log(`[DEBUG] SMS API response:`, JSON.stringify(result, null, 2)); + } catch (smsError) { + console.error(`[DEBUG] SMS API error:`, smsError); + throw smsError; + } + break; + case 'whatsapp': + // Send WhatsApp notification using the same direct approach as OTP + // Format phone number with +91 prefix like the OTP method does + const formattedPhone = `+91${identifier}`; + // Try to use the same template that works for OTP, or fallback to a simple one + const templateId = process.env.WHATSAPP_TEMPLATE_ID || "magic_link_template"; + + // For debugging: let's try both the current template and a fallback + console.log(`[DEBUG] Using WhatsApp template ID: ${templateId}`); + + // Create a shorter, more template-friendly message + const shortMessage = `Click here: ${finalUrl}`; + + const whatsappPayload = { + whatsapp: { + to: [formattedPhone], + templateId: templateId, + templateParams: [finalUrl], // OTP template expects only 1 parameter + gupshupSource: process.env.WHATSAPP_GUPSHUP_SOURCE, + gupshupApiKey: process.env.WHATSAPP_GUPSHUP_API_KEY, + }, + }; + + // Debug: Log the exact template parameters being sent + console.log(`[DEBUG] WhatsApp Template Parameters:`, { + templateId: templateId, + param1: finalUrl, + totalParams: 1 + }); + console.log(`[DEBUG] Magic Link WhatsApp payload:`, JSON.stringify(whatsappPayload, null, 2)); + console.log(`[DEBUG] Original identifier: ${identifier}, Formatted phone: ${formattedPhone}`); + + try { + console.log(`[DEBUG] About to call sendRawNotification with payload:`, JSON.stringify(whatsappPayload, null, 2)); + const result = await this.notificationService.sendRawNotification(whatsappPayload); + console.log(`[DEBUG] WhatsApp API response:`, JSON.stringify(result, null, 2)); + } catch (whatsappError) { + console.error(`[DEBUG] WhatsApp API error:`, whatsappError); + console.error(`[DEBUG] Error details:`, { + message: whatsappError.message, + status: whatsappError.status, + response: whatsappError.response + }); + throw whatsappError; + } + break; + default: + throw new Error(`Unsupported notification channel: ${channel}`); + } + } catch (error) { + console.error(`Failed to send ${channel} notification:`, error); + throw error; // Re-throw to handle in calling method + } + } + + async cleanupExpiredLinks(): Promise { + try { + const expiredLinks = await this.magicLinkRepository + .createQueryBuilder('magicLink') + .where('magicLink.expires_at < :now', { now: new Date() }) + .andWhere('magicLink.is_expired = :isExpired', { isExpired: false }) + .getMany(); + + for (const link of expiredLinks) { + link.is_expired = true; + await this.magicLinkRepository.save(link); + } + } catch (error) { + console.error('Failed to cleanup expired links:', error); + } + } } diff --git a/src/auth/dto/magic-link.dto.ts b/src/auth/dto/magic-link.dto.ts new file mode 100644 index 00000000..5707a2c4 --- /dev/null +++ b/src/auth/dto/magic-link.dto.ts @@ -0,0 +1,60 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsEmail, IsString, IsOptional, IsIn, IsUrl } from 'class-validator'; + +export class RequestMagicLinkDto { + @ApiProperty({ + description: 'User identifier (email, phone, or username)', + example: 'user@example.com' + }) + @IsString() + identifier: string; + + @ApiProperty({ + description: 'Redirect URL after successful authentication', + example: 'https://app.example.com/dashboard', + required: false + }) + @IsOptional() + @IsUrl() + redirectUrl?: string; + + @ApiProperty({ + description: 'Notification channel for sending magic link', + enum: ['email', 'sms', 'whatsapp'], + example: 'email' + }) + @IsIn(['email', 'sms', 'whatsapp']) + notificationChannel: 'email' | 'sms' | 'whatsapp'; +} + +export class MagicLinkResponseDto { + @ApiProperty({ + description: 'Success status', + example: true + }) + success: boolean; + + @ApiProperty({ + description: 'Response message', + example: 'If the identifier is valid, you will receive a magic link' + }) + message: string; +} + +export class MagicLinkValidationDto { + @ApiProperty({ + description: 'Magic link token', + example: 'abc123def456ghi7' + }) + @IsString() + token: string; + + @ApiProperty({ + description: 'Encoded redirect URL', + example: 'https%3A//frontend.com/auth/success', + required: false + }) + @IsOptional() + @IsString() + redirect?: string; +} \ No newline at end of file diff --git a/src/auth/entities/magic-link.entity.ts b/src/auth/entities/magic-link.entity.ts new file mode 100644 index 00000000..bf979e02 --- /dev/null +++ b/src/auth/entities/magic-link.entity.ts @@ -0,0 +1,37 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm'; + +@Entity('MagicLinks') +export class MagicLink { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'varchar', length: 16, unique: true }) + token: string; + + @Column({ type: 'varchar', length: 255 }) + identifier: string; + + @Column({ type: 'varchar', length: 20 }) + identifier_type: string; + + @Column({ type: 'varchar', length: 500, nullable: true }) + redirect_url: string; + + @Column({ type: 'varchar', length: 20 }) + notification_channel: string; + + @Column({ type: 'timestamp' }) + expires_at: Date; + + @Column({ type: 'boolean', default: false }) + is_used: boolean; + + @Column({ type: 'boolean', default: false }) + is_expired: boolean; + + @CreateDateColumn({ type: 'timestamp' }) + created_at: Date; + + @UpdateDateColumn({ type: 'timestamp' }) + updated_at: Date; +} \ No newline at end of file diff --git a/src/common/utils/api-id.config.ts b/src/common/utils/api-id.config.ts index 1453928d..8f471b3d 100644 --- a/src/common/utils/api-id.config.ts +++ b/src/common/utils/api-id.config.ts @@ -58,5 +58,7 @@ export const APIID = { SEND_OTP: "api.send.OTP", VERIFY_OTP: "api.verify.OTP", SEND_RESET_OTP: 'api.send.reset.otp', - SIGNED_URL: 'api.get.signedURL' + SIGNED_URL: 'api.get.signedURL', + REQUEST_MAGIC_LINK: 'api.request.magicLink', + VALIDATE_MAGIC_LINK: 'api.validate.magicLink' }; diff --git a/src/common/utils/notification.axios.ts b/src/common/utils/notification.axios.ts index da36eb75..16bd52b7 100644 --- a/src/common/utils/notification.axios.ts +++ b/src/common/utils/notification.axios.ts @@ -96,6 +96,7 @@ export class NotificationRequest { data: data, }; try { + console.log(`[DEBUG] WhatsApp payload being sent:`, JSON.stringify(body, null, 2)); const response = await axios.request(config); return response.data; } catch (error) { @@ -148,4 +149,58 @@ export class NotificationRequest { ); } } + + async sendEmail(to: string, subject: string, message: string): Promise { + const emailPayload = { + email: { + to: [to], + subject: subject, + body: message, + }, + }; + return this.sendRawNotification(emailPayload); + } + + async sendSMS(to: string, message: string): Promise { + try { + // Use the existing sendRawNotification method with correct SMS format + const smsPayload = { + sms: { + to: [to], + body: message, + }, + }; + return this.sendRawNotification(smsPayload); + } catch (error) { + console.error(`[DEBUG] SMS notification error:`, error); + throw error; + } + } + + async sendWhatsApp(to: string, message: string): Promise { + try { + // Use the existing sendRawNotification method with correct WhatsApp format + const whatsappPayload = { + whatsapp: { + to: [to], + templateId: this.configService.get("WHATSAPP_TEMPLATE_ID") || "magic_link_template", + templateParams: [message], + gupshupSource: this.configService.get("WHATSAPP_GUPSHUP_SOURCE"), + gupshupApiKey: this.configService.get("WHATSAPP_GUPSHUP_API_KEY"), + }, + }; + + console.log(`[DEBUG] WhatsApp payload being sent:`, JSON.stringify(whatsappPayload, null, 2)); + console.log(`[DEBUG] WhatsApp template ID:`, this.configService.get("WHATSAPP_TEMPLATE_ID")); + console.log(`[DEBUG] WhatsApp source:`, this.configService.get("WHATSAPP_GUPSHUP_SOURCE")); + + const result = await this.sendRawNotification(whatsappPayload); + console.log(`[DEBUG] WhatsApp API response:`, JSON.stringify(result, null, 2)); + + return result; + } catch (error) { + console.error(`[DEBUG] WhatsApp notification error:`, error); + throw error; + } + } } \ No newline at end of file diff --git a/src/user/user.module.ts b/src/user/user.module.ts index 861b466c..3bdd28eb 100644 --- a/src/user/user.module.ts +++ b/src/user/user.module.ts @@ -40,5 +40,6 @@ import { KafkaModule } from "src/kafka/kafka.module"; ], controllers: [UserController], providers: [UserAdapter, UploadS3Service, AutomaticMemberService], + exports: [UserAdapter], }) export class UserModule {} diff --git a/src/user/useradapter.ts b/src/user/useradapter.ts index 2b3da79a..6d86fc36 100644 --- a/src/user/useradapter.ts +++ b/src/user/useradapter.ts @@ -5,6 +5,7 @@ import { PostgresUserService } from "src/adapters/postgres/user-adapter"; @Injectable() export class UserAdapter { constructor(private postgresProvider: PostgresUserService) {} + buildUserAdapter(): IServicelocator { let adapter: IServicelocator; @@ -14,4 +15,9 @@ export class UserAdapter { } return adapter; } + + async findUserByIdentifier(identifier: string): Promise { + const adapter = this.buildUserAdapter(); + return adapter.findUserByIdentifier(identifier); + } }