593 lines
18 KiB
TypeScript
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;
|
|
}
|
|
});
|