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 925be84..3bfaa26 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: @@ -301,6 +303,7 @@ functions: updateMinglarProfile: handler: src/modules/minglaradmin/handlers/updateProfile.handler + timeout: 30 package: patterns: - 'src/modules/host/handlers/updateProfile.*' @@ -313,7 +316,6 @@ functions: - 'node_modules/@smithy/**' - 'node_modules/tslib/**' - 'node_modules/fast-xml-parser/**' - events: - httpApi: @@ -425,6 +427,21 @@ functions: 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 package: @@ -500,6 +517,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: @@ -515,6 +547,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: @@ -529,6 +576,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..1999f67 100644 --- a/src/modules/host/handlers/addCompanyDetails.ts +++ b/src/modules/host/handlers/addCompanyDetails.ts @@ -3,18 +3,18 @@ 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'; const prisma = new PrismaService(); const hostService = new HostService(prisma); @@ -50,16 +50,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 +82,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 +148,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 +246,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 +268,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 +280,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 +290,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.fieldName, // 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 +341,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.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 +382,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 287d751..bf54fa3 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 } }) } @@ -480,6 +482,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..87c1bd9 --- /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 : ${hostCompanyName}`; + + 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 : ${hostCompanyName}`; + + 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/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 7452df0..751ca8a 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'; @@ -66,7 +66,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) { @@ -84,6 +84,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 }, @@ -270,154 +276,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() { @@ -530,58 +568,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) { @@ -731,11 +769,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, } }) } @@ -774,5 +832,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."); + } +} 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 4e8c2b4..4ad1532 100644 --- a/src/modules/prepopulate/services/prepopulate.service.ts +++ b/src/modules/prepopulate/services/prepopulate.service.ts @@ -58,6 +58,27 @@ 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 }; + } + async getAllFrequencies() { return await this.prisma.frequencies.findMany({ where: { @@ -66,7 +87,7 @@ export class PrePopulateService { }, select: { id: true, - frequencyName:true + frequencyName: true, }, }); }