This commit is contained in:
paritosh18
2025-11-22 19:36:32 +05:30
18 changed files with 1189 additions and 382 deletions

24
package-lock.json generated
View File

@@ -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": {

View File

@@ -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"
},

View File

@@ -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:

View File

@@ -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)), {

View File

@@ -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,

View File

@@ -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<string, any> = {};
const files: Array<{ buffer: Buffer; mimeType: string; fileName: string; fieldName: string }> = [];
// 3) parse with Busboy
// 3) parse with Busboy - FIXED VERSION
await new Promise<void>((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;
}
});
});

View File

@@ -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({

View File

@@ -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 = `
<p>Dear ${amName},</p>
<p>Host ${hostCompanyName} with reference number: <strong>${hostRefNumber}</strong> has re-submited the application with implimented suggestions.</p>
<p>Please review their appliaction and take the necessary action.</p>
<p>Best regards,<br/>Minglar Team</p>
`;
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 = `
<p>Dear ${minglarAdminName},</p>
<p>Host ${hostCompanyName} with reference number: <strong>${hostRefNumber}</strong> has submited their application.</p>
<p>Please review their appliaction and take the necessary action.</p>
<p>Best regards,<br/>Minglar Team</p>
`;
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.");
}
}

View File

@@ -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<APIGatewayProxyResult> => {
// 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,
}),
};
});

View File

@@ -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,

View File

@@ -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,

View File

@@ -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<APIGatewayProxyResult> => {
// 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,
}),
};
});

View File

@@ -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<APIGatewayProxyResult> => {
// 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,
}),
};
});
});

View File

@@ -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 = `
<p>Dear Host,</p>
<p>Congratulations, Your application to minglar admin has been approved.</p>
<p>You can start onboarding your activities through the host panel.</p>
<p>Best regards,<br/>Minglar Team</p>
`;
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 = `
<p>Dear Host,</p>
<p>Congratulations, Your application to minglar admin has been approved by minglar admin.</p>
<p>Minglar admin will assign account manager to your application.</p>
<p>Best regards,<br/>Minglar Team</p>
`;
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.");
}
}

View File

@@ -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<boolean> {
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<number, number> = {};
groupedHosts.forEach((g) => {
const uid = Number(g.accountManagerXid);
hostCountMap[uid] = g._count.id;
});
// 3. Build quick lookup map: userId -> hostCount
const hostCountMap: Record<number, number> = {};
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,
}
})
}
}

View File

@@ -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 = `
<p>Dear Host,</p>
<p>Sorry to say that, But your application to minglar admin has been rejected.</p>
<p>If you have any questions please contact to minglar admin.</p>
<p>Best regards,<br/>Minglar Team</p>
`;
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 = `
<p>Dear Host,</p>
<p>Your account manager has made some suggestions on your application.<br/>
Please improve it and re-submit the application to onboard on minglar.</p>
<p>If you have any questions please contact to minglar admin.</p>
<p>Best regards,<br/>Minglar Team</p>
`;
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.");
}
}

View File

@@ -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<APIGatewayProxyResult> => {
// 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,
}),
};
});

View File

@@ -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,
},
});
}