made s3 uploader and apis

This commit is contained in:
2025-11-12 19:59:54 +05:30
parent c0e58fe1ce
commit 8e19bb566d
12 changed files with 3083 additions and 385 deletions

2805
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -26,6 +26,7 @@
"seeder": "tsx prisma/seed.ts"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.928.0",
"@nestjs/common": "^10.3.0",
"@nestjs/config": "^3.1.1",
"@nestjs/core": "^10.3.0",
@@ -35,6 +36,9 @@
"@nestjs/swagger": "^7.1.17",
"@nestjs/throttler": "^5.1.1",
"@prisma/client": "^5.8.1",
"@smithy/middleware-stack": "^4.2.5",
"@smithy/protocol-http": "^5.3.5",
"@smithy/types": "^4.9.0",
"@types/http-status": "^1.1.2",
"ajv": "8.12.0",
"aws-lambda": "^1.0.7",

View File

@@ -3,6 +3,10 @@ service: minglarDev
provider:
name: aws
runtime: nodejs20.x
apiGateway:
binaryMediaTypes:
- '*/*' # allow binary uploads
minimumCompressionSize: 0
region: ap-south-1
versionFunctions: false
environment:
@@ -25,6 +29,18 @@ 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:
role:
statements:
- Effect: Allow
Action:
- s3:PutObject
- s3:GetObject
- s3:DeleteObject
Resource: 'arn:aws:s3:::${env:S3_BUCKET_NAME}/*'
httpApi:
@@ -43,7 +59,7 @@ custom:
bundle: true
minify: false
sourcemap: false
exclude: ['aws-sdk']
exclude: ['aws-sdk'] # Exclude AWS SDK v2, but include v3 (@aws-sdk/*)
target: node20
platform: node
concurrency: 10
@@ -133,4 +149,34 @@ functions:
events:
- httpApi:
path: /host/create-password
method: post
addPaymentDetailsForHost:
handler: src/modules/host/handlers/addPaymentDetails.handler
package:
patterns:
- 'src/modules/host/**'
- 'common/**'
- 'node_modules/.prisma/client/libquery_engine-rhel-openssl-3.0.x.so.node'
- 'node_modules/@prisma/client/**'
- 'prisma/schema.prisma'
events:
- httpApi:
path: /host/add-payment-details
method: post
addCompanyDetails:
handler: src/modules/minglaradmin/handlers/addCompanyDetails.handler
package:
patterns:
- 'src/modules/minglaradmin/**'
- 'common/**'
- '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
method: post

View File

@@ -0,0 +1,75 @@
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import ApiError from './ApiError';
import * as crypto from 'crypto';
import config from '@/config/config';
const AWS_REGION = config.aws.region;
const S3_BUCKET_NAME = config.aws.bucketName;
const s3Client = new S3Client({
region: AWS_REGION,
});
interface UploadFileParams {
fileData: string; // base64 encoded file
fileName: string;
folder?: string; // Optional folder path in S3
contentType?: string;
}
/**
* Upload file to S3 and return the S3 URL
*/
export async function uploadFileToS3(params: UploadFileParams): Promise<string> {
try {
const { fileData, fileName, folder = 'documents', contentType = 'application/pdf' } = params;
// Generate unique file name
const fileExtension = fileName.split('.').pop() || 'pdf';
const uniqueFileName = `${crypto.randomUUID()}-${Date.now()}.${fileExtension}`;
const s3Key = folder ? `${folder}/${uniqueFileName}` : uniqueFileName;
// Decode base64 file data
const fileBuffer = Buffer.from(fileData, 'base64');
// Upload to S3
const command = new PutObjectCommand({
Bucket: S3_BUCKET_NAME,
Key: s3Key,
Body: fileBuffer,
ContentType: contentType,
// Make file publicly readable (adjust based on your needs)
// ACL: 'public-read', // Uncomment if you want public access
});
await s3Client.send(command);
// Return S3 URL
const s3Url = `https://${S3_BUCKET_NAME}.s3.${AWS_REGION}.amazonaws.com/${s3Key}`;
return s3Url;
} catch (error) {
console.error('S3 upload error:', error);
throw new ApiError(500, 'Failed to upload file to S3');
}
}
/**
* Upload multiple files to S3
*/
export async function uploadFilesToS3(
files: Array<{ fileData: string; fileName: string; contentType?: string }>,
folder?: string
): Promise<string[]> {
const uploadPromises = files.map((file) =>
uploadFileToS3({
fileData: file.fileData,
fileName: file.fileName,
folder,
contentType: file.contentType,
})
);
return Promise.all(uploadPromises);
}

