Files
MinglarBackendNestJS/src/modules/host/handlers/Host_Admin/onboarding/submitCompanyDetails.ts

593 lines
18 KiB
TypeScript

// modules/host/handlers/addCompanyDetails.ts
import config from '../../../../../config/config';
import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import AWS from 'aws-sdk';
import Busboy from 'busboy';
import { prismaClient } from '../../../../../common/database/prisma.lambda.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,
} from '../../../../../common/utils/validation/host/hostCompanyDetails.validation';
import { HostService } from '../../../services/host.service';
import { sendEmailToAM, sendEmailToMinglarAdmin } from '../../../services/sendHostResubmitEmailToAM.service';
const hostService = new HostService(prismaClient);
function getExtensionFromMime(mimeType: string) {
const map: Record<string, string> = {
'image/jpeg': 'jpg',
'image/png': 'png',
'application/pdf': 'pdf',
'image/webp': 'webp',
};
return map[mimeType] || 'bin';
}
const s3 = new AWS.S3({
region: config.aws.region,
});
function normalizeJsonField(fields: any, key: string) {
if (!fields[key]) return undefined;
const val = fields[key];
if (typeof val === 'object') return val;
if (typeof val === 'string') {
try {
return JSON.parse(val);
} catch (err) {
throw new ApiError(400, `Invalid JSON in field: ${key}`);
}
}
throw new ApiError(400, `Invalid input: ${key} must be object or JSON string.`);
}
function cleanEmptyStrings(obj: any) {
if (!obj || typeof obj !== 'object') return obj;
const cleaned: any = {};
for (const key of Object.keys(obj)) {
if (obj[key] === '') cleaned[key] = undefined;
else if (typeof obj[key] === 'object') cleaned[key] = cleanEmptyStrings(obj[key]);
else cleaned[key] = obj[key];
}
return cleaned;
}
function getS3KeyFromUrl(url: string): string {
const base = `https://${config.aws.bucketName}.s3.${config.aws.region}.amazonaws.com/`;
return url.replace(base, "");
}
async function deleteFromS3(key: string) {
try {
await s3.deleteObject({
Bucket: config.aws.bucketName,
Key: key
}).promise();
console.log("✅ Deleted from S3:", key);
} catch (err) {
console.error("❌ Failed to delete from S3:", key, err);
}
}
export const handler = safeHandler(async (event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> => {
try {
/** 1) AUTH */
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.');
const userInfo = await verifyHostToken(token);
/** 2) CHECK CONTENT TYPE */
const contentType = event.headers['content-type'] || event.headers['Content-Type'];
if (!contentType?.includes('multipart/form-data')) {
throw new ApiError(400, 'Content-Type must be multipart/form-data.');
}
/** 3) HANDLE BODY */
const bodyBuffer = event.isBase64Encoded
? Buffer.from(event.body as string, 'base64')
: Buffer.from(event.body as string, 'binary');
const fields: Record<string, any> = {};
const files: Array<{ buffer: Buffer; mimeType: string; fileName: string; fieldName: string }> = [];
await new Promise<void>((resolve, reject) => {
const bb = Busboy({ headers: { 'content-type': contentType } });
bb.on('file', (fieldname, file, info) => {
const { filename, mimeType } = info;
const chunks: Buffer[] = [];
let totalSize = 0;
const MAX_SIZE = 5 * 1024 * 1024;
file.on('data', (chunk) => {
totalSize += chunk.length;
if (totalSize > MAX_SIZE) {
file.destroy(new Error(`File ${filename} exceeds 5MB limit.`));
return;
}
chunks.push(chunk);
});
file.on('end', () => {
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) => (fields[fieldname] = val));
bb.on('close', () => resolve());
bb.on('error', (error) => reject(new ApiError(400, `Multipart parsing error: ${error.message}`)));
bb.write(bodyBuffer);
bb.end();
});
const deletedFiles = normalizeJsonField(fields, "deletedFiles") || [];
const parentDeletedFiles = normalizeJsonField(fields, "parentDeletedFiles") || [];
const deleteCompanyLogo =
fields.deleteCompanyLogo === 'true' || fields.deleteCompanyLogo === true;
const deleteParentCompanyLogo =
fields.deleteParentCompanyLogo === 'true' || fields.deleteParentCompanyLogo === true;
/** 4) Extract and clean isDraft flag */
const isDraft = fields.isDraft === 'true' || fields.isDraft === true;
/** 5) PROCESS companyDetails ONCE ONLY (IMPORTANT FIX) */
let companyDetailsRaw = normalizeJsonField(fields, 'companyDetails');
if (!companyDetailsRaw) throw new ApiError(400, 'companyDetails is required.');
if (isDraft) {
companyDetailsRaw = cleanEmptyStrings(companyDetailsRaw);
// IMPORTANT: also clean parent company nested fields
if (companyDetailsRaw.parentCompany) {
companyDetailsRaw.parentCompany = cleanEmptyStrings(companyDetailsRaw.parentCompany);
}
}
if (
companyDetailsRaw.parentCompany &&
Object.values(companyDetailsRaw.parentCompany).every(
(v) => v === undefined || v === null
)
) {
companyDetailsRaw.parentCompany = null;
}
/** 6) Profile update if provided */
if (fields.userProfile) {
const userProfileRaw = normalizeJsonField(fields, 'userProfile');
if (userProfileRaw) {
const firstName =
typeof userProfileRaw.firstName === 'string'
? userProfileRaw.firstName.trim()
: undefined;
const lastName =
typeof userProfileRaw.lastName === 'string'
? userProfileRaw.lastName.trim()
: undefined;
const mobileNumber =
typeof userProfileRaw.mobileNumber === 'string'
? userProfileRaw.mobileNumber.trim()
: undefined;
if (mobileNumber) {
const existingUser = await prismaClient.user.findFirst({
where: {
mobileNumber,
id: {
not: Number(userInfo.id),
},
},
select: {
id: true,
},
});
if (existingUser) {
throw new ApiError(
409,
'Mobile number already exists for another user. Please use a different mobile number.',
);
}
}
await prismaClient.user.update({
where: { id: userInfo.id },
data: {
...(firstName !== undefined && { firstName }),
...(lastName !== undefined && { lastName }),
...(mobileNumber !== undefined && { mobileNumber }),
},
});
}
}
/** 7) VALIDATION - SKIPPED IF DRAFT */
let parsedCompany: any = companyDetailsRaw;
if (!isDraft) {
const validate = hostCompanyDetailsSchema.safeParse(companyDetailsRaw);
if (!validate.success) {
const message = validate.error.issues.map((i) => i.message).join(', ');
throw new ApiError(400, `Validation failed: ${message}`);
}
parsedCompany = validate.data;
}
/** 8) DOCUMENT METADATA */
const documentsMetadataRaw = normalizeJsonField(fields, 'documents');
if (!Array.isArray(documentsMetadataRaw)) throw new ApiError(400, 'documents must be an array.');
if (!isDraft) {
const docsParse = hostDocumentsSchema.safeParse(documentsMetadataRaw);
if (!docsParse.success) {
const message = docsParse.error.issues.map((i) => i.message).join(', ');
throw new ApiError(400, `Documents validation failed: ${message}`);
}
}
const documentsMetadata = documentsMetadataRaw.map((d: any) => ({
...d,
owner: d.owner || 'host',
}));
const documentMetadata = documentsMetadata.map((doc: any) => {
const file = files.find((f) => f.fieldName === doc.fieldName);
// In DRAFT mode → allow missing documents
if (!file) {
return { ...doc, file: null };
}
return { ...doc, file };
});
/** 9) SPLIT host & parent docs */
const hostDocs = documentMetadata.filter((d) => d.owner === 'host');
const parentDocs = documentMetadata.filter((d) => d.owner === 'parent');
/** 10) VALIDATE PARENT COMPANY (ONLY IN FINAL SUBMISSION) */
let parsedParentCompany: any = null;
if (!isDraft && parsedCompany.isSubsidairy) {
if (!parsedCompany.parentCompany) {
throw new ApiError(400, 'isSubsidairy is true but parentCompany object is missing.');
}
const parentCheck = parentCompanySchema.safeParse(parsedCompany.parentCompany);
if (!parentCheck.success) {
const message = parentCheck.error.issues.map((i) => i.message).join(', ');
throw new ApiError(400, `Parent company validation failed: ${message}`);
}
parsedParentCompany = parentCheck.data;
} else {
parsedParentCompany = parsedCompany.parentCompany || null;
}
/** 9.5) DELETE DOCUMENTS IF REQUESTED **/
if (Array.isArray(deletedFiles) && deletedFiles.length > 0) {
console.log(`🗑️ Deleting ${deletedFiles.length} documents...`);
for (const del of deletedFiles) {
const id = Number(del.id);
const url = del.url;
if (!id || !url) {
console.log("❌ Invalid delete entry:", del);
continue;
}
// Extract S3 key
const s3Key = getS3KeyFromUrl(url);
// Delete from S3
await deleteFromS3(s3Key);
// Delete from DB
await prismaClient.hostDocuments.delete({
where: { id }
});
console.log(`🗑️ Deleted host document ID ${id}`);
}
}
/** 9.6) DELETE PARENT DOCUMENTS **/
if (parsedCompany.isSubsidairy && Array.isArray(parentDeletedFiles) && parentDeletedFiles.length > 0) {
console.log(`🗑️ Deleting ${parentDeletedFiles.length} PARENT documents...`);
for (const del of parentDeletedFiles) {
const id = Number(del.id);
const url = del.url;
if (!id || !url) {
console.log("⚠️ Invalid parent delete entry:", del);
continue;
}
const s3Key = getS3KeyFromUrl(url);
// Delete S3
await deleteFromS3(s3Key);
// Delete DB
await prismaClient.hostParenetDocuments.delete({
where: { id }
});
console.log(`🗑️ Deleted PARENT document ID ${id}`);
}
}
/** 11) UPLOAD DOCUMENTS */
async function uploadToS3(buffer, mimeType, originalName, folderType, documentTypeXid?, fieldName?) {
// const ext = originalName.split('.').pop() || 'jpg';
const ext = getExtensionFromMime(mimeType);
let s3Key = '';
if (folderType === 'logo') {
s3Key = `Documents/Host/${userInfo.id}/logo/company_logo.${ext}`;
}
else if (folderType === 'parent_company_logo') {
s3Key = `Documents/Host/${userInfo.id}/parent_company/logo/parent_company_logo.${ext}`;
}
else if (folderType === 'documents') {
s3Key = `Documents/Host/${userInfo.id}/documents/${documentTypeXid}_${fieldName}.${ext}`;
}
else if (folderType === 'parent_company') {
s3Key = `Documents/Host/${userInfo.id}/parent_company/${documentTypeXid}_${fieldName}.${ext}`;
}
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 docs */
const uploadedHostDocs: Array<any> = [];
for (const doc of hostDocs) {
if (!doc.file) continue;
const path = await uploadToS3(
doc.file.buffer,
doc.file.mimeType,
doc.file.fileName,
'documents',
doc.documentTypeXid,
doc.fieldName,
);
uploadedHostDocs.push({
documentTypeXid: doc.documentTypeXid,
documentName: doc.fieldName,
filePath: path,
});
}
/** Upload parent docs */
const uploadedParentDocs: Array<any> = [];
for (const doc of parentDocs) {
if (!doc.file) continue; // skip missing files in draft mode
const path = await uploadToS3(
doc.file.buffer,
doc.file.mimeType,
doc.file.fileName,
'parent_company',
doc.documentTypeXid,
doc.fieldName,
);
uploadedParentDocs.push({
documentTypeXid: doc.documentTypeXid,
documentName: doc.documentName,
filePath: path,
});
}
/** DELETE EXISTING LOGO IF REQUESTED */
if (deleteCompanyLogo) {
const existingHost = await prismaClient.hostHeader.findFirst({
where: { userXid: userInfo.id },
select: { logoPath: true },
});
if (existingHost?.logoPath) {
try {
const s3Key = getS3KeyFromUrl(existingHost.logoPath);
await deleteFromS3(s3Key);
} catch (e) {
console.error('S3 delete failed for company logo:', existingHost.logoPath, e);
}
}
parsedCompany.logoPath = null;
}
/** DELETE EXISTING PARENT COMPANY LOGO IF REQUESTED */
if (deleteParentCompanyLogo && parsedCompany.isSubsidairy) {
const existingHost = await prismaClient.hostHeader.findFirst({
where: { userXid: userInfo.id },
select: {
id: true,
hostParent: {
select: {
id: true,
logoPath: true,
},
take: 1,
},
},
});
const existingParent = Array.isArray(existingHost?.hostParent)
? existingHost.hostParent[0]
: existingHost?.hostParent;
if (existingParent?.logoPath) {
try {
const s3Key = getS3KeyFromUrl(existingParent.logoPath);
await deleteFromS3(s3Key);
} catch (e) {
console.error('S3 delete failed for parent company logo:', existingParent.logoPath, e);
}
}
if (parsedParentCompany) {
parsedParentCompany.logoPath = null;
} else {
parsedParentCompany = {
logoPath: null,
};
}
}
/** UPLOAD LOGO (if provided) */
const logoFile = files.find(
(f) => f.fieldName === 'companyLogo' || f.fieldName === 'companyLogoFile'
);
if (logoFile && logoFile.buffer && logoFile.fileName) {
const logoUrl = await uploadToS3(
logoFile.buffer,
logoFile.mimeType,
logoFile.fileName,
'logo'
);
parsedCompany.logoPath = logoUrl;
}
/** UPLOAD PARENT COMPANY LOGO (if provided) */
const parentLogoFile = files.find(
(f) => f.fieldName === 'parentCompanyLogo'
);
if (parentLogoFile && parentLogoFile.buffer && parentLogoFile.mimeType) {
// 🔒 Only upload when an actual file is present
const parentLogoUrl = await uploadToS3(
parentLogoFile.buffer,
parentLogoFile.mimeType,
parentLogoFile.fileName, // safe here because it's a real file
'parent_company_logo',
);
if (parsedParentCompany) {
parsedParentCompany.logoPath = parentLogoUrl;
} else {
parsedParentCompany = {
logoPath: parentLogoUrl,
};
}
}
if (parsedCompany.cityXid) {
const city = await prismaClient.cities.findUnique({
where: { id: Number(parsedCompany.cityXid) }
});
if (!city) {
throw new ApiError(400, `City with ID ${parsedCompany.cityXid} not found`);
}
}
if (!parsedCompany.isSubsidairy) {
const parentDocuments = await hostService.getParentDocumentsByHostId(userInfo.id);
if (parentDocuments.length > 0) {
for (const doc of parentDocuments) {
try {
const s3Key = getS3KeyFromUrl(doc.filePath);
await deleteFromS3(s3Key);
} catch (e) {
console.error("S3 delete failed:", doc.filePath, e);
}
}
}
await hostService.deleteExistingParentRecords(userInfo.id)
}
/** 12) SAVE / UPDATE HOST ENTRY */
const createdOrUpdated = await hostService.addOrUpdateCompanyDetails(
userInfo.id,
parsedCompany,
uploadedHostDocs,
parsedParentCompany,
uploadedParentDocs,
isDraft,
{ deleteCompanyLogo, deleteParentCompanyLogo },
);
if (!createdOrUpdated) throw new ApiError(400, 'Failed to add/update company details.');
/** 13) SEND EMAIL ONLY IN FINAL SUBMISSION */
if (!isDraft) {
const details = await hostService.getSuggestionDetails(userInfo.id);
if (details.hostDetails.accountManagerXid) {
await sendEmailToAM(
details.hostDetails.accountManager.emailAddress,
details.hostDetails.accountManager.firstName,
details.hostDetails.companyName,
details.hostDetails.user.userRefNumber,
);
} else {
await sendEmailToMinglarAdmin(
config.MinglarAdminEmail,
config.MinglarAdminName,
details.hostDetails.companyName,
details.hostDetails.user.userRefNumber,
);
}
}
/** RESPONSE */
return {
statusCode: 200,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
},
body: JSON.stringify({
success: true,
message: isDraft ? 'Company details saved as draft successfully.' : 'Company details uploaded successfully.',
data: {
id: createdOrUpdated.id,
isDraft,
},
}),
};
} catch (error: any) {
console.error('❌ Error in addCompanyDetails:', error);
throw error;
}
});