Merge branch 'paritosh-main1' of http://git.wdipl.com/Mayank.Mishra/MinglarBackendNestJS into paritosh-main1
This commit is contained in:
@@ -1,7 +1,5 @@
|
||||
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';
|
||||
@@ -13,295 +11,80 @@ import {
|
||||
import { HostService } from '../../../services/host.service';
|
||||
|
||||
const hostService = new HostService(prismaClient);
|
||||
const s3 = new AWS.S3({ region: config.aws.region });
|
||||
|
||||
/* ------------------------------- Utilities ------------------------------- */
|
||||
|
||||
function getExtensionFromMime(mimeType: string) {
|
||||
const map: Record<string, string> = {
|
||||
'image/jpeg': 'jpg',
|
||||
'image/png': 'png',
|
||||
'image/webp': 'webp',
|
||||
'video/mp4': 'mp4',
|
||||
'video/quicktime': 'mov',
|
||||
'video/x-msvideo': 'avi',
|
||||
'video/x-matroska': 'mkv',
|
||||
};
|
||||
return map[mimeType] || 'bin';
|
||||
}
|
||||
|
||||
function normalizeJsonField(fields: any, key: string) {
|
||||
if (!fields[key]) return undefined;
|
||||
if (typeof fields[key] === 'object') return fields[key];
|
||||
|
||||
try {
|
||||
return JSON.parse(fields[key]);
|
||||
} catch {
|
||||
throw new ApiError(400, `Invalid JSON in field: ${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeFileName(originalName: string): string {
|
||||
const extIndex = originalName.lastIndexOf('.');
|
||||
const extension =
|
||||
extIndex !== -1 ? originalName.slice(extIndex).toLowerCase() : '';
|
||||
|
||||
const baseName =
|
||||
extIndex !== -1 ? originalName.slice(0, extIndex) : originalName;
|
||||
|
||||
return (
|
||||
baseName
|
||||
.trim()
|
||||
.replace(/\s+/g, '_') // spaces → underscore
|
||||
.replace(/[^a-zA-Z0-9_-]/g, '') // remove special chars
|
||||
.toLowerCase() +
|
||||
extension
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/* -------------------------------- Handler -------------------------------- */
|
||||
|
||||
export const handler = safeHandler(
|
||||
async (event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> => {
|
||||
|
||||
/* 1️⃣ AUTH */
|
||||
const token =
|
||||
event.headers['x-auth-token'] || event.headers['X-Auth-Token'];
|
||||
if (!token) {
|
||||
throw new ApiError(
|
||||
401,
|
||||
'This is a protected route. Please provide a valid token.',
|
||||
);
|
||||
throw new ApiError(401, 'Missing auth token');
|
||||
}
|
||||
|
||||
const userInfo = await verifyHostToken(token);
|
||||
|
||||
/* 2️⃣ 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');
|
||||
/* 2️⃣ PARSE JSON BODY */
|
||||
if (!event.body) {
|
||||
throw new ApiError(400, 'Request body is required');
|
||||
}
|
||||
|
||||
/* 3️⃣ BODY BUFFER */
|
||||
const bodyBuffer = event.isBase64Encoded
|
||||
? Buffer.from(event.body as string, 'base64')
|
||||
: Buffer.from(event.body as string);
|
||||
let body: any;
|
||||
try {
|
||||
body = JSON.parse(event.body);
|
||||
} catch {
|
||||
throw new ApiError(400, 'Invalid JSON body');
|
||||
}
|
||||
|
||||
const fields: Record<string, any> = {};
|
||||
const files: Array<{
|
||||
buffer: Buffer;
|
||||
mimeType: string;
|
||||
fileName: string;
|
||||
fieldName: string;
|
||||
}> = [];
|
||||
const {
|
||||
activity,
|
||||
media = [],
|
||||
isDraft = false,
|
||||
} = body;
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const bb = Busboy({
|
||||
headers: {
|
||||
...event.headers,
|
||||
'content-type': contentType,
|
||||
},
|
||||
});
|
||||
|
||||
bb.on('field', (name, value) => {
|
||||
fields[name] = value;
|
||||
});
|
||||
|
||||
bb.on('file', (fieldName, file, info) => {
|
||||
const { filename, mimeType } = info;
|
||||
const chunks: Buffer[] = [];
|
||||
let size = 0;
|
||||
const MAX_SIZE = 5 * 1024 * 1024;
|
||||
|
||||
file.on('data', (chunk) => {
|
||||
size += chunk.length;
|
||||
if (size > MAX_SIZE) {
|
||||
file.destroy(new Error('File 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: sanitizeFileName(filename || 'unknown'),
|
||||
fieldName,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
bb.on('finish', () => resolve());
|
||||
bb.on('error', (err) => reject(new ApiError(400, err.message)));
|
||||
|
||||
bb.end(bodyBuffer);
|
||||
});
|
||||
|
||||
/* 4️⃣ FLAGS */
|
||||
const isDraft = fields.isDraft === 'true' || fields.isDraft === true;
|
||||
|
||||
/* 5️⃣ ACTIVITY PAYLOAD */
|
||||
const activityPayload: any = normalizeJsonField(fields, 'activity');
|
||||
if (!activityPayload) {
|
||||
if (!activity) {
|
||||
throw new ApiError(400, 'activity payload is required');
|
||||
}
|
||||
|
||||
/* 6️⃣ NORMALIZE IDS */
|
||||
if (activityPayload.activityXid) {
|
||||
activityPayload.activityXid = Number(activityPayload.activityXid);
|
||||
/* 3️⃣ NORMALIZE ACTIVITY ID */
|
||||
if (activity.activityXid) {
|
||||
activity.activityXid = Number(activity.activityXid);
|
||||
}
|
||||
|
||||
const numberKeys = [
|
||||
'currencyXid',
|
||||
'energyLevelXid',
|
||||
'activityDurationMins',
|
||||
'activityTypeXid',
|
||||
'frequenciesXid',
|
||||
'trainerTotalAmount',
|
||||
'pickupDropTotalPrice',
|
||||
'navigationModeTotalPrice',
|
||||
'sustainabilityScore',
|
||||
'safetyScore',
|
||||
'checkInLat',
|
||||
'checkInLong',
|
||||
'checkOutLat',
|
||||
'checkOutLong',
|
||||
];
|
||||
|
||||
for (const key of numberKeys) {
|
||||
if (activityPayload[key] !== undefined && activityPayload[key] !== null && activityPayload[key] !== '') {
|
||||
activityPayload[key] = Number(activityPayload[key]);
|
||||
}
|
||||
/* 4️⃣ ATTACH ACTIVITY MEDIA (S3 URLs) */
|
||||
if (!Array.isArray(media)) {
|
||||
throw new ApiError(400, 'media must be an array');
|
||||
}
|
||||
|
||||
/* 7️⃣ NORMALIZE BOOLEANS */
|
||||
const booleanKeys = [
|
||||
'isInstantBooking',
|
||||
'foodAvailable',
|
||||
'foodIsChargeable',
|
||||
'alcoholAvailable',
|
||||
'trainerAvailable',
|
||||
'trainerIsChargeable',
|
||||
'pickUpDropAvailable',
|
||||
'pickUpDropIsChargeable',
|
||||
'inActivityAvailable',
|
||||
'inActivityIsChargeable',
|
||||
'equipmentAvailable',
|
||||
'equipmentIsChargeable',
|
||||
'cancellationAvailable',
|
||||
'isCheckOutSame',
|
||||
];
|
||||
activity.media = media.map((m: any) => ({
|
||||
mediaType: m.mediaType ?? 'image',
|
||||
mediaFileName: m.mediaFileName,
|
||||
}));
|
||||
|
||||
for (const key of booleanKeys) {
|
||||
if (activityPayload[key] === 'true') activityPayload[key] = true;
|
||||
if (activityPayload[key] === 'false') activityPayload[key] = false;
|
||||
}
|
||||
|
||||
/* 8️⃣ UPLOAD ACTIVITY-LEVEL MEDIA (images/videos) */
|
||||
const uploadedActivityMedia: Array<{ mediaType?: string; mediaFileName: string }> = [];
|
||||
|
||||
for (const file of files.filter(
|
||||
(f) => f.fieldName === 'images' || f.fieldName === 'videos'
|
||||
)) {
|
||||
const s3Key = `ActivityOnboarding/Activity_${activityPayload.activityXid}/Artifacts/${file.fileName}`;
|
||||
|
||||
await s3.upload({
|
||||
Bucket: config.aws.bucketName,
|
||||
Key: s3Key,
|
||||
Body: file.buffer,
|
||||
ContentType: file.mimeType,
|
||||
ACL: 'private',
|
||||
}).promise();
|
||||
|
||||
uploadedActivityMedia.push({
|
||||
mediaType: file.mimeType,
|
||||
mediaFileName: `https://${config.aws.bucketName}.s3.${config.aws.region}.amazonaws.com/${s3Key}`,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/* 🔥 MERGE ACTIVITY MEDIA */
|
||||
const existingMedia = Array.isArray(activityPayload.media)
|
||||
? activityPayload.media
|
||||
: [];
|
||||
activityPayload.media = [...existingMedia, ...uploadedActivityMedia];
|
||||
|
||||
/* 9️⃣ PROCESS VENUE MEDIA UPLOADS */
|
||||
// Group venue files by index: venueImages[0], venueImages[1], etc.
|
||||
const venueFilesMap = new Map<number, typeof files>();
|
||||
|
||||
for (const file of files) {
|
||||
const match = file.fieldName.match(/^venueFiles(\d+)$/);
|
||||
if (!match) continue;
|
||||
|
||||
const venueIndex = Number(match[1]);
|
||||
|
||||
if (!venueFilesMap.has(venueIndex)) {
|
||||
venueFilesMap.set(venueIndex, []);
|
||||
}
|
||||
|
||||
venueFilesMap.get(venueIndex)!.push(file);
|
||||
}
|
||||
|
||||
|
||||
// Upload venue files and attach to corresponding venues
|
||||
if (Array.isArray(activityPayload.venues)) {
|
||||
for (let i = 0; i < activityPayload.venues.length; i++) {
|
||||
const venue = activityPayload.venues[i];
|
||||
const venueFiles = venueFilesMap.get(i) || [];
|
||||
|
||||
const uploadedVenueMedia = [];
|
||||
|
||||
for (const file of venueFiles) {
|
||||
const s3Key = `ActivityOnboarding/Activity_${activityPayload.activityXid}/Venues/Venue_${i}/${file.fileName}`;
|
||||
|
||||
await s3.upload({
|
||||
Bucket: config.aws.bucketName,
|
||||
Key: s3Key,
|
||||
Body: file.buffer,
|
||||
ContentType: file.mimeType,
|
||||
ACL: 'private',
|
||||
}).promise();
|
||||
|
||||
uploadedVenueMedia.push({
|
||||
mediaType: file.mimeType,
|
||||
mediaFileName: `https://${config.aws.bucketName}.s3.${config.aws.region}.amazonaws.com/${s3Key}`,
|
||||
});
|
||||
}
|
||||
|
||||
venue.media = [...(venue.media || []), ...uploadedVenueMedia];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* 🔟 VALIDATION */
|
||||
/* 5️⃣ VALIDATION */
|
||||
let parsedDto: CreateActivityInput;
|
||||
|
||||
if (!isDraft) {
|
||||
const parsed = CreateActivityDto.safeParse(activityPayload);
|
||||
const parsed = CreateActivityDto.safeParse(activity);
|
||||
if (!parsed.success) {
|
||||
throw new ApiError(
|
||||
400,
|
||||
parsed.error.issues.map((i) => i.message).join(', '),
|
||||
parsed.error.issues.map((i) => i.message).join(', ')
|
||||
);
|
||||
}
|
||||
parsedDto = parsed.data;
|
||||
} else {
|
||||
parsedDto = activityPayload as CreateActivityInput;
|
||||
parsedDto = activity as CreateActivityInput;
|
||||
}
|
||||
|
||||
/* 1️⃣1️⃣ SAVE ACTIVITY */
|
||||
const createdActivity = await hostService.createOrUpdateActivity(
|
||||
/* 6️⃣ SAVE TO DB */
|
||||
const result = await hostService.createOrUpdateActivity(
|
||||
userInfo.id,
|
||||
parsedDto,
|
||||
isDraft,
|
||||
isDraft
|
||||
);
|
||||
|
||||
/* 1️⃣2️⃣ RESPONSE */
|
||||
/* 7️⃣ RESPONSE */
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers: {
|
||||
@@ -312,9 +95,9 @@ export const handler = safeHandler(
|
||||
success: true,
|
||||
message: isDraft
|
||||
? 'Activity saved as draft successfully'
|
||||
: 'Activity created successfully',
|
||||
data: createdActivity,
|
||||
: 'Activity submitted successfully',
|
||||
data: result,
|
||||
}),
|
||||
};
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -1,30 +1,97 @@
|
||||
// mediaDelete.ts
|
||||
import { APIGatewayProxyHandler } from 'aws-lambda';
|
||||
import { S3Client, DeleteObjectCommand } from '@aws-sdk/client-s3';
|
||||
import config from '../../../config/config';
|
||||
import ApiError from '../../../common/utils/helper/ApiError';
|
||||
import { verifyHostToken } from '../../../common/middlewares/jwt/authForHost';
|
||||
import { prismaClient } from '../../../common/database/prisma.lambda.service';
|
||||
|
||||
const s3 = new S3Client({ region: config.aws.region, });
|
||||
const s3 = new S3Client({ region: config.aws.region });
|
||||
|
||||
function extractS3Key(input: string): string {
|
||||
if (input.startsWith('s3://')) {
|
||||
return input.replace(`s3://${config.aws.bucketName}/`, '');
|
||||
}
|
||||
|
||||
if (input.startsWith('https://')) {
|
||||
const url = new URL(input);
|
||||
return url.pathname.replace(/^\/+/, '');
|
||||
}
|
||||
|
||||
return input;
|
||||
}
|
||||
|
||||
export const handler: APIGatewayProxyHandler = async (event) => {
|
||||
try {
|
||||
const body = JSON.parse(event.body || '{}');
|
||||
const { key } = body;
|
||||
/* ---------------- AUTH ---------------- */
|
||||
const token = event.headers['x-auth-token'] || event.headers['X-Auth-Token'];
|
||||
if (!token) throw new ApiError(401, 'Missing token.');
|
||||
await verifyHostToken(token);
|
||||
|
||||
/* ---------------- BODY ---------------- */
|
||||
const body = JSON.parse(event.body || '{}');
|
||||
const { key, mediaSource, mediaId } = body;
|
||||
|
||||
if (mediaSource && mediaId) {
|
||||
|
||||
if (!['ACTIVITY', 'VENUE'].includes(mediaSource)) {
|
||||
throw new ApiError(400, 'Invalid mediaSource');
|
||||
}
|
||||
|
||||
/* ---------------- DB DELETE ---------------- */
|
||||
if (mediaSource === 'ACTIVITY') {
|
||||
const media = await prismaClient.activitiesMedia.findUnique({
|
||||
where: { id: Number(mediaId) },
|
||||
});
|
||||
|
||||
if (!media) throw new ApiError(404, 'Activity media not found');
|
||||
|
||||
await prismaClient.activitiesMedia.delete({
|
||||
where: { id: media.id },
|
||||
});
|
||||
}
|
||||
|
||||
if (mediaSource === 'VENUE') {
|
||||
const media = await prismaClient.activityVenueArtifacts.findUnique({
|
||||
where: { id: Number(mediaId) },
|
||||
});
|
||||
|
||||
if (!media) throw new ApiError(404, 'Venue media not found');
|
||||
|
||||
await prismaClient.activityVenueArtifacts.delete({
|
||||
where: { id: media.id },
|
||||
});
|
||||
}
|
||||
|
||||
if (!key) {
|
||||
return response(400, 'S3 key is required');
|
||||
}
|
||||
|
||||
const s3Key = extractS3Key(key);
|
||||
|
||||
/* ---------------- PATH SAFETY ---------------- */
|
||||
const allowedPrefixes = ['ActivityOnboarding/'];
|
||||
if (!allowedPrefixes.some((p) => s3Key.startsWith(p))) {
|
||||
throw new ApiError(403, 'Unauthorized delete path');
|
||||
}
|
||||
|
||||
/* ---------------- S3 DELETE ---------------- */
|
||||
await s3.send(
|
||||
new DeleteObjectCommand({
|
||||
Bucket: config.aws.bucketName!,
|
||||
Key: key,
|
||||
})
|
||||
Key: s3Key,
|
||||
}),
|
||||
);
|
||||
|
||||
return response(200, { success: true });
|
||||
return response(200, {
|
||||
success: true,
|
||||
message: 'Media deleted from DB and S3 successfully',
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
return response(500, 'Failed to delete file');
|
||||
console.error('ERROR:', err);
|
||||
|
||||
if (err instanceof ApiError) {
|
||||
return response(err.statusCode, err.message);
|
||||
}
|
||||
|
||||
return response(500, 'Internal server error');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ export const handler: APIGatewayProxyHandler = async (event) => {
|
||||
if (!token) throw new ApiError(401, 'Missing token.');
|
||||
await verifyHostToken(token);
|
||||
|
||||
|
||||
const body = JSON.parse(event.body || '{}');
|
||||
const { files, venueTempId } = body;
|
||||
|
||||
@@ -35,7 +34,7 @@ export const handler: APIGatewayProxyHandler = async (event) => {
|
||||
throw new ApiError(400, 'activityXid is required in path parameters');
|
||||
}
|
||||
|
||||
const activityDetails = await hostService.getActivityDetailsById(activityXid);
|
||||
const activityDetails = await hostService.getActivityDetailsById(Number(activityXid));
|
||||
if (!activityDetails) {
|
||||
throw new ApiError(404, 'Activity not found');
|
||||
}
|
||||
@@ -79,10 +78,18 @@ export const handler: APIGatewayProxyHandler = async (event) => {
|
||||
files: results,
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
return response(500, 'Failed to generate venue presigned URLs');
|
||||
} catch (err: any) {
|
||||
console.error('ERROR:', err);
|
||||
|
||||
// If it's your ApiError, return its status & message
|
||||
if (err instanceof ApiError) {
|
||||
return response(err.statusCode, err.message);
|
||||
}
|
||||
|
||||
// Fallback for unknown errors
|
||||
return response(500, 'Internal server error');
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
function response(statusCode: number, body: any) {
|
||||
|
||||
@@ -31,7 +31,7 @@ export const handler: APIGatewayProxyHandler = async (event) => {
|
||||
throw new ApiError(400, 'activityXid is required in path parameters');
|
||||
}
|
||||
|
||||
const activityDetails = await hostService.getActivityDetailsById(activityXid);
|
||||
const activityDetails = await hostService.getActivityDetailsById(Number(activityXid));
|
||||
if (!activityDetails) {
|
||||
throw new ApiError(404, 'Activity not found');
|
||||
}
|
||||
@@ -72,10 +72,18 @@ export const handler: APIGatewayProxyHandler = async (event) => {
|
||||
|
||||
return response(200, { files: results });
|
||||
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
return response(500, 'Failed to generate presigned URLs');
|
||||
} catch (err: any) {
|
||||
console.error('ERROR:', err);
|
||||
|
||||
// If it's your ApiError, return its status & message
|
||||
if (err instanceof ApiError) {
|
||||
return response(err.statusCode, err.message);
|
||||
}
|
||||
|
||||
// Fallback for unknown errors
|
||||
return response(500, 'Internal server error');
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
function response(statusCode: number, body: any) {
|
||||
|
||||
@@ -2412,9 +2412,6 @@ export class HostService {
|
||||
const durationHours = Number(payload.durationHours ?? 0);
|
||||
const durationMins = Number(payload.durationMins ?? 0);
|
||||
|
||||
const activityDurationMins =
|
||||
durationDays * 24 * 60 + durationHours * 60 + durationMins;
|
||||
|
||||
/* =====================================================
|
||||
* BASIC GUARDS
|
||||
* ===================================================== */
|
||||
@@ -2457,8 +2454,8 @@ export class HostService {
|
||||
}
|
||||
|
||||
if (
|
||||
activityDurationMins > 0 &&
|
||||
payload.cancellationAllowedBeforeMins >= activityDurationMins
|
||||
durationMins > 0 &&
|
||||
payload.cancellationAllowedBeforeMins >= durationMins
|
||||
) {
|
||||
throw new ApiError(
|
||||
400,
|
||||
@@ -2637,7 +2634,7 @@ export class HostService {
|
||||
checkOutAddress: payload.checkOutAddress ?? undefined,
|
||||
|
||||
// energyLevelXid: payload.energyLevelXid ?? undefined,
|
||||
activityDurationMins: activityDurationMins ?? undefined,
|
||||
activityDurationMins: durationMins ?? undefined,
|
||||
|
||||
currencyXid: payload.currencyXid ?? undefined,
|
||||
sustainabilityScore: payload.sustainabilityScore ?? undefined,
|
||||
|
||||
Reference in New Issue
Block a user