46 Commits

Author SHA1 Message Date
0b81dbf7b1 made a new getAllBucketActivities api 2026-03-13 13:09:25 +05:30
cf2bbbf138 fixed the response structure in the specific api 2026-03-11 15:53:54 +05:30
0aa2b9b53e fixed the search api for the specific and sending by default 15 km radius activities in the nearby activity api 2026-03-11 13:41:15 +05:30
b5cdb20c4f sending the latestactivity image in the bucket 2026-03-10 21:30:43 +05:30
00e07113e5 sending the latest added activity image in the add to bucket api and fixed the presignedurl in the getFilteredLandingPageAllDetails api and fixed the logic to send the activities from the selected activityxids 2026-03-10 19:36:44 +05:30
c8f0f93792 sending the duration cheapprice per person and sustanibility score in the surprise me api 2026-03-10 18:12:26 +05:30
87779664d1 sending the activity type xids in the search api of I am specific 2026-03-10 15:26:57 +05:30
5b31e5f2a9 sending the activity count in the submit personal info 2026-03-09 18:49:40 +05:30
2a073c44a2 Merge branch 'paritosh-main1' of http://git.wdipl.com/Mayank.Mishra/MinglarBackendNestJS into mayankSprint2 2026-03-09 16:09:36 +05:30
paritosh18
220d309087 Added profile image upload functionality to updateHostProfile API, including multipart form data handling and S3 integration for image storage. 2026-03-09 16:09:20 +05:30
f45c33ba83 sending the random activities in the getconnection api 2026-03-09 16:00:31 +05:30
22b3593150 sending 5 random activities in the get surprise me api 2026-03-09 15:57:14 +05:30
d186681ee4 added isActive condition 2026-03-09 15:51:58 +05:30
8f428fc1cb fixed the i am specific new api issue 2026-03-09 15:18:59 +05:30
0b503cf8bb sending the checkIn and checkOut location 2026-03-06 19:59:31 +05:30
7110d0462c Merge branch 'paritosh-main1' of http://git.wdipl.com/Mayank.Mishra/MinglarBackendNestJS into mayankSprint2 2026-03-06 17:37:56 +05:30
96648fe37e sending the isBukcet and isInterested flag in getbyid api and changed the logic in the getactivitiesfrom connections api 2026-03-06 17:36:14 +05:30
paritosh18
2095f8e124 filtered landing page for specific search api 2026-03-06 17:11:21 +05:30
21c8799502 Merge branch 'paritosh-main1' of http://git.wdipl.com/Mayank.Mishra/MinglarBackendNestJS into mayankSprint2 2026-03-06 15:49:46 +05:30
paritosh18
ad9e8e1a3f Added remove api from interested 2026-03-06 15:49:25 +05:30
b200e2cb94 sending the distance in the getbyid of activity 2026-03-06 15:49:13 +05:30
cae66237d2 fixed the check availability api and sending the interested and bucket count in the connection api 2026-03-06 15:40:21 +05:30
25be8a5647 sending the distance in connection activities api 2026-03-05 19:21:16 +05:30
7a4aecdd45 Merge branch 'paritosh-main1' of http://git.wdipl.com/Mayank.Mishra/MinglarBackendNestJS into mayankSprint2 2026-03-05 18:58:34 +05:30
5cced2981a sending the distance in the apis 2026-03-05 18:57:58 +05:30
paritosh18
b9fbab3717 Merge branch 'mayankSprint2' of http://git.wdipl.com/Mayank.Mishra/MinglarBackendNestJS into paritosh-main1 2026-03-05 18:52:32 +05:30
paritosh18
90c897ad48 remove deleteMany call to allow reuse of refresh tokens in generateAuthToken method 2026-03-05 18:44:57 +05:30
4a069cc67a if there is no slot then the venue will not be sent 2026-03-05 18:12:35 +05:30
paritosh18
5d046c4bcf update ActivityOtherDetails model to change string fields to text type for better data handling 2026-03-05 16:54:43 +05:30
accfc4b769 sending the safety instruction and cancellations in the getbyid api of activity in user 2026-03-05 16:15:15 +05:30
e149884f72 sending hostheader data also in the get latest agreement details api response 2026-03-05 13:05:56 +05:30
a31ec97640 Merge branch 'paritosh-main1' of http://git.wdipl.com/Mayank.Mishra/MinglarBackendNestJS into mayankSprint2 2026-03-05 12:49:44 +05:30
0c97412057 sending pincode in getbyid 2026-03-05 11:25:33 +05:30
paritosh18
b4ff39c0d7 removed logs 2026-03-04 18:59:37 +05:30
bb5da7647b sending the address details in the getbyid host api 2026-03-04 18:57:14 +05:30
3f19bb4087 Merge branch 'paritosh-main1' of http://git.wdipl.com/Mayank.Mishra/MinglarBackendNestJS into mayankSprint2 2026-03-04 18:45:44 +05:30
be8b9cef7d gbbb 2026-03-04 18:45:33 +05:30
paritosh18
77cef98091 Implement updateHostProfile endpoint and related service logic; remove navigation modes seeding; add logging for user activities 2026-03-04 17:04:14 +05:30
97f9c2b26e fixed the get in the getbyid 2026-03-04 12:23:29 +05:30
paritosh18
b93cd6b32c Merge branch 'mayankSprint2' of http://git.wdipl.com/Mayank.Mishra/MinglarBackendNestJS into paritosh-main1 2026-03-02 19:07:20 +05:30
paritosh18
51319a69fc Enhance getCityByState handler to support optional search term with validation and update service method to filter cities accordingly. 2026-03-02 19:06:48 +05:30
5ad46309ef getting and storing the string for the navigation modes not the xids 2026-03-02 19:05:08 +05:30
paritosh18
781212277a ghfghf 2026-03-02 13:01:53 +05:30
6b0ee461c5 sending the hostId in the get stepper api 2026-03-02 12:35:51 +05:30
cc2fa3eb6b sending the 5 random users profile image in the getbyid api 2026-02-28 13:36:33 +05:30
fe6bb59cc7 Made add to bucket interested api and sending the count of bucket interested in the landing page api and surprise me api and sending the updated count in the add to bucket api 2026-02-28 13:11:26 +05:30
23 changed files with 3142 additions and 385 deletions

View File

