feat: enhance scheduling service to support instant booking and late check-in options, and improve activity listing logic
This commit is contained in:
@@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -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.',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user