diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 41da670..b41e8ac 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -15,12 +15,14 @@ model User { firstName String? @map("first_name") lastName String? @map("last_name") roleXid Int? @map("role_xid") + dateOfBirth DateTime? @map("date_of_birth") role Roles? @relation(fields: [roleXid], references: [id], onDelete: Restrict) emailAddress String @unique @map("email_address") isdCode String? @map("isd_code") mobileNumber String? @map("mobile_number") userPassword String? @map("user_password") userPasscode String? @map("user_passcode") + profileImage String? @map("profile_image") isEmailVerfied Boolean? @default(false) @map("is_email_verified") isMobileVerfied Boolean? @default(false) @map("is_mobile_verified") isActive Boolean? @default(true) @map("is_active") @@ -55,11 +57,53 @@ model User { connectDetails ConnectDetails[] friends Friends[] friendOf Friends[] @relation("FriendUser") + userAddressDetails UserAddressDetails[] + userDocuments UserDocuments[] @@map("users") @@schema("usr") } +model UserAddressDetails { + id Int @id @default(autoincrement()) + userXid Int @map("user_xid") + user User @relation(fields: [userXid], references: [id], onDelete: Cascade) + address1 String @map("address_1") + address2 String? @map("address_2") + countryXid Int @map("country_xid") + country Countries @relation(fields: [countryXid], references: [id], onDelete: Restrict) + stateXid Int @map("state_xid") + states States @relation(fields: [stateXid], references: [id], onDelete: Restrict) + cityXid Int @map("city_xid") + cities Cities @relation(fields: [cityXid], references: [id], onDelete: Restrict) + pinCode String @map("pin_code") + locationName String? @map("location_name") + locationAddress String? @map("location_address") + locationLat Float? @map("location_lat") + locationLong Float? @map("location_long") + isActive Boolean @default(true) @map("is_active") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + deletedAt DateTime? @map("deleted_at") + + @@map("user_address_details") + @@schema("usr") +} + +model UserDocuments { + id Int @id @default(autoincrement()) + userXid Int @map("user_xid") + user User @relation(fields: [userXid], references: [id], onDelete: Cascade) + fileName String @map("file_name") + isActive Boolean @default(true) @map("is_active") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + deletedAt DateTime? @map("deleted_at") + + @@map("user_documents") + @@schema("usr") +} + model UserOtp { id Int @id @default(autoincrement()) userXid Int @map("user_xid") @@ -160,20 +204,21 @@ model UserInterests { } model Countries { - id Int @id @default(autoincrement()) - countryName String @unique @map("country_name") - countryCode String @unique @map("country_code") - countryFlag String @map("country_flag") - isActive Boolean @default(true) @map("is_active") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") - deletedAt DateTime? @map("deleted_at") - Currencies Currencies[] - States States[] - Taxes Taxes[] - Banks Banks[] - HostHeader HostHeader[] - hostParent HostParent[] + id Int @id @default(autoincrement()) + countryName String @unique @map("country_name") + countryCode String @unique @map("country_code") + countryFlag String @map("country_flag") + isActive Boolean @default(true) @map("is_active") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + deletedAt DateTime? @map("deleted_at") + Currencies Currencies[] + States States[] + Taxes Taxes[] + Banks Banks[] + HostHeader HostHeader[] + hostParent HostParent[] + userAddressDetails UserAddressDetails[] @@map("countries") @@schema("mst") @@ -197,35 +242,37 @@ model Currencies { } model States { - id Int @id @default(autoincrement()) - countryXid Int @map("country_xid") - country Countries @relation(fields: [countryXid], references: [id], onDelete: Cascade) - stateName String @unique @map("state_name") - isActive Boolean @default(true) @map("is_active") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") - deletedAt DateTime? @map("deleted_at") - Cities Cities[] - BankBranches BankBranches[] - HostHeader HostHeader[] - hostParent HostParent[] + id Int @id @default(autoincrement()) + countryXid Int @map("country_xid") + country Countries @relation(fields: [countryXid], references: [id], onDelete: Cascade) + stateName String @unique @map("state_name") + isActive Boolean @default(true) @map("is_active") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + deletedAt DateTime? @map("deleted_at") + Cities Cities[] + BankBranches BankBranches[] + HostHeader HostHeader[] + hostParent HostParent[] + userAddressDetails UserAddressDetails[] @@map("states") @@schema("mst") } model Cities { - id Int @id @default(autoincrement()) - stateXid Int @map("state_xid") - states States @relation(fields: [stateXid], references: [id], onDelete: Cascade) - cityName String @unique @map("city_name") - isActive Boolean @default(true) @map("is_active") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") - deletedAt DateTime? @map("deleted_at") - BankBranches BankBranches[] - HostHeader HostHeader[] - hostParent HostParent[] + id Int @id @default(autoincrement()) + stateXid Int @map("state_xid") + states States @relation(fields: [stateXid], references: [id], onDelete: Cascade) + cityName String @unique @map("city_name") + isActive Boolean @default(true) @map("is_active") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + deletedAt DateTime? @map("deleted_at") + BankBranches BankBranches[] + HostHeader HostHeader[] + hostParent HostParent[] + userAddressDetails UserAddressDetails[] @@map("cities") @@schema("mst") diff --git a/serverless.yml b/serverless.yml index 1ac4f3d..3a08ef2 100644 --- a/serverless.yml +++ b/serverless.yml @@ -258,6 +258,23 @@ functions: path: /minglaradmin/create-password method: post + updateMinglarProfile: + handler: src/modules/minglaradmin/handlers/updateProfile.handler + package: + patterns: + - "src/modules/host/handlers/addCompanyDetails.*" + - "src/modules/host/services/**" + - "common/**" + - "src/common/**" + - "node_modules/@prisma/client/**" + - "node_modules/.prisma/**" + - "node_modules/@aws-sdk/**" + - "node_modules/@smithy/**" + + events: + - httpApi: + path: /minglaradmin/update-profile + method: patch addCompanyDetails: handler: src/modules/host/handlers/addCompanyDetails.handler diff --git a/src/modules/minglaradmin/handlers/addCompanyDetails.ts b/src/modules/minglaradmin/handlers/addCompanyDetails.ts deleted file mode 100644 index 4f955a0..0000000 --- a/src/modules/minglaradmin/handlers/addCompanyDetails.ts +++ /dev/null @@ -1,177 +0,0 @@ -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/minglaradmin/handlers/updateProfile.ts b/src/modules/minglaradmin/handlers/updateProfile.ts new file mode 100644 index 0000000..4708dca --- /dev/null +++ b/src/modules/minglaradmin/handlers/updateProfile.ts @@ -0,0 +1,151 @@ +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'; +import config from '@/config/config'; + +const prismaService = new PrismaService(); +const minglarService = new MinglarService(prismaService); + +const s3 = new AWS.S3({ + region: config.aws.region, +}); + +export const handler = safeHandler(async ( + event: APIGatewayProxyEvent, + context?: Context +): Promise => { + // Extract token from headers + const token = event.headers['x-auth-token'] || event.headers['X-Auth-Token']; + if (!token) { + throw new ApiError(400, 'This is a protected route. Please provide a valid token.'); + } + + // 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'); + } + + // 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}`; + + await s3.upload({ + Bucket: config.aws.bucketName, + Key: s3Key, + Body: profileImageFile.data, + ContentType: profileImageFile.contentType, + ACL: 'private', + }).promise(); + + profileImagePath = `https://${config.aws.bucketName}.s3.${config.aws.region}.amazonaws.com/${s3Key}`; + } + + // 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, + }), + }; +}); + diff --git a/src/modules/minglaradmin/services/minglar.service.ts b/src/modules/minglaradmin/services/minglar.service.ts index 811c6af..9d342a5 100644 --- a/src/modules/minglaradmin/services/minglar.service.ts +++ b/src/modules/minglaradmin/services/minglar.service.ts @@ -131,4 +131,161 @@ export class MinglarService { return existingUser; } + + async updateProfile( + userId: number, + userData: { + firstName?: string; + lastName?: string; + mobileNumber?: string; + dateOfBirth?: string; + profileImage?: string; + }, + addressData: { + address1?: string; + address2?: string; + stateXid?: number; + countryXid?: number; + cityXid?: number; + pinCode?: string; + }, + 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; + + if (Object.keys(userUpdateData).length > 0) { + await tx.user.update({ + where: { id: userId }, + data: userUpdateData, + }); + } + + // 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'); + } + await tx.userAddressDetails.create({ + data: { + userXid: userId, + ...addressUpdateData, + }, + }); + } + } + + // 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' }, + }); + + // 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 + }); + } else { + // Create new document + await tx.userDocuments.create({ + data: { + userXid: userId, + fileName: doc.filePath, // Store S3 URL in fileName + }, + }); + } + } + } + + // 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 }, + }, + }, + }); + + if (!updatedUser) { + throw new ApiError(404, 'User not found'); + } + + // 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 + } + + 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), + }; + }); + } }