View File

@@ -34,11 +34,12 @@ export const hostCompanyDetailsSchema = z.object({
currencyXid: z.number().min(1, "Currency is required"),
});
// Validation for documents
// Validation for documents with file data (base64)
export const hostDocumentsSchema = z.array(
z.object({
documentTypeXid: z.number(),
documentName: z.string(),
filePath: z.string(),
fileData: z.string().min(1, "File data is required"), // base64 encoded file
contentType: z.string().optional(), // e.g., "application/pdf", "image/png"
})
);

View File

@@ -72,4 +72,22 @@ export class GetHostLoginResponseDTO {
this.accessToken = accessToken;
this.refreshToken = refreshToken;
}
}
export class AddPaymentDetailsDTO {
bankXid: number;
bankBranchXid: number;
accountNumber: number;
accountHolderName: string;
ifscCode: string;
hostXid: number;
constructor(bankXid: number, bankBranchXid: number, accountNumber: number, accountHolderName: string, ifscCode: string, hostXid: number) {
this.bankXid = bankXid;
this.bankBranchXid = bankBranchXid;
this.accountNumber = accountNumber;
this.accountHolderName = accountHolderName;
this.ifscCode = ifscCode;
this.hostXid = hostXid;
}
}

View File

@@ -0,0 +1,65 @@
import { APIGatewayProxyEvent, APIGatewayProxyResult, Context } from 'aws-lambda';
import { safeHandler } from '../../../common/utils/handlers/safeHandler';
import { PrismaService } from '../../../common/database/prisma.service';
import { HostService } from '../services/host.service';
import ApiError from '../../../common/utils/helper/ApiError';
import { verifyHostToken } from '@/common/middlewares/jwt/authForHost';
import { hostBankDetailsSchema } from '@/common/utils/validation/host/addPaymentDetails.validation';
const prismaService = new PrismaService();
const hostService = new HostService(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
const userInfo = await verifyHostToken(token);
const hostId = userInfo.id;
if (Number.isNaN(hostId)) {
throw new ApiError(400, 'Host id must be a number');
}
// Parse request body
let body: { bankXid?: number; bankBranchXid?: number; accountNumber?: string; accountHolderName?: string; ifscCode?: string };
try {
body = event.body ? JSON.parse(event.body) : {};
} catch (error) {
throw new ApiError(400, 'Invalid JSON in request body');
}
// ✅ Validate payload using Zod
const validationResult = hostBankDetailsSchema.safeParse({
...(body as object),
hostXid: hostId, // inject hostId from token (not from user input)
});
if (!validationResult.success) {
const errorMessages = validationResult.error.issues.map(e => e.message).join(', ');
throw new ApiError(400, `Validation failed: ${errorMessages}`);
}
const validatedData = validationResult.data;
await hostService.addPaymentDetails(hostId, validatedData);
return {
statusCode: 200,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
},
body: JSON.stringify({
success: true,
message: 'Payment details added successfully',
}),
};
});

View File

@@ -1,7 +1,7 @@
// src/modules/host/services/host.service.ts
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../../common/database/prisma.service';
import { CreateHostDto, UpdateHostDto } from '../dto/host.dto';
import { AddPaymentDetailsDTO, CreateHostDto, UpdateHostDto } from '../dto/host.dto';
import * as bcrypt from 'bcryptjs';
import ApiError from '../../../common/utils/helper/ApiError';
import { User } from '@prisma/client';
@@ -143,4 +143,24 @@ export class HostService {
return true;
}
async addPaymentDetails(id: number, data: AddPaymentDetailsDTO): Promise<AddPaymentDetailsDTO> {
const existingUser = await this.prisma.user.findUnique({
where: { id },
});
if (!existingUser) {
throw new ApiError(404, 'User not found');
}
const addedPaymentDetails = await this.prisma.hostBankDetails.create({
data,
});
if (!addedPaymentDetails) {
throw new ApiError(400, 'Failed to add payment details');
}
return addedPaymentDetails;
}
}

View File

