feat: enhance user and activity models with location relations and add landing page details API

This commit is contained in:
2026-02-04 15:32:11 +05:30
parent 1f53180b4e
commit 93fb58f4f4
9 changed files with 529 additions and 92 deletions

View File

@@ -235,6 +235,9 @@ model Countries {
HostHeader HostHeader[]
hostParent HostParent[]
userAddressDetails UserAddressDetails[]
// 🔹 Activity relations
checkInActivities Activities[] @relation("CheckInCountry")
checkOutActivities Activities[] @relation("CheckOutCountry")
@@map("countries")
@@schema("mst")
@@ -272,6 +275,9 @@ model States {
HostHeader HostHeader[]
hostParent HostParent[]
userAddressDetails UserAddressDetails[]
// 🔹 Activity relations
checkInActivities Activities[] @relation("CheckInState")
checkOutActivities Activities[] @relation("CheckOutState")
@@map("states")
@@schema("mst")
@@ -281,7 +287,7 @@ model Cities {
id Int @id @default(autoincrement())
stateXid Int @map("state_xid")
states States @relation(fields: [stateXid], references: [id], onDelete: Cascade)
cityName String @unique @map("city_name") @db.VarChar(50)
cityName String @map("city_name") @db.VarChar(50)
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@ -290,6 +296,9 @@ model Cities {
HostHeader HostHeader[]
hostParent HostParent[]
userAddressDetails UserAddressDetails[]
// 🔹 Activity relations
checkInActivities Activities[] @relation("CheckInCity")
checkOutActivities Activities[] @relation("CheckOutCity")
@@map("cities")
@@schema("mst")
@@ -886,10 +895,22 @@ model Activities {
pickUpDropIsChargeable Boolean? @map("pick_up_drop_is_chargeable")
inActivityAvailable Boolean? @map("in_activity_available")
inActivityIsChargeable Boolean? @map("in_activity_is_chargeable")
is_late_checking_allowed Boolean? @map("is_late_checking_allowed")
isLateCheckingAllowed Boolean? @map("is_late_checking_allowed")
equipmentAvailable Boolean? @map("equipment_available")
equipmentIsChargeable Boolean? @map("equipment_is_chargeable")
cancellationAvailable Boolean? @map("cancellation_available")
checkInStateXid Int? @map("check_in_state_xid")
checkInState States? @relation("CheckInState", fields: [checkInStateXid], references: [id], onDelete: Restrict)
checkInCityXid Int? @map("check_in_city_xid")
checkInCity Cities? @relation("CheckInCity", fields: [checkInCityXid], references: [id], onDelete: Restrict)
checkInCountryXid Int? @map("check_in_country_xid")
checkInCountry Countries? @relation("CheckInCountry", fields: [checkInCountryXid], references: [id], onDelete: Restrict)
checkOutStateXid Int? @map("check_out_state_xid")
checkOutState States? @relation("CheckOutState", fields: [checkOutStateXid], references: [id], onDelete: Restrict)
checkOutCityXid Int? @map("check_out_city_xid")
checkOutCity Cities? @relation("CheckOutCity", fields: [checkOutCityXid], references: [id], onDelete: Restrict)
checkOutCountryXid Int? @map("check_out_country_xid")
checkOutCountry Countries? @relation("CheckOutCountry", fields: [checkOutCountryXid], references: [id], onDelete: Restrict)
// 🔹 Creator / owner
userId Int?
user User? @relation("UserActivities", fields: [userId], references: [id])

View File

@@ -90,4 +90,19 @@ setUserLocationss:
events:
- httpApi:
path: /user/set-location-user
method: post
method: post
getLandingPageDetails:
handler: src/modules/user/handlers/activities/landingPageAllDetails.handler
memorySize: 384
package:
patterns:
- 'src/modules/user/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/get-landing-page-details
method: get

View File

@@ -45,7 +45,7 @@ export const scheduleActivity = z.object({
occurrenceDate: z.string().nullable().optional(),
maxCapacity: z.number(),
})
).min(1),
).optional().default([]),
})
),

View File

