diff --git a/prisma/schema.prisma b/prisma/schema.prisma index afa4f72..0775237 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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]) diff --git a/serverless/functions/user.yml b/serverless/functions/user.yml index fad4ae4..dcf8adf 100644 --- a/serverless/functions/user.yml +++ b/serverless/functions/user.yml @@ -90,4 +90,19 @@ setUserLocationss: events: - httpApi: path: /user/set-location-user - method: post \ No newline at end of file + 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 \ No newline at end of file diff --git a/src/common/utils/validation/host/createSchedulingOfAct.validation.ts b/src/common/utils/validation/host/createSchedulingOfAct.validation.ts index c769aac..cec2f21 100644 --- a/src/common/utils/validation/host/createSchedulingOfAct.validation.ts +++ b/src/common/utils/validation/host/createSchedulingOfAct.validation.ts @@ -45,7 +45,7 @@ export const scheduleActivity = z.object({ occurrenceDate: z.string().nullable().optional(), maxCapacity: z.number(), }) - ).min(1), + ).optional().default([]), }) ), diff --git a/src/modules/host/dto/createActivity.schema.ts b/src/modules/host/dto/createActivity.schema.ts index e97a625..385dc01 100644 --- a/src/modules/host/dto/createActivity.schema.ts +++ b/src/modules/host/dto/createActivity.schema.ts @@ -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(), diff --git a/src/modules/host/services/activityScheduling.service.ts b/src/modules/host/services/activityScheduling.service.ts index c7672db..b228df1 100644 --- a/src/modules/host/services/activityScheduling.service.ts +++ b/src/modules/host/services/activityScheduling.service.ts @@ -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: { diff --git a/src/modules/host/services/host.service.ts b/src/modules/host/services/host.service.ts index e3a4690..7818279 100644 --- a/src/modules/host/services/host.service.ts +++ b/src/modules/host/services/host.service.ts @@ -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 * -------------------------------- */ diff --git a/src/modules/user/handlers/activities/landingPageAllDetails.ts b/src/modules/user/handlers/activities/landingPageAllDetails.ts new file mode 100644 index 0000000..523f328 --- /dev/null +++ b/src/modules/user/handlers/activities/landingPageAllDetails.ts @@ -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 => { + // 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, + }), + }; +}); + diff --git a/src/modules/user/handlers/authentication/registration.ts b/src/modules/user/handlers/authentication/registration.ts index f35026a..b9e2e63 100644 --- a/src/modules/user/handlers/authentication/registration.ts +++ b/src/modules/user/handlers/authentication/registration.ts @@ -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, + }, }), }; }); diff --git a/src/modules/user/services/user.service.ts b/src/modules/user/services/user.service.ts index 4a64c48..5699696 100644 --- a/src/modules/user/services/user.service.ts +++ b/src/modules/user/services/user.service.ts @@ -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 { + ): Promise { 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; + } + } \ No newline at end of file