@@ -0,0 +1,185 @@
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<APIGatewayProxyResult> => {
// ✅ 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',
}),
};
});

View File

@@ -0,0 +1,65 @@
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/authForHost';
const prismaService = new PrismaService();
const minglarService = new MinglarService(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
const userInfo = await verifyHostToken(token);
const user_xid = userInfo.id;
// Parse request body
let body: { password?: string; confirmPassword?: string };
try {
body = event.body ? JSON.parse(event.body) : {};
} catch (error) {
throw new ApiError(400, 'Invalid JSON in request body');
}
const { password, confirmPassword } = body;
if (!password || !confirmPassword) {
throw new ApiError(400, 'Password and confirm password are required');
}
// Validate password match
if (password !== confirmPassword) {
throw new ApiError(400, 'Password and confirm password do not match');
}
// Validate password length
if (password.length < 8) {
throw new ApiError(400, 'Password must be at least 8 characters long');
}
await minglarService.createPassword(user_xid, password);
return {
statusCode: 200,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
},
body: JSON.stringify({
success: true,
message: 'Password created successfully',
data: null,
}),
};
});

View File

@@ -0,0 +1,70 @@
// import { APIGatewayProxyEvent, APIGatewayProxyResult, Context } from 'aws-lambda';
// import { safeHandler } from '../../../common/utils/handlers/safeHandler';
// import { PrismaService } from '../../../common/database/prisma.service';
// import { HostService } from '../services/host.service';
// import { TokenService } from '../services/token.service';
// import { GetHostLoginResponseDTO } from '../dto/host.dto';
// import ApiError from '../../../common/utils/helper/ApiError';
// import * as bcrypt from 'bcryptjs';
// const prismaService = new PrismaService();
// const hostService = new HostService(prismaService);
// const tokenService = new TokenService();
// export const handler = safeHandler(async (
// event: APIGatewayProxyEvent,
// context?: Context
// ): Promise<APIGatewayProxyResult> => {
// // Parse request body
// let body: { emailAddress?: string; userPassword?: string };
// try {
// body = event.body ? JSON.parse(event.body) : {};
// } catch (error) {
// throw new ApiError(400, 'Invalid JSON in request body');
// }
// const { emailAddress } = body;
// if (!emailAddress) {
// throw new ApiError(400, 'Email is required');
// }
// const loginForHost = await hostService.loginForHost(emailAddress);
// if (!loginForHost) {
// throw new ApiError(400, 'Failed to login');
// }
// if (!matchPassword) {
// throw new ApiError(401, 'Invalid credentials');
// }
// const generateTokenForHost = await tokenService.generateAuthToken(
// loginForHost.id
// );
// if (!generateTokenForHost) {
// throw new ApiError(500, 'Failed to generate token');
// }
// const loginForHostResponse = new GetHostLoginResponseDTO(
// loginForHost,
// generateTokenForHost.access.token,
// generateTokenForHost.refresh.token
// );
// return {
// statusCode: 200,
// headers: {
// 'Content-Type': 'application/json',
// 'Access-Control-Allow-Origin': '*',
// },
// body: JSON.stringify({
// success: true,
// message: 'Login successful',
// data: loginForHostResponse,
// }),
// };
// });

View File

@@ -0,0 +1,106 @@
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<typeof hostCompanyDetailsSchema>;
// Document input after S3 upload (with S3 URL as filePath)
interface HostDocumentInput {
documentTypeXid: number;
documentName: string;
filePath: string; // S3 URL
}
@Injectable()
export class MinglarService {
constructor(private prisma: PrismaService) { }
async createPassword(user_xid: number, password: string): Promise<boolean> {
// Find user by id
const user = await this.prisma.user.findUnique({
where: { id: user_xid },
select: { id: true, emailAddress: true, userPassword: true },
});
if (!user) {
throw new ApiError(404, 'User not found');
}
// Check if password already exists
if (user.userPassword) {
throw new ApiError(400, 'Password already exists. Use update password instead.');
}
// Hash the password
const saltRounds = parseInt(process.env.SALT_ROUNDS || '10', 10);
const hashedPassword = await bcrypt.hash(password, saltRounds);
// Update user with hashed password
await this.prisma.user.update({
where: { id: user.id },
data: { userPassword: hashedPassword },
});
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;
});
}
}