@@ -107,6 +107,12 @@ export const CreateActivityDto = z.object({
checkOutLat: z.number().nullable().optional(),
checkOutLong: z.number().nullable().optional(),
checkOutAddress: z.string().nullable().optional(),
checkInStateName: z.string().nullable().optional(),
checkInCityName: z.string().nullable().optional(),
checkInCountryName: z.string().nullable().optional(),
checkOutStateName: z.string().nullable().optional(),
checkOutCityName: z.string().nullable().optional(),
checkOutCountryName: z.string().nullable().optional(),
energyLevelXid: z.number().int().nullable().optional(),
durationDays: z.number().int().optional(),

View File

@@ -371,7 +371,7 @@ export class SchedulingService {
activityDurationMins: true,
activityTitle: true,
activityRefNumber: true,
is_late_checking_allowed: true,
isLateCheckingAllowed: true,
isInstantBooking: true,
frequenciesXid: true,
frequency: {

View File

@@ -102,6 +102,94 @@ function computeBasePriceAndTaxes(
return { basePrice, taxDetails };
}
const normalize = (v?: string | null) =>
v ? v.trim().toLowerCase() : null;
const findOrCreateCountry = async (
tx: any,
countryName?: string | null,
) => {
if (!countryName) return null;
const name = normalize(countryName);
let country = await tx.countries.findFirst({
where: {
countryName: { equals: name, mode: 'insensitive' },
isActive: true,
},
});
if (!country) {
country = await tx.countries.create({
data: {
countryName: countryName.trim(),
countryCode: countryName
.substring(0, 2)
.toUpperCase(), // fallback
countryFlag: '',
},
});
}
return country.id;
};
const findOrCreateState = async (
tx: any,
stateName?: string | null,
countryXid?: number | null,
) => {
if (!stateName || !countryXid) return null;
const state = await tx.states.findFirst({
where: {
stateName: { equals: stateName.trim(), mode: 'insensitive' },
countryXid,
isActive: true,
},
});
if (state) return state.id;
const created = await tx.states.create({
data: {
stateName: stateName.trim(),
countryXid,
},
});
return created.id;
};
const findOrCreateCity = async (
tx: any,
cityName?: string | null,
stateXid?: number | null,
) => {
if (!cityName || !stateXid) return null;
const city = await tx.cities.findFirst({
where: {
cityName: { equals: cityName.trim(), mode: 'insensitive' },
stateXid,
isActive: true,
},
});
if (city) return city.id;
const created = await tx.cities.create({
data: {
cityName: cityName.trim(),
stateXid,
},
});
return created.id;
};
const bucket = config.aws.bucketName;
@Injectable()
@@ -2688,6 +2776,47 @@ export class HostService {
}
}
/* --------------------------------
* 🌍 RESOLVE CHECK-IN LOCATION
* -------------------------------- */
const checkInCountryXid = await findOrCreateCountry(
tx,
payload.checkInCountryName,
);
const checkInStateXid = await findOrCreateState(
tx,
payload.checkInStateName,
checkInCountryXid,
);
const checkInCityXid = await findOrCreateCity(
tx,
payload.checkInCityName,
checkInStateXid,
);
/* --------------------------------
* 🌍 RESOLVE CHECK-OUT LOCATION
* -------------------------------- */
const checkOutCountryXid = await findOrCreateCountry(
tx,
payload.checkOutCountryName,
);
const checkOutStateXid = await findOrCreateState(
tx,
payload.checkOutStateName,
checkOutCountryXid,
);
const checkOutCityXid = await findOrCreateCity(
tx,
payload.checkOutCityName,
checkOutStateXid,
);
/* --------------------------------
* 4⃣ UPDATE ACTIVITY CORE + FLAGS
* -------------------------------- */
@@ -2735,6 +2864,13 @@ export class HostService {
activityDisplayStatus,
amInternalStatus,
amDisplayStatus,
checkInCountryXid: checkInCountryXid ?? undefined,
checkInStateXid: checkInStateXid ?? undefined,
checkInCityXid: checkInCityXid ?? undefined,
checkOutCountryXid: checkOutCountryXid ?? undefined,
checkOutStateXid: checkOutStateXid ?? undefined,
checkOutCityXid: checkOutCityXid ?? undefined,
},
});
@@ -2969,14 +3105,14 @@ export class HostService {
});
}
if (Array.isArray(payload.equipments) && payload.equipments.length) {
if (Array.isArray(payload.equipments)) {
for (const eq of payload.equipments) {
const isChargeable = toBool(eq.isEquipmentChargeable);
const totalPrice = isChargeable
? toNumber(eq.equipmentTotalPrice) ?? 0
: 0;
// On submit enforce > 0, on draft just skip invalid/zero
// ❌ Validate only on submit
if (!isDraft && isChargeable && totalPrice <= 0) {
throw new ApiError(
400,
@@ -2984,13 +3120,12 @@ export class HostService {
);
}
if (!isChargeable || totalPrice <= 0) continue;
const { basePrice, taxDetails } = computeBasePriceAndTaxes(
totalPrice,
rootTaxes,
);
const { basePrice, taxDetails } =
isChargeable && totalPrice > 0
? computeBasePriceAndTaxes(totalPrice, rootTaxes)
: { basePrice: 0, taxDetails: [] };
// ✅ ALWAYS CREATE EQUIPMENT
const equipment = await tx.activityEquipments.create({
data: {
activityXid,
@@ -3001,7 +3136,8 @@ export class HostService {
},
});
if (taxDetails.length) {
// 💰 Taxes ONLY if chargeable
if (isChargeable && taxDetails.length) {
await tx.activityEquipmentTaxes.createMany({
data: taxDetails.map((t) => ({
activityEquipmentXid: equipment.id,
@@ -3117,12 +3253,12 @@ export class HostService {
if (Array.isArray(payload.pickupDetails)) {
for (const detail of payload.pickupDetails) {
const isChargeable = pickUpDropIsChargeable;
// 🔒 HARD RULE: NOT chargeable → ALWAYS 0
const totalPrice = isChargeable
? toNumber(detail.transportTotalPrice) ?? 0
: 0;
// ❌ Validate ONLY when chargeable + submit
if (!isDraft && isChargeable && totalPrice <= 0) {
throw new ApiError(
@@ -3130,12 +3266,12 @@ export class HostService {
'transportTotalPrice must be > 0 when pickup/drop is chargeable',
);
}
const { basePrice, taxDetails } =
isChargeable && totalPrice > 0
? computeBasePriceAndTaxes(totalPrice, rootTaxes)
: { basePrice: 0, taxDetails: [] };
// ✅ ALWAYS CREATE PICKUP DETAIL
const pickupDetail = await tx.activityPickUpDetails.create({
data: {
@@ -3144,13 +3280,13 @@ export class HostService {
locationLat: toNumber(detail.locationLat),
locationLong: toNumber(detail.locationLong),
locationAddress: detail.locationAddress ?? null,
// ✅ Guaranteed consistency
transportBasePrice: basePrice,
transportTotalPrice: totalPrice,
},
});
// 💰 Taxes ONLY when chargeable
if (isChargeable && taxDetails.length) {
await tx.activityPickUpTransportTaxes.createMany({
@@ -3164,7 +3300,7 @@ export class HostService {
}
}
}
/* --------------------------------
* 1⃣2⃣ CLEAN & CREATE NAVIGATION MODES WITH TAXES
* -------------------------------- */

View File

@@ -0,0 +1,44 @@
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.getLandingPageAllDetails(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

@@ -48,13 +48,18 @@ export const handler = safeHandler(async (
});
let newUserLocal;
let isNewUser = false;
const referenceNumber = await generateUserRefNumber(tx);
if (user && !user.userPasscode) {
// reuse existing invited user record
newUserLocal = user;
} else {
} else if (user) {
// Fully registered user already exists
newUserLocal = user;
}
else {
// create new user record within the transaction
newUserLocal = await tx.user.create({
data: {
@@ -68,6 +73,8 @@ export const handler = safeHandler(async (
userStatus: USER_STATUS.ACTIVE
},
});
isNewUser = true;
}
// Generate OTP (6-digit) and store within the same transaction
@@ -93,7 +100,7 @@ export const handler = safeHandler(async (
const encryptedId = encryptUserId(String(newUserLocal.id));
return { newUser: newUserLocal, otp, encryptedId };
return { newUser: newUserLocal, otp, encryptedId, isNewUser };
});
if (!transactionResult || !transactionResult.otp) {
@@ -112,7 +119,9 @@ export const handler = safeHandler(async (
body: JSON.stringify({
success: true,
message: 'OTP sent successfully.',
data: {},
data: {
isNewUser: transactionResult.isNewUser,
},
}),
};
});

View File

@@ -1,9 +1,37 @@
import { Injectable } from '@nestjs/common';
import { PrismaClient, User, UserAddressDetails } from '@prisma/client';
import ApiError from '@/common/utils/helper/ApiError';
import ApiError from '../../../common/utils/helper/ApiError';
import * as bcrypt from 'bcryptjs';
import { UserPersonalInfoSchema } from '@/common/utils/validation/user/addPersonalInfo.validation';
import { create } from 'domain';
import { UserPersonalInfoSchema } from '../../../common/utils/validation/user/addPersonalInfo.validation';
import { ACTIVITY_AM_INTERNAL_STATUS, ACTIVITY_INTERNAL_STATUS } from '../../../common/utils/constants/host.constant';
import { getPresignedUrl } from '../../../common/middlewares/aws/getPreSignedUrl';
import config from '@/config/config';
// function deg2rad(deg) {
// return deg * (Math.PI / 180);
// }
// function getDistanceFromLatLon(userLat1, userLon1, activityLat2, activityLon2) {
// const R = 6371; // Earth radius in km
// const dLat = deg2rad(activityLat2 - userLat1);
// const dLon = deg2rad(activityLon2 - userLon1);
// const a =
// Math.sin(dLat / 2) * Math.sin(dLat / 2) +
// Math.cos(deg2rad(userLat1)) *
// Math.cos(deg2rad(activityLat2)) *
// Math.sin(dLon / 2) *
// Math.sin(dLon / 2);
// const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
// return R * c;
// }
const bucket = config.aws.bucketName;
@Injectable()
export class UserService {
constructor(private prisma: PrismaClient) { }
@@ -150,75 +178,253 @@ export class UserService {
longitude?: number,
locationName?: string,
locationAddress?: string
): Promise<UserAddressDetails> {
): Promise<UserAddressDetails> {
return this.prisma.$transaction(async (tx) => {
// 1⃣ Country: find or create
let country = await tx.countries.findUnique({
where: { countryName },
select: { id: true },
});
if (!country) {
country = await tx.countries.create({
data: {
countryName,
countryCode: countryName.slice(0, 3).toUpperCase(),
countryFlag: '',
},
select: { id: true },
// 1⃣ Country: find or create
let country = await tx.countries.findUnique({
where: { countryName },
select: { id: true },
});
}
// 2⃣ State: find or create (GLOBAL UNIQUE)
let state = await tx.states.findUnique({
where: { stateName },
select: { id: true },
});
if (!state) {
state = await tx.states.create({
data: {
stateName,
countryXid: country.id,
},
select: { id: true },
if (!country) {
country = await tx.countries.create({
data: {
countryName,
countryCode: countryName.slice(0, 3).toUpperCase(),
countryFlag: '',
},
select: { id: true },
});
}
// 2⃣ State: find or create (GLOBAL UNIQUE)
let state = await tx.states.findUnique({
where: { stateName },
select: { id: true },
});
}
// 3⃣ City: find or create (GLOBAL UNIQUE)
let city = await tx.cities.findUnique({
where: { cityName },
select: { id: true },
});
if (!city) {
city = await tx.cities.create({
data: {
cityName,
stateXid: state.id,
},
select: { id: true },
if (!state) {
state = await tx.states.create({
data: {
stateName,
countryXid: country.id,
},
select: { id: true },
});
}
// 3⃣ City: find or create (GLOBAL UNIQUE)
let city = await tx.cities.findUnique({
where: { cityName },
select: { id: true },
});
}
return tx.userAddressDetails.create({
data: {
user: { connect: { id: userId } },
country: { connect: { id: country.id } },
states: { connect: { id: state.id } },
cities: { connect: { id: city.id } },
address1: locationAddress ?? '',
pinCode,
locationName: locationName ?? null,
locationAddress: locationAddress ?? null,
locationLat: latitude ?? null,
locationLong: longitude ?? null,
},
});
if (!city) {
city = await tx.cities.create({
data: {
cityName,
stateXid: state.id,
},
select: { id: true },
});
}
return tx.userAddressDetails.create({
data: {
user: { connect: { id: userId } },
country: { connect: { id: country.id } },
states: { connect: { id: state.id } },
cities: { connect: { id: city.id } },
address1: locationAddress ?? '',
pinCode,
locationName: locationName ?? null,
locationAddress: locationAddress ?? null,
locationLat: latitude ?? null,
locationLong: longitude ?? null,
},
});
});
}
}
async getLandingPageAllDetails(userId: number) {
const data = await this.prisma.$transaction(async (tx) => {
const userInterests = await tx.userInterests.findMany({
where: { userXid: userId, isActive: true },
select: {
id: true,
interestXid: true,
interest: {
select: {
id: true,
interestName: true,
interestColor: true,
interestImage: true,
displayOrder: true
}
},
}
})
if (!userInterests.length) {
return { userAddress: null, activities: [] };
}
const activitiyTypesOfUserInterests = await tx.activityTypes.findMany({
where: { interestXid: { in: userInterests.map(ui => ui.interestXid) }, isActive: true },
select: {
id: true
}
})
const userAddressDetails = await tx.userAddressDetails.findFirst({
where: { userXid: userId },
select: {
id: true,
address1: true,
address2: true,
pinCode: true,
locationName: true,
stateXid: true,
cityXid: true,
countryXid: true,
locationLat: true,
locationLong: true,
}
})
if (!activitiyTypesOfUserInterests.length) {
return {
userAddressDetails,
activities: [],
};
}
const activities = await tx.activities.findMany({
where: {
isActive: true,
activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED,
amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED,
activityTypeXid: {
in: activitiyTypesOfUserInterests.map(at => at.id),
},
},
select: {
id: true,
activityTitle: true,
activityDurationMins: true,
sustainabilityScore: true,
checkInLat: true,
checkInLong: true,
activityType: {
select: {
interestXid: true, // ✅ VERY IMPORTANT
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) => {
let cheapestPrice: number | null = null;
for (const venue of activity.ActivityVenues) {
for (const price of venue.ActivityPrices) {
if (
typeof price.sellPrice === 'number' &&
(cheapestPrice === null || price.sellPrice < cheapestPrice)
) {
cheapestPrice = price.sellPrice;
}
}
}
const media = await Promise.all(
(activity.ActivitiesMedia ?? []).map(async (m) => {
if (!m?.mediaFileName) return null;
const key = m.mediaFileName.startsWith('http')
? new URL(m.mediaFileName).pathname.replace(/^\/+/, '')
: m.mediaFileName;
return {
id: m.id,
mediaType: m.mediaType,
mediaFileName: m.mediaFileName,
presignedUrl: await getPresignedUrl(bucket, key),
};
})
);
return {
interestXid: activity.activityType.interestXid,
activityTitle: activity.activityTitle,
activityDurationMins: activity.activityDurationMins,
sustainabilityScore: activity.sustainabilityScore,
energyLevel: activity.activityType?.energyLevel ?? null,
cheapestPrice,
media: media.filter(Boolean), // ✅ IMPORTANT
};
})
);
const interestsWithActivities = userInterests.map(ui => {
const activitiesForInterest = formattedActivities.filter(
act => act.interestXid === ui.interestXid
);
return {
interestId: ui.interest.id,
interestName: ui.interest.interestName,
interestColor: ui.interest.interestColor,
interestImage: ui.interest.interestImage,
displayOrder: ui.interest.displayOrder,
activities: activitiesForInterest.map(({ interestXid, ...rest }) => rest),
};
});
return {
userAddressDetails,
interests: interestsWithActivities,
};
})
return data;
}
}