feat: enhance scheduling service to support instant booking and late check-in options, and improve activity listing logic

This commit is contained in:
2026-02-05 16:07:43 +05:30
parent 93fb58f4f4
commit 00d53adf3d
3 changed files with 249 additions and 53 deletions

View File

@@ -41,6 +41,8 @@ export class SchedulingService {
venues,
earlyCheckInMins,
bookingCutOffMins,
isLateCheckingAllowed,
isInstantBooking
} = data;
return this.prisma.$transaction(async (tx) => {
@@ -90,8 +92,32 @@ export class SchedulingService {
---------------------------------- */
const createdHeaders: number[] = [];
if (isInstantBooking !== undefined || isLateCheckingAllowed !== undefined) {
await tx.activities.update({
where: { id: activityXid, isActive: true },
data: { isInstantBooking, isLateCheckingAllowed },
});
}
if (listNow) {
await tx.activities.update({
where: { id: activityXid, isActive: true },
data: {
activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED,
activityDisplayStatus: ACTIVITY_DISPLAY_STATUS.ACTIVITY_LISTED,
amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED,
amDisplayStatus: ACTIVITY_AM_DISPLAY_STATUS.ACTIVITY_LISTED
}
})
}
for (const venue of venues) {
if (!venue.slots || venue.slots.length === 0) {
continue;
}
const header = await tx.scheduleHeader.create({
data: {
activityXid,
@@ -109,8 +135,20 @@ export class SchedulingService {
// WEEKLY
if (scheduleType === SCHEDULING_TYPE.WEEKLY) {
const uniqueWeekdays = [
...new Set(
venue.slots
.map(s => s.weekDay)
.filter((d): d is "MONDAY" | "TUESDAY" | "WEDNESDAY" | "THURSDAY" | "FRIDAY" | "SATURDAY" | "SUNDAY" => !!d)
),
];
if (!uniqueWeekdays.length) {
throw new ApiError(400, 'Weekly schedule requires weekDay in slots');
}
await tx.scheduleRecurrence.createMany({
data: rules.weekdays!.map(day => ({
data: uniqueWeekdays.map(day => ({
scheduleHeaderXid: header.id,
weekDay: day,
isActive: true,
@@ -118,10 +156,23 @@ export class SchedulingService {
});
}
// MONTHLY
if (scheduleType === SCHEDULING_TYPE.MONTHLY) {
const uniqueDays = [
...new Set(
venue.slots
.map(s => s.dayOfMonth)
.filter((d): d is number => d !== null && d !== undefined)
),
];
if (!uniqueDays.length) {
throw new ApiError(400, 'Monthly schedule requires dayOfMonth in slots');
}
await tx.scheduleRecurrence.createMany({
data: rules.monthDates!.map(day => ({
data: uniqueDays.map(day => ({
scheduleHeaderXid: header.id,
dayOfMonth: day,
isActive: true,
@@ -129,17 +180,27 @@ export class SchedulingService {
});
}
// CUSTOM / ONCE
if (scheduleType === SCHEDULING_TYPE.CUSTOM || scheduleType === SCHEDULING_TYPE.ONCE) {
const uniqueDates = [
...new Set(
venue.slots
.map(s => s.occurrenceDate)
.filter(Boolean)
),
];
await tx.scheduleOccurences.createMany({
data: rules.customDates!.map(d => ({
data: uniqueDates.map(d => ({
scheduleHeaderXid: header.id,
occurenceDate: new Date(d),
occurenceDate: new Date(d!),
isActive: true,
})),
});
}
// Slots
for (const slot of venue.slots) {
await tx.scheduleDetails.create({
@@ -155,18 +216,6 @@ export class SchedulingService {
},
});
}
if (listNow) {
await tx.activities.update({
where: { id: activityXid, isActive: true },
data: {
activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED,
activityDisplayStatus: ACTIVITY_DISPLAY_STATUS.ACTIVITY_LISTED,
amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED,
amDisplayStatus: ACTIVITY_AM_DISPLAY_STATUS.ACTIVITY_LISTED
}
})
}
}
return { success: true, scheduleHeaderIds: createdHeaders };
@@ -330,6 +379,15 @@ export class SchedulingService {
mediaType: true,
},
},
ScheduleHeader: {
where: { isActive: true },
select: {
id: true,
scheduleType: true,
startDate: true
},
orderBy: { createdAt: 'desc' }
}
},
orderBy: {
createdAt: 'desc',
@@ -360,6 +418,8 @@ export class SchedulingService {
activityInternalStatus: activity.activityInternalStatus,
activityDisplayStatus: activity.activityDisplayStatus,
media: activity.ActivitiesMedia,
scheduleType: activity.ScheduleHeader?.length ? activity.ScheduleHeader[0].scheduleType : null,
scheduleStartDate: activity.ScheduleHeader?.length ? activity.ScheduleHeader[0].startDate : null,
}));
}

View File

@@ -3263,7 +3263,7 @@ export class HostService {
if (!isDraft && isChargeable && totalPrice <= 0) {
throw new ApiError(
400,
'transportTotalPrice must be > 0 when pickup/drop is chargeable',
'Pick-up and drop-off price is required.',
);
}

View File

@@ -7,6 +7,7 @@ import { ACTIVITY_AM_INTERNAL_STATUS, ACTIVITY_INTERNAL_STATUS } from '../../../
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);
// }
@@ -28,6 +29,28 @@ import config from '@/config/config';
// return R * c;
// }
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;
@@ -62,7 +85,7 @@ export class UserService {
}
async getAllInterestDetails() {
return await this.prisma.interests.findMany({
const interests = await this.prisma.interests.findMany({
where: { isActive: true },
select: {
id: true,
@@ -72,6 +95,20 @@ export class UserService {
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;
}
@@ -250,6 +287,25 @@ export class UserService {
async getLandingPageAllDetails(userId: number) {
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,
}
})
const userStateXid = userAddressDetails?.stateXid ?? null;
const userCountryXid = userAddressDetails?.countryXid ?? null;
const userInterests = await tx.userInterests.findMany({
where: { userXid: userId, isActive: true },
select: {
@@ -278,22 +334,6 @@ export class UserService {
}
})
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,
@@ -355,6 +395,92 @@ export class UserService {
},
});
const otherStatesActivities = 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),
},
// ✅ Exclude user's state
...(userStateXid && {
checkInStateXid: { not: userStateXid },
}),
...(userCountryXid && {
checkInCountryXid: userCountryXid,
}),
},
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 overSeasActivity = 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),
},
// ✅ Exclude user's state
...(userCountryXid && {
checkInCountryXid: { not: userCountryXid },
}),
},
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 formattedActivities = await Promise.all(
activities.map(async (activity) => {
@@ -371,35 +497,39 @@ export class UserService {
}
}
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
rating: 4,
distanceFromUser: 2,
connectionsCount: 10,
energyLevel: activity.activityType?.energyLevel ?? null,
media: await attachMediaWithPresignedUrl(activity.ActivitiesMedia),
};
})
);
const formattedOtherStatesActivities = await Promise.all(
otherStatesActivities.map(async (activity) => ({
activityTitle: activity.activityTitle,
energyLevel: activity.activityType.energyLevel,
media: await attachMediaWithPresignedUrl(activity.ActivitiesMedia),
}))
);
const formattedOverSeasActivities = await Promise.all(
overSeasActivity.map(async (activity) => ({
activityTitle: activity.activityTitle,
energyLevel: activity.activityType.energyLevel,
media: await attachMediaWithPresignedUrl(activity.ActivitiesMedia),
}))
);
const interestsWithActivities = userInterests.map(ui => {
const activitiesForInterest = formattedActivities.filter(
act => act.interestXid === ui.interestXid
@@ -419,7 +549,13 @@ export class UserService {
return {
userAddressDetails,
experiencesLogged: 25,
citiesDiscovered: 10,
loggedInNetworkCount: 0,
citiesInNetworkCount: 0,
interests: interestsWithActivities,
otherStatesActivities: formattedOtherStatesActivities,
overSeasActivities: formattedOverSeasActivities
};
})