diff --git a/CURL_EXAMPLE.md b/CURL_EXAMPLE.md new file mode 100644 index 0000000..2332ae6 --- /dev/null +++ b/CURL_EXAMPLE.md @@ -0,0 +1,115 @@ +# CURL Command for Testing addCompanyDetails Lambda + +## Prerequisites +1. Replace `YOUR_API_URL` with your actual API Gateway URL +2. Replace `YOUR_AUTH_TOKEN` with a valid JWT token +3. Replace file paths with actual document files on your system + +## Form Data Structure + +The endpoint expects: +- `companyDetails`: JSON string containing company information +- `documents`: JSON string containing array of document metadata +- File fields: One file field per document (e.g., `panFile`, `gstFile`, etc.) + +## CURL Command + +```bash +curl -X POST "YOUR_API_URL/minglaradmin/add-company-details" \ + -H "x-auth-token: YOUR_AUTH_TOKEN" \ + -F "companyDetails={\"companyName\":\"Test Company\",\"hostRefNumber\":\"HOST001\",\"address1\":\"123 Main St\",\"address2\":\"Suite 100\",\"cityXid\":1,\"stateXid\":1,\"countryXid\":1,\"pinCode\":\"12345\",\"isSubsidairy\":false,\"registrationNumber\":\"REG123456\",\"panNumber\":\"ABCDE1234F\",\"gstNumber\":\"27ABCDE1234F1Z5\",\"formationDate\":\"2020-01-01\",\"companyType\":\"Private Limited\",\"currencyXid\":1}" \ + -F "documents=[{\"documentTypeXid\":1,\"documentName\":\"pan.pdf\",\"fieldName\":\"panFile\"},{\"documentTypeXid\":2,\"documentName\":\"gst.pdf\",\"fieldName\":\"gstFile\"},{\"documentTypeXid\":3,\"documentName\":\"registration.pdf\",\"fieldName\":\"registrationFile\"},{\"documentTypeXid\":4,\"documentName\":\"aadhaar.pdf\",\"fieldName\":\"aadhaarFile\"}]" \ + -F "panFile=@/path/to/pan.pdf" \ + -F "gstFile=@/path/to/gst.pdf" \ + -F "registrationFile=@/path/to/registration.pdf" \ + -F "aadhaarFile=@/path/to/aadhaar.pdf" +``` + +## Example with Real Values + +```bash +curl -X POST "https://abc123.execute-api.ap-south-1.amazonaws.com/minglaradmin/add-company-details" \ + -H "x-auth-token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." \ + -F "companyDetails={\"companyName\":\"Acme Corp\",\"hostRefNumber\":\"HOST001\",\"address1\":\"123 Business Park\",\"address2\":\"Building A\",\"cityXid\":1,\"stateXid\":1,\"countryXid\":1,\"pinCode\":\"400001\",\"isSubsidairy\":false,\"registrationNumber\":\"U12345MH2020PTC123456\",\"panNumber\":\"ABCDE1234F\",\"gstNumber\":\"27ABCDE1234F1Z5\",\"formationDate\":\"2020-01-15\",\"companyType\":\"Private Limited\",\"websiteUrl\":\"https://acme.com\",\"currencyXid\":1}" \ + -F "documents=[{\"documentTypeXid\":1,\"documentName\":\"pan-certificate.pdf\",\"fieldName\":\"panFile\"},{\"documentTypeXid\":2,\"documentName\":\"gst-certificate.pdf\",\"fieldName\":\"gstFile\"},{\"documentTypeXid\":3,\"documentName\":\"registration-certificate.pdf\",\"fieldName\":\"registrationFile\"},{\"documentTypeXid\":4,\"documentName\":\"aadhaar.pdf\",\"fieldName\":\"aadhaarFile\"}]" \ + -F "panFile=@./documents/pan.pdf" \ + -F "gstFile=@./documents/gst.pdf" \ + -F "registrationFile=@./documents/registration.pdf" \ + -F "aadhaarFile=@./documents/aadhaar.pdf" +``` + +## Postman Setup + +1. **Method**: POST +2. **URL**: `YOUR_API_URL/minglaradmin/add-company-details` +3. **Headers**: + - `x-auth-token`: `YOUR_AUTH_TOKEN` +4. **Body**: Select `form-data` +5. **Add Fields**: + - `companyDetails` (Text): JSON string with company details + - `documents` (Text): JSON array string with document metadata + - `panFile` (File): Select PDF file + - `gstFile` (File): Select PDF file + - `registrationFile` (File): Select PDF file + - `aadhaarFile` (File): Select PDF file + +## Required Document Types + +Based on `REQUIRED_DOC_TYPES`: +- `documentTypeXid: 1` - PAN +- `documentTypeXid: 2` - GST +- `documentTypeXid: 3` - REGISTRATION +- `documentTypeXid: 4` - AADHAAR + +## Company Details JSON Structure + +```json +{ + "companyName": "string (required)", + "hostRefNumber": "string (required)", + "address1": "string (required)", + "address2": "string (optional)", + "cityXid": "number (required)", + "stateXid": "number (required)", + "countryXid": "number (required)", + "pinCode": "string (required, min 4 chars)", + "logoPath": "string (optional)", + "isSubsidairy": "boolean (required)", + "registrationNumber": "string (required)", + "panNumber": "string (required)", + "gstNumber": "string (optional)", + "formationDate": "string (required, ISO date)", + "companyType": "string (required)", + "websiteUrl": "string (optional, must be valid URL)", + "instagramUrl": "string (optional, must be valid URL)", + "facebookUrl": "string (optional, must be valid URL)", + "linkedinUrl": "string (optional, must be valid URL)", + "twitterUrl": "string (optional, must be valid URL)", + "currencyXid": "number (required)" +} +``` + +## Documents JSON Structure + +```json +[ + { + "documentTypeXid": 1, + "documentName": "pan.pdf", + "fieldName": "panFile" + }, + { + "documentTypeXid": 2, + "documentName": "gst.pdf", + "fieldName": "gstFile" + } +] +``` + +## Notes + +- All 4 required document types (PAN, GST, REGISTRATION, AADHAAR) must be provided +- File field names in the `documents` array must match the form field names +- Files can be PDF, images, or any other document type +- The endpoint supports both `multipart/form-data` and JSON (for backward compatibility) + diff --git a/package-lock.json b/package-lock.json index 1773c13..40d7015 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "MIT", "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", "@aws-sdk/client-s3": "^3.928.0", "@nestjs/common": "^10.3.0", "@nestjs/config": "^3.1.1", diff --git a/package.json b/package.json index 0fb780b..98b26c6 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "seeder": "tsx prisma/seed.ts" }, "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", "@aws-sdk/client-s3": "^3.928.0", "@nestjs/common": "^10.3.0", "@nestjs/config": "^3.1.1", diff --git a/serverless.yml b/serverless.yml index 9ba7a5b..82f61fe 100644 --- a/serverless.yml +++ b/serverless.yml @@ -29,7 +29,6 @@ provider: JWT_SECRET: ${env:JWT_SECRET} SALT_ROUNDS: ${env:SALT_ROUNDS} NODE_ENV: ${env:NODE_ENV} - # AWS_REGION: ${env:AWS_REGION} S3_BUCKET_NAME: ${env:S3_BUCKET_NAME} iam: @@ -59,7 +58,7 @@ custom: bundle: true minify: false sourcemap: false - exclude: ['aws-sdk'] # Exclude AWS SDK v2, but include v3 (@aws-sdk/*) + exclude: [] target: node20 platform: node concurrency: 10 @@ -68,15 +67,17 @@ custom: package: individually: true patterns: - - '!node_modules/**' # exclude all node_modules first - '!**/*.spec.ts' - '!**/*.test.ts' - '!**/*.log' - - 'src/**' # include all source files - - 'common/**' # include common modules + - 'src/**' + - 'common/**' + - 'prisma/schema.prisma' - 'node_modules/@prisma/client/**' - 'node_modules/.prisma/client/**' - - 'prisma/schema.prisma' + - 'node_modules/@aws-sdk/**' + - 'node_modules/@smithy/**' # ✅ include AWS SDK v3 internal deps + - 'node_modules/@aws-crypto/**' functions: # 👇 Example Lambda for Host Module @@ -167,16 +168,18 @@ functions: method: post addCompanyDetails: - handler: src/modules/minglaradmin/handlers/addCompanyDetails.handler + handler: src/modules/host/handlers/addCompanyDetails.handler package: patterns: - - 'src/modules/minglaradmin/**' + - 'src/modules/host/**' - 'common/**' + - 'node_modules/@aws-sdk/**' + - 'node_modules/@smithy/**' # ✅ critical fix + - 'node_modules/@aws-crypto/**' - 'node_modules/.prisma/client/libquery_engine-rhel-openssl-3.0.x.so.node' - 'node_modules/@prisma/client/**' - - 'node_modules/@aws-sdk/**' - 'prisma/schema.prisma' events: - httpApi: - path: /minglaradmin/add-company-details + path: /host/add-company-details method: post \ No newline at end of file diff --git a/src/common/middlewares/jwt/authForMinglarAdmin.ts b/src/common/middlewares/jwt/authForMinglarAdmin.ts index ff28b99..8fb6f9c 100644 --- a/src/common/middlewares/jwt/authForMinglarAdmin.ts +++ b/src/common/middlewares/jwt/authForMinglarAdmin.ts @@ -29,7 +29,7 @@ declare module 'express-serve-static-core' { * Core authentication function - verifies JWT and validates Host user * Can be used by both Express middleware and Lambda handlers */ -export async function verifyHostToken(token: string): Promise<{ id: number; role?: string }> { +export async function verifyMinglarAdminToken(token: string): Promise<{ id: number; role?: string }> { if (!token) { throw new ApiError(httpStatus.UNAUTHORIZED, 'Please authenticate'); } @@ -86,7 +86,7 @@ const verifyCallback = async ( const token = req.header('x-auth-token') || req.cookies?.accessToken; try { - const userInfo = await verifyHostToken(token); + const userInfo = await verifyMinglarAdminToken(token); // Attach user to request req.user = { id: userInfo.id.toString(), role: userInfo.role }; diff --git a/src/common/middlewares/jwt/authForUser.ts b/src/common/middlewares/jwt/authForUser.ts index d88c4ab..ed2cae3 100644 --- a/src/common/middlewares/jwt/authForUser.ts +++ b/src/common/middlewares/jwt/authForUser.ts @@ -29,7 +29,7 @@ declare module 'express-serve-static-core' { * Core authentication function - verifies JWT and validates Host user * Can be used by both Express middleware and Lambda handlers */ -export async function verifyHostToken(token: string): Promise<{ id: number; role?: string }> { +export async function verifyUserToken(token: string): Promise<{ id: number; role?: string }> { if (!token) { throw new ApiError(httpStatus.UNAUTHORIZED, 'Please authenticate'); } @@ -86,7 +86,7 @@ const verifyCallback = async ( const token = req.header('x-auth-token') || req.cookies?.accessToken; try { - const userInfo = await verifyHostToken(token); + const userInfo = await verifyUserToken(token); // Attach user to request req.user = { id: userInfo.id.toString(), role: userInfo.role }; diff --git a/src/common/utils/helper/parseMultipartFormData.ts b/src/common/utils/helper/parseMultipartFormData.ts new file mode 100644 index 0000000..f83f8b3 --- /dev/null +++ b/src/common/utils/helper/parseMultipartFormData.ts @@ -0,0 +1,126 @@ +import ApiError from './ApiError'; + +interface ParsedFormData { + fields: Record; + files: Array<{ + fieldName: string; + fileName: string; + contentType: string; + data: Buffer; + }>; +} + +/** + * Parse multipart/form-data from Lambda event + * Supports both base64 encoded and binary body + */ +export function parseMultipartFormData( + eventBody: string | null, + contentType: string | undefined, + isBase64Encoded: boolean = false +): ParsedFormData { + if (!eventBody) { + throw new ApiError(400, 'Request body is required'); + } + + if (!contentType || !contentType.includes('multipart/form-data')) { + throw new ApiError(400, 'Content-Type must be multipart/form-data'); + } + + // Extract boundary from Content-Type header + const boundaryMatch = contentType.match(/boundary=([^;]+)/); + if (!boundaryMatch) { + throw new ApiError(400, 'Invalid multipart boundary'); + } + const boundary = boundaryMatch[1].trim(); + + // Decode base64 body if needed (API Gateway sends base64 encoded for binary media types) + let bodyBuffer: Buffer; + try { + if (isBase64Encoded) { + bodyBuffer = Buffer.from(eventBody, 'base64'); + } else { + // Try to detect if it's base64 + if (eventBody.match(/^[A-Za-z0-9+/=]+$/)) { + bodyBuffer = Buffer.from(eventBody, 'base64'); + } else { + bodyBuffer = Buffer.from(eventBody, 'binary'); + } + } + } catch (error) { + throw new ApiError(400, 'Invalid request body encoding'); + } + + // Split by boundary + const parts = bodyBuffer.toString('binary').split(`--${boundary}`); + + const fields: Record = {}; + const files: ParsedFormData['files'] = []; + + for (const part of parts) { + if (!part || part.trim() === '' || part.trim() === '--') { + continue; + } + + // Split headers and body + const [headers, ...bodyParts] = part.split('\r\n\r\n'); + if (!headers || bodyParts.length === 0) { + continue; + } + + const body = bodyParts.join('\r\n\r\n').trim(); + if (!body) { + continue; + } + + // Parse Content-Disposition header + const contentDispositionMatch = headers.match(/Content-Disposition:\s*form-data;\s*name="([^"]+)"/); + if (!contentDispositionMatch) { + continue; + } + + const fieldName = contentDispositionMatch[1]; + + // Check if it's a file + const filenameMatch = headers.match(/filename="([^"]+)"/); + const contentTypeMatch = headers.match(/Content-Type:\s*([^\r\n]+)/); + + if (filenameMatch) { + // It's a file + const fileName = filenameMatch[1]; + const fileContentType = contentTypeMatch ? contentTypeMatch[1].trim() : 'application/octet-stream'; + + // Convert body to buffer (remove trailing boundary markers) + const fileData = Buffer.from(body.replace(/\r\n--$/, ''), 'binary'); + + files.push({ + fieldName, + fileName, + contentType: fileContentType, + data: fileData, + }); + } else { + // It's a regular field + fields[fieldName] = body.replace(/\r\n--$/, '').trim(); + } + } + + return { fields, files }; +} + +/** + * Parse JSON field from form data + */ +export function parseJsonField(fields: Record, fieldName: string): any { + const value = fields[fieldName]; + if (!value) { + return null; + } + + try { + return JSON.parse(value); + } catch (error) { + throw new ApiError(400, `Invalid JSON in field: ${fieldName}`); + } +} + diff --git a/src/common/utils/validation/host/hostCompanyDetails.validation.ts b/src/common/utils/validation/host/hostCompanyDetails.validation.ts index 866e94d..c37ad80 100644 --- a/src/common/utils/validation/host/hostCompanyDetails.validation.ts +++ b/src/common/utils/validation/host/hostCompanyDetails.validation.ts @@ -2,8 +2,8 @@ import { z } from "zod"; // Allowed document types (must match your DocumentType master table IDs) export const REQUIRED_DOC_TYPES = { - PAN: 1, - GST: 2, + GST: 1, + PAN: 2, REGISTRATION: 3, AADHAAR: 4, }; diff --git a/src/config/config.ts b/src/config/config.ts index 1a6e381..c48d1b2 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -31,6 +31,8 @@ const envVarsSchema = yup .number() .default(10) .required('minutes after which verify email token expires'), + AWS_REGION: yup.string().required('AWS region is required'), + S3_BUCKET_NAME: yup.string().required('S3 bucket name is required'), //SMTP and BREVO // BREVO_SMTP_HOST: yup // .string() @@ -129,6 +131,10 @@ function getConfig() { socketPath: '/var/run/mysqld/mysqld.sock', }, }, + aws: { + region: envVars.AWS_REGION, + bucketName: envVars.S3_BUCKET_NAME, + }, byPassOTP: envVars.BYPASS_OTP, // BaseURL: envVars.BASEURL, // FRONTEND_URL: envVars.FRONTEND_URL, diff --git a/src/modules/host/handlers/addCompanyDetails.ts b/src/modules/host/handlers/addCompanyDetails.ts new file mode 100644 index 0000000..4f955a0 --- /dev/null +++ b/src/modules/host/handlers/addCompanyDetails.ts @@ -0,0 +1,177 @@ +import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; +import { safeHandler } from '../../../common/utils/handlers/safeHandler'; +import { PrismaService } from '../../../common/database/prisma.service'; +import { HostService } from '../../host/services/host.service'; +import ApiError from '../../../common/utils/helper/ApiError'; +import { verifyHostToken } from '../../../common/middlewares/jwt/authForHost'; +import { + hostCompanyDetailsSchema, + REQUIRED_DOC_TYPES, +} from '../../../common/utils/validation/host/hostCompanyDetails.validation'; +import AWS from 'aws-sdk'; +import Busboy from 'busboy'; +import crypto from 'crypto'; +import config from '@/config/config'; + +const prisma = new PrismaService(); +const hostService = new HostService(prisma); + +const s3 = new AWS.S3({ + region: config.aws.region, +}); + +export const handler = safeHandler(async (event: APIGatewayProxyEvent): Promise => { + try { + // ✅ 1. Verify Token + // 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 + const userInfo = await verifyHostToken(token); + + // ✅ 2. Ensure content-type is multipart/form-data + 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.'); + + 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 multipart data using Busboy + await new Promise((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; // 5 MB + + file.on('data', (chunk) => { + totalSize += chunk.length; + if (totalSize > MAX_SIZE) { + file.resume(); + return reject(new ApiError(400, `File ${filename} exceeds 5MB limit.`)); + } + chunks.push(chunk); + }); + + file.on('end', () => { + files.push({ + buffer: Buffer.concat(chunks), + mimeType, + fileName: filename, + fieldName: fieldname, + }); + }); + }); + + bb.on('field', (fieldname, val) => { + try { + fields[fieldname] = JSON.parse(val); + } catch { + fields[fieldname] = val; + } + }); + + bb.on('close', resolve); + bb.on('error', reject); + bb.end(bodyBuffer); + }); + + // ✅ 4. Validate fields + if (!fields.companyDetails) throw new ApiError(400, 'Missing companyDetails field.'); + if (!fields.documents) throw new ApiError(400, 'Missing documents field.'); + if (files.length === 0) throw new ApiError(400, 'At least one document file is required.'); + + // ✅ Parse & validate JSON inputs + let companyDetails; + try { + companyDetails = typeof fields.companyDetails === 'string' ? JSON.parse(fields.companyDetails) : fields.companyDetails; + } catch { + throw new ApiError(400, 'Invalid JSON in companyDetails.'); + } + + const companyValidation = hostCompanyDetailsSchema.safeParse(companyDetails); + if (!companyValidation.success) { + const message = companyValidation.error.issues.map((e) => e.message).join(', '); + throw new ApiError(400, `Validation failed: ${message}`); + } + const parsedCompany = companyValidation.data; + + let documentsMetadata; + try { + documentsMetadata = typeof fields.documents === 'string' ? JSON.parse(fields.documents) : fields.documents; + } catch { + throw new ApiError(400, 'Invalid JSON in documents.'); + } + + if (!Array.isArray(documentsMetadata) || documentsMetadata.length === 0) + throw new ApiError(400, 'Documents must be a non-empty array.'); + + // ✅ 5. Map uploaded files to document 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 }; + }); + + // ✅ 6. Ensure all required document types exist + const uploadedDocTypes = documentMetadata.map((d) => d.documentTypeXid); + const missingDocs = Object.entries(REQUIRED_DOC_TYPES) + .filter(([_, typeId]) => !uploadedDocTypes.includes(typeId)) + .map(([name]) => name); + if (missingDocs.length > 0) + throw new ApiError(400, `Missing mandatory documents: ${missingDocs.join(', ')}`); + + // ✅ 7. Upload to S3 + const uploadedDocs: Array<{ documentTypeXid: number; documentName: string; filePath: string }> = []; + for (const doc of documentMetadata) { + const uniqueKey = `${userInfo.id}_${crypto.randomUUID()}_${doc.file.fileName}`; + const s3Key = `Documents/Host/${uniqueKey}`; + await s3 + .upload({ + Bucket: config.aws.bucketName, + Key: s3Key, + Body: doc.file.buffer, + ContentType: doc.file.mimeType, + ACL: 'private', + }) + .promise(); + + uploadedDocs.push({ + documentTypeXid: doc.documentTypeXid, + documentName: doc.documentName, + filePath: `https://${config.aws.bucketName}.s3.${config.aws.region}.amazonaws.com/${s3Key}`, + }); + } + + // ✅ 8. Save company details + documents in DB via MinglarService + const createdHost = await hostService.addCompanyDetails(parsedCompany, uploadedDocs); + if (!createdHost) throw new ApiError(400, 'Failed to add company details.'); + + // ✅ 9. Success response + return { + statusCode: 200, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + body: JSON.stringify({ + success: true, + message: 'Company details and documents uploaded successfully.', + data: createdHost, + }), + }; + } catch (error: any) { + console.error('❌ Error in addCompanyDetails:', error); + throw error; + } +}); diff --git a/src/modules/host/services/host.service.ts b/src/modules/host/services/host.service.ts index 83acc5a..1302b8d 100644 --- a/src/modules/host/services/host.service.ts +++ b/src/modules/host/services/host.service.ts @@ -5,6 +5,17 @@ import { AddPaymentDetailsDTO, CreateHostDto, UpdateHostDto } from '../dto/host. import * as bcrypt from 'bcryptjs'; import ApiError from '../../../common/utils/helper/ApiError'; import { User } from '@prisma/client'; +import { z } from 'zod'; +import { hostCompanyDetailsSchema } from '@/common/utils/validation/host/hostCompanyDetails.validation'; + +type HostCompanyDetailsInput = z.infer; + +// Document input after S3 upload (with S3 URL as filePath) +interface HostDocumentInput { + documentTypeXid: number; + documentName: string; + filePath: string; // S3 URL +} @Injectable() export class HostService { @@ -163,4 +174,61 @@ export class HostService { return addedPaymentDetails; } + + async addCompanyDetails( + companyData: HostCompanyDetailsInput, + documents: HostDocumentInput[] // Documents with S3 URLs + ) { + return await this.prisma.$transaction(async (tx) => { + // ✅ Check for existing company + const existingHost = await tx.hostHeader.findFirst({ + where: { registrationNumber: companyData.registrationNumber }, + }); + + if (existingHost) { + throw new ApiError(400, 'Company already exists with this registration number'); + } + + // ✅ Create company record + const createdHost = await tx.hostHeader.create({ + data: { + companyName: companyData.companyName, + hostRefNumber: companyData.hostRefNumber, + address1: companyData.address1, + address2: companyData.address2, + cityXid: companyData.cityXid, + stateXid: companyData.stateXid, + countryXid: companyData.countryXid, + pinCode: companyData.pinCode, + logoPath: companyData.logoPath, + isSubsidairy: companyData.isSubsidairy, + registrationNumber: companyData.registrationNumber, + panNumber: companyData.panNumber, + gstNumber: companyData.gstNumber, + formationDate: new Date(companyData.formationDate), + companyType: companyData.companyType, + websiteUrl: companyData.websiteUrl, + instagramUrl: companyData.instagramUrl, + facebookUrl: companyData.facebookUrl, + linkedinUrl: companyData.linkedinUrl, + twitterUrl: companyData.twitterUrl, + currencyXid: companyData.currencyXid, + }, + }); + + // ✅ Create documents (if provided) + if (documents && documents.length > 0) { + const docsData = documents.map((doc) => ({ + hostXid: createdHost.id, + documentTypeXid: doc.documentTypeXid, + documentName: doc.documentName, + filePath: doc.filePath, + })); + + await tx.hostDocuments.createMany({ data: docsData }); + } + + return createdHost; + }); + } } diff --git a/src/modules/minglaradmin/handlers/addCompanyDetails.ts b/src/modules/minglaradmin/handlers/addCompanyDetails.ts deleted file mode 100644 index 5dc373a..0000000 --- a/src/modules/minglaradmin/handlers/addCompanyDetails.ts +++ /dev/null @@ -1,185 +0,0 @@ -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 { verifyHostToken } from '../../../common/middlewares/jwt/authForMinglarAdmin'; -import { - hostCompanyDetailsSchema, - REQUIRED_DOC_TYPES, -} from '../../../common/utils/validation/host/hostCompanyDetails.validation'; -import { uploadFilesToS3 } from '../../../common/utils/helper/s3Upload'; -import { parseMultipartFormData } from '../../../common/utils/helper/parseMultipartFormData'; - -const prismaService = new PrismaService(); -const minglarService = new MinglarService(prismaService); - -export const handler = safeHandler(async ( - event: APIGatewayProxyEvent, - context?: Context -): Promise => { - - // ✅ 1. Extract & verify token - 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 and parse accordingly - const contentType = event.headers['content-type'] || event.headers['Content-Type'] || ''; - let parsedCompany: any; - let documentsWithS3Urls: Array<{ documentTypeXid: number; documentName: string; filePath: string }> = []; - - if (contentType.includes('multipart/form-data')) { - // ✅ Parse multipart/form-data - // API Gateway sets isBase64Encoded to true for binary media types - const isBase64Encoded = (event as any).isBase64Encoded === true; - const formData = parseMultipartFormData(event.body, contentType, isBase64Encoded); - - // ✅ Parse companyDetails from form field (should be JSON string) - const companyDetailsJson = formData.fields['companyDetails']; - if (!companyDetailsJson) { - throw new ApiError(400, 'Company details are required in form data'); - } - - try { - parsedCompany = JSON.parse(companyDetailsJson); - } catch { - throw new ApiError(400, 'Invalid JSON in companyDetails field'); - } - - // ✅ Validate company details - const companyValidation = hostCompanyDetailsSchema.safeParse(parsedCompany); - if (!companyValidation.success) { - const errorMessages = companyValidation.error.issues.map(e => e.message).join(', '); - throw new ApiError(400, `Validation failed: ${errorMessages}`); - } - parsedCompany = companyValidation.data; - - // ✅ Process uploaded files - if (formData.files.length === 0) { - throw new ApiError(400, 'At least one document file is required'); - } - - // ✅ Parse documents metadata (JSON array) - const documentsJson = formData.fields['documents']; - if (!documentsJson) { - throw new ApiError(400, 'Documents metadata is required in form data'); - } - - let documentsMetadata: Array<{ documentTypeXid: number; documentName: string; fieldName: string }>; - try { - documentsMetadata = JSON.parse(documentsJson); - } catch { - throw new ApiError(400, 'Invalid JSON in documents field'); - } - - if (!Array.isArray(documentsMetadata) || documentsMetadata.length === 0) { - throw new ApiError(400, 'Documents must be a non-empty array'); - } - - // ✅ Map files to document structure - const documentMetadata: Array<{ documentTypeXid: number; documentName: string; file: typeof formData.files[0] }> = []; - - for (const docMeta of documentsMetadata) { - const file = formData.files.find((f) => f.fieldName === docMeta.fieldName); - if (!file) { - throw new ApiError(400, `File not found for field: ${docMeta.fieldName}`); - } - - documentMetadata.push({ - documentTypeXid: docMeta.documentTypeXid, - documentName: docMeta.documentName, - file, - }); - } - - // ✅ Ensure all required documents exist - const uploadedDocTypes = documentMetadata.map((doc) => doc.documentTypeXid); - const missingDocs = Object.entries(REQUIRED_DOC_TYPES) - .filter(([_, typeId]) => !uploadedDocTypes.includes(typeId)) - .map(([name]) => name); - - if (missingDocs.length > 0) { - throw new ApiError(400, `Missing mandatory documents: ${missingDocs.join(', ')}`); - } - - // ✅ Upload files to S3 - const filesToUpload = documentMetadata.map((doc) => ({ - fileData: doc.file.data.toString('base64'), - fileName: doc.file.fileName, - contentType: doc.file.contentType, - })); - - const s3Urls = await uploadFilesToS3(filesToUpload, `host-documents/${userInfo.id}`); - - // ✅ Map S3 URLs to documents - documentsWithS3Urls = documentMetadata.map((doc, index) => ({ - documentTypeXid: doc.documentTypeXid, - documentName: doc.documentName, - filePath: s3Urls[index], - })); - } else { - // ✅ Fallback to JSON parsing (for backward compatibility) - let body: { companyDetails?: unknown; documents?: unknown[] }; - try { - body = event.body ? JSON.parse(event.body) : {}; - } catch { - throw new ApiError(400, 'Invalid JSON in request body'); - } - - const { companyDetails, documents } = body; - - if (!companyDetails) { - throw new ApiError(400, 'Company details are required'); - } - - // ✅ Validate company details - const companyValidation = hostCompanyDetailsSchema.safeParse(companyDetails); - if (!companyValidation.success) { - const errorMessages = companyValidation.error.issues.map(e => e.message).join(', '); - throw new ApiError(400, `Validation failed: ${errorMessages}`); - } - parsedCompany = companyValidation.data; - - // For JSON, we still expect base64 encoded files in documents array - // This maintains backward compatibility - if (documents && Array.isArray(documents) && documents.length > 0) { - const filesToUpload = documents.map((doc: any) => ({ - fileData: doc.fileData, - fileName: doc.documentName, - contentType: doc.contentType || 'application/pdf', - })); - - const s3Urls = await uploadFilesToS3(filesToUpload, `host-documents/${userInfo.id}`); - - documentsWithS3Urls = documents.map((doc: any, index: number) => ({ - documentTypeXid: doc.documentTypeXid, - documentName: doc.documentName, - filePath: s3Urls[index], - })); - } - } - - // ✅ 7. Pass validated data to service - const createdHost = await minglarService.addCompanyDetails(parsedCompany, documentsWithS3Urls); - - if (!createdHost) { - throw new ApiError(400, 'Failed to add company details'); - } - - // ✅ 6. Success response - return { - statusCode: 200, - headers: { - 'Content-Type': 'application/json', - 'Access-Control-Allow-Origin': '*', - }, - body: JSON.stringify({ - success: true, - message: 'Company details and documents uploaded successfully', - }), - }; -}); diff --git a/src/modules/minglaradmin/services/minglar.service.ts b/src/modules/minglaradmin/services/minglar.service.ts index b3c697b..07f0d60 100644 --- a/src/modules/minglaradmin/services/minglar.service.ts +++ b/src/modules/minglaradmin/services/minglar.service.ts @@ -2,17 +2,7 @@ import { Injectable } from '@nestjs/common'; import { PrismaService } from '../../../common/database/prisma.service'; import ApiError from '../../../common/utils/helper/ApiError'; import * as bcrypt from 'bcryptjs'; -import { z } from 'zod'; -import { hostCompanyDetailsSchema } from '../../../common/utils/validation/host/hostCompanyDetails.validation'; -type HostCompanyDetailsInput = z.infer; - -// Document input after S3 upload (with S3 URL as filePath) -interface HostDocumentInput { - documentTypeXid: number; - documentName: string; - filePath: string; // S3 URL -} @Injectable() export class MinglarService { @@ -46,61 +36,4 @@ export class MinglarService { return true; } - - async addCompanyDetails( - companyData: HostCompanyDetailsInput, - documents: HostDocumentInput[] // Documents with S3 URLs - ) { - return await this.prisma.$transaction(async (tx) => { - // ✅ Check for existing company - const existingHost = await tx.hostHeader.findFirst({ - where: { registrationNumber: companyData.registrationNumber }, - }); - - if (existingHost) { - throw new ApiError(400, 'Company already exists with this registration number'); - } - - // ✅ Create company record - const createdHost = await tx.hostHeader.create({ - data: { - companyName: companyData.companyName, - hostRefNumber: companyData.hostRefNumber, - address1: companyData.address1, - address2: companyData.address2, - cityXid: companyData.cityXid, - stateXid: companyData.stateXid, - countryXid: companyData.countryXid, - pinCode: companyData.pinCode, - logoPath: companyData.logoPath, - isSubsidairy: companyData.isSubsidairy, - registrationNumber: companyData.registrationNumber, - panNumber: companyData.panNumber, - gstNumber: companyData.gstNumber, - formationDate: new Date(companyData.formationDate), - companyType: companyData.companyType, - websiteUrl: companyData.websiteUrl, - instagramUrl: companyData.instagramUrl, - facebookUrl: companyData.facebookUrl, - linkedinUrl: companyData.linkedinUrl, - twitterUrl: companyData.twitterUrl, - currencyXid: companyData.currencyXid, - }, - }); - - // ✅ Create documents (if provided) - if (documents && documents.length > 0) { - const docsData = documents.map((doc) => ({ - hostXid: createdHost.id, - documentTypeXid: doc.documentTypeXid, - documentName: doc.documentName, - filePath: doc.filePath, - })); - - await tx.hostDocuments.createMany({ data: docsData }); - } - - return createdHost; - }); - } }