Files
MinglarBackendNestJS/src/modules/user/services/user.service.ts

1104 lines
38 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Injectable } from '@nestjs/common';
import { PrismaClient, User, UserAddressDetails } from '@prisma/client';
import ApiError from '../../../common/utils/helper/ApiError';
import * as bcrypt from 'bcryptjs';
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';
import { isNotIn } from 'class-validator';
// 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;
// }
async function findOrCreateLocation(
tx: any,
{
countryName,
stateName,
cityName,
}: {
countryName: string;
stateName: string;
cityName: string;
}
) {
/* ---------------------------
1⃣ COUNTRY
----------------------------*/
let country = await tx.countries.findFirst({
where: {
countryName: {
equals: countryName,
mode: 'insensitive',
},
isActive: true,
},
});
if (!country) {
country = await tx.countries.create({
data: {
countryName: countryName.trim(),
countryCode: countryName.slice(0, 3).toUpperCase(), // optional
countryFlag: '',
isActive: true,
},
});
}
/* ---------------------------
2⃣ STATE
----------------------------*/
let state = await tx.states.findFirst({
where: {
stateName: {
equals: stateName,
mode: 'insensitive',
},
countryXid: country.id,
isActive: true,
},
});
if (!state) {
state = await tx.states.create({
data: {
stateName: stateName.trim(),
countryXid: country.id,
isActive: true,
},
});
}
/* ---------------------------
3⃣ CITY
----------------------------*/
let city = await tx.cities.findFirst({
where: {
cityName: {
equals: cityName,
mode: 'insensitive',
},
stateXid: state.id,
isActive: true,
},
});
if (!city) {
city = await tx.cities.create({
data: {
cityName: cityName.trim(),
stateXid: state.id,
isActive: true,
},
});
}
return {
countryXid: country.id,
stateXid: state.id,
cityXid: city.id,
};
}
const attachMediaWithPresignedUrl = async (mediaArr = []) => {
return (
await Promise.all(
mediaArr.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),
};
})
)
).filter(Boolean);
};
const bucket = config.aws.bucketName;
@Injectable()
export class UserService {
constructor(private prisma: PrismaClient) { }
async getUserById(userId: number) {
return this.prisma.user.findUnique({
where: { id: userId, isActive: true },
});
}
async addPersonalInfo(userId: number, data: UserPersonalInfoSchema) {
return await this.prisma.$transaction(async (tx) => {
const updatedUser = await tx.user.update({
where: { id: userId },
data: {
firstName: data.firstName,
lastName: data.lastName ?? null,
genderName: data.genderName,
dateOfBirth: data.dateOfBirth
? new Date(data.dateOfBirth)
: null,
isProfileUpdated: true,
},
});
return updatedUser;
});
}
async getAllInterestDetails() {
const interests = await this.prisma.interests.findMany({
where: { isActive: true },
select: {
id: true,
interestName: true,
interestColor: true,
interestImage: true,
displayOrder: true
}
})
for (const interest of interests) {
if (interest.interestImage) {
const key = interest.interestImage.startsWith('http')
? new URL(interest.interestImage).pathname.replace(/^\/+/, '')
: interest.interestImage;
(interest as any).presignedUrl = await getPresignedUrl(bucket, key);
} else {
(interest as any).presignedUrl = null;
}
}
return interests;
}
async getUserByMobileNumber(mobileNumber: string): Promise<User | null> {
return this.prisma.user.findFirst({
where: { mobileNumber: mobileNumber, isActive: true },
});
}
async verifyHostOtp(mobileNumber: string, otp: string): Promise<boolean> {
const user = await this.prisma.user.findFirst({
where: { mobileNumber: mobileNumber, isActive: true },
select: {
id: true,
mobileNumber: true,
UserOtp: {
where: { isActive: true, isVerified: false },
orderBy: { createdAt: 'desc' },
take: 1,
},
},
});
if (!user) {
throw new ApiError(404, 'User not found.');
}
const userOtp = user.UserOtp[0];
if (!userOtp) {
throw new ApiError(400, 'No OTP found.');
}
if (new Date() > userOtp.expiresOn) {
throw new ApiError(400, 'OTP has expired.');
}
const isMatch = await bcrypt.compare(otp, userOtp.otpCode);
if (!isMatch) {
throw new ApiError(400, 'Invalid OTP.');
}
await this.prisma.userOtp.update({
where: { id: userOtp.id },
data: {
isVerified: true,
verifiedOn: new Date(),
isActive: false,
},
});
return true;
}
async setUserPasscode(userId: number, userPasscode: string): Promise<User> {
// Validate passcode format (6 digits)
if (!userPasscode || userPasscode.length !== 6 || !/^\d{6}$/.test(userPasscode)) {
throw new ApiError(400, 'Passcode must be exactly 6 digits');
}
// Hash the passcode
const hashedPasscode = await bcrypt.hash(userPasscode, 10);
// Update user with passcode
const updatedUser = await this.prisma.user.update({
where: { id: userId },
data: {
userPasscode: hashedPasscode,
},
});
if (!updatedUser) {
throw new ApiError(400, 'Failed to set passcode');
}
return updatedUser;
}
async setUserInterests(userId: number, interest_Xid: number[]): Promise<void> {
// Remove existing interests
await this.prisma.userInterests.deleteMany({
where: { userXid: userId },
});
// Add new interests
const interestRecords = interest_Xid.map((interestId) => ({
userXid: userId,
interestXid: interestId,
}));
await this.prisma.userInterests.createMany({
data: interestRecords,
});
}
async setUserLocationDetails(
userId: number,
countryName: string,
stateName: string,
cityName: string,
pinCode: string,
latitude?: number,
longitude?: number,
locationName?: string,
locationAddress?: string
): 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 },
});
}
// 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 },
});
}
// 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 },
});
}
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,
page: number,
limit: number,
countryName: string,
stateName: string,
cityName: string,
userLat: string,
userLong: string
) {
const data = await this.prisma.$transaction(async (tx) => {
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,
}
})
let effectiveLocation: {
countryXid?: number | null;
stateXid?: number | null;
cityXid?: number | null;
} | null = null;
const hasRequestLocation = countryName && stateName && cityName;
if (hasRequestLocation) {
// ✅ Create/find ONLY if request location is sent
effectiveLocation = await findOrCreateLocation(tx, {
countryName: countryName!,
stateName: stateName!,
cityName: cityName!,
});
} else if (userAddressDetails) {
// ✅ Fallback to users saved address
effectiveLocation = {
countryXid: userAddressDetails.countryXid,
stateXid: userAddressDetails.stateXid,
cityXid: userAddressDetails.cityXid,
};
}
const effectiveCountryXid = effectiveLocation?.countryXid ?? null;
const effectiveStateXid = effectiveLocation?.stateXid ?? null;
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 {
userAddressDetails,
interests: [],
otherStatesActivities: null,
overSeasActivities: null,
};
}
const activitiyTypesOfUserInterests = await tx.activityTypes.findMany({
where: { interestXid: { in: userInterests.map(ui => ui.interestXid) }, isActive: true },
select: {
id: true
}
})
if (!activitiyTypesOfUserInterests.length) {
return {
userAddressDetails,
interests: [],
otherStatesActivities: null,
overSeasActivities: null,
};
}
const skip = (page - 1) * limit;
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),
},
},
skip,
take: limit,
orderBy: { id: 'desc' },
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 mostHypedTotalCount = await tx.userBucketInterested.groupBy({
by: ['activityXid'],
where: {
isActive: true,
isBucket: false,
},
});
const mostHypedGrouped = await tx.userBucketInterested.groupBy({
by: ['activityXid'],
where: {
isActive: true,
isBucket: false
},
_count: {
activityXid: true,
},
orderBy: {
_count: {
activityXid: 'desc',
},
},
skip,
take: limit,
});
const totalHypedActivities = mostHypedTotalCount.length;
const mostHypedActivityIds = mostHypedGrouped.map(a => a.activityXid);
const mostHypedActivitiesRaw = await tx.activities.findMany({
where: {
id: { in: mostHypedActivityIds },
isActive: true,
activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED,
amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED,
},
select: {
id: true,
activityTitle: true,
activityType: {
select: {
energyLevel: {
select: {
id: true,
energyLevelName: true,
energyColor: true,
energyIcon: true,
},
},
},
},
ActivitiesMedia: {
where: { isActive: true },
select: {
id: true,
mediaFileName: true,
mediaType: true,
},
},
},
});
const mostHypedActivities = await Promise.all(
mostHypedGrouped.map(async g => {
const activity = mostHypedActivitiesRaw.find(a => a.id === g.activityXid);
if (!activity) return null;
return {
activityId: activity.id,
activityTitle: activity.activityTitle,
hypeCount: g._count.activityXid, // 🔥 VERY IMPORTANT
energyLevel: activity.activityType.energyLevel,
media: await attachMediaWithPresignedUrl(activity.ActivitiesMedia),
};
})
).then(arr => arr.filter(Boolean));
const formattedMostHypedActivities = {
page,
limit,
totalCount: totalHypedActivities,
hasMore: skip + limit < totalHypedActivities,
activities: mostHypedActivities,
};
const newArrivalsCount = await tx.activities.count({
where: {
isActive: true,
activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED,
amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED,
createdAt: { gte: new Date(Date.now() - 31 * 24 * 60 * 60 * 1000) }
},
});
const newArrivalsActivities = await tx.activities.findMany({
where: {
isActive: true,
activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED,
amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED,
// // ✅ Only user's interest types
// activityTypeXid: {
// in: activitiyTypesOfUserInterests.map(at => at.id),
// },
createdAt: { gte: new Date(Date.now() - 31 * 24 * 60 * 60 * 1000) }
},
skip,
take: limit,
orderBy: { id: 'desc' },
select: {
id: true,
activityTitle: true,
activityType: {
select: {
energyLevel: {
select: {
id: true,
energyLevelName: true,
energyColor: true,
energyIcon: true,
},
},
},
},
ActivitiesMedia: {
where: { isActive: true },
select: {
id: true,
mediaFileName: true,
mediaType: true,
},
},
},
})
/* =====================================================
6⃣ OTHER STATES ACTIVITIES
===================================================== */
const otherStatesWhere: any = {
isActive: true,
activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED,
amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED,
};
if (effectiveCountryXid) {
otherStatesWhere.checkInCountryXid = effectiveCountryXid;
}
if (effectiveStateXid) {
otherStatesWhere.checkInStateXid = { not: effectiveStateXid };
}
const otherStatesTotalCount = await tx.activities.count({
where: otherStatesWhere,
});
const otherStatesActivities = await tx.activities.findMany({
where: otherStatesWhere,
skip,
take: limit,
orderBy: { id: 'desc' },
select: {
activityTitle: true,
activityType: { select: { energyLevel: true } },
ActivitiesMedia: {
where: { isActive: true },
select: { id: true, mediaFileName: true, mediaType: true },
},
},
});
/* =====================================================
7⃣ OVERSEAS ACTIVITIES
===================================================== */
const overseasWhere: any = {
isActive: true,
activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED,
amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED,
};
if (effectiveCountryXid) {
overseasWhere.checkInCountryXid = { not: effectiveCountryXid };
}
const overSeasTotalCount = await tx.activities.count({
where: overseasWhere,
});
const overSeasActivities = await tx.activities.findMany({
where: overseasWhere,
skip,
take: limit,
orderBy: { id: 'desc' },
select: {
activityTitle: true,
activityType: { select: { energyLevel: 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;
return {
interestXid: activity.activityType.interestXid,
activityId: activity.id,
activityTitle: activity.activityTitle,
activityDurationMins: activity.activityDurationMins,
sustainabilityScore: activity.sustainabilityScore,
cheapestPrice,
energyLevel: activity.activityType.energyLevel,
media: await attachMediaWithPresignedUrl(activity.ActivitiesMedia),
};
})
);
const formattedOtherStatesActivities = {
page,
limit,
totalCount: otherStatesTotalCount,
hasMore: skip + limit < otherStatesTotalCount,
activities: await Promise.all(
otherStatesActivities.map(async a => ({
activityTitle: a.activityTitle,
energyLevel: a.activityType.energyLevel,
media: await attachMediaWithPresignedUrl(a.ActivitiesMedia),
}))
),
};
const formattedNewArrivalsActivities = {
page,
limit,
totalCount: newArrivalsCount,
hasMore: skip + limit < newArrivalsCount,
activities: await Promise.all(
newArrivalsActivities.map(async a => ({
activityTitle: a.activityTitle,
energyLevel: a.activityType.energyLevel,
media: await attachMediaWithPresignedUrl(a.ActivitiesMedia),
}))
),
};
const interestsWithActivities = [...userInterests]
.sort((a, b) =>
a.interest.interestName.localeCompare(b.interest.interestName)
)
.map(ui => ({
interestId: ui.interest.id,
interestName: ui.interest.interestName,
interestColor: ui.interest.interestColor,
interestImage: ui.interest.interestImage,
displayOrder: ui.interest.displayOrder,
page,
limit,
hasMore: formattedActivities.length === limit,
activities: formattedActivities
.filter(a => a.interestXid === ui.interestXid)
.map(({ interestXid, ...rest }) => rest),
}));
return {
userAddressDetails,
experiencesLogged: 25,
citiesDiscovered: 10,
loggedInNetworkCount: 0,
citiesInNetworkCount: 0,
pagination: {
page,
limit,
},
interests: interestsWithActivities,
otherStatesActivities: {
page,
limit,
totalCount: otherStatesTotalCount,
hasMore: skip + limit < otherStatesTotalCount,
activities: await Promise.all(
otherStatesActivities.map(async a => ({
activityTitle: a.activityTitle,
energyLevel: a.activityType.energyLevel,
media: await attachMediaWithPresignedUrl(a.ActivitiesMedia),
}))
),
},
overSeasActivities: {
page,
limit,
totalCount: overSeasTotalCount,
hasMore: skip + limit < overSeasTotalCount,
activities: await Promise.all(
overSeasActivities.map(async a => ({
activityTitle: a.activityTitle,
energyLevel: a.activityType.energyLevel,
media: await attachMediaWithPresignedUrl(a.ActivitiesMedia),
}))
),
},
newArrivalsActivities: formattedNewArrivalsActivities,
mostHypedActivities: formattedMostHypedActivities,
};
})
return data;
}
async getActivityDetailsById(
userId: number,
activityXid: number) {
return await this.prisma.$transaction(async (tx) => {
const activity = await tx.activities.findUnique({
where: {
id: activityXid,
isActive: true,
activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED,
amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED
},
select: {
id: true,
activityTitle: true,
activityDurationMins: true,
sustainabilityScore: true,
checkInLat: true,
checkInLong: true,
activityRefNumber: true,
checkInAddress: true,
checkOutAddress: true,
checkOutLat: true,
checkOutLong: true,
activityDescription: true,
foodAvailable: true,
foodIsChargeable: true,
alcoholAvailable: true,
trainerAvailable: true,
trainerIsChargeable: true,
pickUpDropAvailable: true,
pickUpDropIsChargeable: true,
inActivityAvailable: true,
inActivityIsChargeable: true,
isLateCheckingAllowed: true,
equipmentAvailable: true,
equipmentIsChargeable: true,
cancellationAvailable: true,
cancellationAllowedBeforeMins: true,
activityType: {
select: {
interestXid: true, // ✅ VERY IMPORTANT
activityTypeName: true,
energyLevel: {
select: {
id: true,
energyLevelName: true,
energyColor: true,
energyIcon: true,
},
},
},
},
ActivityOtherDetails: {
where: { isActive: true },
select: {
id: true,
exclusiveNotes: true,
dosNotes: true,
dontsNotes: true,
tipsNotes: true,
termsAndCondition: true
}
},
ActivityEligibility: {
where: { isActive: true },
select: {
id: true,
isAgeRestriction: true,
ageRestrictionName: true,
ageEntered: true,
ageIn: true,
minAge: true,
maxAge: true,
isWeightRestriction: true,
weightRestrictionName: true,
weightEntered: true,
weightIn: true,
minWeight: true,
maxWeight: true,
isHeightRestriction: true,
heightRestrictionName: true,
heightEntered: true,
heightIn: true,
minHeight: true,
maxHeight: true
}
},
ActivityTrainers: {
where: { isActive: true },
select: {
id: true,
totalAmount: true
}
},
ActivityAllowedEntry: {
where: { isActive: true },
select: {
id: true,
allowedEntryTypeXid: true,
allowedEntryType: {
select: {
id: true,
allowedEntryTypeName: true
}
}
}
},
ActivityFoodCost: {
where: { isActive: true },
select: {
id: true,
totalAmount: true
}
},
activityFoodTypes: {
where: { isActive: true },
select: {
id: true,
foodTypeXid: true,
foodType: {
select: {
id: true,
foodTypeName: true
}
}
}
},
ActivityEquipments: {
where: { isActive: true },
select: {
id: true,
equipmentName: true,
isEquipmentChargeable: true,
equipmentTotalPrice: true
}
},
ActivityNavigationModes: {
where: { isActive: true },
select: {
id: true,
navigationModeXid: true,
navigationMode: {
select: {
id: true,
navigationModeName: true,
navigationModeIcon: true
}
},
isInActivityChargeable: true,
navigationModesTotalPrice: true
}
},
ActivityAmenities: {
where: { isActive: true },
select: {
id: true,
amenitiesXid: true,
amenities: {
select: {
id: true,
amenitiesName: true
}
}
}
},
ActivityPickUpDetails: {
where: { isActive: true },
select: {
id: true,
isPickUp: true,
locationLat: true,
locationLong: true,
locationAddress: true,
transportTotalPrice: true
}
},
activityCuisines: {
where: { isActive: true },
select: {
id: true,
foodCuisineXid: true,
foodCuisine: {
select: {
id: true,
cuisineName: true
}
}
}
},
ActivityVenues: {
where: {
isActive: true
},
select: {
venueName: true,
venueLabel: true,
venueCapacity: true,
availableSeats: true,
isMinPeopleReqMandatory: true,
minPeopleRequired: true,
minReqfullfilledBeforeMins: true,
venueDescription: true,
ActivityPrices: {
select: {
id: true,
sellPrice: true,
},
},
},
},
ActivitiesMedia: {
where: { isActive: true },
select: {
id: true,
mediaFileName: true,
mediaType: true,
},
},
},
})
const interestedCount = await tx.userBucketInterested.count({
where: {
activityXid,
isActive: true,
},
})
return {
activity,
interestedCount,
rating: 0, // ⭐ Placeholder, implement rating logic as needed
distance: 0
}
})
}
}