From 0d858f54119b065338a0b3cd7913eb93dd8a0e0e Mon Sep 17 00:00:00 2001 From: Mayank Mishra Date: Sat, 22 Nov 2025 11:59:48 +0530 Subject: [PATCH 1/3] made getall document country city and replacing the files on s3 when updating the company details sending mail to am or admin --- package-lock.json | 24 +- package.json | 4 + serverless.yml | 26 ++ .../host/hostCompanyDetails.validation.ts | 2 +- src/config/config.ts | 6 + .../host/handlers/addCompanyDetails.ts | 257 +++++++++++++----- src/modules/host/services/host.service.ts | 44 +++ .../sendHostResubmitEmailToAM.service.ts | 78 ++++++ .../handlers/getAllDocTypeWithCountryState.ts | 39 +++ .../services/prepopulate.service.ts | 20 ++ 10 files changed, 432 insertions(+), 68 deletions(-) create mode 100644 src/modules/host/services/sendHostResubmitEmailToAM.service.ts create mode 100644 src/modules/prepopulate/handlers/getAllDocTypeWithCountryState.ts diff --git a/package-lock.json b/package-lock.json index 6b371ae..bdd691d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,9 +9,12 @@ "version": "1.0.0", "license": "MIT", "dependencies": { + "@aws-crypto/crc32c": "^5.2.0", + "@aws-crypto/sha256-browser": "^5.2.0", "@aws-crypto/sha256-js": "^5.2.0", "@aws-sdk/client-s3": "^3.928.0", "@aws-sdk/s3-request-presigner": "^3.310.0", + "@aws/lambda-invoke-store": "^0.2.1", "@nestjs/common": "^10.3.0", "@nestjs/config": "^3.1.1", "@nestjs/core": "^10.3.0", @@ -44,6 +47,7 @@ "serverless": "4.17.0", "swagger-ui-express": "^5.0.0", "tslib": "^2.8.1", + "uuid": "^13.0.0", "yup": "^1.7.1", "zod": "^4.1.12" }, @@ -5928,6 +5932,14 @@ "node": ">= 10.0.0" } }, + "node_modules/aws-sdk/node_modules/uuid": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.0.0.tgz", + "integrity": "sha512-jOXGuXZAWdsTH7eZLtyXMqUb9EcWMGZNbL9YcGBJl4MH4nrxHmZJhEHvyLFrkxo+28uLb/NYRcStH48fnD0Vzw==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/axios": { "version": "1.13.2", "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", @@ -13364,11 +13376,15 @@ } }, "node_modules/uuid": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.0.0.tgz", - "integrity": "sha512-jOXGuXZAWdsTH7eZLtyXMqUb9EcWMGZNbL9YcGBJl4MH4nrxHmZJhEHvyLFrkxo+28uLb/NYRcStH48fnD0Vzw==", + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", + "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], "bin": { - "uuid": "dist/bin/uuid" + "uuid": "dist-node/bin/uuid" } }, "node_modules/v8-compile-cache-lib": { diff --git a/package.json b/package.json index 521d923..5879671 100644 --- a/package.json +++ b/package.json @@ -26,9 +26,12 @@ "seeder": "tsx prisma/seed.ts" }, "dependencies": { + "@aws-crypto/crc32c": "^5.2.0", + "@aws-crypto/sha256-browser": "^5.2.0", "@aws-crypto/sha256-js": "^5.2.0", "@aws-sdk/client-s3": "^3.928.0", "@aws-sdk/s3-request-presigner": "^3.310.0", + "@aws/lambda-invoke-store": "^0.2.1", "@nestjs/common": "^10.3.0", "@nestjs/config": "^3.1.1", "@nestjs/core": "^10.3.0", @@ -61,6 +64,7 @@ "serverless": "4.17.0", "swagger-ui-express": "^5.0.0", "tslib": "^2.8.1", + "uuid": "^13.0.0", "yup": "^1.7.1", "zod": "^4.1.12" }, diff --git a/serverless.yml b/serverless.yml index 95f9e7c..dfb53f6 100644 --- a/serverless.yml +++ b/serverless.yml @@ -40,6 +40,8 @@ provider: SALT_ROUNDS: ${env:SALT_ROUNDS} NODE_ENV: ${env:NODE_ENV} S3_BUCKET_NAME: ${env:S3_BUCKET_NAME} + MINGLAR_ADMIN_NAME: ${env:MINGLAR_ADMIN_NAME} + MINGLAR_ADMIN_EMAIL: ${env:MINGLAR_ADMIN_EMAIL} iam: role: @@ -408,6 +410,22 @@ functions: - httpApi: path: /prepopulate/get-all-bank-currency-details method: get + + + getAllDocumentCountryStateCityDetails: + handler: src/modules/prepopulate/handlers/getAllDocTypeWithCountryState.handler + package: + patterns: + - 'src/modules/minglaradmin/**' + - 'common/**' + - 'src/common/**' + - 'node_modules/@prisma/client/**' + - 'node_modules/.prisma/**' + + events: + - httpApi: + path: /prepopulate/get-all-doc-country + method: get getAllPqqQuesAns: handler: src/modules/prepopulate/handlers/getAllPQQQuesWithAns.handler @@ -498,6 +516,14 @@ functions: - 'node_modules/@smithy/**' - 'node_modules/tslib/**' - 'node_modules/fast-xml-parser/**' + - 'node_modules/lambda-multipart-parser/**' + - 'node_modules/busboy/**' + - 'node_modules/@aws-crypto/**' + - 'node_modules/uuid/**' + - 'node_modules/@aws/util-uri-escape/**' + - 'node_modules/@aws/util-middleware/**' + - 'node_modules/@aws/smithy-client/**' + - 'node_modules/@aws/lambda-invoke-store/**' events: - httpApi: diff --git a/src/common/utils/validation/host/hostCompanyDetails.validation.ts b/src/common/utils/validation/host/hostCompanyDetails.validation.ts index 418835f..5bcebfc 100644 --- a/src/common/utils/validation/host/hostCompanyDetails.validation.ts +++ b/src/common/utils/validation/host/hostCompanyDetails.validation.ts @@ -55,7 +55,7 @@ export const hostCompanyDetailsSchema = z.object({ pinCode: z.string().min(4, "Pincode/Zipcode is required"), logoPath: z.string().optional(), isSubsidairy: z.boolean(), - registrationNumber: z.string().min(1, "Registration number is required"), + registrationNumber: z.string().optional(), panNumber: z.string().min(1, "PAN number is required"), gstNumber: z.string().optional(), formationDate: z.string().refine((val) => !isNaN(Date.parse(val)), { diff --git a/src/config/config.ts b/src/config/config.ts index 36abc1a..975aa21 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -59,6 +59,9 @@ const envVarsSchema = yup .nullable() .required('the from field in the emails sent by the app api key'), BREVO_API_BASEURL: yup.string().required('Brevo base URL is required'), + // Minglar Admin + MINGLAR_ADMIN_EMAIL: yup.string().required('Minglar admin email address is required.'), + MINGLAR_ADMIN_NAME: yup.string().required('Minglar admin name is required.'), // //one signal // ONESIGNAL_APPID: yup.string().required('One signal app id is required'), // ONESIGNAL_REST_APIKEY: yup @@ -152,6 +155,9 @@ function getConfig() { api_key: envVars?.BREVO_EMAIL_API_KEY, BrevobaseURL: envVars?.BREVO_API_BASEURL, }, + //Minglar admin + MinglarAdminEmail: envVars.MINGLAR_ADMIN_EMAIL, + MinglarAdminName: envVars.MINGLAR_ADMIN_NAME, // oneSignal: { // appID: envVars.ONESIGNAL_APPID, // restApiKey: envVars.ONESIGNAL_REST_APIKEY, diff --git a/src/modules/host/handlers/addCompanyDetails.ts b/src/modules/host/handlers/addCompanyDetails.ts index 311c911..9901f42 100644 --- a/src/modules/host/handlers/addCompanyDetails.ts +++ b/src/modules/host/handlers/addCompanyDetails.ts @@ -15,6 +15,7 @@ import { hostDocumentsSchema } from '../../../common/utils/validation/host/hostCompanyDetails.validation'; import { HostService } from '../../host/services/host.service'; +import { sendEmailToAM, sendEmailToMinglarAdmin } from '../services/sendHostResubmitEmailToAM.service'; const prisma = new PrismaService(); const hostService = new HostService(prisma); @@ -50,16 +51,28 @@ export const handler = safeHandler(async (event: APIGatewayProxyEvent): Promise< // 2) multipart check const contentType = event.headers['content-type'] || event.headers['Content-Type']; - if (!contentType?.startsWith('multipart/form-data')) throw new ApiError(400, 'Content-Type must be multipart/form-data.'); - if (!event.isBase64Encoded) throw new ApiError(400, 'Event body must be base64 encoded for multipart uploads.'); + if (!contentType?.includes('multipart/form-data')) { + throw new ApiError(400, 'Content-Type must be multipart/form-data.'); + } + + // Handle both base64 and non-base64 encoded bodies + let bodyBuffer: Buffer; + if (event.isBase64Encoded) { + bodyBuffer = Buffer.from(event.body as string, 'base64'); + } else { + bodyBuffer = Buffer.from(event.body as string, 'binary'); + } - const bodyBuffer = Buffer.from(event.body as string, 'base64'); const fields: Record = {}; const files: Array<{ buffer: Buffer; mimeType: string; fileName: string; fieldName: string }> = []; - // 3) parse with Busboy + // 3) parse with Busboy - FIXED VERSION await new Promise((resolve, reject) => { - const bb = Busboy({ headers: { 'content-type': contentType } }); + const bb = Busboy({ + headers: { + 'content-type': contentType + } + }); bb.on('file', (fieldname, file, info) => { const { filename, mimeType } = info; @@ -70,36 +83,64 @@ export const handler = safeHandler(async (event: APIGatewayProxyEvent): Promise< file.on('data', (chunk) => { totalSize += chunk.length; if (totalSize > MAX_SIZE) { - file.resume(); - return reject(new ApiError(400, `File ${filename} exceeds 5MB limit.`)); + file.destroy(new Error(`File ${filename} exceeds 5MB limit.`)); + return; } chunks.push(chunk); }); file.on('end', () => { - files.push({ - buffer: Buffer.concat(chunks), - mimeType, - fileName: filename, - fieldName: fieldname, - }); + if (chunks.length > 0) { + files.push({ + buffer: Buffer.concat(chunks), + mimeType: mimeType || 'application/octet-stream', + fileName: filename || 'unknown', + fieldName: fieldname, + }); + } + }); + + file.on('error', (error) => { + reject(new ApiError(400, `File upload error: ${error.message}`)); }); }); bb.on('field', (fieldname, val) => { - // Keep raw string for JSON parse later; try parse for convenience - try { - fields[fieldname] = JSON.parse(val); - } catch { - fields[fieldname] = val; - } + // Store as string initially, parse later in normalizeJsonField + fields[fieldname] = val; }); - bb.on('close', resolve); - bb.on('error', reject); - bb.end(bodyBuffer); + bb.on('close', () => { + resolve(); + }); + + bb.on('error', (error) => { + reject(new ApiError(400, `Multipart parsing error: ${error.message}`)); + }); + + bb.write(bodyBuffer); + bb.end(); }); + if (fields.userProfile) { + const userProfileRaw = normalizeJsonField(fields, "userProfile"); + if (userProfileRaw) { + const { firstName, lastName, mobileNumber } = userProfileRaw; + + // Update user profile if provided + if (firstName || lastName || mobileNumber) { + await prisma.user.update({ + where: { id: userInfo.id }, + data: { + ...(firstName && { firstName }), + ...(lastName && { lastName }), + ...(mobileNumber && { mobileNumber }), + }, + }); + } + } + } + // 4) Validate required root fields if (!fields.companyDetails) throw new ApiError(400, 'Missing companyDetails field.'); if (!fields.documents) throw new ApiError(400, 'Missing documents field.'); @@ -108,6 +149,83 @@ export const handler = safeHandler(async (event: APIGatewayProxyEvent): Promise< const companyDetailsRaw = normalizeJsonField(fields, "companyDetails"); if (!companyDetailsRaw) throw new ApiError(400, "companyDetails is required."); + // Get existing host to determine host ID for folder structure + const existingHost = await prisma.hostHeader.findFirst({ + where: { userXid: userInfo.id }, + }); + + let hostId: number; + if (existingHost) { + hostId = existingHost.id; + } else { + // For new hosts, we'll use user ID temporarily and update after host creation + hostId = userInfo.id; + } + + // Define uploadToS3 function with proper folder structure using fieldName for filenames + // Define uploadToS3 function with proper folder structure using fieldName for filenames + async function uploadToS3(buffer: Buffer, mimeType: string, originalName: string, folderType: 'logo' | 'documents' | 'parent_company', documentTypeXid?: number, fieldName?: string) { + let s3Key: string; + + // Sanitize file name: remove special characters and spaces + const sanitizeFileName = (name: string) => { + return name + .toLowerCase() + .replace(/[^a-z0-9.]/g, '_') // Replace special characters with underscore + .replace(/_+/g, '_') // Replace multiple underscores with single + .replace(/^_+|_+$/g, ''); // Remove leading/trailing underscores + }; + + // Get file extension from original file name + const fileExtension = originalName.split('.').pop() || 'pdf'; + + // Determine folder structure based on type + if (folderType === 'logo') { + // Logo: Documents/Host/logo/{HostID}/{sanitized_filename} + const sanitizedFileName = sanitizeFileName(originalName); + s3Key = `Documents/Host/logo/${hostId}/${sanitizedFileName}`; + } else if (folderType === 'documents' && documentTypeXid && fieldName) { + // Host Documents: Documents/Host/documents/{HostID}/{documentTypeXid}_{fieldName}.{extension} + const fileName = `${documentTypeXid}_${fieldName}.${fileExtension}`; + const sanitizedFileName = sanitizeFileName(fileName); + s3Key = `Documents/Host/documents/${hostId}/${sanitizedFileName}`; + } else if (folderType === 'parent_company' && documentTypeXid && fieldName) { + // Parent Documents: Documents/Host/parent_company/{HostID}/{documentTypeXid}_{fieldName}.{extension} + const fileName = `${documentTypeXid}_${fieldName}.${fileExtension}`; + const sanitizedFileName = sanitizeFileName(fileName); + s3Key = `Documents/Host/parent_company/${hostId}/${sanitizedFileName}`; + } else { + throw new ApiError(400, 'Invalid folder type or missing documentTypeXid/fieldName'); + } + + // Upload new file (S3 will automatically replace if same key exists) + await s3 + .upload({ + Bucket: config.aws.bucketName, + Key: s3Key, + Body: buffer, + ContentType: mimeType, + ACL: 'private', + }) + .promise(); + + console.log(`File uploaded successfully: ${s3Key}`); + return `https://${config.aws.bucketName}.s3.${config.aws.region}.amazonaws.com/${s3Key}`; + } + + // 5.5) Handle company logo upload + const logoFile = files.find((f) => f.fieldName === 'companyLogo'); + if (logoFile) { + const logoPath = await uploadToS3( + logoFile.buffer, + logoFile.mimeType, + logoFile.fileName, + 'logo' + ); + companyDetailsRaw.logoPath = logoPath; + console.log('Company logo uploaded:', logoPath); + } + // 6) Zod validation for companyDetails (includes optional parentCompany) const companyValidation = hostCompanyDetailsSchema.safeParse(companyDetailsRaw); if (!companyValidation.success) { @@ -129,21 +247,21 @@ export const handler = safeHandler(async (event: APIGatewayProxyEvent): Promise< } const documentsMetadata = documentsMetadataRaw.map((d: any) => ({ ...d, - owner: d.owner === 'parent' ? 'parent' : 'host', // default host + owner: d.owner || 'host', // default to host })); - // 9) Map uploaded files to metadata (one entry per file - Q2 = A) + // 8) Map uploaded files to metadata const documentMetadata = documentsMetadata.map((doc: any) => { const file = files.find((f) => f.fieldName === doc.fieldName); if (!file) throw new ApiError(400, `File not found for field: ${doc.fieldName}`); return { ...doc, file }; }); - // 10) Split host vs parent docs + // 9) Split host vs parent docs const hostDocs = documentMetadata.filter((d) => d.owner === 'host'); const parentDocs = documentMetadata.filter((d) => d.owner === 'parent'); - // 11) Ensure required docs for host exist (IDs 1,2,3,4) + // 10) Ensure required docs for host exist (IDs 1,2,3,4) const hostUploadedTypes = hostDocs.map((d) => d.documentTypeXid); const requiredHostTypes = Object.values(REQUIRED_DOC_TYPES); const missingHostDocs = requiredHostTypes.filter((typeId) => !hostUploadedTypes.includes(typeId)); @@ -151,7 +269,7 @@ export const handler = safeHandler(async (event: APIGatewayProxyEvent): Promise< throw new ApiError(400, `Missing mandatory documents for host: ${missingHostDocs.join(', ')}`); } - // 12) If isSubsidairy === true and parentCompany provided -> validate parent company & docs + // 11) If isSubsidairy === true and parentCompany provided -> validate parent company & docs let parsedParentCompany: any = null; if (parsedCompany.isSubsidairy) { if (!parsedCompany.parentCompany) { @@ -163,16 +281,7 @@ export const handler = safeHandler(async (event: APIGatewayProxyEvent): Promise< const message = parentValidation.error.issues.map((i) => i.message).join(', '); throw new ApiError(400, `Parent company validation failed: ${message}`); } - - let parentCompanyRaw = parsedCompany.parentCompany; - if (typeof parentCompanyRaw === "string") { - try { - parentCompanyRaw = JSON.parse(parentCompanyRaw); - } catch { - throw new ApiError(400, "Invalid JSON in parentCompany."); - } - } - parsedParentCompany = parentCompanyRaw; + parsedParentCompany = parsedCompany.parentCompany; const parentUploadedTypes = parentDocs.map((d) => d.documentTypeXid); const requiredParentTypes = Object.values(REQUIRED_DOC_TYPES); @@ -182,49 +291,47 @@ export const handler = safeHandler(async (event: APIGatewayProxyEvent): Promise< } } - // 13) Upload files to S3 (host docs under Documents/Host/, parent docs under Documents/Host/parent_company/) + // 12) Upload files to S3 with proper folder structure using fieldName for filenames const uploadedHostDocs: Array<{ documentTypeXid: number; documentName: string; filePath: string }> = []; const uploadedParentDocs: Array<{ documentTypeXid: number; documentName: string; filePath: string }> = []; - async function uploadToS3(buffer: Buffer, mimeType: string, originalName: string, prefix: string) { - const uniqueKey = `${userInfo.id}_${crypto.randomUUID()}_${originalName}`; - const s3Key = `${prefix}/${uniqueKey}`; - await s3 - .upload({ - Bucket: config.aws.bucketName, - Key: s3Key, - Body: buffer, - ContentType: mimeType, - ACL: 'private', - }) - .promise(); - - return `https://${config.aws.bucketName}.s3.${config.aws.region}.amazonaws.com/${s3Key}`; - } - - // upload host files + // Upload host documents with proper folder structure using fieldName for (const doc of hostDocs) { - const filePath = await uploadToS3(doc.file.buffer, doc.file.mimeType, doc.file.fileName, 'Documents/Host'); + const filePath = await uploadToS3( + doc.file.buffer, + doc.file.mimeType, + doc.file.fileName, // Use original file name for extension + 'documents', + doc.documentTypeXid, + doc.fieldName // Use fieldName for the filename + ); uploadedHostDocs.push({ documentTypeXid: doc.documentTypeXid, - documentName: doc.documentName, + documentName: doc.documentName, // Keep documentName for database filePath, }); } - // upload parent files (if any) + // Upload parent company documents with proper folder structure using fieldName if (parentDocs.length > 0) { for (const doc of parentDocs) { - const filePath = await uploadToS3(doc.file.buffer, doc.file.mimeType, doc.file.fileName, 'Documents/Host/parent_company'); + const filePath = await uploadToS3( + doc.file.buffer, + doc.file.mimeType, + doc.file.fileName, // Use original file name for extension + 'parent_company', + doc.documentTypeXid, + doc.fieldName // Use fieldName for the filename + ); uploadedParentDocs.push({ documentTypeXid: doc.documentTypeXid, - documentName: doc.documentName, + documentName: doc.documentName, // Keep documentName for database filePath, }); } } - // 14) Persist using hostService + // 13) Persist using hostService const createdOrUpdated = await hostService.addOrUpdateCompanyDetails( userInfo.id, parsedCompany, @@ -235,7 +342,31 @@ export const handler = safeHandler(async (event: APIGatewayProxyEvent): Promise< if (!createdOrUpdated) throw new ApiError(400, 'Failed to add/update company details.'); - // 15) Success + // Update hostId if it was a new creation + if (!existingHost) { + hostId = createdOrUpdated.id; + console.log(`Host created with ID: ${hostId}`); + } + + const getSuggestionDetails = await hostService.getSuggestionDetails(userInfo.id) + + if (getSuggestionDetails && getSuggestionDetails.hostDetails.accountManagerXid !== null) { + await sendEmailToAM( + getSuggestionDetails.hostDetails.accountManager.emailAddress, + getSuggestionDetails.hostDetails.accountManager.firstName, + getSuggestionDetails.hostDetails.companyName, + getSuggestionDetails.hostDetails.hostRefNumber + ); + } else { + await sendEmailToMinglarAdmin( + config.MinglarAdminEmail, + config.MinglarAdminName, + getSuggestionDetails.hostDetails.companyName, + getSuggestionDetails.hostDetails.hostRefNumber + ) + } + + // 14) Success return { statusCode: 200, headers: { @@ -252,4 +383,4 @@ export const handler = safeHandler(async (event: APIGatewayProxyEvent): Promise< console.error('❌ Error in addCompanyDetails:', error); throw error; } -}); +}); \ No newline at end of file diff --git a/src/modules/host/services/host.service.ts b/src/modules/host/services/host.service.ts index 4b4d69a..3fb1693 100644 --- a/src/modules/host/services/host.service.ts +++ b/src/modules/host/services/host.service.ts @@ -480,6 +480,50 @@ export class HostService { }); } + async getSuggestionDetails(user_xid: number) { + const hostDetails = await this.prisma.hostHeader.findFirst({ + where: { userXid: user_xid, isActive: true }, + include: { + user: { + select: { + id: true, + emailAddress: true, + firstName: true, + } + }, + accountManager: { + select: { + id: true, + emailAddress: true, + firstName: true, + } + } + } + }); + + if (!hostDetails) { + return { hostSuggestionDetails: [], hostDetails: null }; + } + + const hostSuggestionDetails = await this.prisma.hostSuggestion.findMany({ + where: { hostXid: hostDetails.id, isActive: true, isreviewed: false } + }); + + if (hostSuggestionDetails) { + await this.prisma.hostSuggestion.updateMany({ + where: { hostXid: hostDetails.id, isActive: true, isreviewed: false }, + data: { + isreviewed: true, + reviewedByXid: hostDetails.id, + reviewOn: new Date(), + } + }) + } + + return { hostSuggestionDetails, hostDetails }; + } + + async generateHostRefNumber(tx: any) { const lastHost = await tx.hostHeader.findFirst({ diff --git a/src/modules/host/services/sendHostResubmitEmailToAM.service.ts b/src/modules/host/services/sendHostResubmitEmailToAM.service.ts new file mode 100644 index 0000000..8565bb8 --- /dev/null +++ b/src/modules/host/services/sendHostResubmitEmailToAM.service.ts @@ -0,0 +1,78 @@ +import { brevoService } from "@/common/email/brevoApi"; +import ApiError from "@/common/utils/helper/ApiError"; + +export async function sendEmailToAM( + emailAddress: string, + amName: string, + hostCompanyName: string, + hostRefNumber: string +): Promise<{ + sent: boolean; + // messageId: string +}> { + + const subject = "Host Application Re-Submited"; + + const htmlContent = ` +

Dear ${amName},

+

Host ${hostCompanyName} with reference number: ${hostRefNumber} has re-submited the application with implimented suggestions.

+

Please review their appliaction and take the necessary action.

+

Best regards,
Minglar Team

+ `; + + try { + const result = await brevoService.sendEmail({ + recipients: [{ email: emailAddress }], + subject, + htmlContent, + }); + + // console.log("📧 Email sent successfully:", result); + + return { + sent: true, + // messageId: result.messageId + }; + } catch (err) { + console.error("Brevo email send failed:", err); + throw new ApiError(500, "Failed to send OTP to host via email."); + } +} + +export async function sendEmailToMinglarAdmin( + emailAddress: string, + minglarAdminName: string, + hostCompanyName: string, + hostRefNumber: string +): Promise<{ + sent: boolean; + // messageId: string +}> { + + const subject = "New Host Application Recieved"; + + const htmlContent = ` +

Dear ${minglarAdminName},

+

Host ${hostCompanyName} with reference number: ${hostRefNumber} has submited their application.

+

Please review their appliaction and take the necessary action.

+

Best regards,
Minglar Team

+ `; + + try { + const result = await brevoService.sendEmail({ + recipients: [{ email: emailAddress }], + subject, + htmlContent, + }); + + // console.log("📧 Email sent successfully:", result); + + return { + sent: true, + // messageId: result.messageId + }; + } catch (err) { + console.error("Brevo email send failed:", err); + throw new ApiError(500, "Failed to send OTP to host via email."); + } +} diff --git a/src/modules/prepopulate/handlers/getAllDocTypeWithCountryState.ts b/src/modules/prepopulate/handlers/getAllDocTypeWithCountryState.ts new file mode 100644 index 0000000..4cb64a4 --- /dev/null +++ b/src/modules/prepopulate/handlers/getAllDocTypeWithCountryState.ts @@ -0,0 +1,39 @@ +import { APIGatewayProxyEvent, APIGatewayProxyResult, Context } from 'aws-lambda'; +import { PrismaService } from '../../../common/database/prisma.service'; +import { safeHandler } from '../../../common/utils/handlers/safeHandler'; +import ApiError from '../../../common/utils/helper/ApiError'; +import { PrePopulateService } from '../services/prepopulate.service'; +import { verifyHostToken } from '@/common/middlewares/jwt/authForHost'; + +const prismaService = new PrismaService(); +const prePopulateService = new PrePopulateService(prismaService); + +export const handler = safeHandler(async ( + event: APIGatewayProxyEvent, + context?: Context +): Promise => { + // Extract token from headers + const token = event.headers['x-auth-token'] || event.headers['X-Auth-Token'] + if (!token) { + throw new ApiError(400, 'This is a protected route. Please provide a valid token.'); + } + + // Authenticate user using the shared authForHost function + await verifyHostToken(token); + + const result = await prePopulateService.getAllDocumentTypeWithCountryStateCity(); + + return { + statusCode: 200, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + body: JSON.stringify({ + success: true, + message: 'Data retrieved successfully', + data: result, + }), + }; +}); + diff --git a/src/modules/prepopulate/services/prepopulate.service.ts b/src/modules/prepopulate/services/prepopulate.service.ts index 4903490..4ec4431 100644 --- a/src/modules/prepopulate/services/prepopulate.service.ts +++ b/src/modules/prepopulate/services/prepopulate.service.ts @@ -59,6 +59,26 @@ export class PrePopulateService { }); } + async getAllDocumentTypeWithCountryStateCity() { + const [documentDetails, countryDetails, stateDetails, cityDetails] = + await this.prisma.$transaction([ + this.prisma.documentType.findMany({ + where: { isActive: true, isVisible: true }, + orderBy: { displayOrder: 'asc' } + }), + this.prisma.countries.findMany({ + where: { isActive: true }, + }), + this.prisma.states.findMany({ + where: { isActive: true } + }), + this.prisma.cities.findMany({ + where: { isActive: true } + }) + ]); + + return { documentDetails, countryDetails, stateDetails, cityDetails }; + } From e7c94a1b19ec170d35290d88b8d8d48bfce5c805 Mon Sep 17 00:00:00 2001 From: Mayank Mishra Date: Sat, 22 Nov 2025 12:09:37 +0530 Subject: [PATCH 2/3] formatted the files --- .../host/handlers/addCompanyDetails.ts | 2 +- .../minglaradmin/services/minglar.service.ts | 88 +++++++++---------- 2 files changed, 45 insertions(+), 45 deletions(-) diff --git a/src/modules/host/handlers/addCompanyDetails.ts b/src/modules/host/handlers/addCompanyDetails.ts index 9901f42..b25ea1e 100644 --- a/src/modules/host/handlers/addCompanyDetails.ts +++ b/src/modules/host/handlers/addCompanyDetails.ts @@ -307,7 +307,7 @@ export const handler = safeHandler(async (event: APIGatewayProxyEvent): Promise< ); uploadedHostDocs.push({ documentTypeXid: doc.documentTypeXid, - documentName: doc.documentName, // Keep documentName for database + documentName: doc.fieldName, // Keep documentName for database filePath, }); } diff --git a/src/modules/minglaradmin/services/minglar.service.ts b/src/modules/minglaradmin/services/minglar.service.ts index 6d766be..71a0d99 100644 --- a/src/modules/minglaradmin/services/minglar.service.ts +++ b/src/modules/minglaradmin/services/minglar.service.ts @@ -529,58 +529,58 @@ export class MinglarService { } async getAllCoadminAndAM() { - // 1. Fetch all required users (Admin, Co-Admin, AM) - const users = await this.prisma.user.findMany({ - where: { - roleXid: { - in: [ - ROLE.MINGLAR_ADMIN, // Admin - ROLE.CO_ADMIN, // Co-Admin - ROLE.ACCOUNT_MANAGER // AM - ] + // 1. Fetch all required users (Admin, Co-Admin, AM) + const users = await this.prisma.user.findMany({ + where: { + roleXid: { + in: [ + ROLE.MINGLAR_ADMIN, // Admin + ROLE.CO_ADMIN, // Co-Admin + ROLE.ACCOUNT_MANAGER // AM + ] + }, + isActive: true, + userStatus: USER_STATUS.ACTIVE, }, - isActive: true, - userStatus: USER_STATUS.ACTIVE, - }, - include: { - role: { - select: { - id: true, - roleName: true, + include: { + role: { + select: { + id: true, + roleName: true, + }, }, }, - }, - }); + }); - if (!users.length) return []; + if (!users.length) return []; - const userIds = users.map((u) => u.id); + const userIds = users.map((u) => u.id); - // 2. Count assigned hosts for ANY user (Admin / Co-Admin / AM) - const groupedHosts = await this.prisma.hostHeader.groupBy({ - by: ["accountManagerXid"], - where: { - accountManagerXid: { in: userIds }, // assigned user - isActive: true, - }, - _count: { - id: true, - }, - }); + // 2. Count assigned hosts for ANY user (Admin / Co-Admin / AM) + const groupedHosts = await this.prisma.hostHeader.groupBy({ + by: ["accountManagerXid"], + where: { + accountManagerXid: { in: userIds }, // assigned user + isActive: true, + }, + _count: { + id: true, + }, + }); - // 3. Build quick lookup map: userId -> hostCount - const hostCountMap: Record = {}; - groupedHosts.forEach((g) => { - const uid = Number(g.accountManagerXid); - hostCountMap[uid] = g._count.id; - }); + // 3. Build quick lookup map: userId -> hostCount + const hostCountMap: Record = {}; + groupedHosts.forEach((g) => { + const uid = Number(g.accountManagerXid); + hostCountMap[uid] = g._count.id; + }); - // 4. Attach host counts to each user - return users.map((user) => ({ - ...user, - assignedHostCount: hostCountMap[user.id] ?? 0, - })); -} + // 4. Attach host counts to each user + return users.map((user) => ({ + ...user, + assignedHostCount: hostCountMap[user.id] ?? 0, + })); + } async assignAMToHost(userId: number, hostXid: number, accountManagerXid: number) { From 3b1aac921fa6ac0d340b57350a7cb97a63c5917a Mon Sep 17 00:00:00 2001 From: Mayank Mishra Date: Sat, 22 Nov 2025 19:22:34 +0530 Subject: [PATCH 3/3] Add new handlers for accepting and rejecting host applications, including email notifications. Updated serverless configuration with new function timeouts and added missing handlers. Refactored profile update logic to improve file upload handling and added user detail retrieval methods in the Minglar service. --- serverless.yml | 35 +- .../host/handlers/addCompanyDetails.ts | 7 +- src/modules/host/services/host.service.ts | 4 +- .../sendHostResubmitEmailToAM.service.ts | 4 +- .../handlers/acceptHostAppMinglar.ts | 65 ++++ .../handlers/acceptHostApplication.ts | 3 + .../handlers/rejectHostApplication.ts | 3 + .../handlers/rejectHostApplicationAM.ts | 65 ++++ .../minglaradmin/handlers/updateProfile.ts | 312 +++++++++------- .../services/approvalMailtoHost.service.ts | 72 ++++ .../minglaradmin/services/minglar.service.ts | 347 +++++++++++------- .../services/rejectionMailtoHost.service.ts | 73 ++++ 12 files changed, 716 insertions(+), 274 deletions(-) create mode 100644 src/modules/minglaradmin/handlers/acceptHostAppMinglar.ts create mode 100644 src/modules/minglaradmin/handlers/rejectHostApplicationAM.ts create mode 100644 src/modules/minglaradmin/services/approvalMailtoHost.service.ts create mode 100644 src/modules/minglaradmin/services/rejectionMailtoHost.service.ts diff --git a/serverless.yml b/serverless.yml index dfb53f6..b37fc6d 100644 --- a/serverless.yml +++ b/serverless.yml @@ -287,6 +287,7 @@ functions: updateMinglarProfile: handler: src/modules/minglaradmin/handlers/updateProfile.handler + timeout: 30 package: patterns: - 'src/modules/host/handlers/updateProfile.*' @@ -299,7 +300,6 @@ functions: - 'node_modules/@smithy/**' - 'node_modules/tslib/**' - 'node_modules/fast-xml-parser/**' - events: - httpApi: @@ -410,8 +410,7 @@ functions: - httpApi: path: /prepopulate/get-all-bank-currency-details method: get - - + getAllDocumentCountryStateCityDetails: handler: src/modules/prepopulate/handlers/getAllDocTypeWithCountryState.handler package: @@ -487,6 +486,21 @@ functions: path: /minglaradmin/accept-host-application method: patch + acceptHostApplicationMinglar: + handler: src/modules/minglaradmin/handlers/acceptHostAppMinglar.handler + package: + patterns: + - 'src/modules/minglaradmin/**' + - 'common/**' + - 'src/common/**' + - 'node_modules/@prisma/client/**' + - 'node_modules/.prisma/**' + + events: + - httpApi: + path: /minglaradmin/accept-host-application-minglar + method: patch + rejectHostApplication: handler: src/modules/minglaradmin/handlers/rejectHostApplication.handler package: @@ -502,6 +516,21 @@ functions: path: /minglaradmin/reject-host-application method: patch + rejectHostApplicationAM: + handler: src/modules/minglaradmin/handlers/rejectHostApplicationAM.handler + package: + patterns: + - 'src/modules/minglaradmin/**' + - 'common/**' + - 'src/common/**' + - 'node_modules/@prisma/client/**' + - 'node_modules/.prisma/**' + + events: + - httpApi: + path: /minglaradmin/reject-host-application-am + method: patch + addCompanyDetails: handler: src/modules/host/handlers/addCompanyDetails.handler package: diff --git a/src/modules/host/handlers/addCompanyDetails.ts b/src/modules/host/handlers/addCompanyDetails.ts index b25ea1e..1999f67 100644 --- a/src/modules/host/handlers/addCompanyDetails.ts +++ b/src/modules/host/handlers/addCompanyDetails.ts @@ -3,16 +3,15 @@ import config from '@/config/config'; import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; import AWS from 'aws-sdk'; import Busboy from 'busboy'; -import crypto from 'crypto'; import { PrismaService } from '../../../common/database/prisma.service'; import { verifyHostToken } from '../../../common/middlewares/jwt/authForHost'; import { safeHandler } from '../../../common/utils/handlers/safeHandler'; import ApiError from '../../../common/utils/helper/ApiError'; import { hostCompanyDetailsSchema, + hostDocumentsSchema, parentCompanySchema, - REQUIRED_DOC_TYPES, - hostDocumentsSchema + REQUIRED_DOC_TYPES } from '../../../common/utils/validation/host/hostCompanyDetails.validation'; import { HostService } from '../../host/services/host.service'; import { sendEmailToAM, sendEmailToMinglarAdmin } from '../services/sendHostResubmitEmailToAM.service'; @@ -350,7 +349,7 @@ export const handler = safeHandler(async (event: APIGatewayProxyEvent): Promise< const getSuggestionDetails = await hostService.getSuggestionDetails(userInfo.id) - if (getSuggestionDetails && getSuggestionDetails.hostDetails.accountManagerXid !== null) { + if (getSuggestionDetails.hostDetails.accountManagerXid !== null) { await sendEmailToAM( getSuggestionDetails.hostDetails.accountManager.emailAddress, getSuggestionDetails.hostDetails.accountManager.firstName, diff --git a/src/modules/host/services/host.service.ts b/src/modules/host/services/host.service.ts index 3fb1693..3456ccd 100644 --- a/src/modules/host/services/host.service.ts +++ b/src/modules/host/services/host.service.ts @@ -228,7 +228,9 @@ export class HostService { await this.prisma.hostHeader.update({ where: { id: hostDetails.id }, data: { - stepper: STEPPER.AGREEMENT_ACCEPTED + stepper: STEPPER.AGREEMENT_ACCEPTED, + agreementAccepted: true, + isApproved: true } }) } diff --git a/src/modules/host/services/sendHostResubmitEmailToAM.service.ts b/src/modules/host/services/sendHostResubmitEmailToAM.service.ts index 8565bb8..87c1bd9 100644 --- a/src/modules/host/services/sendHostResubmitEmailToAM.service.ts +++ b/src/modules/host/services/sendHostResubmitEmailToAM.service.ts @@ -11,7 +11,7 @@ export async function sendEmailToAM( // messageId: string }> { - const subject = "Host Application Re-Submited"; + const subject = `Host Application Re-Submited : ${hostCompanyName}`; const htmlContent = `

Dear ${amName},

@@ -49,7 +49,7 @@ export async function sendEmailToMinglarAdmin( // messageId: string }> { - const subject = "New Host Application Recieved"; + const subject = `New Host Application Recieved : ${hostCompanyName}`; const htmlContent = `

Dear ${minglarAdminName},

diff --git a/src/modules/minglaradmin/handlers/acceptHostAppMinglar.ts b/src/modules/minglaradmin/handlers/acceptHostAppMinglar.ts new file mode 100644 index 0000000..00b6fd4 --- /dev/null +++ b/src/modules/minglaradmin/handlers/acceptHostAppMinglar.ts @@ -0,0 +1,65 @@ +import { verifyOnlyMinglarAdminToken } from '@/common/middlewares/jwt/authForOnlyMinglarAdmin'; +import { APIGatewayProxyEvent, APIGatewayProxyResult, Context } from 'aws-lambda'; +import { PrismaService } from '../../../common/database/prisma.service'; +import { safeHandler } from '../../../common/utils/handlers/safeHandler'; +import ApiError from '../../../common/utils/helper/ApiError'; +import { sendEmailToHostForMinglarApproval } from '../services/approvalMailtoHost.service'; +import { MinglarService } from '../services/minglar.service'; + +const prismaService = new PrismaService(); +const minglarService = new MinglarService(prismaService); + +interface AddSuggestionBody { + hostXid: number; + title: string; + comments: string; +} + +/** + * Add suggestion handler for host applications + * Allows Minglar Admin, Co_Admin, and Account Manager to add suggestions + * Types: Setup Profile, Review Account, Add Payment Details, Agreement + */ +export const handler = safeHandler(async ( + event: APIGatewayProxyEvent, + context?: Context +): Promise => { + // Verify authentication token + const token = event.headers['x-auth-token'] || event.headers['X-Auth-Token']; + if (!token) { + throw new ApiError(401, 'This is a protected route. Please provide a valid token.'); + } + + // Verify token and get user info + const userInfo = await verifyOnlyMinglarAdminToken(token); + + // Parse request body + let body: AddSuggestionBody; + + try { + body = event.body ? JSON.parse(event.body) : {}; + } catch (error) { + throw new ApiError(400, 'Invalid JSON in request body'); + } + + const { hostXid } = body; + + + // Add suggestion using service + await minglarService.acceptHostApplicationMinglarAdmin(hostXid, userInfo.id); + const hostDetails = await minglarService.getUserDetails(userInfo.id) + await sendEmailToHostForMinglarApproval(hostDetails.emailAddress) + + return { + statusCode: 200, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + body: JSON.stringify({ + success: true, + message: 'Application accepted successfully', + data: null, + }), + }; +}); diff --git a/src/modules/minglaradmin/handlers/acceptHostApplication.ts b/src/modules/minglaradmin/handlers/acceptHostApplication.ts index a903f16..a71cc7d 100644 --- a/src/modules/minglaradmin/handlers/acceptHostApplication.ts +++ b/src/modules/minglaradmin/handlers/acceptHostApplication.ts @@ -4,6 +4,7 @@ import { verifyMinglarAdminToken } from '../../../common/middlewares/jwt/authFor import { safeHandler } from '../../../common/utils/handlers/safeHandler'; import ApiError from '../../../common/utils/helper/ApiError'; import { MinglarService } from '../services/minglar.service'; +import { sendEmailToHostForApprovedApplication } from '../services/approvalMailtoHost.service' const prismaService = new PrismaService(); const minglarService = new MinglarService(prismaService); @@ -46,6 +47,8 @@ export const handler = safeHandler(async ( // Add suggestion using service await minglarService.acceptHostApplication(hostXid, userInfo.id); + const hostDetails = await minglarService.getUserDetails(userInfo.id) + await sendEmailToHostForApprovedApplication(hostDetails.emailAddress) return { statusCode: 200, diff --git a/src/modules/minglaradmin/handlers/rejectHostApplication.ts b/src/modules/minglaradmin/handlers/rejectHostApplication.ts index 075a62e..780a0b6 100644 --- a/src/modules/minglaradmin/handlers/rejectHostApplication.ts +++ b/src/modules/minglaradmin/handlers/rejectHostApplication.ts @@ -4,6 +4,7 @@ import { PrismaService } from '../../../common/database/prisma.service'; import { safeHandler } from '../../../common/utils/handlers/safeHandler'; import ApiError from '../../../common/utils/helper/ApiError'; import { MinglarService } from '../services/minglar.service'; +import { sendEmailToHostForRejectedApplication } from '../services/rejectionMailtoHost.service'; const prismaService = new PrismaService(); const minglarService = new MinglarService(prismaService); @@ -46,6 +47,8 @@ export const handler = safeHandler(async ( // Add suggestion using service await minglarService.rejectHostApplication(hostXid, userInfo.id); + const hostDetails = await minglarService.getUserDetails(userInfo.id) + await sendEmailToHostForRejectedApplication(hostDetails.emailAddress) return { statusCode: 200, diff --git a/src/modules/minglaradmin/handlers/rejectHostApplicationAM.ts b/src/modules/minglaradmin/handlers/rejectHostApplicationAM.ts new file mode 100644 index 0000000..9f02400 --- /dev/null +++ b/src/modules/minglaradmin/handlers/rejectHostApplicationAM.ts @@ -0,0 +1,65 @@ +import { verifyMinglarAdminToken } from '@/common/middlewares/jwt/authForMinglarAdmin'; +import { APIGatewayProxyEvent, APIGatewayProxyResult, Context } from 'aws-lambda'; +import { PrismaService } from '../../../common/database/prisma.service'; +import { safeHandler } from '../../../common/utils/handlers/safeHandler'; +import ApiError from '../../../common/utils/helper/ApiError'; +import { MinglarService } from '../services/minglar.service'; +import { sendAMRejectionMailtoHost } from '../services/rejectionMailtoHost.service'; + +const prismaService = new PrismaService(); +const minglarService = new MinglarService(prismaService); + +interface AddSuggestionBody { + hostXid: number; + title: string; + comments: string; +} + +/** + * Add suggestion handler for host applications + * Allows Minglar Admin, Co_Admin, and Account Manager to add suggestions + * Types: Setup Profile, Review Account, Add Payment Details, Agreement + */ +export const handler = safeHandler(async ( + event: APIGatewayProxyEvent, + context?: Context +): Promise => { + // Verify authentication token + const token = event.headers['x-auth-token'] || event.headers['X-Auth-Token']; + if (!token) { + throw new ApiError(401, 'This is a protected route. Please provide a valid token.'); + } + + // Verify token and get user info + const userInfo = await verifyMinglarAdminToken(token); + + // Parse request body + let body: AddSuggestionBody; + + try { + body = event.body ? JSON.parse(event.body) : {}; + } catch (error) { + throw new ApiError(400, 'Invalid JSON in request body'); + } + + const { hostXid } = body; + + + // Add suggestion using service + await minglarService.rejectHostApplicationAM(hostXid, userInfo.id); + const hostDetails = await minglarService.getUserDetails(userInfo.id) + await sendAMRejectionMailtoHost(hostDetails.emailAddress) + + return { + statusCode: 200, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + body: JSON.stringify({ + success: true, + message: 'Application rejected successfully', + data: null, + }), + }; +}); diff --git a/src/modules/minglaradmin/handlers/updateProfile.ts b/src/modules/minglaradmin/handlers/updateProfile.ts index 4708dca..d9d33eb 100644 --- a/src/modules/minglaradmin/handlers/updateProfile.ts +++ b/src/modules/minglaradmin/handlers/updateProfile.ts @@ -1,13 +1,13 @@ -import { APIGatewayProxyEvent, APIGatewayProxyResult, Context } from 'aws-lambda'; -import { safeHandler } from '../../../common/utils/handlers/safeHandler'; -import { PrismaService } from '../../../common/database/prisma.service'; -import { MinglarService } from '../services/minglar.service'; -import ApiError from '../../../common/utils/helper/ApiError'; -import { verifyMinglarAdminToken } from '../../../common/middlewares/jwt/authForMinglarAdmin'; -import { parseMultipartFormData, parseJsonField } from '../../../common/utils/helper/parseMultipartFormData'; -import AWS from 'aws-sdk'; -import crypto from 'crypto'; +// modules/minglar/handlers/updateProfile.ts import config from '@/config/config'; +import { APIGatewayProxyEvent, APIGatewayProxyResult, Context } from 'aws-lambda'; +import AWS from 'aws-sdk'; +import { PrismaService } from '../../../common/database/prisma.service'; +import { verifyMinglarAdminToken } from '../../../common/middlewares/jwt/authForMinglarAdmin'; +import { safeHandler } from '../../../common/utils/handlers/safeHandler'; +import ApiError from '../../../common/utils/helper/ApiError'; +import { parseJsonField, parseMultipartFormData } from '../../../common/utils/helper/parseMultipartFormData'; +import { MinglarService } from '../services/minglar.service'; const prismaService = new PrismaService(); const minglarService = new MinglarService(prismaService); @@ -16,136 +16,184 @@ const s3 = new AWS.S3({ region: config.aws.region, }); +// Define uploadToS3 function with proper folder structure and file replacement +async function uploadToS3(buffer: Buffer, mimeType: string, originalName: string, folderType: 'profile' | 'documents', userId: number, documentType?: string) { + let s3Key: string; + + // Sanitize file name: remove special characters and spaces + const sanitizeFileName = (name: string) => { + return name + .toLowerCase() + .replace(/[^a-z0-9.]/g, '_') // Replace special characters with underscore + .replace(/_+/g, '_') // Replace multiple underscores with single + .replace(/^_+|_+$/g, ''); // Remove leading/trailing underscores + }; + + // Get file extension from original file name + const fileExtension = originalName.split('.').pop() || 'jpg'; + + // Determine folder structure based on type + if (folderType === 'profile') { + // Profile Images: MinglarAdmin/ProfileImages/{UserID}/profile_image.{extension} + const fileName = `profile_image.${fileExtension}`; + const sanitizedFileName = sanitizeFileName(fileName); + s3Key = `MinglarAdmin/ProfileImages/${userId}/${sanitizedFileName}`; + } else if (folderType === 'documents' && documentType) { + // Documents: MinglarAdmin/Documents/{UserID}/{documentType}.{extension} + const fileName = `${documentType}.${fileExtension}`; + const sanitizedFileName = sanitizeFileName(fileName); + s3Key = `MinglarAdmin/Documents/${userId}/${sanitizedFileName}`; + } else { + throw new ApiError(400, 'Invalid folder type or missing documentType'); + } + + // Upload new file (S3 will automatically replace if same key exists) + await s3 + .upload({ + Bucket: config.aws.bucketName, + Key: s3Key, + Body: buffer, + ContentType: mimeType, + ACL: 'private', + }) + .promise(); + + console.log(`File uploaded successfully: ${s3Key}`); + return `https://${config.aws.bucketName}.s3.${config.aws.region}.amazonaws.com/${s3Key}`; +} + export const handler = safeHandler(async ( event: APIGatewayProxyEvent, context?: Context ): Promise => { - // Extract token from headers - const token = event.headers['x-auth-token'] || event.headers['X-Auth-Token']; - if (!token) { - throw new ApiError(400, 'This is a protected route. Please provide a valid token.'); - } + try { + // Extract token from headers + const token = event.headers['x-auth-token'] || event.headers['X-Auth-Token']; + if (!token) { + throw new ApiError(400, 'This is a protected route. Please provide a valid token.'); + } - // Verify token and get user info - const userInfo = await verifyMinglarAdminToken(token); - const userId = Number(userInfo.id); + // Verify token and get user info + const userInfo = await verifyMinglarAdminToken(token); + const userId = Number(userInfo.id); - if (!userId || isNaN(userId)) { - throw new ApiError(400, 'Invalid user ID'); - } + if (!userId || isNaN(userId)) { + throw new ApiError(400, 'Invalid user ID'); + } - // Parse multipart form data - const contentType = event.headers['Content-Type'] || event.headers['content-type']; - const isBase64Encoded = event.isBase64Encoded || false; - - const { fields, files } = parseMultipartFormData( - event.body, - contentType, - isBase64Encoded - ); - - // Parse JSON fields - const userData = parseJsonField(fields, 'userData') || {}; - const addressData = parseJsonField(fields, 'addressData') || {}; - - // Extract user fields - const { firstName, lastName, mobileNumber, dateOfBirth, profileImage } = userData; - - // Extract address fields - const { address1, address2, stateXid, countryXid, cityXid, pinCode } = addressData; - - // Handle file uploads (profileImage, aadharCard, panCard) - const uploadedFiles: Array<{ fileName: string; filePath: string; documentType?: string }> = []; - let profileImagePath: string | undefined = profileImage; - - // Upload profile image if provided as file - const profileImageFile = files.find(f => f.fieldName === 'profileImage'); - if (profileImageFile) { - const uniqueKey = `${userId}_${crypto.randomUUID()}_${profileImageFile.fileName}`; - const s3Key = `MinglarAdmin/ProfileImages/${uniqueKey}`; + // Parse multipart form data + const contentType = event.headers['Content-Type'] || event.headers['content-type']; + const isBase64Encoded = event.isBase64Encoded || false; - await s3.upload({ - Bucket: config.aws.bucketName, - Key: s3Key, - Body: profileImageFile.data, - ContentType: profileImageFile.contentType, - ACL: 'private', - }).promise(); + const { fields, files } = parseMultipartFormData( + event.body, + contentType, + isBase64Encoded + ); - profileImagePath = `https://${config.aws.bucketName}.s3.${config.aws.region}.amazonaws.com/${s3Key}`; + // Parse JSON fields + const userData = parseJsonField(fields, 'userData') || {}; + const addressData = parseJsonField(fields, 'addressData') || {}; + + // Extract user fields + const { firstName, lastName, mobileNumber, dateOfBirth, profileImage } = userData; + + // Extract address fields + const { address1, address2, stateXid, countryXid, cityXid, pinCode } = addressData; + + // Handle file uploads with proper folder structure and replacement + const uploadedFiles: Array<{ fileName: string; filePath: string; documentType?: string }> = []; + let profileImagePath: string | undefined = profileImage; + + // Upload profile image if provided as file + const profileImageFile = files.find(f => f.fieldName === 'profileImage'); + if (profileImageFile) { + profileImagePath = await uploadToS3( + profileImageFile.data, + profileImageFile.contentType, + profileImageFile.fileName, + 'profile', + userId + ); + console.log('Profile image uploaded:', profileImagePath); + } + + // Upload documents (aadharCard, panCard) with proper naming and replacement + const aadharFile = files.find(f => f.fieldName === 'aadharCard'); + const panFile = files.find(f => f.fieldName === 'panCard'); + + if (aadharFile) { + const filePath = await uploadToS3( + aadharFile.data, + aadharFile.contentType, + aadharFile.fileName, + 'documents', + userId, + 'aadhar' + ); + uploadedFiles.push({ + fileName: aadharFile.fileName, + filePath, + documentType: 'aadhar' + }); + console.log('Aadhar document uploaded:', filePath); + } + + if (panFile) { + const filePath = await uploadToS3( + panFile.data, + panFile.contentType, + panFile.fileName, + 'documents', + userId, + 'pan' + ); + uploadedFiles.push({ + fileName: panFile.fileName, + filePath, + documentType: 'pan' + }); + console.log('PAN document uploaded:', filePath); + } + + // Update profile using service + const result = await minglarService.updateProfile( + userId, + { + firstName, + lastName, + mobileNumber, + dateOfBirth, + profileImage: profileImagePath, + }, + { + address1, + address2, + stateXid, + countryXid, + cityXid, + pinCode, + }, + uploadedFiles.filter(f => f.documentType).map(f => ({ + fileName: f.fileName, + filePath: f.filePath, + })) + ); + + return { + statusCode: 200, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + body: JSON.stringify({ + success: true, + message: 'Profile updated successfully', + data: result, + }), + }; + } catch (error: any) { + console.error('❌ Error in updateProfile:', error); + throw error; } - - // Upload documents (aadharCard, panCard) - const aadharFile = files.find(f => f.fieldName === 'aadharCard'); - const panFile = files.find(f => f.fieldName === 'panCard'); - - if (aadharFile) { - const uniqueKey = `${userId}_${crypto.randomUUID()}_${aadharFile.fileName}`; - const s3Key = `MinglarAdmin/Documents/${uniqueKey}`; - - await s3.upload({ - Bucket: config.aws.bucketName, - Key: s3Key, - Body: aadharFile.data, - ContentType: aadharFile.contentType, - ACL: 'private', - }).promise(); - - const filePath = `https://${config.aws.bucketName}.s3.${config.aws.region}.amazonaws.com/${s3Key}`; - uploadedFiles.push({ fileName: aadharFile.fileName, filePath, documentType: 'aadhar' }); - } - - if (panFile) { - const uniqueKey = `${userId}_${crypto.randomUUID()}_${panFile.fileName}`; - const s3Key = `MinglarAdmin/${userId}/documents/pan_${uniqueKey}`; - - await s3.upload({ - Bucket: config.aws.bucketName, - Key: s3Key, - Body: panFile.data, - ContentType: panFile.contentType, - ACL: 'private', - }).promise(); - - const filePath = `https://${config.aws.bucketName}.s3.${config.aws.region}.amazonaws.com/${s3Key}`; - uploadedFiles.push({ fileName: panFile.fileName, filePath, documentType: 'pan' }); - } - - // Update profile using service - const result = await minglarService.updateProfile( - userId, - { - firstName, - lastName, - mobileNumber, - dateOfBirth, - profileImage: profileImagePath, - }, - { - address1, - address2, - stateXid, - countryXid, - cityXid, - pinCode, - }, - uploadedFiles.filter(f => f.documentType).map(f => ({ - fileName: f.fileName, - filePath: f.filePath, - })) - ); - - return { - statusCode: 200, - headers: { - 'Content-Type': 'application/json', - 'Access-Control-Allow-Origin': '*', - }, - body: JSON.stringify({ - success: true, - message: 'Profile updated successfully', - data: result, - }), - }; -}); - +}); \ No newline at end of file diff --git a/src/modules/minglaradmin/services/approvalMailtoHost.service.ts b/src/modules/minglaradmin/services/approvalMailtoHost.service.ts new file mode 100644 index 0000000..f39fbf4 --- /dev/null +++ b/src/modules/minglaradmin/services/approvalMailtoHost.service.ts @@ -0,0 +1,72 @@ +import { brevoService } from "@/common/email/brevoApi"; +import ApiError from "@/common/utils/helper/ApiError"; + +export async function sendEmailToHostForApprovedApplication( + emailAddress: string, +): Promise<{ + sent: boolean; + // messageId: string +}> { + + const subject = "Approval for your application"; + + const htmlContent = ` +

Dear Host,

+

Congratulations, Your application to minglar admin has been approved.

+

You can start onboarding your activities through the host panel.

+

Best regards,
Minglar Team

+ `; + + try { + const result = await brevoService.sendEmail({ + recipients: [{ email: emailAddress }], + subject, + htmlContent, + }); + + console.log("📧 Email sent successfully:", result); + + return { + sent: true, + // messageId: result.messageId + }; + } catch (err) { + console.error("Brevo email send failed:", err); + throw new ApiError(500, "Failed to send OTP to minglar admin via email."); + } +} + +export async function sendEmailToHostForMinglarApproval( + emailAddress: string, +): Promise<{ + sent: boolean; + // messageId: string +}> { + + const subject = "Approval for your application"; + + const htmlContent = ` +

Dear Host,

+

Congratulations, Your application to minglar admin has been approved by minglar admin.

+

Minglar admin will assign account manager to your application.

+

Best regards,
Minglar Team

+ `; + + try { + const result = await brevoService.sendEmail({ + recipients: [{ email: emailAddress }], + subject, + htmlContent, + }); + + console.log("📧 Email sent successfully:", result); + + return { + sent: true, + // messageId: result.messageId + }; + } catch (err) { + console.error("Brevo email send failed:", err); + throw new ApiError(500, "Failed to send OTP to minglar admin via email."); + } +} diff --git a/src/modules/minglaradmin/services/minglar.service.ts b/src/modules/minglaradmin/services/minglar.service.ts index 71a0d99..f5fcdfd 100644 --- a/src/modules/minglaradmin/services/minglar.service.ts +++ b/src/modules/minglaradmin/services/minglar.service.ts @@ -1,5 +1,5 @@ import { ROLE, USER_STATUS } from '@/common/utils/constants/common.constant'; -import { HOST_STATUS_DISPLAY, HOST_STATUS_INTERNAL } from '@/common/utils/constants/host.constant'; +import { HOST_STATUS_DISPLAY, HOST_STATUS_INTERNAL, STEPPER } from '@/common/utils/constants/host.constant'; import { MINGLAR_INVITATION_STATUS, MINGLAR_STATUS_DISPLAY, MINGLAR_STATUS_INTERNAL } from '@/common/utils/constants/minglar.constant'; import { Injectable } from '@nestjs/common'; import { User } from '@prisma/client'; @@ -65,7 +65,7 @@ export class MinglarService { } async getAllHosts() { - return this.prisma.user.findMany({ where: { roleXid: 3 } }); + return this.prisma.user.findMany({ where: { roleXid: ROLE.HOST } }); } async updateHost(id: number, data: UpdateMinglarDto) { @@ -83,6 +83,12 @@ export class MinglarService { return this.prisma.user.findUnique({ where: { emailAddress: email } }); } + async getUserDetails(id: number) { + return await this.prisma.user.findUnique({ + where: { id: id } + }) + } + async verifyHostOtp(email: string, otp: string): Promise { const user = await this.prisma.user.findUnique({ where: { emailAddress: email }, @@ -269,154 +275,186 @@ export class MinglarService { }, documents: Array<{ fileName: string; filePath: string }> ) { - return await this.prisma.$transaction(async (tx) => { - // 1. Update User table - const userUpdateData: any = {}; - if (userData.firstName !== undefined) userUpdateData.firstName = userData.firstName; - if (userData.lastName !== undefined) userUpdateData.lastName = userData.lastName; - if (userData.mobileNumber !== undefined) userUpdateData.mobileNumber = userData.mobileNumber; - if (userData.dateOfBirth !== undefined) userUpdateData.dateOfBirth = new Date(userData.dateOfBirth); - if (userData.profileImage !== undefined) userUpdateData.profileImage = userData.profileImage; + try { + return await this.prisma.$transaction(async (tx) => { + console.log('Starting transaction for user:', userId); - if (Object.keys(userUpdateData).length > 0) { - await tx.user.update({ - where: { id: userId }, - data: userUpdateData, - }); - } + // 1. Update User table (optimized) + const userUpdateData: any = {}; + const userFields = ['firstName', 'lastName', 'mobileNumber', 'dateOfBirth', 'profileImage']; - // 2. Update or create UserAddressDetails - if (Object.keys(addressData).length > 0) { - const existingAddress = await tx.userAddressDetails.findFirst({ - where: { userXid: userId, isActive: true }, - }); - - const addressUpdateData: any = {}; - if (addressData.address1 !== undefined) addressUpdateData.address1 = addressData.address1; - if (addressData.address2 !== undefined) addressUpdateData.address2 = addressData.address2; - if (addressData.stateXid !== undefined) addressUpdateData.stateXid = addressData.stateXid; - if (addressData.countryXid !== undefined) addressUpdateData.countryXid = addressData.countryXid; - if (addressData.cityXid !== undefined) addressUpdateData.cityXid = addressData.cityXid; - if (addressData.pinCode !== undefined) addressUpdateData.pinCode = addressData.pinCode; - - if (existingAddress) { - await tx.userAddressDetails.update({ - where: { id: existingAddress.id }, - data: addressUpdateData, - }); - } else { - if (!addressData.address1 || !addressData.stateXid || !addressData.countryXid || !addressData.cityXid || !addressData.pinCode) { - throw new ApiError(400, 'All address fields are required for new address'); + userFields.forEach(field => { + if (userData[field as keyof typeof userData] !== undefined) { + if (field === 'dateOfBirth' && userData.dateOfBirth) { + userUpdateData[field] = new Date(userData.dateOfBirth); + } else { + userUpdateData[field] = userData[field as keyof typeof userData]; + } } - await tx.userAddressDetails.create({ - data: { - userXid: userId, - ...addressUpdateData, - }, + }); + + if (Object.keys(userUpdateData).length > 0) { + console.log('Updating user data:', userUpdateData); + await tx.user.update({ + where: { id: userId }, + data: userUpdateData, }); } - } - // 3. Update or create UserDocuments (store S3 URL in fileName field) - if (documents && documents.length > 0) { - const existingDocs = await tx.userDocuments.findMany({ - where: { userXid: userId, isActive: true }, - orderBy: { createdAt: 'asc' }, - }); + // 2. Update or create UserAddressDetails + if (Object.keys(addressData).length > 0) { + console.log('Processing address data:', addressData); - // Update existing documents or create new ones - for (let i = 0; i < documents.length; i++) { - const doc = documents[i]; - if (existingDocs[i]) { - // Update existing document - await tx.userDocuments.update({ - where: { id: existingDocs[i].id }, - data: { fileName: doc.filePath }, // Store S3 URL in fileName + const existingAddress = await tx.userAddressDetails.findFirst({ + where: { userXid: userId, isActive: true }, + select: { id: true } // Only select needed field + }); + + const addressUpdateData: any = {}; + const addressFields = ['address1', 'address2', 'stateXid', 'countryXid', 'cityXid', 'pinCode']; + + addressFields.forEach(field => { + if (addressData[field as keyof typeof addressData] !== undefined) { + addressUpdateData[field] = addressData[field as keyof typeof addressData]; + } + }); + + if (existingAddress) { + await tx.userAddressDetails.update({ + where: { id: existingAddress.id }, + data: addressUpdateData, }); } else { - // Create new document - await tx.userDocuments.create({ + // Validate required fields + const requiredFields = ['address1', 'stateXid', 'countryXid', 'cityXid', 'pinCode']; + const missingFields = requiredFields.filter(field => !addressData[field as keyof typeof addressData]); + + if (missingFields.length > 0) { + throw new ApiError(400, `Missing required address fields: ${missingFields.join(', ')}`); + } + + await tx.userAddressDetails.create({ data: { userXid: userId, - fileName: doc.filePath, // Store S3 URL in fileName + ...addressUpdateData, }, }); } } - } - // 4. Fetch updated user data to calculate percentage - const updatedUser = await tx.user.findUnique({ - where: { id: userId }, - include: { - userAddressDetails: { - where: { isActive: true }, - take: 1, - }, - userDocuments: { - where: { isActive: true }, - }, - }, - }); + // 3. Handle documents more efficiently + if (documents && documents.length > 0) { + console.log('Processing documents:', documents.length); - if (!updatedUser) { - throw new ApiError(404, 'User not found'); - } + // Use deleteMany and createMany for better performance + await tx.userDocuments.deleteMany({ + where: { userXid: userId, isActive: true }, + }); - // 5. Calculate profile completion percentage - let percentage = 0; - - // Profile Image: 15% - if (updatedUser.profileImage) { - percentage += 15; - } - - // Name and Phone Number: 15% - if (updatedUser.firstName && updatedUser.lastName && updatedUser.mobileNumber) { - percentage += 15; - } - - // Location Info: 25% - if (updatedUser.userAddressDetails && updatedUser.userAddressDetails.length > 0) { - const address = updatedUser.userAddressDetails[0]; - if (address.address1 && address.stateXid && address.countryXid && address.cityXid && address.pinCode) { - percentage += 25; - } - } - - // Documents (Aadhar and PAN): 45% - if (updatedUser.userDocuments && updatedUser.userDocuments.length >= 2) { - percentage += 45; - } else if (updatedUser.userDocuments && updatedUser.userDocuments.length === 1) { - percentage += 22.5; // Half if only one document - } - - const profilePercentage = Math.min(percentage, 100) - if (profilePercentage > 80) { - await this.prisma.user.update({ - where: { - id: userId - }, - data: { - isProfileUpdated: true + if (documents.length > 0) { + await tx.userDocuments.createMany({ + data: documents.map(doc => ({ + userXid: userId, + fileName: doc.filePath, + isActive: true, + })), + }); } - }) - } + } - return { - user: { - id: updatedUser.id, - firstName: updatedUser.firstName, - lastName: updatedUser.lastName, - mobileNumber: updatedUser.mobileNumber, - dateOfBirth: updatedUser.dateOfBirth, - profileImage: updatedUser.profileImage, - }, - address: updatedUser.userAddressDetails[0] || null, - documents: updatedUser.userDocuments, - profileCompletionPercentage: Math.min(percentage, 100), - }; - }); + // 4. Fetch updated user data efficiently + const updatedUser = await tx.user.findUnique({ + where: { id: userId }, + select: { + id: true, + firstName: true, + lastName: true, + mobileNumber: true, + dateOfBirth: true, + profileImage: true, + userAddressDetails: { + where: { isActive: true }, + take: 1, + select: { + id: true, + address1: true, + address2: true, + stateXid: true, + countryXid: true, + cityXid: true, + pinCode: true, + } + }, + userDocuments: { + where: { isActive: true }, + select: { + id: true, + fileName: true, + } + }, + }, + }); + + if (!updatedUser) { + throw new ApiError(404, 'User not found after update'); + } + + // 5. Calculate profile completion percentage + let percentage = 0; + + // Profile Image: 15% + if (updatedUser.profileImage) percentage += 15; + + // Name and Phone Number: 15% + if (updatedUser.firstName && updatedUser.lastName && updatedUser.mobileNumber) { + percentage += 15; + } + + // Location Info: 25% + if (updatedUser.userAddressDetails.length > 0) { + const address = updatedUser.userAddressDetails[0]; + if (address.address1 && address.stateXid && address.countryXid && address.cityXid && address.pinCode) { + percentage += 25; + } + } + + // Documents: 45% + if (updatedUser.userDocuments.length >= 2) { + percentage += 45; + } else if (updatedUser.userDocuments.length === 1) { + percentage += 22.5; + } + + const profilePercentage = Math.min(percentage, 100); + + // Update profile completion status + if (profilePercentage > 80) { + await tx.user.update({ + where: { id: userId }, + data: { isProfileUpdated: true } + }); + } + + console.log('Transaction completed successfully'); + + return { + user: { + id: updatedUser.id, + firstName: updatedUser.firstName, + lastName: updatedUser.lastName, + mobileNumber: updatedUser.mobileNumber, + dateOfBirth: updatedUser.dateOfBirth, + profileImage: updatedUser.profileImage, + }, + address: updatedUser.userAddressDetails[0] || null, + documents: updatedUser.userDocuments, + profileCompletionPercentage: profilePercentage, + }; + }); + } catch (error) { + console.error('Error in updateProfile transaction:', error); + throw error; + } } async getAllInvitationDetails() { @@ -704,11 +742,31 @@ export class MinglarService { adminStatusDisplay: MINGLAR_STATUS_DISPLAY.TO_REVIEW }, data: { - isApproved: true, hostStatusInternal: HOST_STATUS_INTERNAL.APPROVED, hostStatusDisplay: HOST_STATUS_DISPLAY.APPROVED, adminStatusInternal: MINGLAR_STATUS_INTERNAL.AM_APPROVED, - adminStatusDisplay: MINGLAR_STATUS_DISPLAY.APPROVED + adminStatusDisplay: MINGLAR_STATUS_DISPLAY.APPROVED, + stepper: STEPPER.COMPANY_DETAILS_APPROVED + } + }) + } + + + async acceptHostApplicationMinglarAdmin(host_xid: number, user_xid: number) { + return await this.prisma.hostHeader.update({ + where: { + id: host_xid, + hostStatusInternal: HOST_STATUS_INTERNAL.HOST_SUBMITTED, + hostStatusDisplay: HOST_STATUS_DISPLAY.UNDER_REVIEW, + adminStatusInternal: MINGLAR_STATUS_INTERNAL.ADMIN_TO_REVIEW, + adminStatusDisplay: MINGLAR_STATUS_DISPLAY.NEW + }, + data: { + isApproved: true, + hostStatusInternal: HOST_STATUS_INTERNAL.HOST_SUBMITTED, + hostStatusDisplay: HOST_STATUS_DISPLAY.UNDER_REVIEW, + adminStatusInternal: MINGLAR_STATUS_INTERNAL.AM_NOT_ASSIGNED, + adminStatusDisplay: MINGLAR_STATUS_DISPLAY.AM_NOT_ASSIGNED, } }) } @@ -747,5 +805,30 @@ export class MinglarService { } + async rejectHostApplicationAM(host_xid: number, user_xid: number) { + const hostDetails = await this.prisma.hostHeader.findFirst({ + where: { id: host_xid }, + select: { id: true, userXid: true } + }) + if (!hostDetails) { + throw new Error("Host not found"); + } + await this.prisma.hostHeader.update({ + where: { + id: host_xid, + hostStatusInternal: HOST_STATUS_INTERNAL.HOST_SUBMITTED, + hostStatusDisplay: HOST_STATUS_DISPLAY.UNDER_REVIEW + }, + data: { + hostStatusInternal: HOST_STATUS_INTERNAL.REJECTED, + hostStatusDisplay: HOST_STATUS_DISPLAY.REJECTED, + adminStatusInternal: MINGLAR_STATUS_INTERNAL.ADMIN_REJECTED, + adminStatusDisplay: MINGLAR_STATUS_DISPLAY.REJECTED, + + } + }) + } + + } diff --git a/src/modules/minglaradmin/services/rejectionMailtoHost.service.ts b/src/modules/minglaradmin/services/rejectionMailtoHost.service.ts new file mode 100644 index 0000000..48b6e2d --- /dev/null +++ b/src/modules/minglaradmin/services/rejectionMailtoHost.service.ts @@ -0,0 +1,73 @@ +import { brevoService } from "@/common/email/brevoApi"; +import ApiError from "@/common/utils/helper/ApiError"; + +export async function sendEmailToHostForRejectedApplication( + emailAddress: string, +): Promise<{ + sent: boolean; + // messageId: string +}> { + + const subject = "Rejection for your application"; + + const htmlContent = ` +

Dear Host,

+

Sorry to say that, But your application to minglar admin has been rejected.

+

If you have any questions please contact to minglar admin.

+

Best regards,
Minglar Team

+ `; + + try { + const result = await brevoService.sendEmail({ + recipients: [{ email: emailAddress }], + subject, + htmlContent, + }); + + console.log("📧 Email sent successfully:", result); + + return { + sent: true, + // messageId: result.messageId + }; + } catch (err) { + console.error("Brevo email send failed:", err); + throw new ApiError(500, "Failed to send OTP to minglar admin via email."); + } +} + +export async function sendAMRejectionMailtoHost( + emailAddress: string, +): Promise<{ + sent: boolean; + // messageId: string +}> { + + const subject = "Improvement of your application"; + + const htmlContent = ` +

Dear Host,

+

Your account manager has made some suggestions on your application.
+ Please improve it and re-submit the application to onboard on minglar.

+

If you have any questions please contact to minglar admin.

+

Best regards,
Minglar Team

+ `; + + try { + const result = await brevoService.sendEmail({ + recipients: [{ email: emailAddress }], + subject, + htmlContent, + }); + + console.log("📧 Email sent successfully:", result); + + return { + sent: true, + // messageId: result.messageId + }; + } catch (err) { + console.error("Brevo email send failed:", err); + throw new ApiError(500, "Failed to send OTP to minglar admin via email."); + } +}