@@ -2,6 +2,7 @@ generator client {
provider = "prisma-client-js"
binaryTargets = ["native", "rhel-openssl-3.0.x"] // Add Linux target
previewFeatures = ["multiSchema"]
engineType = "library"
}
datasource db {
@@ -554,20 +555,6 @@ model Frequencies {
@@schema("mst")
}
model NavigationModes {
id Int @id @default(autoincrement())
navigationModeName String @unique @map("navigation_mode_name") @db.VarChar(30)
navigationModeIcon String @map("navigation_mode_icon") @db.VarChar(500)
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")
ActivityNavigationModes ActivityNavigationModes[]
@@map("navigation_modes")
@@schema("mst")
}
model TransportModes {
id Int @id @default(autoincrement())
transportModeName String @unique @map("transport_mode_name") @db.VarChar(60)
@@ -1052,13 +1039,13 @@ model ActivityOtherDetails {
id Int @id @default(autoincrement())
activityXid Int @map("activity_xid")
activity Activities @relation(fields: [activityXid], references: [id], onDelete: Cascade)
exclusiveNotes String? @map("exclusive_notes") @db.VarChar(500)
SafetyInstruction String? @map("safety_instruction") @db.VarChar(400)
Cancellations String? @map("cancellations") @db.VarChar(400)
dosNotes String? @map("dos_notes") @db.VarChar(400)
dontsNotes String? @map("donts_notes") @db.VarChar(400)
tipsNotes String? @map("tips_notes") @db.VarChar(400)
termsAndCondition String? @map("terms_and_condition") @db.VarChar(500)
exclusiveNotes String? @map("exclusive_notes") @db.Text
SafetyInstruction String? @map("safety_instruction") @db.Text
Cancellations String? @map("cancellations") @db.Text
dosNotes String? @map("dos_notes") @db.Text
dontsNotes String? @map("donts_notes") @db.Text
tipsNotes String? @map("tips_notes") @db.Text
termsAndCondition String? @map("terms_and_condition") @db.Text
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@ -1456,8 +1443,7 @@ model ActivityNavigationModes {
id Int @id @default(autoincrement())
activityXid Int @map("activity_xid")
activity Activities @relation(fields: [activityXid], references: [id], onDelete: Cascade)
navigationModeXid Int @map("navigation_mode_xid")
navigationMode NavigationModes @relation(fields: [navigationModeXid], references: [id], onDelete: Restrict)
navigationModeName String @map("navigation_mode_name") @db.VarChar(30)
isInActivityChargeable Boolean @default(false) @map("is_in_activity_chargeable")
navigationModesBasePrice Int @map("navigation_modes_base_price")
navigationModesTotalPrice Int @map("navigation_modes_total_price")
@@ -1637,8 +1623,8 @@ model Cancellations {
scheduleHeaderXid Int @map("schedule_header_xid")
scheduleHeader ScheduleHeader @relation(fields: [scheduleHeaderXid], references: [id], onDelete: Cascade)
occurenceDate DateTime? @map("occurence_date")
startTime String? @map("start_time") @db.VarChar(30)
endTime String? @map("end_time") @db.VarChar(30)
startTime String? @map("start_time") @db.VarChar(30)
endTime String? @map("end_time") @db.VarChar(30)
cancellationReason String? @map("cancellation_reason")
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at")

View File

@@ -268,9 +268,9 @@ async function main() {
create: { interestName: 'Nightlife & Events', displayOrder: 10, interestColor: 'Blue', interestImage: 'https://minglar-dev-bucket.s3.ap-south-1.amazonaws.com/StaticImages/InterestTypes/NightlifeandEvents.png', interestCode: 'NE' },
});
const furfam = await prisma.interests.upsert({
where: { interestName: 'Fur Fam' },
where: { interestName: 'Pet space' },
update: {},
create: { interestName: 'Fur Fam', displayOrder: 11, interestColor: 'Blue', interestImage: 'https://minglar-dev-bucket.s3.ap-south-1.amazonaws.com/StaticImages/InterestTypes/petspace.jpg', interestCode: 'PS' },
create: { interestName: 'Pet space', displayOrder: 11, interestColor: 'Blue', interestImage: 'https://minglar-dev-bucket.s3.ap-south-1.amazonaws.com/StaticImages/InterestTypes/petspace.jpg', interestCode: 'PS' },
});
const dogoodfeelgood = await prisma.interests.upsert({
where: { interestName: 'Do Good, Feel Good' },
@@ -693,16 +693,6 @@ async function main() {
skipDuplicates: true,
});
// ✅ Navigation Modes
await prisma.navigationModes.createMany({
data: [
{ navigationModeName: 'Elephant Ride', navigationModeIcon: '🚗' },
{ navigationModeName: 'Horse Ride', navigationModeIcon: '🏍️' },
{ navigationModeName: 'Camel Ride', navigationModeIcon: '🚶' },
],
skipDuplicates: true,
});
// ✅ Transport Modes
await prisma.transportModes.createMany({
data: [

View File

@@ -1,21 +1,21 @@
# Host Module Functions
# All authentication and host management endpoints
getHosts:
handler: src/modules/host/handlers/host.handler
memorySize: 384
package:
patterns:
- 'src/modules/host/handlers/host.*'
- 'src/modules/host/services/**'
- ${file(./serverless/patterns/base.yml):pattern1}
- ${file(./serverless/patterns/base.yml):pattern2}
- ${file(./serverless/patterns/base.yml):pattern3}
- ${file(./serverless/patterns/base.yml):pattern4}
events:
- httpApi:
path: /host
method: get
# getHosts:
# handler: src/modules/host/handlers/host.handler
# memorySize: 384
# package:
# patterns:
# - 'src/modules/host/handlers/host.*'
# - 'src/modules/host/services/**'
# - ${file(./serverless/patterns/base.yml):pattern1}
# - ${file(./serverless/patterns/base.yml):pattern2}
# - ${file(./serverless/patterns/base.yml):pattern3}
# - ${file(./serverless/patterns/base.yml):pattern4}
# events:
# - httpApi:
# path: /host
# method: get
verifyOTP:
handler: src/modules/host/handlers/Host_Admin/onboarding/verifyOTP.handler
@@ -258,6 +258,22 @@ acceptAggrement:
path: /host/Host_Admin/onboarding/accept-agreement
method: patch
getLatestAgreement:
handler: src/modules/host/handlers/Host_Admin/onboarding/getLatestAgreement.handler
memorySize: 384
package:
patterns:
- 'src/modules/host/handlers/Host_Admin/onboarding/getLatestAgreement.*'
- 'src/modules/host/services/**'
- ${file(./serverless/patterns/base.yml):pattern1}
- ${file(./serverless/patterns/base.yml):pattern2}
- ${file(./serverless/patterns/base.yml):pattern3}
- ${file(./serverless/patterns/base.yml):pattern4}
events:
- httpApi:
path: /host/Host_Admin/onboarding/get-latest-agreement
method: get
getStepperInfo:
handler: src/modules/host/handlers/getStepper.handler
memorySize: 384
@@ -276,6 +292,22 @@ getStepperInfo:
path: /stepper
method: get
updateHostProfile:
handler: src/modules/host/handlers/updateHostProfile.handler
memorySize: 384
package:
patterns:
- 'src/modules/host/handlers/updateHostProfile.*'
- 'src/modules/host/services/**'
- ${file(./serverless/patterns/base.yml):pattern1}
- ${file(./serverless/patterns/base.yml):pattern2}
- ${file(./serverless/patterns/base.yml):pattern3}
- ${file(./serverless/patterns/base.yml):pattern4}
events:
- httpApi:
path: /host/profile
method: patch
# Functions with S3/AWS SDK dependencies
submitCompanyDetails:
handler: src/modules/host/handlers/Host_Admin/onboarding/submitCompanyDetails.handler

View File

@@ -346,4 +346,64 @@ getNearbyActivities:
events:
- httpApi:
path: /user/activities/get-nearby-activities
method: get
addActivityToBucketInterested:
handler: src/modules/user/handlers/activities/addToBucketInterested.handler
memorySize: 384
package:
patterns:
- 'src/modules/user/handlers/activities/**'
- ${file(./serverless/patterns/base.yml):pattern1}
- ${file(./serverless/patterns/base.yml):pattern2}
- ${file(./serverless/patterns/base.yml):pattern3}
- ${file(./serverless/patterns/base.yml):pattern4}
events:
- httpApi:
path: /user/activities/add-to-bucket-interested
method: post
removeActivityFromBucketInterested:
handler: src/modules/user/handlers/activities/removeFromBucketInterested.handler
memorySize: 384
package:
patterns:
- 'src/modules/user/handlers/activities/**'
- ${file(./serverless/patterns/base.yml):pattern1}
- ${file(./serverless/patterns/base.yml):pattern2}
- ${file(./serverless/patterns/base.yml):pattern3}
- ${file(./serverless/patterns/base.yml):pattern4}
events:
- httpApi:
path: /user/activities/remove-from-bucket-interested
method: post
getFilteredLandingPageAllDetails:
handler: src/modules/user/handlers/activities/filteredLandingPageAllDetails.handler
memorySize: 512
package:
patterns:
- 'src/modules/user/**'
- ${file(./serverless/patterns/base.yml):pattern1}
- ${file(./serverless/patterns/base.yml):pattern2}
- ${file(./serverless/patterns/base.yml):pattern3}
- ${file(./serverless/patterns/base.yml):pattern4}
events:
- httpApi:
path: /user/activities/get-filtered-landing-page-details
method: get
getAllBucketActivities:
handler: src/modules/user/handlers/activities/getAllBucketActivities.handler
memorySize: 512
package:
patterns:
- 'src/modules/user/**'
- ${file(./serverless/patterns/base.yml):pattern1}
- ${file(./serverless/patterns/base.yml):pattern2}
- ${file(./serverless/patterns/base.yml):pattern3}
- ${file(./serverless/patterns/base.yml):pattern4}
events:
- httpApi:
path: /user/activities/get-all-bucket-activities
method: get

View File

@@ -54,7 +54,7 @@ export const EquipmentDto = z.object({
/* ================= NAVIGATION MODE ================= */
export const NavigationModeDto = z.object({
navigationModeXid: z.number().int(),
navigationModeName: z.string().optional(),
isChargeable: z.boolean().optional(),
totalPrice: z.number().int().optional().default(0),
});

View File

@@ -1,6 +1,6 @@
import { verifyHostToken } from '../../../../../common/middlewares/jwt/authForHost';
import { APIGatewayProxyEvent, APIGatewayProxyResult, Context } from 'aws-lambda';
import { prismaClient } from '../../../../../common/database/prisma.lambda.service';
import { verifyHostToken } from '../../../../../common/middlewares/jwt/authForHost';
import { safeHandler } from '../../../../../common/utils/handlers/safeHandler';
import ApiError from '../../../../../common/utils/helper/ApiError';
import { HostService } from '../../../services/host.service';
@@ -25,9 +25,8 @@ export const handler = safeHandler(async (
// Verify token and get user info
const userInfo = await verifyHostToken(token);
// Add suggestion using service
await hostService.acceptMinglarAgreement(userInfo.id);
// Accept agreement and get dynamic fields and PDF URL
const result = await hostService.acceptMinglarAgreement(userInfo.id);
return {
statusCode: 200,
@@ -38,7 +37,10 @@ export const handler = safeHandler(async (
body: JSON.stringify({
success: true,
message: 'Application accepted successfully',
data: null,
data: {
filePath: result.filePath,
dynamicFields: result.dynamicFields,
},
}),
};
});

View File

@@ -0,0 +1,54 @@
import { verifyMinglarAdminHostToken } from '../../../../../common/middlewares/jwt/authForMinglarAdminHost';
import { APIGatewayProxyEvent, APIGatewayProxyResult, Context } from 'aws-lambda';
import { prismaClient } from '../../../../../common/database/prisma.lambda.service';
import { safeHandler } from '../../../../../common/utils/handlers/safeHandler';
import ApiError from '../../../../../common/utils/helper/ApiError';
import { HostService } from '../../../services/host.service';
const hostService = new HostService(prismaClient);
/**
* Get latest active agreement for a specific host by hostXid.
* Accessible for Minglar Admin / Host Admin using admin-host token.
*/
export const handler = safeHandler(async (
event: APIGatewayProxyEvent,
context?: Context,
): Promise<APIGatewayProxyResult> => {
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.');
}
// Validate admin/host admin token
await verifyMinglarAdminHostToken(token);
const hostXidParam =
event.queryStringParameters?.hostXid ?? event.queryStringParameters?.host_xid;
const hostXid = Number(hostXidParam);
if (!hostXidParam) {
throw new ApiError(400, 'hostXid is required');
}
if (Number.isNaN(hostXid)) {
throw new ApiError(400, 'Invalid hostXid format');
}
const agreement = await hostService.getLatestHostAgreement(hostXid);
return {
statusCode: 200,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
},
body: JSON.stringify({
success: true,
message: 'Latest host agreement retrieved successfully',
data: agreement,
}),
};
});

View File

@@ -39,6 +39,7 @@ export const handler = safeHandler(async (
data: {
stepper: host?.host?.stepper || null,
emailAddress: host.user?.emailAddress || null,
hostId: host.user?.userRefNumber || null,
},
}),
};

View File

@@ -0,0 +1,344 @@
import { APIGatewayProxyEvent, APIGatewayProxyResult, Context } from 'aws-lambda';
import dayjs from 'dayjs';
import { z } from 'zod';
import AWS from 'aws-sdk';
import config from '../../../config/config';
import { prismaClient } from '../../../common/database/prisma.lambda.service';
import { verifyHostToken } from '../../../common/middlewares/jwt/authForHost';
import { safeHandler } from '../../../common/utils/handlers/safeHandler';
import ApiError from "../../../common/utils/helper/ApiError";
import { ROLE } from '../../../common/utils/constants/common.constant';
import { parseMultipartFormData } from '../../../common/utils/helper/parseMultipartFormData';
const s3 = new AWS.S3({
region: config.aws.region,
});
const updateHostProfileSchema = z
.strictObject({
// Personal
fullName: z.string().min(1).optional(),
firstName: z.string().min(1).optional(),
lastName: z.string().min(1).optional(),
isdCode: z.string().min(1).max(6).optional(),
mobileNumber: z.string().min(5).max(15).optional(),
dateOfBirth: z.string().min(1).optional(),
profileImage: z.string().url().optional(),
// Address
address1: z.string().min(1).optional(),
address2: z.string().min(1).optional(),
countryXid: z.number().int().positive().optional(),
stateXid: z.number().int().positive().optional(),
cityXid: z.number().int().positive().optional(),
pinCode: z.string().min(1).optional(),
// explicitly forbidden
emailAddress: z.any().optional(),
})
.strip();
async function uploadProfileImageToS3(buffer: Buffer, mimeType: string, originalName: string, userId: number) {
const sanitizeFileName = (name: string) => {
return name
.toLowerCase()
.replace(/[^a-z0-9.]/g, '_')
.replace(/_+/g, '_')
.replace(/^_+|_+$/g, '');
};
const fileExtension = originalName.split('.').pop() || 'jpg';
const fileName = `profile_image.${fileExtension}`;
const sanitizedFileName = sanitizeFileName(fileName);
const s3Key = `Host/ProfileImages/${userId}/${sanitizedFileName}`;
await s3
.upload({
Bucket: config.aws.bucketName,
Key: s3Key,
Body: buffer,
ContentType: mimeType,
ACL: 'private',
})
.promise();
return `https://${config.aws.bucketName}.s3.${config.aws.region}.amazonaws.com/${s3Key}`;
}
function parseDob(dateOfBirth: string): Date {
const parsed = dayjs(dateOfBirth, ['YYYY-MM-DD', 'MM/DD/YYYY', 'DD/MM/YYYY'], true);
if (!parsed.isValid()) {
throw new ApiError(400, 'Invalid dateOfBirth. Use YYYY-MM-DD (recommended) or MM/DD/YYYY.');
}
return parsed.toDate();
}
function splitFullName(fullName: string): { firstName: string; lastName: string | null } {
const parts = fullName.trim().split(/\s+/).filter(Boolean);
const firstName = parts[0] || '';
const lastName = parts.length > 1 ? parts.slice(1).join(' ') : null;
return { firstName, lastName };
}
function getAuthToken(event: APIGatewayProxyEvent): string {
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.');
}
return token;
}
function parseJsonBody(event: APIGatewayProxyEvent): any {
try {
return event.body ? JSON.parse(event.body) : {};
} catch {
throw new ApiError(400, 'Invalid JSON in request body');
}
}
function validateBody(body: any) {
const parsed = updateHostProfileSchema.safeParse(body);
if (!parsed.success) {
throw new ApiError(400, parsed.error.issues.map((i) => i.message).join(', '));
}
if (parsed.data.emailAddress !== undefined) {
throw new ApiError(400, 'Email address cannot be updated.');
}
return parsed.data;
}
function normalizeNameFields(data: any): { firstName?: string; lastName?: string | null } {
if (data.fullName && !data.firstName && !data.lastName) {
const split = splitFullName(data.fullName);
return { firstName: split.firstName, lastName: split.lastName };
}
return { firstName: data.firstName, lastName: data.lastName };
}
function buildAddressInput(data: any) {
return {
address1: data.address1,
address2: data.address2,
countryXid: data.countryXid,
stateXid: data.stateXid,
cityXid: data.cityXid,
pinCode: data.pinCode,
};
}
function hasAnyDefined(obj: Record<string, unknown>) {
return Object.values(obj).some((v) => v !== undefined);
}
async function ensureHostUser(tx: any, userId: number) {
const user = await tx.user.findUnique({
where: { id: userId, isActive: true },
select: { id: true, roleXid: true },
});
if (!user) throw new ApiError(404, 'User not found');
if (user.roleXid !== ROLE.HOST) throw new ApiError(403, 'Access denied.');
}
async function updateUserIfNeeded(
tx: any,
userId: number,
input: {
firstName?: string;
lastName?: string | null;
isdCode?: string;
mobileNumber?: string;
dateOfBirth?: string;
profileImage?: string;
},
) {
const userUpdateData: any = {};
if (input.firstName !== undefined) userUpdateData.firstName = input.firstName || null;
if (input.lastName !== undefined) userUpdateData.lastName = input.lastName;
if (input.isdCode !== undefined) userUpdateData.isdCode = input.isdCode || null;
if (input.mobileNumber !== undefined) userUpdateData.mobileNumber = input.mobileNumber || null;
if (input.dateOfBirth !== undefined) {
userUpdateData.dateOfBirth = input.dateOfBirth ? parseDob(input.dateOfBirth) : null;
}
if (input.profileImage !== undefined) {
userUpdateData.profileImage = input.profileImage || null;
}
if (!hasAnyDefined(userUpdateData)) return;
await tx.user.update({
where: { id: userId },
data: {
...userUpdateData,
isProfileUpdated: true,
},
});
}
async function upsertAddressIfNeeded(tx: any, userId: number, addressData: Record<string, any>) {
if (!hasAnyDefined(addressData)) return;
const existingAddress = await tx.userAddressDetails.findFirst({
where: { userXid: userId, isActive: true },
select: { id: true },
});
const addressUpdateData: any = {};
if (addressData.address1 !== undefined) addressUpdateData.address1 = addressData.address1;
if (addressData.address2 !== undefined) addressUpdateData.address2 = addressData.address2;
if (addressData.countryXid !== undefined) addressUpdateData.countryXid = addressData.countryXid;
if (addressData.stateXid !== undefined) addressUpdateData.stateXid = addressData.stateXid;
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,
});
return;
}
const required = ['address1', 'countryXid', 'stateXid', 'cityXid', 'pinCode'] as const;
const missing = required.filter((k) => addressData[k] === undefined);
if (missing.length) {
throw new ApiError(400, `Missing required address fields: ${missing.join(', ')}`);
}
await tx.userAddressDetails.create({
data: {
userXid: userId,
...addressUpdateData,
},
});
}
async function getProfileSnapshot(tx: any, userId: number) {
const updated = await tx.user.findUnique({
where: { id: userId },
select: {
id: true,
firstName: true,
lastName: true,
emailAddress: true,
isdCode: true,
mobileNumber: true,
dateOfBirth: true,
profileImage: true,
isProfileUpdated: true,
userAddressDetails: {
where: { isActive: true },
take: 1,
select: {
id: true,
address1: true,
address2: true,
countryXid: true,
stateXid: true,
cityXid: true,
pinCode: true,
},
},
},
});
return {
user: updated,
address: updated?.userAddressDetails?.[0] ?? null,
};
}
export const handler = safeHandler(async (
event: APIGatewayProxyEvent,
context?: Context,
): Promise<APIGatewayProxyResult> => {
const token = getAuthToken(event);
const userInfo = await verifyHostToken(token);
const userId = Number(userInfo.id);
if (!userId || Number.isNaN(userId)) {
throw new ApiError(400, 'Invalid user id');
}
const contentType = event.headers['Content-Type'] || event.headers['content-type'] || '';
const isMultipart = contentType.includes('multipart/form-data');
let body: any;
if (isMultipart) {
const isBase64Encoded = event.isBase64Encoded || false;
const { fields, files } = parseMultipartFormData(event.body || null, contentType, isBase64Encoded);
const multipartBody: any = {};
const copyIfPresent = (key: string) => {
if (fields[key] !== undefined) {
multipartBody[key] = fields[key];
}
};
['fullName', 'firstName', 'lastName', 'isdCode', 'mobileNumber', 'dateOfBirth', 'address1', 'address2', 'pinCode'].forEach(
copyIfPresent,
);
const parseNumberField = (key: string) => {
if (fields[key] !== undefined) {
const value = Number(fields[key]);
if (!Number.isNaN(value)) {
multipartBody[key] = value;
}
}
};
['countryXid', 'stateXid', 'cityXid'].forEach(parseNumberField);
const profileImageFile = files.find((f) => f.fieldName === 'profileImage');
if (profileImageFile) {
const uploadedUrl = await uploadProfileImageToS3(
profileImageFile.data,
profileImageFile.contentType,
profileImageFile.fileName,
userId,
);
multipartBody.profileImage = uploadedUrl;
} else if (fields.profileImage) {
multipartBody.profileImage = fields.profileImage;
}
body = multipartBody;
} else {
body = parseJsonBody(event);
}
const data = validateBody(body);
const name = normalizeNameFields(data);
const address = buildAddressInput(data);
const result = await prismaClient.$transaction(async (tx) => {
await ensureHostUser(tx, userId);
await updateUserIfNeeded(tx, userId, {
firstName: name.firstName,
lastName: name.lastName,
isdCode: data.isdCode,
mobileNumber: data.mobileNumber,
dateOfBirth: data.dateOfBirth,
profileImage: data.profileImage,
});
await upsertAddressIfNeeded(tx, userId, address);
return getProfileSnapshot(tx, userId);
});
return {
statusCode: 200,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
},
body: JSON.stringify({
success: true,
message: 'Profile updated successfully',
data: result,
}),
};
});

View File

@@ -362,6 +362,9 @@ export class SchedulingService {
isActive: true,
startDate: { lte: date },
OR: [{ endDate: null }, { endDate: { gte: date } }],
ScheduleDetails: {
some: {}
}
},
include: {
activityVenue: {

View File

@@ -177,8 +177,8 @@ function computeBasePriceAndTaxes(
return { basePrice, taxDetails };
}
const normalize = (v?: string | null) =>
v ? v.trim().toLowerCase() : null;
const normalize = (v?: string | null, maxLength: number = 50) =>
v ? v.trim().toLowerCase().substring(0, maxLength) : null;
async function renderAgreementPdf(vars: {
effectiveDate: string;
@@ -338,9 +338,11 @@ const findOrCreateState = async (
) => {
if (!stateName || !countryXid) return null;
const trimmedStateName = stateName.trim().substring(0, 50);
const state = await tx.states.findFirst({
where: {
stateName: { equals: stateName.trim(), mode: 'insensitive' },
stateName: { equals: trimmedStateName, mode: 'insensitive' },
countryXid,
isActive: true,
},
@@ -350,7 +352,7 @@ const findOrCreateState = async (
const created = await tx.states.create({
data: {
stateName: stateName.trim(),
stateName: trimmedStateName,
countryXid,
},
});
@@ -365,9 +367,11 @@ const findOrCreateCity = async (
) => {
if (!cityName || !stateXid) return null;
const trimmedCityName = cityName.trim().substring(0, 50);
const city = await tx.cities.findFirst({
where: {
cityName: { equals: cityName.trim(), mode: 'insensitive' },
cityName: { equals: trimmedCityName, mode: 'insensitive' },
stateXid,
isActive: true,
},
@@ -377,7 +381,7 @@ const findOrCreateCity = async (
const created = await tx.cities.create({
data: {
cityName: cityName.trim(),
cityName: trimmedCityName,
stateXid,
},
});
@@ -391,6 +395,22 @@ const s3 = new AWS.S3({
region: config.aws.region,
});
type UpdateHostProfileInput = {
firstName?: string;
lastName?: string | null;
isdCode?: string;
mobileNumber?: string;
dateOfBirth?: Date;
address?: {
address1?: string;
address2?: string;
countryXid?: number;
stateXid?: number;
cityXid?: number;
pinCode?: string;
};
};
@Injectable()
export class HostService {
constructor(private prisma: PrismaClient) { }
@@ -415,8 +435,8 @@ export class HostService {
});
const user = await this.prisma.user.findUnique({
where: { id: user_xid },
select: { id: true, emailAddress: true },
where: { id: user_xid, isActive: true },
select: { id: true, emailAddress: true, userRefNumber: true },
});
return { host, user };
}
@@ -465,6 +485,39 @@ export class HostService {
profileImage: true,
userStatus: true,
userRefNumber: true,
userAddressDetails: {
where: { isActive: true },
select: {
id: true,
address1: true,
address2: true,
locationAddress: true,
locationLat: true,
locationLong: true,
pinCode: true,
cityXid: true,
cities: {
select: {
id: true,
cityName: true,
}
},
stateXid: true,
states: {
select: {
id: true,
stateName: true,
}
},
countryXid: true,
country: {
select: {
id: true,
countryName: true
}
}
}
}
},
},
companyTypes: {
@@ -577,6 +630,114 @@ export class HostService {
return this.prisma.user.delete({ where: { id } });
}
/**
* Update the logged-in Host's personal profile details.
* Email is intentionally NOT editable here.
*/
async updateHostProfileDetails(userId: number, input: UpdateHostProfileInput) {
return this.prisma.$transaction(async (tx) => {
const user = await tx.user.findUnique({
where: { id: userId, isActive: true },
select: { id: true, roleXid: true },
});
if (!user) throw new ApiError(404, 'User not found');
if (user.roleXid !== ROLE.HOST) throw new ApiError(403, 'Access denied.');
// 1) Update `User` (whitelist only)
const userUpdateData: any = {};
if (input.firstName !== undefined) userUpdateData.firstName = input.firstName || null;
if (input.lastName !== undefined) userUpdateData.lastName = input.lastName;
if (input.isdCode !== undefined) userUpdateData.isdCode = input.isdCode || null;
if (input.mobileNumber !== undefined) userUpdateData.mobileNumber = input.mobileNumber || null;
if (input.dateOfBirth !== undefined) userUpdateData.dateOfBirth = input.dateOfBirth;
if (Object.keys(userUpdateData).length > 0) {
await tx.user.update({
where: { id: userId },
data: {
...userUpdateData,
isProfileUpdated: true,
},
});
}
// 2) Update/Create `UserAddressDetails` (if any address field sent)
const addressData = input.address || {};
const hasAnyAddressField = Object.values(addressData).some((v) => v !== undefined);
if (hasAnyAddressField) {
const existingAddress = await tx.userAddressDetails.findFirst({
where: { userXid: userId, isActive: true },
select: { id: true },
});
const addressUpdateData: any = {};
if (addressData.address1 !== undefined) addressUpdateData.address1 = addressData.address1;
if (addressData.address2 !== undefined) addressUpdateData.address2 = addressData.address2;
if (addressData.countryXid !== undefined) addressUpdateData.countryXid = addressData.countryXid;
if (addressData.stateXid !== undefined) addressUpdateData.stateXid = addressData.stateXid;
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 {
const required = ['address1', 'countryXid', 'stateXid', 'cityXid', 'pinCode'] as const;
const missing = required.filter((k) => addressData[k] === undefined);
if (missing.length) {
throw new ApiError(400, `Missing required address fields: ${missing.join(', ')}`);
}
await tx.userAddressDetails.create({
data: {
userXid: userId,
...addressUpdateData,
},
});
}
}
// 3) Return updated profile snapshot (including read-only email)
const updated = await tx.user.findUnique({
where: { id: userId },
select: {
id: true,
firstName: true,
lastName: true,
emailAddress: true,
isdCode: true,
mobileNumber: true,
dateOfBirth: true,
profileImage: true,
isProfileUpdated: true,
userAddressDetails: {
where: { isActive: true },
take: 1,
select: {
id: true,
address1: true,
address2: true,
countryXid: true,
stateXid: true,
cityXid: true,
pinCode: true,
},
},
},
});
return {
user: updated,
address: updated?.userAddressDetails?.[0] ?? null,
};
});
}
async getHostByEmail(email: string): Promise<User> {
return this.prisma.user.findUnique({ where: { emailAddress: email } });
}
@@ -919,55 +1080,150 @@ export class HostService {
acceptDate,
};
const pdfBuffer = await renderAgreementPdf(agreementVars);
let pdfUrl: string | null = null;
const existingCount = await this.prisma.hostAgreement.count({
where: { hostXid: host.id, isActive: true },
});
try {
const pdfBuffer = await renderAgreementPdf(agreementVars);
const nextVersionNumber = `AG${existingCount + 1}`;
const baseKey = `Documents/Host/${host.id}/agreements/${nextVersionNumber}`;
const pdfKey = `${baseKey}.pdf`;
await s3
.upload({
Bucket: config.aws.bucketName,
Key: pdfKey,
Body: pdfBuffer,
ContentType: 'application/pdf',
ACL: 'private',
})
.promise();
const pdfUrl = `https://${config.aws.bucketName}.s3.${config.aws.region}.amazonaws.com/${pdfKey}`;
await this.prisma.$transaction(async (tx) => {
// Optional: mark previous agreements inactive
await tx.hostAgreement.updateMany({
const existingCount = await this.prisma.hostAgreement.count({
where: { hostXid: host.id, isActive: true },
data: { isActive: false },
});
await tx.hostAgreement.create({
data: {
hostXid: host.id,
filePath: pdfUrl,
versionNumber: nextVersionNumber,
isActive: true,
},
const nextVersionNumber = `AG${existingCount + 1}`;
const baseKey = `Documents/Host/${host.id}/agreements/${nextVersionNumber}`;
const pdfKey = `${baseKey}.pdf`;
await s3
.upload({
Bucket: config.aws.bucketName,
Key: pdfKey,
Body: pdfBuffer,
ContentType: 'application/pdf',
ACL: 'private',
})
.promise();
pdfUrl = `https://${config.aws.bucketName}.s3.${config.aws.region}.amazonaws.com/${pdfKey}`;
} catch (error) {
console.error('Error generating or uploading PDF:', error);
// Continue without PDF - will return dynamic fields instead
}
try {
const existingCount = await this.prisma.hostAgreement.count({
where: { hostXid: host.id, isActive: true },
});
await tx.hostHeader.update({
where: { id: host.id },
data: {
stepper: STEPPER.AGREEMENT_ACCEPTED,
isApproved: true,
agreementAccepted: true,
agreementStartDate: host.agreementStartDate || new Date(),
},
const nextVersionNumber = `AG${existingCount + 1}`;
await this.prisma.$transaction(async (tx) => {
// Optional: mark previous agreements inactive
await tx.hostAgreement.updateMany({
where: { hostXid: host.id, isActive: true },
data: { isActive: false },
});
await tx.hostAgreement.create({
data: {
hostXid: host.id,
filePath: pdfUrl,
versionNumber: nextVersionNumber,
isActive: true,
},
});
await tx.hostHeader.update({
where: { id: host.id },
data: {
stepper: STEPPER.AGREEMENT_ACCEPTED,
isApproved: true,
agreementAccepted: true,
agreementStartDate: host.agreementStartDate || new Date(),
},
});
});
} catch (error) {
console.error('Error creating host agreement record:', error);
// Continue without creating agreement record - will return dynamic fields instead
}
// Return dynamic fields and PDF URL
return {
filePath: pdfUrl,
dynamicFields: agreementVars,
};
}
/**
* Get the latest (active) agreement for a specific host by hostXid.
*/
async getLatestHostAgreement(hostXid: number) {
if (!hostXid || Number.isNaN(hostXid)) {
throw new ApiError(400, 'Valid hostXid is required');
}
const hostHeader = await this.prisma.hostHeader.findFirst({
where: { id: hostXid, isActive: true },
select: {
id: true,
isCommisionBase: true,
commisionPer: true,
durationNumber: true,
durationFrequency: true,
amountPerBooking: true,
agreementStartDate: true,
payoutDurationNum: true,
payoutDurationFrequency: true,
registrationNumber: true,
companyName: true,
companyTypes: {
select: {
id: true,
companyTypeName: true
}
}
}
});
const agreement = await this.prisma.hostAgreement.findFirst({
where: { hostXid, isActive: true },
orderBy: { createdAt: 'desc' },
select: {
id: true,
hostXid: true,
filePath: true,
versionNumber: true,
createdAt: true,
updatedAt: true,
},
});
// ❌ If both missing
if (!agreement && !hostHeader) {
throw new ApiError(404, 'No active agreement found for this host');
}
let presignedUrl = "";
if (agreement?.filePath) {
const key = agreement.filePath.startsWith('http')
? agreement.filePath.split('.com/')[1]
: agreement.filePath;
const bucket = config.aws.bucketName;
presignedUrl = await getPresignedUrl(bucket, key);
}
return {
hostHeader: hostHeader || null,
agreement: agreement
? {
...agreement,
presignedUrl
}
: null
};
}
async getPQQQuestionDetail(question_xid: number, activity_xid: number) {
@@ -2374,15 +2630,9 @@ export class HostService {
},
select: {
id: true,
navigationModeName: true,
isInActivityChargeable: true,
navigationModesTotalPrice: true,
navigationMode: {
select: {
id: true,
navigationModeName: true,
navigationModeIcon: true,
},
},
},
},
equipmentAvailable: true,
@@ -3706,7 +3956,7 @@ export class HostService {
const navMode = await tx.activityNavigationModes.create({
data: {
activityXid,
navigationModeXid: mode.navigationModeXid,
navigationModeName: mode.navigationModeName,
isInActivityChargeable: isChargeable,
navigationModesBasePrice: basePrice,
navigationModesTotalPrice: totalPrice,

View File

@@ -53,10 +53,10 @@ export class TokenService {
config.jwt.secret
);
await this.prisma.token.deleteMany({
where: { userXid: user_xid }
})
// Optionally keep existing refresh tokens alive instead of deleting
// Removed deleteMany call so the same refresh token can be used multiple
// times. If you want to limit refresh tokens later you can implement
// rotation or blacklist logic elsewhere.
await this.prisma.token.create({
data: {
token: refreshToken.token,

View File

@@ -27,15 +27,21 @@ export const handler = safeHandler(async (
// 2) Authenticate user
await verifyMinglarAdminHostToken(token);
// 3) Get bankXid from query params
// 3) Get stateXid and optional search term from query params
const stateXid = Number(event.queryStringParameters?.stateXid);
const search = event.queryStringParameters?.search?.trim();
if (!stateXid || isNaN(stateXid)) {
throw new ApiError(400, "Valid stateXid is required in query params.");
}
// 4) Fetch branches for the bank
const branches = await prePopulateService.getCityByStateId(stateXid);
// If search is provided, enforce minimum 3 characters
if (search && search.length < 3) {
throw new ApiError(400, "Search term must be at least 3 characters long.");
}
// 4) Fetch cities for the state (optionally filtered by search)
const branches = await prePopulateService.getCityByStateId(stateXid, search);
return {
statusCode: 200,

View File

@@ -39,12 +39,20 @@ export class PrePopulateService {
}
async getCityByStateId(stateXid: number) {
async getCityByStateId(stateXid: number, search?: string) {
return await this.prisma.cities.findMany({
where: {
stateXid,
isActive: true,
deletedAt: null
deletedAt: null,
...(search && search.length >= 3
? {
cityName: {
contains: search,
mode: 'insensitive',
},
}
: {}),
},
select: {
id: true,
@@ -153,7 +161,6 @@ export class PrePopulateService {
foodType,
cuisineDetails,
vehicleType,
navigationMode,
taxDetails,
energyLevel,
aminitiesDetails,
@@ -171,9 +178,6 @@ export class PrePopulateService {
this.prisma.transportModes.findMany({
where: { isActive: true },
}),
this.prisma.navigationModes.findMany({
where: { isActive: true },
}),
this.prisma.taxes.findMany({
where: { isActive: true },
}),
@@ -215,7 +219,6 @@ export class PrePopulateService {
foodType,
cuisineDetails,
vehicleType,
navigationMode,
taxDetails,
energyLevel,
aminitiesDetails,

View File

@@ -0,0 +1,74 @@
import { APIGatewayProxyEvent, APIGatewayProxyResult, Context } from 'aws-lambda';
import { prismaClient } from '../../../../common/database/prisma.lambda.service';
import { verifyUserToken } from '../../../../common/middlewares/jwt/authForUser';
import { safeHandler } from '../../../../common/utils/handlers/safeHandler';
import ApiError from '../../../../common/utils/helper/ApiError';
import { UserService } from '../../services/user.service';
const userService = new UserService(prismaClient);
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 verifyUserToken
const userInfo = await verifyUserToken(token);
const userId = userInfo.id;
if (Number.isNaN(userId)) {
throw new ApiError(400, 'User id must be a number');
}
const user = await userService.getUserById(userId);
if (!user) {
throw new ApiError(404, 'User not found');
}
// Parse request body
let body: { activityXid: number; isBucket: boolean; bucketTypeName: string; };
try {
body = event.body ? JSON.parse(event.body) : {};
} catch (error) {
throw new ApiError(400, 'Invalid JSON in request body');
}
const { activityXid, isBucket, bucketTypeName } = body;
// Validate required fields
if (
typeof activityXid !== 'number' ||
typeof isBucket !== 'boolean' ||
!bucketTypeName
) {
throw new ApiError(400, 'Required fields missing or invalid');
}
// Set the passcode
const counts = await userService.addToBucketInterested(userId, isBucket, bucketTypeName, activityXid);
return {
statusCode: 200,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
},
body: JSON.stringify({
success: true,
message: `Activity added to ${isBucket ? 'bucket' : 'interested'} successfully`,
data: {
bucketCount: counts.bucketCount,
interestedCount: counts.interestedCount,
coverImage: counts.coverImage,
coverImagePresignedUrl: counts.coverImagePresignedUrl,
}
}),
};
});

View File

@@ -50,64 +50,73 @@ export const handler = safeHandler(async (
const activity = activityDetails.activity;
// Rooms: combine ActivityVenues with their respective slots for the selected date
const Venues = (activity.ActivityVenues || []).map((v: any) => {
const header = scheduleDetails.find((h: any) => h.activityVenue?.venueXid === v.id);
const Venues = (activity.ActivityVenues || [])
.map((v: any) => {
const header = scheduleDetails.find(
(h: any) => h.activityVenue?.venueXid === v.id
);
const roomSlots = (header?.slots || []).map((s: any) => {
let status = 'Available';
if (s.maxCapacity === 0) status = 'Housefull';
else if (s.maxCapacity <= 2) status = '2 Slots Left';
else if (s.maxCapacity <= 5) status = 'Fast Filling';
if (!header || !header.slots?.length) {
return null; // ❌ venue has no slots for selected date
}
const roomSlots = header.slots.map((s: any) => {
let status = "Available";
if (s.maxCapacity === 0) status = "Housefull";
else if (s.maxCapacity <= 2) status = "2 Slots Left";
else if (s.maxCapacity <= 5) status = "Fast Filling";
return {
slotId: s.slotId,
startTime: s.startTime,
endTime: s.endTime,
status,
maxCapacity: s.maxCapacity,
};
});
return {
slotId: s.slotId,
startTime: s.startTime,
endTime: s.endTime,
status,
maxCapacity: s.maxCapacity,
venueXid: v.id,
venueName: v.venueName,
venueLabel: v.venueLabel,
venueCapacity: v.venueCapacity,
availableSeats: v.availableSeats ?? null,
price: v.ActivityPrices?.[0]?.sellPrice ?? null,
endDate: header?.endDate ?? null,
slots: roomSlots,
slotsCount: roomSlots.length,
venueMedia: (v.ActivityVenueArtifacts || []).map((media: any) => ({
id: media.id,
mediaType: media.mediaType,
mediaFileName: media.mediaFileName,
presignedUrl: media.presignedUrl,
})),
};
});
})
.filter(Boolean); // ✅ removes null venues
return {
venueXid: v.id,
venueName: v.venueName,
venueLabel: v.venueLabel,
venueCapacity: v.venueCapacity,
availableSeats: v.availableSeats ?? null,
price: v.ActivityPrices?.[0]?.sellPrice ?? null,
endDate: header?.endDate ?? null,
slots: roomSlots,
slotsCount: roomSlots.length,
venueMedia: (v.ActivityVenueArtifacts || []).map((media: any) => ({
id: media.id,
mediaType: media.mediaType,
mediaFileName: media.mediaFileName, // original S3 key / URL
presignedUrl: media.presignedUrl, // presigned URL
})),
// derive check-in/out from all room slots (earliest start, latest end)
const allSlots = Venues.flatMap(r => r.slots || []);
const startTimes = allSlots.map(s => s.startTime).filter(Boolean);
const endTimes = allSlots.map(s => s.endTime).filter(Boolean);
const checkInTime = startTimes.length ? startTimes.sort()[0] : null;
const checkOutTime = endTimes.length ? endTimes.sort().reverse()[0] : null;
const responsePayload = {
selectedDate,
Venues,
checkInTime,
checkOutTime,
};
return {
statusCode: 200,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
},
body: JSON.stringify({ success: true, data: responsePayload }),
};
});
// derive check-in/out from all room slots (earliest start, latest end)
const allSlots = Venues.flatMap(r => r.slots || []);
const startTimes = allSlots.map(s => s.startTime).filter(Boolean);
const endTimes = allSlots.map(s => s.endTime).filter(Boolean);
const checkInTime = startTimes.length ? startTimes.sort()[0] : null;
const checkOutTime = endTimes.length ? endTimes.sort().reverse()[0] : null;
const responsePayload = {
selectedDate,
Venues,
checkInTime,
checkOutTime,
};
return {
statusCode: 200,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
},
body: JSON.stringify({ success: true, data: responsePayload }),
};
});

View File

@@ -0,0 +1,74 @@
import { APIGatewayProxyEvent, APIGatewayProxyResult, Context } from 'aws-lambda';
import { prismaClient } from '../../../../common/database/prisma.lambda.service';
import { verifyUserToken } from '../../../../common/middlewares/jwt/authForUser';
import { safeHandler } from '../../../../common/utils/handlers/safeHandler';
import ApiError from '../../../../common/utils/helper/ApiError';
import { FilteredLandingPageService } from '../../services/filteredLandingPage.service';
const filteredLandingPageService = new FilteredLandingPageService(prismaClient);
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.');
}
// Verify token and get user info
const userInfo = await verifyUserToken(token);
const userId = Number(userInfo.id);
if (!userId || isNaN(userId)) {
throw new ApiError(400, 'Invalid user ID');
}
const page = Number(event.queryStringParameters?.page ?? 1);
const limit = Number(event.queryStringParameters?.limit ?? 20);
const countryName = event.queryStringParameters?.countryName ?? '';
const stateName = event.queryStringParameters?.stateName ?? '';
const cityName = event.queryStringParameters?.cityName ?? '';
const userLat = event.queryStringParameters?.userLat ?? '';
const userLong = event.queryStringParameters?.userLong ?? '';
let activityTypeXids: number[] | undefined;
if (event.queryStringParameters?.activityTypeXids) {
try {
activityTypeXids = JSON.parse(event.queryStringParameters.activityTypeXids);
} catch (error) {
// Handle invalid JSON if needed
}
}
if (page < 1 || limit < 1) {
throw new ApiError(400, 'Invalid pagination values');
}
// Fetch filtered landing page details
const result = await filteredLandingPageService.getFilteredLandingPageAllDetails(
userId,
page,
limit,
countryName,
stateName,
cityName,
userLat,
userLong,
activityTypeXids
);
return {
statusCode: 200,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
},
body: JSON.stringify({
success: true,
message: 'Filtered landing page data retrieved successfully',
data: result,
}),
};
});

View File

@@ -0,0 +1,46 @@
import { APIGatewayProxyEvent, APIGatewayProxyResult, Context } from 'aws-lambda';
import { safeHandler } from '../../../../common/utils/handlers/safeHandler';
import { prismaClient } from '../../../../common/database/prisma.lambda.service';
import ApiError from '../../../../common/utils/helper/ApiError';
import { UserService } from '../../services/user.service';
import { verifyUserToken } from '../../../../common/middlewares/jwt/authForUser';
const userService = new UserService(prismaClient);
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.');
}
// Verify token and get user info
const userInfo = await verifyUserToken(token);
const userId = Number(userInfo.id);
if (!userId || isNaN(userId)) {
throw new ApiError(400, 'Invalid user ID');
}
// Fetch user with their HostHeader stepper info
const result = await userService.getAllBucketActivities(
userId
);
return {
statusCode: 200,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
},
body: JSON.stringify({
success: true,
message: 'Data retrieved successfully',
data: result,
}),
};
});

View File

@@ -27,16 +27,16 @@ export const handler = safeHandler(async (
const longParam = event.queryStringParameters?.long ?? event.queryStringParameters?.lng ?? event.queryStringParameters?.longitude;
const radiusParam = event.queryStringParameters?.radiusKm ?? event.queryStringParameters?.radius;
if (!latParam || !longParam || !radiusParam) {
throw new ApiError(400, 'lat, long and radiusKm (in km) are required as query parameters');
}
const userLat = latParam ? Number(latParam) : undefined;
const userLong = longParam ? Number(longParam) : undefined;
const radiusKm = radiusParam ? Number(radiusParam) : 15; // default 15km
const userLat = Number(latParam);
const userLong = Number(longParam);
const radiusKm = Number(radiusParam);
if (Number.isNaN(userLat) || Number.isNaN(userLong) || Number.isNaN(radiusKm)) {
throw new ApiError(400, 'lat, long and radiusKm must be valid numbers');
if (
(userLat !== undefined && Number.isNaN(userLat)) ||
(userLong !== undefined && Number.isNaN(userLong)) ||
Number.isNaN(radiusKm)
) {
throw new ApiError(400, 'Invalid lat/long values');
}
const page = Number(event.queryStringParameters?.page ?? 1);

View File

@@ -26,19 +26,11 @@ export const handler = safeHandler(async (
}
// Extract query parameters for search
const activityTitle = event.queryStringParameters?.activityTitle?.trim();
const activityType = event.queryStringParameters?.activityType?.trim();
const checkInCity = event.queryStringParameters?.checkInCity?.trim();
// At least one search parameter should be provided
if (!activityTitle && !activityType && !checkInCity) {
throw new ApiError(400, 'At least one search parameter (activityTitle, activityType, or checkInCity) must be provided');
}
// Fetch activities based on search criteria
const result = await userService.searchActivities(
userId,
{ activityTitle, activityType, checkInCity }
activityType
);
return {

View File

@@ -0,0 +1,71 @@
import { APIGatewayProxyEvent, APIGatewayProxyResult, Context } from 'aws-lambda';
import { prismaClient } from '../../../../common/database/prisma.lambda.service';
import { verifyUserToken } from '../../../../common/middlewares/jwt/authForUser';
import { safeHandler } from '../../../../common/utils/handlers/safeHandler';
import ApiError from '../../../../common/utils/helper/ApiError';
import { UserService } from '../../services/user.service';
const userService = new UserService(prismaClient);
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 verifyUserToken
const userInfo = await verifyUserToken(token);
const userId = userInfo.id;
if (Number.isNaN(userId)) {
throw new ApiError(400, 'User id must be a number');
}
const user = await userService.getUserById(userId);
if (!user) {
throw new ApiError(404, 'User not found');
}
// Parse request body
let body: { activityXid: number; isBucket: boolean; bucketTypeName: string; };
try {
body = event.body ? JSON.parse(event.body) : {};
} catch (error) {
throw new ApiError(400, 'Invalid JSON in request body');
}
const { activityXid, isBucket, bucketTypeName } = body;
// Validate required fields
if (
typeof activityXid !== 'number' ||
typeof isBucket !== 'boolean' ||
!bucketTypeName
) {
throw new ApiError(400, 'Required fields missing or invalid');
}
// Remove from bucket/interested
const counts = await userService.removeFromBucketInterested(userId, isBucket, bucketTypeName, activityXid);
return {
statusCode: 200,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
},
body: JSON.stringify({
success: true,
message: `Activity removed from ${isBucket ? 'bucket' : 'interested'} successfully`,
data: {
bucketCount: counts.bucketCount,
interestedCount: counts.interestedCount,
}
}),
};
});

View File

@@ -0,0 +1,938 @@
import { Injectable } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { getPresignedUrl } from '../../../common/middlewares/aws/getPreSignedUrl';
import {
ACTIVITY_AM_INTERNAL_STATUS,
ACTIVITY_INTERNAL_STATUS,
} from '../../../common/utils/constants/host.constant';
import config from '../../../config/config';
const bucket = config.aws.bucketName;
const attachPresignedUrl = async (file: string | null | undefined) => {
if (!file) return null;
const key = file.startsWith('http')
? new URL(file).pathname.replace(/^\/+/, '')
: file;
return getPresignedUrl(bucket, key);
};
const attachMediaWithPresignedUrl = async (mediaArr: any[] = []) => {
return Promise.all(
mediaArr.map(async (m) => ({
...m,
presignedUrl: await attachPresignedUrl(m.mediaFileName),
})),
);
};
@Injectable()
export class FilteredLandingPageService {
constructor(private readonly prisma: PrismaClient) { }
normalizeName = (name: string): string => {
return name
.toLowerCase()
.replace(/[^a-z0-9\s]/g, '')
.replace(/\s+/g, ' ')
.trim();
};
findOrCreateLocation = async (
countryName: string,
stateName: string,
cityName: string,
tx: any,
) => {
const normalizedCountry = this.normalizeName(countryName);
const normalizedState = this.normalizeName(stateName);
const normalizedCity = this.normalizeName(cityName);
let country = await tx.countries.findFirst({
where: {
countryName: { contains: normalizedCountry, mode: 'insensitive' },
},
});
if (!country) {
country = await tx.countries.create({
data: {
countryName: countryName.trim(),
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
},
});
}
let state = await tx.states.findFirst({
where: {
countryXid: country.id,
stateName: { contains: normalizedState, mode: 'insensitive' },
},
});
if (!state) {
state = await tx.states.create({
data: {
countryXid: country.id,
stateName: stateName.trim(),
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
},
});
}
let city = await tx.cities.findFirst({
where: {
stateXid: state.id,
cityName: { contains: normalizedCity, mode: 'insensitive' },
},
});
if (!city) {
city = await tx.cities.create({
data: {
stateXid: state.id,
cityName: cityName.trim(),
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
},
});
}
return {
countryXid: country.id,
stateXid: state.id,
cityXid: city.id,
};
};
// attachMediaWithPresignedUrl = async (mediaArr = []) => {
// return (
// await Promise.all(
// mediaArr.map(async (m) => {
// return {
// ...m,
// presignedUrl: await this.attachPresignedUrl(m.mediaFileName),
// };
// }),
// )
// );
// };
calculateDistance = (
lat1: number | null,
lon1: number | null,
lat2: number | null,
lon2: number | null,
) => {
if (!lat1 || !lon1 || !lat2 || !lon2) return null;
const R = 6371; // km
const dLat = ((lat2 - lat1) * Math.PI) / 180;
const dLon = ((lon2 - lon1) * Math.PI) / 180;
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos((lat1 * Math.PI) / 180) *
Math.cos((lat2 * Math.PI) / 180) *
Math.sin(dLon / 2) *
Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
const distance = R * c;
// return distance rounded to 2 decimals
return Number(distance.toFixed(2));
};
async rankAndPaginateActivities(
tx: any,
whereClause: any,
page: number,
limit: number,
connectionInterestMap: Map<number, number>,
) {
const skip = (page - 1) * limit;
// Get total count
const totalCount = await tx.activities.count({ where: whereClause });
// Fetch activities with ranking metadata
const activities = await tx.activities.findMany({
where: whereClause,
skip,
take: limit,
select: {
id: true,
activityTitle: true,
sustainabilityScore: true,
totalScore: true,
activityType: {
select: {
interestXid: true,
energyLevel: {
select: {
id: true,
energyLevelName: true,
energyColor: true,
energyIcon: true,
},
},
},
},
ActivitiesMedia: {
where: { isActive: true },
select: {
id: true,
mediaFileName: true,
mediaType: true,
},
},
// Fetch ranking metadata
ItineraryActivities: {
select: {
ActivityFeedbacks: {
select: { activityStars: true },
},
},
},
ActivityVenues: {
select: {
ActivityPrices: {
select: { sellPrice: true },
},
},
},
},
});
// Sort and format
const sortedActivities = activities
.map((act) => {
const feedbacks = act.ItineraryActivities.flatMap(
(ia) => ia.ActivityFeedbacks,
);
const totalStars = feedbacks.reduce(
(sum, f) => sum + f.activityStars,
0,
);
const avgRating =
feedbacks.length > 0 ? totalStars / feedbacks.length : 0;
const prices = act.ActivityVenues.flatMap((v) =>
v.ActivityPrices.map((p) => p.sellPrice),
).filter((p) => p !== null) as number[];
const minPrice = prices.length > 0 ? Math.min(...prices) : Infinity;
return {
...act,
avgRating,
minPrice,
sustainabilityScore: act.sustainabilityScore ?? 0,
totalScore: act.totalScore ?? 0,
};
})
.sort((a, b) => {
if (b.avgRating !== a.avgRating) return b.avgRating - a.avgRating;
if (a.minPrice !== b.minPrice) return a.minPrice - b.minPrice;
if (b.sustainabilityScore !== a.sustainabilityScore)
return b.sustainabilityScore - a.sustainabilityScore;
return b.totalScore - a.totalScore;
});
const formattedActivities = await Promise.all(
sortedActivities.map(async (activity) => ({
interestXid: activity.activityType.interestXid,
activityId: activity.id,
connectionInterestedCount:
connectionInterestMap.get(activity.id) ?? 0,
activityTitle: activity.activityTitle,
sustainabilityScore: activity.sustainabilityScore,
cheapestPrice: activity.minPrice === Infinity ? null : activity.minPrice,
distance: 0,
rating: activity.avgRating,
energyLevel: activity.activityType.energyLevel
? {
...activity.activityType.energyLevel,
presignedUrl: await attachPresignedUrl(
activity.activityType.energyLevel.energyIcon
),
}
: null,
media: await attachMediaWithPresignedUrl(activity.ActivitiesMedia),
})),
);
return {
page,
limit,
totalCount,
hasMore: skip + limit < totalCount,
activities: formattedActivities,
};
}
async getFilteredLandingPageAllDetails(
userId: number,
page: number,
limit: number,
countryName: string,
stateName: string,
cityName: string,
userLat: string,
userLong: string,
activityTypeXids?: number[],
) {
const data = await this.prisma.$transaction(async (tx) => {
const userAddressDetails = await tx.userAddressDetails.findFirst({
where: { userXid: userId, isActive: true },
select: {
id: true,
address1: true,
address2: true,
pinCode: true,
locationName: true,
stateXid: true,
cityXid: true,
countryXid: true,
locationLat: true,
locationLong: true,
},
});
const userLatitude = userAddressDetails?.locationLat ?? null;
const userLongitude = userAddressDetails?.locationLong ?? null;
let effectiveLocation: {
countryXid?: number | null;
stateXid?: number | null;
cityXid?: number | null;
} | null = null;
const hasRequestLocation = countryName && stateName && cityName;
if (hasRequestLocation) {
effectiveLocation = await this.findOrCreateLocation(
countryName!,
stateName!,
cityName!,
tx,
);
} else if (userAddressDetails) {
effectiveLocation = {
countryXid: userAddressDetails.countryXid,
stateXid: userAddressDetails.stateXid,
cityXid: userAddressDetails.cityXid,
};
}
const effectiveCountryXid = effectiveLocation?.countryXid ?? null;
const effectiveStateXid = effectiveLocation?.stateXid ?? null;
// Get all activity types for user interests, filtered by selected activity types if provided
const activityTypeWhere: any = {
isActive: true,
};
if (activityTypeXids && activityTypeXids.length > 0) {
activityTypeWhere.id = { in: activityTypeXids };
}
const activityTypesWithInterests = await tx.activityTypes.findMany({
where: activityTypeWhere,
select: {
id: true,
activityTypeName: true,
interestXid: true,
interests: {
select: {
id: true,
interestName: true,
interestColor: true,
interestImage: true,
displayOrder: true,
},
},
},
});
if (!activityTypesWithInterests.length) {
return {
userAddressDetails,
interests: [],
activityTypes: [],
otherStatesActivities: null,
overSeasActivities: null,
};
}
const userBucketInterested = await tx.userBucketInterested.findMany({
where: {
userXid: userId,
isActive: true,
},
select: {
activityXid: true,
isBucket: true,
},
});
const userBucketActivityIds = userBucketInterested
.filter(u => u.isBucket)
.map(u => u.activityXid);
const userInterestedActivityIds = userBucketInterested
.filter(u => !u.isBucket)
.map(u => u.activityXid);
const allUserExcludedActivityIds = userBucketInterested.map(
u => u.activityXid,
);
const userConnectionDetails = await tx.connectDetails.findMany({
where: { userXid: userId, isActive: true },
select: {
id: true,
schoolCompanyXid: true,
}
});
const otherConnectionUsers = await tx.connectDetails.findMany({
where: { userXid: { notIn: [userId] }, isActive: true, schoolCompanyXid: { in: userConnectionDetails.map((u) => u.schoolCompanyXid) } },
select: {
id: true,
userXid: true,
}
});
const connectionUserIds =
otherConnectionUsers.length > 0
? otherConnectionUsers.map(u => u.userXid)
: [-1];
const connectionInterestByActivity = await tx.userBucketInterested.groupBy({
by: ['activityXid'],
where: {
userXid: { in: connectionUserIds },
isActive: true,
},
_count: {
activityXid: true,
},
});
const connectionInterestMap = new Map(
connectionInterestByActivity.map(item => [
item.activityXid,
item._count.activityXid,
])
);
const skip = (page - 1) * limit;
// Group activity types by interest
const activityTypesByInterest = activityTypesWithInterests.reduce((acc, at) => {
if (!acc[at.interestXid]) {
acc[at.interestXid] = {
interest: at.interests,
activityTypes: [],
};
}
acc[at.interestXid].activityTypes.push({
activityTypeId: at.id,
activityTypeName: at.activityTypeName,
});
return acc;
}, {} as any);
// Fetch activities for each activity type with excluded activities filter
const activitiesByActivityType = await Promise.all(
activityTypesWithInterests.map(async (activityType) => {
const activities = await tx.activities.findMany({
where: {
isActive: true,
activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED,
amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED,
activityTypeXid: activityType.id,
id: {
notIn: allUserExcludedActivityIds.length
? allUserExcludedActivityIds
: [-1],
},
},
skip,
take: limit,
orderBy: { id: 'desc' },
select: {
id: true,
activityTitle: true,
activityDurationMins: true,
sustainabilityScore: true,
checkInLat: true,
checkInLong: true,
activityType: {
select: {
id: true,
activityTypeName: true,
interestXid: true,
energyLevel: {
select: {
id: true,
energyLevelName: true,
energyColor: true,
energyIcon: true,
},
},
},
},
ActivityVenues: {
select: {
ActivityPrices: {
select: {
sellPrice: true,
},
},
},
},
ActivitiesMedia: {
where: { isActive: true },
select: {
id: true,
mediaFileName: true,
mediaType: true,
},
},
},
});
const formattedActivities = await Promise.all(
activities.map(async (activity) => {
const cheapestPrice =
activity.ActivityVenues.flatMap((v) => v.ActivityPrices)
.map((p) => p.sellPrice)
.filter(Boolean)
.sort((a, b) => a - b)[0] ?? null;
const distance = this.calculateDistance(
userLatitude,
userLongitude,
activity.checkInLat,
activity.checkInLong,
);
return {
activityId: activity.id,
connectionInterestedCount:
connectionInterestMap.get(activity.id) ?? 0,
activityTitle: activity.activityTitle,
activityDurationMins: activity.activityDurationMins,
sustainabilityScore: activity.sustainabilityScore,
cheapestPrice,
distance,
rating: 0,
energyLevel: activity.activityType.energyLevel
? {
...activity.activityType.energyLevel,
presignedUrl: await attachPresignedUrl(
activity.activityType.energyLevel.energyIcon
),
}
: null,
media: await attachMediaWithPresignedUrl(activity.ActivitiesMedia),
};
}),
);
return {
activityTypeId: activityType.id,
activityTypeName: activityType.activityTypeName,
interestXid: activityType.interestXid,
activities: formattedActivities,
pagination: {
page,
limit,
hasMore: formattedActivities.length === limit,
},
};
}),
);
// Group by interests for the final structure
const interestsWithActivities = await Promise.all(
Object.values(activityTypesByInterest).map(async (interestGroup: any) => {
// collect all activities belonging to this interest
const activitiesForInterest = activitiesByActivityType
.filter(a => a.interestXid === interestGroup.interest.id)
.flatMap(a => a.activities);
return {
interestId: interestGroup.interest.id,
interestName: interestGroup.interest.interestName,
interestColor: interestGroup.interest.interestColor,
interestImage: interestGroup.interest.interestImage,
interestImagePresignedUrl: await attachPresignedUrl(
interestGroup.interest.interestImage
),
displayOrder: interestGroup.interest.displayOrder,
page,
limit,
hasMore: activitiesForInterest.length === limit,
activities: activitiesForInterest
};
})
);
// Most Hyped Activities with filtering
const mostHypedGrouped = await tx.userBucketInterested.groupBy({
by: ['activityXid'],
where: {
isActive: true,
isBucket: false,
activityXid: {
notIn: allUserExcludedActivityIds.length ? allUserExcludedActivityIds : [-1],
},
},
_count: {
activityXid: true,
},
orderBy: {
_count: {
activityXid: 'desc',
},
},
});
// Filter most hyped activities by activity type if provided
let filteredMostHypedActivityIds = mostHypedGrouped.map((a) => a.activityXid);
if (activityTypeXids && activityTypeXids.length > 0) {
const activitiesWithTypes = await tx.activities.findMany({
where: {
id: { in: filteredMostHypedActivityIds },
activityTypeXid: { in: activityTypeXids },
},
select: { id: true },
});
filteredMostHypedActivityIds = activitiesWithTypes.map(a => a.id);
}
const finalMostHypedGrouped = mostHypedGrouped
.filter(group => filteredMostHypedActivityIds.includes(group.activityXid))
.slice(skip, skip + limit);
const totalHypedActivities = filteredMostHypedActivityIds.length;
const mostHypedActivityIds = finalMostHypedGrouped.map((a) => a.activityXid);
const mostHypedActivitiesRaw = await tx.activities.findMany({
where: {
id: {
in: mostHypedActivityIds,
notIn: allUserExcludedActivityIds.length
? allUserExcludedActivityIds
: [-1],
},
isActive: true,
activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED,
amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED,
},
select: {
id: true,
activityTitle: true,
sustainabilityScore: true,
totalScore: true,
activityType: {
select: {
energyLevel: {
select: {
id: true,
energyLevelName: true,
energyColor: true,
energyIcon: true,
},
},
},
},
ActivitiesMedia: {
where: { isActive: true },
select: {
id: true,
mediaFileName: true,
mediaType: true,
},
},
ItineraryActivities: {
select: {
ActivityFeedbacks: {
select: { activityStars: true },
},
},
},
ActivityVenues: {
select: {
ActivityPrices: {
select: { sellPrice: true },
},
},
},
},
});
// Sort Most Hyped by the 4 criteria
const mostHypedSorted = mostHypedActivitiesRaw
.map((act) => {
const feedbacks = act.ItineraryActivities.flatMap(
(ia) => ia.ActivityFeedbacks,
);
const totalStars = feedbacks.reduce(
(sum, f) => sum + f.activityStars,
0,
);
const avgRating =
feedbacks.length > 0 ? totalStars / feedbacks.length : 0;
const prices = act.ActivityVenues.flatMap((v) =>
v.ActivityPrices.map((p) => p.sellPrice),
).filter((p) => p !== null) as number[];
const minPrice = prices.length > 0 ? Math.min(...prices) : Infinity;
return {
...act,
avgRating,
minPrice,
sustainabilityScore: act.sustainabilityScore ?? 0,
totalScore: act.totalScore ?? 0,
hypeCount:
finalMostHypedGrouped.find((g) => g.activityXid === act.id)?._count
.activityXid ?? 0,
};
})
.sort((a, b) => {
if (b.avgRating !== a.avgRating) return b.avgRating - a.avgRating;
if (a.minPrice !== b.minPrice) return a.minPrice - b.minPrice;
if (b.sustainabilityScore !== a.sustainabilityScore)
return b.sustainabilityScore - a.sustainabilityScore;
return b.totalScore - a.totalScore;
});
const mostHypedActivities = await Promise.all(
mostHypedSorted.map(async (activity) => ({
activityId: activity.id,
activityTitle: activity.activityTitle,
connectionInterestedCount:
connectionInterestMap.get(activity.id) ?? 0,
hypeCount: activity.hypeCount,
distance: 0,
rating: 0,
energyLevel: activity.activityType.energyLevel
? {
...activity.activityType.energyLevel,
presignedUrl: await attachPresignedUrl(
activity.activityType.energyLevel.energyIcon
),
}
: null,
media: await attachMediaWithPresignedUrl(activity.ActivitiesMedia),
})),
);
const formattedMostHypedActivities = {
page,
limit,
totalCount: totalHypedActivities,
hasMore: skip + limit < totalHypedActivities,
activities: mostHypedActivities,
};
// New Arrivals with filtering
const newArrivalsWhere: any = {
isActive: true,
activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED,
amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED,
id: {
notIn: allUserExcludedActivityIds.length
? allUserExcludedActivityIds
: [-1],
},
createdAt: { gte: new Date(Date.now() - 31 * 24 * 60 * 60 * 1000) },
};
if (activityTypeXids && activityTypeXids.length > 0) {
newArrivalsWhere.activityTypeXid = { in: activityTypeXids };
}
const formattedNewArrivalsActivities = await this.rankAndPaginateActivities(
tx,
newArrivalsWhere,
page,
limit,
connectionInterestMap
);
// Other States Activities with filtering
const otherStatesWhere: any = {
isActive: true,
activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED,
amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED,
id: {
notIn: allUserExcludedActivityIds.length
? allUserExcludedActivityIds
: [-1],
},
};
if (effectiveCountryXid) {
otherStatesWhere.checkInCountryXid = effectiveCountryXid;
}
if (effectiveStateXid) {
otherStatesWhere.checkInStateXid = { not: effectiveStateXid };
}
if (activityTypeXids && activityTypeXids.length > 0) {
otherStatesWhere.activityTypeXid = { in: activityTypeXids };
}
const formattedOtherStatesActivities = await this.rankAndPaginateActivities(
tx,
otherStatesWhere,
page,
limit,
connectionInterestMap
);
// Random Activities with filtering
const totalActiveCount = await tx.activities.count({
where: {
isActive: true,
activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED,
amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED,
deletedAt: null,
id: {
notIn: allUserExcludedActivityIds.length
? allUserExcludedActivityIds
: [-1],
},
...(activityTypeXids && activityTypeXids.length > 0 && {
activityTypeXid: { in: activityTypeXids },
}),
},
});
let randomActivities: any[] = [];
if (totalActiveCount > 0) {
const takeCount = Math.min(5, totalActiveCount);
const randomOffsets = new Set<number>();
while (randomOffsets.size < takeCount) {
randomOffsets.add(Math.floor(Math.random() * totalActiveCount));
}
const randomFetched = await Promise.all(
Array.from(randomOffsets).map((offset) =>
tx.activities.findFirst({
skip: offset,
where: {
isActive: true,
activityInternalStatus:
ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED,
amInternalStatus:
ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED,
deletedAt: null,
id: {
notIn: allUserExcludedActivityIds.length
? allUserExcludedActivityIds
: [-1],
},
...(activityTypeXids && activityTypeXids.length > 0 && {
activityTypeXid: { in: activityTypeXids },
}),
},
select: {
id: true,
activityTitle: true,
ActivitiesMedia: {
where: { isActive: true, isCoverImage: true },
orderBy: { displayOrder: 'asc' },
take: 1,
select: {
mediaFileName: true,
},
},
},
}),
),
);
randomActivities = await Promise.all(
randomFetched
.filter(Boolean)
.map(async (activity) => {
const cover = activity!.ActivitiesMedia?.[0];
return {
activityId: activity!.id,
activityTitle: activity!.activityTitle,
coverImage: cover?.mediaFileName ?? null,
coverImagePresignedUrl: await attachPresignedUrl(
cover?.mediaFileName
),
};
}),
);
}
// Overseas Activities with filtering
const overseasWhere: any = {
isActive: true,
activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED,
amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED,
id: {
notIn: allUserExcludedActivityIds.length
? allUserExcludedActivityIds
: [-1],
},
};
if (effectiveCountryXid) {
overseasWhere.checkInCountryXid = { not: effectiveCountryXid };
}
if (activityTypeXids && activityTypeXids.length > 0) {
overseasWhere.activityTypeXid = { in: activityTypeXids };
}
const formattedOverSeasActivities = await this.rankAndPaginateActivities(
tx,
overseasWhere,
page,
limit,
connectionInterestMap
);
return {
userAddressDetails,
experiencesLogged: 0,
citiesDiscovered: 0,
loggedInNetworkCount: 0,
citiesInNetworkCount: 0,
rating: 0,
interestedCount: userInterestedActivityIds.length,
bucketCount: userBucketActivityIds.length,
pagination: {
page,
limit,
},
randomActivities,
interests: interestsWithActivities,
otherStatesActivities: formattedOtherStatesActivities,
overSeasActivities: formattedOverSeasActivities,
newArrivalsActivities: formattedNewArrivalsActivities,
mostHypedActivities: formattedMostHypedActivities,
};
});
return data;
}
}

File diff suppressed because it is too large Load Diff