16 Commits

Author SHA1 Message Date
paritosh18
0d18a77ab5 changed logic form filter only with start date 2026-04-27 20:02:21 +05:30
paritosh18
e6ba52520d getmemberpermissions of co-admin , operator 2026-04-27 16:41:35 +05:30
paritosh18
e164e1c25a sending venue details in after booking api 2026-04-27 15:24:04 +05:30
paritosh18
ce9a9b6211 change afterbooking api 2026-04-27 14:51:24 +05:30
paritosh18
4e04781a06 check in and checkout otp activity venue 2026-04-27 13:44:20 +05:30
paritosh18
e77d9e50c9 added pagination in get-matching-bucket-interested-activities 2026-04-27 13:04:38 +05:30
paritosh18
be9780f9ec added api for scan qr and get info of person and actvity 2026-04-27 12:51:39 +05:30
paritosh18
3c56c45b01 api for operator get actvity 2026-04-27 12:24:04 +05:30
paritosh18
d1c4ad76ba message changed 2026-04-27 11:55:50 +05:30
paritosh18
75025b62d9 ADDDED BOOKING FORM CLANEDER 2026-04-24 16:40:18 +05:30
paritosh18
04ae88b239 added new verify otp for operator 2026-04-24 15:49:24 +05:30
paritosh18
b18b6bc468 change in response oprator 2026-04-23 19:20:48 +05:30
paritosh18
6f1504e93f remove serverless prefix 2026-04-23 19:07:57 +05:30
paritosh18
1a6411acdc added check 2026-04-23 19:03:00 +05:30
paritosh18
841539b8cc Merge branch 'paritosh-main1' of http://git.wdipl.com/Mayank.Mishra/MinglarBackendNestJS into paritosh-main1 2026-04-23 18:41:39 +05:30
paritosh18
5fcff67916 add oprator apis 2026-04-23 18:41:37 +05:30
30 changed files with 3368 additions and 42 deletions

12
serverless.operator.yml Normal file
View File

@@ -0,0 +1,12 @@
service: minglar-operator
useDotenv: ${file(./serverless/common.yml):useDotenv}
params: ${file(./serverless/common.yml):params}
provider: ${file(./serverless/common.yml):provider}
build: ${file(./serverless/common.yml):build}
package: ${file(./serverless/common.yml):package}
plugins: ${file(./serverless/common.yml):plugins}
custom: ${file(./serverless/common.yml):custom}
functions:
- ${file(./serverless/functions/operator.yml)}

View File

@@ -155,6 +155,7 @@ package:
# Import function definitions from separate files organized by module
functions:
- ${file(./serverless/functions/host.yml)}
- ${file(./serverless/functions/operator.yml)}
- ${file(./serverless/functions/minglaradmin.yml)}
- ${file(./serverless/functions/prepopulate.yml)}
- ${file(./serverless/functions/user.yml)}

View File

@@ -388,6 +388,22 @@ getHostMemberRoles:
path: /settings/member-roles
method: get
getMemberPermissions:
handler: src/modules/host/handlers/settings/getMemberPermissions.handler
memorySize: 384
package:
patterns:
- 'src/modules/host/handlers/settings/**'
- 'src/modules/host/services/**'
- ${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: /settings/member-permissions/{memberUserXid}
method: get
# Functions with S3/AWS SDK dependencies
submitCompanyDetails:
handler: src/modules/host/handlers/Host_Admin/onboarding/submitCompanyDetails.handler
@@ -511,7 +527,6 @@ resendOTPmail:
path: /resend-otp
method: post
mediaUploadTos3:
handler: src/modules/host/handlers/mediaUploadToS3.handler
memorySize: 512
@@ -527,7 +542,6 @@ mediaUploadTos3:
path: /media/upload/activity/{activityXid}
method: post
venueMediaUploadTos3:
handler: src/modules/host/handlers/mediaUploadForVenueToS3.handler
memorySize: 512
@@ -543,7 +557,6 @@ venueMediaUploadTos3:
path: /media/upload/venue/activity/{activityXid}
method: post
mediaDeleteFroms3:
handler: src/modules/host/handlers/mediaDeleteFromS3.handler
memorySize: 512

View File

@@ -0,0 +1,198 @@
# Operator Module Functions
operatorSignUp:
handler: src/modules/host/handlers/operator/signUp.handler
memorySize: 384
package:
patterns:
- 'src/modules/host/handlers/operator/**'
- 'src/modules/host/services/operatorAuth.service.ts'
- 'src/modules/host/services/token.service.ts'
- 'src/common/**'
- ${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: /signup
method: post
operatorVerifyOtp:
handler: src/modules/host/handlers/operator/verifyOtp.handler
memorySize: 384
package:
patterns:
- 'src/modules/host/handlers/operator/**'
- 'src/modules/host/services/operatorAuth.service.ts'
- 'src/modules/host/services/token.service.ts'
- 'src/common/**'
- ${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: /verify-otp
method: post
operatorCreatePassword:
handler: src/modules/host/handlers/operator/createPassword.handler
memorySize: 384
package:
patterns:
- 'src/modules/host/handlers/operator/**'
- 'src/modules/host/services/operatorAuth.service.ts'
- 'src/modules/host/services/token.service.ts'
- 'src/common/**'
- ${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: /create-password
method: post
operatorLogin:
handler: src/modules/host/handlers/operator/login.handler
memorySize: 384
package:
patterns:
- 'src/modules/host/handlers/operator/**'
- 'src/modules/host/services/operatorAuth.service.ts'
- 'src/modules/host/services/token.service.ts'
- 'src/common/**'
- ${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: /login
method: post
operatorVerifyPassword:
handler: src/modules/host/handlers/operator/verifyPassword.handler
memorySize: 384
package:
patterns:
- 'src/modules/host/handlers/operator/**'
- 'src/modules/host/services/operatorAuth.service.ts'
- 'src/common/**'
- ${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: /verify-password
method: post
operatorGetActivitiesByDate:
handler: src/modules/host/handlers/operator/getActivitiesByDate.handler
memorySize: 384
package:
patterns:
- 'src/modules/host/handlers/operator/**'
- 'src/modules/host/services/operatorActivity.service.ts'
- 'src/modules/host/dto/operator.activity.dto.ts'
- 'src/common/**'
- ${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: /activities-by-date
method: get
operatorGetReservationByCheckInCode:
handler: src/modules/host/handlers/operator/getReservationByCheckInCode.handler
memorySize: 384
package:
patterns:
- 'src/modules/host/handlers/operator/**'
- 'src/modules/host/services/operatorActivity.service.ts'
- 'src/modules/host/dto/operator.activity.dto.ts'
- 'src/common/**'
- ${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: /reservation-by-checkin-code
method: get
operatorSendOtpCheckIn:
handler: src/modules/host/handlers/operator/sendOtpCheckIn.handler
memorySize: 384
package:
patterns:
- 'src/modules/host/handlers/operator/**'
- 'src/modules/host/services/operatorActivity.service.ts'
- 'src/modules/host/dto/operator.activity.dto.ts'
- 'src/common/**'
- ${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: /send-otp-checkin
method: post
operatorSendOtpCheckout:
handler: src/modules/host/handlers/operator/sendOtpCheckout.handler
memorySize: 384
package:
patterns:
- 'src/modules/host/handlers/operator/**'
- 'src/modules/host/services/operatorActivity.service.ts'
- 'src/modules/host/dto/operator.activity.dto.ts'
- 'src/common/**'
- ${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: /send-otp-checkout
method: post
operatorVerifyOtpCheckIn:
handler: src/modules/host/handlers/operator/verifyOtpCheckIn.handler
memorySize: 384
package:
patterns:
- 'src/modules/host/handlers/operator/**'
- 'src/modules/host/services/operatorActivity.service.ts'
- 'src/modules/host/dto/operator.activity.dto.ts'
- 'src/common/**'
- ${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: /verify-otp-checkin
method: post
operatorVerifyOtpCheckout:
handler: src/modules/host/handlers/operator/verifyOtpCheckout.handler
memorySize: 384
package:
patterns:
- 'src/modules/host/handlers/operator/**'
- 'src/modules/host/services/operatorActivity.service.ts'
- 'src/modules/host/dto/operator.activity.dto.ts'
- 'src/common/**'
- ${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: /verify-otp-checkout
method: post

View File

@@ -485,7 +485,7 @@ saveItineraryActivitySelections:
getAllUserSavedItineraries:
handler: src/modules/user/handlers/itinerary/getAllUserSavedItineraries.handler
memorySize: 512
memorySize: 1024
package:
patterns:
- 'src/modules/user/**'
@@ -513,6 +513,21 @@ cancelUserItinerary:
path: /itinerary/cancel-itinerary
method: post
afterBookingFromCalendar:
handler: src/modules/user/handlers/itinerary/afterBookingFromCalendar.handler
memorySize: 512
package:
patterns:
- 'src/modules/user/**'
- ${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: /itinerary/after-booking-from-calendar
method: post
createRazorpayOrder:
handler: src/modules/user/handlers/payment/createOrder.handler
memorySize: 512

View File

@@ -1,10 +1,10 @@
import jwt from 'jsonwebtoken';
import httpStatus from 'http-status';
import { Request, Response, NextFunction } from 'express';
import ApiError from '../../utils/helper/ApiError';
import config from '../../../config/config';
import { ROLE } from '@/common/utils/constants/common.constant';
import { NextFunction, Request, Response } from 'express';
import httpStatus from 'http-status';
import jwt from 'jsonwebtoken';
import config from '../../../config/config';
import { prisma } from '../../database/prisma.client';
import ApiError from '../../utils/helper/ApiError';
interface DecodedToken {
id?: number;
@@ -29,6 +29,68 @@ declare module 'express-serve-static-core' {
* Core authentication function - verifies JWT and validates Host user
* Can be used by both Express middleware and Lambda handlers
*/
/**
* Verifies JWT and validates Operator user (role_xid = 5)
*/
export async function verifyOperatorToken(token: string): Promise<{ id: number; role?: string }> {
if (!token) {
throw new ApiError(httpStatus.UNAUTHORIZED, 'Please authenticate');
}
try {
const decoded = jwt.verify(token, config.jwt.secret) as unknown as DecodedToken;
const userId = decoded.id ?? (decoded.sub ? Number(decoded.sub) : null);
if (!userId) {
throw new ApiError(httpStatus.UNAUTHORIZED, 'Invalid token payload');
}
// ✅ Fetch user from Prisma (Operator user only)
const user = await prisma.user.findUnique({
where: { id: userId },
include: { role: true },
});
const latestToken = await prisma.token.findFirst({
where: {
userXid: userId
},
orderBy: { id: 'desc' }
})
if (latestToken?.isBlackListed == true) {
throw new ApiError(401, "This session is expired. Please login.")
}
if (!user) {
throw new ApiError(httpStatus.UNAUTHORIZED, 'User not found');
}
// ✅ Check if user is active
if (user.isActive === false) {
throw new ApiError(httpStatus.FORBIDDEN, 'Your account is deactivated by admin.');
}
// ✅ Check Operator role (role_xid = 5)
if (user.roleXid !== ROLE.OPERATOR) {
throw new ApiError(httpStatus.FORBIDDEN, 'Access denied.');
}
return { id: user.id, role: user.role?.roleName };
} catch (error) {
if (error instanceof jwt.TokenExpiredError) {
throw new ApiError(httpStatus.UNAUTHORIZED, 'Your session has expired. Please log in again.');
}
if (error instanceof ApiError) {
throw error;
}
throw new ApiError(httpStatus.FORBIDDEN, 'Invalid or expired authentication token.');
}
}
export async function verifyHostToken(token: string): Promise<{ id: number; role?: string }> {
if (!token) {
throw new ApiError(httpStatus.UNAUTHORIZED, 'Please authenticate');

View File

@@ -0,0 +1,112 @@
import jwt from 'jsonwebtoken';
import httpStatus from 'http-status';
import { NextFunction, Request, Response } from 'express';
import { prisma } from '../../database/prisma.client';
import ApiError from '../../utils/helper/ApiError';
import config from '../../../config/config';
import { ROLE } from '../../utils/constants/common.constant';
interface DecodedToken {
id?: number;
sub?: string | number;
role?: string;
iat: number;
exp: number;
}
interface UserPayload {
id: string;
role?: string;
}
declare module 'express-serve-static-core' {
interface Request {
user?: UserPayload;
}
}
export async function verifyOperatorToken(
token: string,
): Promise<{ id: number; role?: string }> {
if (!token) {
throw new ApiError(httpStatus.UNAUTHORIZED, 'Please authenticate');
}
try {
const decoded = jwt.verify(token, config.jwt.secret) as unknown as DecodedToken;
const userId = decoded.id ?? (decoded.sub ? Number(decoded.sub) : null);
if (!userId) {
throw new ApiError(httpStatus.UNAUTHORIZED, 'Invalid token payload');
}
const user = await prisma.user.findUnique({
where: { id: userId },
include: { role: true },
});
const latestToken = await prisma.token.findFirst({
where: { userXid: userId },
orderBy: { id: 'desc' },
});
if (latestToken?.isBlackListed === true) {
throw new ApiError(401, 'This session is expired. Please login.');
}
if (!user) {
throw new ApiError(httpStatus.UNAUTHORIZED, 'User not found');
}
if (user.isActive === false) {
throw new ApiError(httpStatus.FORBIDDEN, 'Your account is deactivated by admin.');
}
if (user.roleXid !== ROLE.OPERATOR) {
throw new ApiError(httpStatus.FORBIDDEN, 'Access denied.');
}
return { id: user.id, role: user.role?.roleName };
} catch (error) {
if (error instanceof jwt.TokenExpiredError) {
throw new ApiError(
httpStatus.UNAUTHORIZED,
'Your session has expired. Please log in again.',
);
}
if (error instanceof ApiError) {
throw error;
}
throw new ApiError(httpStatus.FORBIDDEN, 'Invalid or expired authentication token.');
}
}
const verifyCallback = async (
req: Request,
resolve: (value?: unknown) => void,
reject: (reason?: Error) => void,
) => {
const token = req.header('x-auth-token') || req.cookies?.accessToken;
try {
const userInfo = await verifyOperatorToken(token);
req.user = { id: userInfo.id.toString(), role: userInfo.role };
resolve();
} catch (error) {
return reject(error as Error);
}
};
const authForOperator =
() =>
async (req: Request, res: Response, next: NextFunction) => {
return new Promise((resolve, reject) => {
verifyCallback(req, resolve, reject);
})
.then(() => next())
.catch((err) => next(err));
};
export default authForOperator;

View File

@@ -12,7 +12,7 @@ export interface OtpResult {
export async function resendOtpHelper(
prisma: any,
userId: number,
emailPurpose: "Register" | "Login" | "ForgotPassword",
emailPurpose: string,
otpLength: 4 | 6 = 4,
expiryMinutes: number = 5
): Promise<OtpResult> {

View File

@@ -16,7 +16,7 @@ export async function generateOtpHelper(
prisma: any, // ⭐ Inject prisma
userId: number,
email: string,
emailPurpose: "Register" | "Login" | "ForgotPassword",
emailPurpose: string,
otpLength: 4 | 6 = 4,
expiryMinutes: number = 5
): Promise<OtpResult> {

View File

@@ -0,0 +1,113 @@
export class GetActivitiesByDateRequestDTO {
activityDate?: string; // ISO date format: YYYY-MM-DD (optional, defaults to today)
}
export class GetReservationByCheckInCodeRequestDTO {
checkInCode!: string;
}
export class OperatorReservationVerificationOtpRequestDTO {
checkInCode!: string;
}
export class OperatorReservationVerifyOtpRequestDTO {
checkInCode!: string;
otp!: string;
}
export class DateBreakdownDTO {
date: string;
count: number;
}
export class ActivitySummaryDTO {
activityName: string;
activityImage: string | null;
activityImagePreSignedUrl: string | null;
count: number;
dateBreakdown: DateBreakdownDTO[]; // Booking count for each scheduled date
}
export class GetActivitiesByDateResponseDTO {
success: boolean;
message: string;
data: {
date: string;
activities: ActivitySummaryDTO[];
totalCount: number;
};
}
export class OperatorReservationPersonalDetailsDTO {
fullName: string;
firstName: string | null;
lastName: string | null;
role: string | null;
mobileNumber: string | null;
profileImage: string | null;
profileImagePreSignedUrl: string | null;
tags: string[];
}
export class OperatorReservationBookingInformationDTO {
activityName: string | null;
slot: string | null;
startTime: string | null;
endTime: string | null;
track: string | null;
trackLabel: string | null;
date: string | null;
dateLabel: string | null;
bookedOn: string | null;
bookedOnLabel: string | null;
}
export class OperatorReservationBookingIncludedDTO {
food: string;
selectedFoodTypes: string[];
equipment: string;
selectedEquipments: string[];
trainerOrGuide: string;
pickupLocation: string | null;
}
export class OperatorReservationByCheckInCodeDTO {
itineraryHeaderXid: number;
itineraryActivityXid: number;
bookingId: string | null;
checkInCode: string | null;
reservationStatus: string | null;
personalDetails: OperatorReservationPersonalDetailsDTO;
bookingInformation: OperatorReservationBookingInformationDTO;
bookingIncluded: OperatorReservationBookingIncludedDTO;
}
export class GetReservationByCheckInCodeResponseDTO {
success: boolean;
message: string;
data: OperatorReservationByCheckInCodeDTO;
}
export class OperatorReservationVerificationOtpResponseDTO {
itineraryActivityXid: number;
itineraryMemberXid: number;
activityName: string | null;
checkInCode: string;
verificationType: 'check-in' | 'checkout';
requestedChannel: 'email' | 'mobile';
deliveryChannel: 'email';
destination: string;
reservationStatus: string | null;
expiresInMinutes: number;
deliveryNote: string | null;
}
export class OperatorReservationVerifyOtpResponseDTO {
itineraryActivityXid: number;
itineraryMemberXid: number;
activityName: string | null;
checkInCode: string;
verificationType: 'check-in' | 'checkout';
verified: boolean;
reservationStatus: string;
}

View File

@@ -0,0 +1,58 @@
import { APIGatewayProxyEvent, APIGatewayProxyResult, Context } from 'aws-lambda';
import { prismaClient } from '../../../../common/database/prisma.lambda.service';
import { safeHandler } from '../../../../common/utils/handlers/safeHandler';
import ApiError from '../../../../common/utils/helper/ApiError';
import { verifyOperatorToken } from '../../../../common/middlewares/jwt/authForOperator';
import { OperatorAuthService } from '../../services/operatorAuth.service';
const operatorAuthService = new OperatorAuthService(prismaClient);
export const handler = safeHandler(async (
event: APIGatewayProxyEvent,
context?: Context,
): Promise<APIGatewayProxyResult> => {
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.');
}
const userInfo = await verifyOperatorToken(token);
let body: { password?: string; confirmPassword?: string };
try {
body = event.body ? JSON.parse(event.body) : {};
} catch (error) {
throw new ApiError(400, 'Invalid JSON in request body');
}
const { password, confirmPassword } = body;
if (!password || !confirmPassword) {
throw new ApiError(400, 'Password and confirm password are required');
}
if (password !== confirmPassword) {
throw new ApiError(400, 'Password and confirm password do not match');
}
if (password.length < 8) {
throw new ApiError(400, 'Password must be at least 8 characters long');
}
await operatorAuthService.createOperatorPassword(userInfo.id, password);
return {
statusCode: 200,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
},
body: JSON.stringify({
success: true,
message: 'Password created successfully',
data: null,
}),
};
});

View File

@@ -0,0 +1,59 @@
import { APIGatewayProxyEvent, APIGatewayProxyResult, Context } from 'aws-lambda';
import { prismaClient } from '../../../../common/database/prisma.lambda.service';
import { verifyOperatorToken } from '../../../../common/middlewares/jwt/authForHost';
import { safeHandler } from '../../../../common/utils/handlers/safeHandler';
import ApiError from '../../../../common/utils/helper/ApiError';
import { GetActivitiesByDateRequestDTO } from '../../dto/operator.activity.dto';
import { OperatorActivityService } from '../../services/operatorActivity.service';
const operatorActivityService = new OperatorActivityService(prismaClient);
export const handler = safeHandler(async (
event: APIGatewayProxyEvent,
context?: Context,
): Promise<APIGatewayProxyResult> => {
try {
// 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 operator info
const operatorInfo = await verifyOperatorToken(token);
const operatorId = Number(operatorInfo.id);
if (!operatorId || isNaN(operatorId)) {
throw new ApiError(400, 'Invalid operator ID');
}
// Get activityDate from query parameters
const { activityDate } = event.queryStringParameters || {};
const requestDTO: GetActivitiesByDateRequestDTO = {
activityDate: activityDate?.trim(),
};
// Fetch activities by date and operator
const result = await operatorActivityService.getActivitiesByDate(
operatorId,
requestDTO.activityDate,
);
return {
statusCode: 200,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
},
body: JSON.stringify({
success: true,
message: 'Activities fetched successfully',
data: result,
}),
};
} catch (error) {
// Error will be handled by safeHandler
throw error;
}
});

View File

@@ -0,0 +1,65 @@
import {
APIGatewayProxyEvent,
APIGatewayProxyResult,
Context,
} from 'aws-lambda';
import { prismaClient } from '../../../../common/database/prisma.lambda.service';
import { verifyOperatorToken } from '../../../../common/middlewares/jwt/authForHost';
import { safeHandler } from '../../../../common/utils/handlers/safeHandler';
import ApiError from '../../../../common/utils/helper/ApiError';
import { GetReservationByCheckInCodeRequestDTO } from '../../dto/operator.activity.dto';
import { OperatorActivityService } from '../../services/operatorActivity.service';
const operatorActivityService = new OperatorActivityService(prismaClient);
export const handler = safeHandler(
async (
event: APIGatewayProxyEvent,
context?: Context,
): Promise<APIGatewayProxyResult> => {
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.',
);
}
const operatorInfo = await verifyOperatorToken(token);
const operatorId = Number(operatorInfo.id);
if (!operatorId || Number.isNaN(operatorId)) {
throw new ApiError(400, 'Invalid operator ID');
}
const requestDTO: GetReservationByCheckInCodeRequestDTO = {
checkInCode:
event.queryStringParameters?.checkInCode?.trim() ||
event.queryStringParameters?.offlineCode?.trim() ||
'',
};
if (!requestDTO.checkInCode) {
throw new ApiError(400, 'checkInCode is required.');
}
const result = await operatorActivityService.getReservationByCheckInCode(
operatorId,
requestDTO.checkInCode,
);
return {
statusCode: 200,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
},
body: JSON.stringify({
success: true,
message: 'Reservation details fetched successfully',
data: result,
}),
};
},
);

View File

@@ -0,0 +1,55 @@
import { APIGatewayProxyEvent, APIGatewayProxyResult, Context } from 'aws-lambda';
import { prismaClient } from '../../../../common/database/prisma.lambda.service';
import { safeHandler } from '../../../../common/utils/handlers/safeHandler';
import ApiError from '../../../../common/utils/helper/ApiError';
import { GetHostLoginResponseDTO } from '../../dto/host.dto';
import { OperatorAuthService } from '../../services/operatorAuth.service';
import { TokenService } from '../../services/token.service';
const operatorAuthService = new OperatorAuthService(prismaClient);
const tokenService = new TokenService(prismaClient);
export const handler = safeHandler(async (
event: APIGatewayProxyEvent,
context?: Context,
): Promise<APIGatewayProxyResult> => {
let body: { emailAddress?: string; userPassword?: string };
try {
body = event.body ? JSON.parse(event.body) : {};
} catch (error) {
throw new ApiError(400, 'Invalid JSON in request body');
}
const { emailAddress, userPassword } = body;
if (!emailAddress || !userPassword) {
throw new ApiError(400, 'Email and password are required');
}
const operator = await operatorAuthService.loginForOperator(
emailAddress.trim().toLowerCase(),
userPassword,
);
const generatedToken = await tokenService.generateAuthToken(operator.id);
const response = new GetHostLoginResponseDTO(
operator,
generatedToken.access.token,
generatedToken.refresh.token,
);
return {
statusCode: 200,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
},
body: JSON.stringify({
success: true,
message: 'Login successful',
data: response,
}),
};
});

View File

@@ -0,0 +1,70 @@
import {
APIGatewayProxyEvent,
APIGatewayProxyResult,
Context,
} from 'aws-lambda';
import { prismaClient } from '../../../../common/database/prisma.lambda.service';
import { verifyOperatorToken } from '../../../../common/middlewares/jwt/authForHost';
import { safeHandler } from '../../../../common/utils/handlers/safeHandler';
import ApiError from '../../../../common/utils/helper/ApiError';
import { OperatorReservationVerificationOtpRequestDTO } from '../../dto/operator.activity.dto';
import { OperatorActivityService } from '../../services/operatorActivity.service';
const operatorActivityService = new OperatorActivityService(prismaClient);
export const handler = safeHandler(
async (
event: APIGatewayProxyEvent,
context?: Context,
): Promise<APIGatewayProxyResult> => {
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.',
);
}
const operatorInfo = await verifyOperatorToken(token);
const operatorId = Number(operatorInfo.id);
if (!operatorId || Number.isNaN(operatorId)) {
throw new ApiError(400, 'Invalid operator ID');
}
let body: OperatorReservationVerificationOtpRequestDTO;
try {
body = event.body ? JSON.parse(event.body) : { checkInCode: '' };
} catch (error) {
throw new ApiError(400, 'Invalid JSON in request body');
}
const requestDTO: OperatorReservationVerificationOtpRequestDTO = {
checkInCode: body?.checkInCode?.trim() || '',
};
if (!requestDTO.checkInCode) {
throw new ApiError(400, 'checkInCode is required.');
}
const result = await operatorActivityService.sendOtpCheckIn(
operatorId,
requestDTO.checkInCode,
);
return {
statusCode: 200,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
},
body: JSON.stringify({
success: true,
message: 'Check-in OTP sent successfully',
data: result,
}),
};
},
);

View File

@@ -0,0 +1,70 @@
import {
APIGatewayProxyEvent,
APIGatewayProxyResult,
Context,
} from 'aws-lambda';
import { prismaClient } from '../../../../common/database/prisma.lambda.service';
import { verifyOperatorToken } from '../../../../common/middlewares/jwt/authForHost';
import { safeHandler } from '../../../../common/utils/handlers/safeHandler';
import ApiError from '../../../../common/utils/helper/ApiError';
import { OperatorReservationVerificationOtpRequestDTO } from '../../dto/operator.activity.dto';
import { OperatorActivityService } from '../../services/operatorActivity.service';
const operatorActivityService = new OperatorActivityService(prismaClient);
export const handler = safeHandler(
async (
event: APIGatewayProxyEvent,
context?: Context,
): Promise<APIGatewayProxyResult> => {
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.',
);
}
const operatorInfo = await verifyOperatorToken(token);
const operatorId = Number(operatorInfo.id);
if (!operatorId || Number.isNaN(operatorId)) {
throw new ApiError(400, 'Invalid operator ID');
}
let body: OperatorReservationVerificationOtpRequestDTO;
try {
body = event.body ? JSON.parse(event.body) : { checkInCode: '' };
} catch (error) {
throw new ApiError(400, 'Invalid JSON in request body');
}
const requestDTO: OperatorReservationVerificationOtpRequestDTO = {
checkInCode: body?.checkInCode?.trim() || '',
};
if (!requestDTO.checkInCode) {
throw new ApiError(400, 'checkInCode is required.');
}
const result = await operatorActivityService.sendOtpCheckout(
operatorId,
requestDTO.checkInCode,
);
return {
statusCode: 200,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
},
body: JSON.stringify({
success: true,
message: 'Checkout OTP sent successfully',
data: result,
}),
};
},
);

View File

@@ -0,0 +1,53 @@
import { APIGatewayProxyEvent, APIGatewayProxyResult, Context } from 'aws-lambda';
import { prismaClient } from '../../../../common/database/prisma.lambda.service';
import { safeHandler } from '../../../../common/utils/handlers/safeHandler';
import ApiError from '../../../../common/utils/helper/ApiError';
import { OperatorAuthService } from '../../services/operatorAuth.service';
const operatorAuthService = new OperatorAuthService(prismaClient);
export const handler = safeHandler(async (
event: APIGatewayProxyEvent,
context?: Context,
): Promise<APIGatewayProxyResult> => {
let body: {
firstName?: string;
lastName?: string;
emailAddress?: string;
isdCode?: string;
mobileNumber?: string;
};
try {
body = event.body ? JSON.parse(event.body) : {};
} catch (error) {
throw new ApiError(400, 'Invalid JSON in request body');
}
const { firstName, lastName, emailAddress, isdCode, mobileNumber } = body;
if (!emailAddress && !mobileNumber) {
throw new ApiError(400, 'Email address or mobile number is required');
}
const signupResponse = await operatorAuthService.signUpOperator({
firstName,
lastName,
emailAddress,
isdCode,
mobileNumber,
});
return {
statusCode: 200,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
},
body: JSON.stringify({
success: true,
message: 'OTP sent successfully',
data: signupResponse,
}),
};
});

View File

@@ -0,0 +1,51 @@
import { APIGatewayProxyEvent, APIGatewayProxyResult, Context } from 'aws-lambda';
import { prismaClient } from '../../../../common/database/prisma.lambda.service';
import { safeHandler } from '../../../../common/utils/handlers/safeHandler';
import ApiError from '../../../../common/utils/helper/ApiError';
import { OperatorAuthService } from '../../services/operatorAuth.service';
import { TokenService } from '../../services/token.service';
const operatorAuthService = new OperatorAuthService(prismaClient);
const tokenService = new TokenService(prismaClient);
export const handler = safeHandler(async (
event: APIGatewayProxyEvent,
context?: Context,
): Promise<APIGatewayProxyResult> => {
let body: { emailAddress?: string; mobileNumber?: string; otp?: string };
try {
body = event.body ? JSON.parse(event.body) : {};
} catch (error) {
throw new ApiError(400, 'Invalid JSON in request body');
}
const { emailAddress, mobileNumber, otp } = body;
if ((!emailAddress && !mobileNumber) || !otp) {
throw new ApiError(400, 'Email address or mobile number and OTP are required');
}
const operator = await operatorAuthService.verifyOperatorOtp({
emailAddress,
mobileNumber,
otp,
});
const generatedToken = await tokenService.generateAuthToken(operator.id);
return {
statusCode: 200,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
},
body: JSON.stringify({
success: true,
message: 'OTP verified successfully',
accessToken: generatedToken.access.token,
refreshToken: generatedToken.refresh.token,
data: null,
}),
};
});

View File

@@ -0,0 +1,72 @@
import {
APIGatewayProxyEvent,
APIGatewayProxyResult,
Context,
} from 'aws-lambda';
import { prismaClient } from '../../../../common/database/prisma.lambda.service';
import { verifyOperatorToken } from '../../../../common/middlewares/jwt/authForHost';
import { safeHandler } from '../../../../common/utils/handlers/safeHandler';
import ApiError from '../../../../common/utils/helper/ApiError';
import { OperatorReservationVerifyOtpRequestDTO } from '../../dto/operator.activity.dto';
import { OperatorActivityService } from '../../services/operatorActivity.service';
const operatorActivityService = new OperatorActivityService(prismaClient);
export const handler = safeHandler(
async (
event: APIGatewayProxyEvent,
context?: Context,
): Promise<APIGatewayProxyResult> => {
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.',
);
}
const operatorInfo = await verifyOperatorToken(token);
const operatorId = Number(operatorInfo.id);
if (!operatorId || Number.isNaN(operatorId)) {
throw new ApiError(400, 'Invalid operator ID');
}
let body: OperatorReservationVerifyOtpRequestDTO;
try {
body = event.body ? JSON.parse(event.body) : { checkInCode: '', otp: '' };
} catch (error) {
throw new ApiError(400, 'Invalid JSON in request body');
}
const requestDTO: OperatorReservationVerifyOtpRequestDTO = {
checkInCode: body?.checkInCode?.trim() || '',
otp: body?.otp?.trim() || '',
};
if (!requestDTO.checkInCode || !requestDTO.otp) {
throw new ApiError(400, 'checkInCode and otp are required.');
}
const result = await operatorActivityService.verifyOtpCheckIn(
operatorId,
requestDTO.checkInCode,
requestDTO.otp,
);
return {
statusCode: 200,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
},
body: JSON.stringify({
success: true,
message: 'Check-in OTP verified successfully',
data: result,
}),
};
},
);

View File

@@ -0,0 +1,72 @@
import {
APIGatewayProxyEvent,
APIGatewayProxyResult,
Context,
} from 'aws-lambda';
import { prismaClient } from '../../../../common/database/prisma.lambda.service';
import { verifyOperatorToken } from '../../../../common/middlewares/jwt/authForHost';
import { safeHandler } from '../../../../common/utils/handlers/safeHandler';
import ApiError from '../../../../common/utils/helper/ApiError';
import { OperatorReservationVerifyOtpRequestDTO } from '../../dto/operator.activity.dto';
import { OperatorActivityService } from '../../services/operatorActivity.service';
const operatorActivityService = new OperatorActivityService(prismaClient);
export const handler = safeHandler(
async (
event: APIGatewayProxyEvent,
context?: Context,
): Promise<APIGatewayProxyResult> => {
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.',
);
}
const operatorInfo = await verifyOperatorToken(token);
const operatorId = Number(operatorInfo.id);
if (!operatorId || Number.isNaN(operatorId)) {
throw new ApiError(400, 'Invalid operator ID');
}
let body: OperatorReservationVerifyOtpRequestDTO;
try {
body = event.body ? JSON.parse(event.body) : { checkInCode: '', otp: '' };
} catch (error) {
throw new ApiError(400, 'Invalid JSON in request body');
}
const requestDTO: OperatorReservationVerifyOtpRequestDTO = {
checkInCode: body?.checkInCode?.trim() || '',
otp: body?.otp?.trim() || '',
};
if (!requestDTO.checkInCode || !requestDTO.otp) {
throw new ApiError(400, 'checkInCode and otp are required.');
}
const result = await operatorActivityService.verifyOtpCheckout(
operatorId,
requestDTO.checkInCode,
requestDTO.otp,
);
return {
statusCode: 200,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
},
body: JSON.stringify({
success: true,
message: 'Checkout OTP verified successfully',
data: result,
}),
};
},
);

View File

@@ -0,0 +1,44 @@
import { APIGatewayProxyEvent, APIGatewayProxyResult, Context } from 'aws-lambda';
import { prismaClient } from '../../../../common/database/prisma.lambda.service';
import { safeHandler } from '../../../../common/utils/handlers/safeHandler';
import ApiError from '../../../../common/utils/helper/ApiError';
import { OperatorAuthService } from '../../services/operatorAuth.service';
const operatorAuthService = new OperatorAuthService(prismaClient);
export const handler = safeHandler(async (
event: APIGatewayProxyEvent,
context?: Context,
): Promise<APIGatewayProxyResult> => {
let body: { emailAddress?: string; userPassword?: string };
try {
body = event.body ? JSON.parse(event.body) : {};
} catch (error) {
throw new ApiError(400, 'Invalid JSON in request body');
}
const { emailAddress, userPassword } = body;
if (!emailAddress || !userPassword) {
throw new ApiError(400, 'Email and password are required');
}
const isPasswordValid = await operatorAuthService.verifyPasswordForOperator(
emailAddress.trim().toLowerCase(),
userPassword,
);
return {
statusCode: 200,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
},
body: JSON.stringify({
success: true,
message: isPasswordValid ? 'Password is valid' : 'Password is invalid',
data: { isValid: isPasswordValid },
}),
};
});

View File

@@ -0,0 +1,58 @@
import {
APIGatewayProxyEvent,
APIGatewayProxyResult,
Context,
} from 'aws-lambda';
import { prismaClient } from '../../../../common/database/prisma.lambda.service';
import { verifyHostToken } from '../../../../common/middlewares/jwt/authForHost';
import { safeHandler } from '../../../../common/utils/handlers/safeHandler';
import ApiError from '../../../../common/utils/helper/ApiError';
import { HostRolePermissionService } from '../../services/hostRolePermission.service';
const hostRolePermissionService = new HostRolePermissionService(prismaClient);
export const handler = safeHandler(async (
event: APIGatewayProxyEvent,
context?: Context,
): Promise<APIGatewayProxyResult> => {
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.',
);
}
const userInfo = await verifyHostToken(token);
const memberUserXid = event.pathParameters?.memberUserXid;
if (!memberUserXid) {
throw new ApiError(400, 'memberUserXid is required.');
}
const memberUserId = parseInt(memberUserXid, 10);
if (isNaN(memberUserId)) {
throw new ApiError(400, 'Invalid memberUserXid. Must be a number.');
}
const result = await hostRolePermissionService.getMemberPermissions({
hostUserXid: userInfo.id,
memberUserXid: memberUserId,
});
return {
statusCode: 200,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
},
body: JSON.stringify({
success: true,
message: 'Member permissions retrieved successfully',
data: result,
}),
};
});

View File

@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { ROLE } from '../../../common/utils/constants/common.constant';
import ApiError from '../../../common/utils/helper/ApiError';
@@ -126,4 +127,128 @@ export class HostRolePermissionService {
};
});
}
async getMemberPermissions(input: {
hostUserXid: number;
memberUserXid: number;
}) {
return this.prisma.$transaction(async (tx) => {
// Find the host
const host = await tx.hostHeader.findFirst({
where: {
userXid: input.hostUserXid,
isActive: true,
deletedAt: null,
},
select: {
id: true,
companyName: true,
userXid: true,
},
});
if (!host) {
throw new ApiError(404, 'Host company not found for the logged-in user.');
}
// Find the host member
const hostMember = await tx.hostMembers.findFirst({
where: {
hostXid: host.id,
userXid: input.memberUserXid,
isActive: true,
deletedAt: null,
memberStatus: 'accepted',
},
select: {
id: true,
userXid: true,
roleXid: true,
role: {
select: {
id: true,
roleName: true,
},
},
hostRolePermissionMasterXid: true,
},
});
if (!hostMember) {
throw new ApiError(404, 'Host member not found or not active.');
}
// Check if role is operator or co-admin
if (hostMember.roleXid !== ROLE.CO_ADMIN && hostMember.roleXid !== ROLE.OPERATOR) {
throw new ApiError(400, 'Member is not an operator or co-admin.');
}
if (!hostMember.hostRolePermissionMasterXid) {
// Return empty permissions if no role permissions assigned
return {
host,
member: {
userXid: hostMember.userXid,
role: hostMember.role,
},
permissions: [],
};
}
// Get the role permissions
const rolePermissions = await tx.hostRolePermissionMasters.findFirst({
where: {
id: hostMember.hostRolePermissionMasterXid,
isActive: true,
deletedAt: null,
},
select: {
id: true,
permissionMasterXids: true,
},
});
if (!rolePermissions) {
return {
host,
member: {
userXid: hostMember.userXid,
role: hostMember.role,
},
permissions: [],
};
}
// Get the actual permissions
const permissionIds = rolePermissions.permissionMasterXids as number[];
const permissions = await tx.hostPermissionMasters.findMany({
where: {
id: { in: permissionIds },
isActive: true,
deletedAt: null,
},
select: {
id: true,
permissionKey: true,
permissionGroup: true,
permissionSection: true,
permissionAction: true,
displayLabel: true,
displayOrder: true,
},
orderBy: {
displayOrder: 'asc',
},
});
return {
host,
member: {
userXid: hostMember.userXid,
role: hostMember.role,
},
permissions,
};
});
}
}

View File

@@ -0,0 +1,992 @@
import { Injectable } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import * as bcrypt from 'bcryptjs';
import { brevoService } from '../../../common/email/brevoApi';
import { getPresignedUrl } from '../../../common/middlewares/aws/getPreSignedUrl';
import ApiError from '../../../common/utils/helper/ApiError';
import { generateOtpHelper } from '../../../common/utils/helper/sendOtp';
import config from '../../../config/config';
import {
ActivitySummaryDTO,
OperatorReservationByCheckInCodeDTO,
OperatorReservationVerificationOtpResponseDTO,
OperatorReservationVerifyOtpResponseDTO,
} from '../dto/operator.activity.dto';
const formatDateOnly = (date: Date): string => date.toISOString().split('T')[0];
const formatReadableDateTime = (date: Date): string =>
new Intl.DateTimeFormat('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
hour12: true,
}).format(date);
const formatReadableDate = (date: Date): string =>
new Intl.DateTimeFormat('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
}).format(date);
const getDateLabel = (date: Date): string => {
const today = new Date();
return formatDateOnly(today) === formatDateOnly(date)
? 'Today'
: formatReadableDate(date);
};
const buildFullName = (
firstName?: string | null,
lastName?: string | null,
): string => `${firstName ?? ''} ${lastName ?? ''}`.trim();
type ReservationVerificationPurpose = 'CheckInVerify' | 'CheckOutVerify';
type ReservationVerificationRequestChannel = 'email' | 'mobile';
@Injectable()
export class OperatorActivityService {
constructor(private prisma: PrismaClient) {}
private async getAcceptedHostXidsForOperator(
operatorId: number,
): Promise<number[]> {
const hostMembers = await this.prisma.hostMembers.findMany({
where: {
userXid: operatorId,
isActive: true,
memberStatus: 'accepted',
deletedAt: null,
},
select: {
hostXid: true,
},
});
return hostMembers.map((member) => member.hostXid);
}
private maskEmailAddress(emailAddress: string): string {
const [localPart, domainPart = ''] = emailAddress.split('@');
if (!localPart) {
return emailAddress;
}
if (localPart.length <= 2) {
return `${localPart[0] ?? '*'}*@${domainPart}`;
}
return `${localPart[0]}${'*'.repeat(
Math.max(localPart.length - 2, 1),
)}${localPart[localPart.length - 1]}@${domainPart}`;
}
private async sendReservationVerificationOtpEmail(input: {
emailAddress: string;
otp: string;
checkInCode: string;
verificationType: 'check-in' | 'checkout';
firstName?: string | null;
}): Promise<void> {
const subject =
input.verificationType === 'check-in'
? 'Your Minglar check-in verification code'
: 'Your Minglar checkout verification code';
const greetingName = input.firstName?.trim() || 'there';
const htmlContent = `
<p>Hi ${greetingName},</p>
<p>Your ${input.verificationType} verification code for Minglar is:</p>
<p><strong>${input.otp}</strong></p>
<p>Check-in code: <strong>${input.checkInCode}</strong></p>
<p>This code is valid for the next 5 minutes.</p>
<p>If you did not request this code, you can ignore this email.</p>
<p>Warm regards,<br />Team Minglar</p>
`;
try {
await brevoService.sendEmail({
recipients: [{ email: input.emailAddress }],
subject,
htmlContent,
});
} catch (error) {
console.error('Reservation OTP email send failed:', error);
throw new ApiError(500, 'Failed to send OTP to the registered email address.');
}
}
private async findReservationForOperatorByCheckInCode(
operatorId: number,
checkInCode: string,
) {
const hostXids = await this.getAcceptedHostXidsForOperator(operatorId);
if (hostXids.length === 0) {
throw new ApiError(404, 'Reservation not found for this check-in code');
}
const reservation = await this.prisma.itineraryDetails.findFirst({
where: {
offlineCode: checkInCode,
itineraryKind: 'ACTIVITY',
isActive: true,
deletedAt: null,
itineraryActivity: {
itineraryType: 'ACTIVITY',
isActive: true,
deletedAt: null,
activity: {
hostXid: {
in: hostXids,
},
isActive: true,
deletedAt: null,
},
},
},
select: {
id: true,
itineraryMemberXid: true,
offlineCode: true,
activityStatus: true,
itineraryMember: {
select: {
id: true,
member: {
select: {
id: true,
firstName: true,
lastName: true,
emailAddress: true,
mobileNumber: true,
},
},
},
},
itineraryActivity: {
select: {
id: true,
bookingStatus: true,
activity: {
select: {
activityTitle: true,
},
},
},
},
},
});
if (!reservation) {
throw new ApiError(404, 'Reservation not found for this check-in code');
}
return reservation;
}
private async verifyReservationOtp(
operatorId: number,
checkInCode: string,
otp: string,
input: {
otpType: ReservationVerificationPurpose;
verificationType: 'check-in' | 'checkout';
nextStatus: 'checked_in' | 'checked_out';
},
): Promise<OperatorReservationVerifyOtpResponseDTO> {
const normalizedCheckInCode = checkInCode.trim();
const trimmedOtp = otp.trim();
if (!normalizedCheckInCode) {
throw new ApiError(400, 'checkInCode is required');
}
if (!trimmedOtp) {
throw new ApiError(400, 'otp is required');
}
const reservation = await this.findReservationForOperatorByCheckInCode(
operatorId,
normalizedCheckInCode,
);
const member = reservation.itineraryMember?.member;
if (!member?.id) {
throw new ApiError(404, 'User not found for this reservation');
}
const currentStatus = (
reservation.activityStatus ??
reservation.itineraryActivity.bookingStatus ??
''
)
.trim()
.toLowerCase();
if (input.verificationType === 'check-in') {
if (currentStatus === 'checked_in') {
throw new ApiError(400, 'This user is already checked in.');
}
if (currentStatus === 'checked_out') {
throw new ApiError(400, 'This user has already checked out.');
}
}
if (input.verificationType === 'checkout') {
if (currentStatus === 'checked_out') {
throw new ApiError(400, 'This user is already checked out.');
}
if (currentStatus !== 'checked_in') {
throw new ApiError(
400,
'Check-in verification must be completed before checkout.',
);
}
}
const latestOtp = await this.prisma.userOtp.findFirst({
where: {
userXid: member.id,
otpType: input.otpType,
isActive: true,
isVerified: false,
},
orderBy: {
createdAt: 'desc',
},
});
if (!latestOtp) {
throw new ApiError(400, 'No OTP found.');
}
if (new Date() > latestOtp.expiresOn) {
throw new ApiError(400, 'OTP has expired.');
}
const isMatch = await bcrypt.compare(trimmedOtp, latestOtp.otpCode);
if (!isMatch) {
throw new ApiError(400, 'Invalid OTP.');
}
await this.prisma.$transaction(async (tx) => {
await tx.userOtp.update({
where: {
id: latestOtp.id,
},
data: {
isVerified: true,
verifiedOn: new Date(),
isActive: false,
},
});
await tx.itineraryDetails.update({
where: {
id: reservation.id,
},
data: {
activityStatus: input.nextStatus,
updatedOn: new Date(),
},
});
});
return {
itineraryActivityXid: reservation.itineraryActivity.id,
itineraryMemberXid: reservation.itineraryMemberXid,
activityName: reservation.itineraryActivity.activity?.activityTitle ?? null,
checkInCode: normalizedCheckInCode,
verificationType: input.verificationType,
verified: true,
reservationStatus: input.nextStatus,
};
}
private async requestReservationVerificationOtp(
operatorId: number,
checkInCode: string,
input: {
otpType: ReservationVerificationPurpose;
verificationType: 'check-in' | 'checkout';
requestedChannel: ReservationVerificationRequestChannel;
},
): Promise<OperatorReservationVerificationOtpResponseDTO> {
const normalizedCheckInCode = checkInCode.trim();
if (!normalizedCheckInCode) {
throw new ApiError(400, 'checkInCode is required');
}
const reservation = await this.findReservationForOperatorByCheckInCode(
operatorId,
normalizedCheckInCode,
);
const member = reservation.itineraryMember?.member;
if (!member?.id) {
throw new ApiError(404, 'User not found for this reservation');
}
const emailAddress = member.emailAddress?.trim().toLowerCase() || null;
const mobileNumber = member.mobileNumber?.trim() || null;
if (!emailAddress && !mobileNumber) {
throw new ApiError(
400,
'No registered mobile number or email address found for this user.',
);
}
if (!emailAddress) {
throw new ApiError(
501,
'SMS OTP delivery is not configured yet for reservation verification. Please add a registered email address for this user.',
);
}
const deliveryNote =
input.requestedChannel === 'mobile'
? 'SMS OTP delivery is not configured yet, so the OTP was sent to the registered email address instead.'
: null;
const otpResult = await generateOtpHelper(
this.prisma,
member.id,
emailAddress,
input.otpType,
6,
5,
);
await this.sendReservationVerificationOtpEmail({
emailAddress,
otp: otpResult.otp,
checkInCode: normalizedCheckInCode,
verificationType: input.verificationType,
firstName: member.firstName,
});
return {
itineraryActivityXid: reservation.itineraryActivity.id,
itineraryMemberXid: reservation.itineraryMemberXid,
activityName: reservation.itineraryActivity.activity?.activityTitle ?? null,
checkInCode: normalizedCheckInCode,
verificationType: input.verificationType,
requestedChannel: input.requestedChannel,
deliveryChannel: 'email',
destination: this.maskEmailAddress(emailAddress),
reservationStatus:
reservation.activityStatus ?? reservation.itineraryActivity.bookingStatus,
expiresInMinutes: 5,
deliveryNote,
};
}
private async attachPresignedUrl(
fileName?: string | null,
): Promise<string | null> {
if (!fileName) {
return null;
}
try {
return await getPresignedUrl(config.aws.bucketName, fileName);
} catch (error) {
console.error(`Failed to generate presigned URL for ${fileName}:`, error);
return null;
}
}
async getActivitiesByDate(
operatorId: number,
activityDate?: string,
): Promise<{
date: string;
activities: ActivitySummaryDTO[];
totalCount: number;
}> {
try {
// Get operator's assigned hosts
const hostMembers = await this.prisma.hostMembers.findMany({
where: {
userXid: operatorId,
isActive: true,
memberStatus: 'accepted', // Only accepted memberships
},
select: {
hostXid: true,
},
});
if (hostMembers.length === 0) {
return {
date: new Date().toISOString().split('T')[0],
activities: [],
totalCount: 0,
};
}
const hostXids = hostMembers.map((m) => m.hostXid);
// Use today's date if not provided
const queryDate = activityDate ? new Date(activityDate) : new Date();
// Validate date format
if (isNaN(queryDate.getTime())) {
throw new ApiError(400, 'Invalid date format. Use YYYY-MM-DD format');
}
// Set time to start of day (UTC)
queryDate.setUTCHours(0, 0, 0, 0);
// Set end of day
const endOfDay = new Date(queryDate);
endOfDay.setUTCHours(23, 59, 59, 999);
// Get all schedule occurrences from today onwards (not just query date)
// This includes future dates to show all scheduled dates
const allScheduleOccurrences =
await this.prisma.scheduleOccurences.findMany({
where: {
occurenceDate: {
gte: queryDate,
},
isActive: true,
scheduleHeader: {
activity: {
hostXid: {
in: hostXids,
},
},
},
},
include: {
scheduleHeader: {
include: {
activity: {
select: {
id: true,
activityTitle: true,
isActive: true,
ActivitiesMedia: {
where: {
isCoverImage: true,
isActive: true,
},
select: {
mediaFileName: true,
},
take: 1,
},
},
},
},
},
},
orderBy: {
occurenceDate: 'asc',
},
});
if (allScheduleOccurrences.length === 0) {
return {
date: queryDate.toISOString().split('T')[0],
activities: [],
totalCount: 0,
};
}
// Group activities by activity ID with all scheduled dates
const activityMap = new Map<
number,
{
activityTitle: string;
coverImage: string | null;
coverImageUrl: string | null;
activityId: number;
scheduledDates: Date[];
}
>();
// Process all schedule occurrences to build activity list with all scheduled dates
for (const occurrence of allScheduleOccurrences) {
const activity = occurrence.scheduleHeader.activity;
if (!activityMap.has(activity.id)) {
let coverImage: string | null = null;
let coverImageUrl: string | null = null;
if (activity.ActivitiesMedia.length > 0) {
coverImage = activity.ActivitiesMedia[0].mediaFileName;
try {
// Generate presigned URL for the image
coverImageUrl = await getPresignedUrl(
config.aws.bucketName,
coverImage,
);
} catch (error) {
console.error(
`Failed to generate presigned URL for ${coverImage}:`,
error,
);
coverImageUrl = null;
}
}
activityMap.set(activity.id, {
activityTitle: activity.activityTitle || 'Unknown Activity',
coverImage,
coverImageUrl,
activityId: activity.id,
scheduledDates: [new Date(occurrence.occurenceDate)],
});
} else {
// Add to scheduled dates if not already present
const existingActivity = activityMap.get(activity.id)!;
const occurrenceDateStr = new Date(occurrence.occurenceDate)
.toISOString()
.split('T')[0];
const dateExists = existingActivity.scheduledDates.some(
(d) => d.toISOString().split('T')[0] === occurrenceDateStr,
);
if (!dateExists) {
existingActivity.scheduledDates.push(
new Date(occurrence.occurenceDate),
);
}
}
}
// Get all unique scheduled dates for all activities
const allScheduledDates = new Set<string>();
activityMap.forEach((activity) => {
activity.scheduledDates.forEach((date) => {
allScheduledDates.add(date.toISOString().split('T')[0]);
});
});
// Get all activity IDs
const activityIds = Array.from(activityMap.keys());
// Query all bookings for these activities from query date onwards
const allBookings = await this.prisma.itineraryActivities.findMany({
where: {
activityXid: {
in: activityIds,
},
occurenceDate: {
gte: queryDate,
},
isActive: true,
},
select: {
activityXid: true,
itineraryHeaderXid: true,
occurenceDate: true,
},
});
// Count bookings by activity and date
const bookingsByActivityAndDate = new Map<
number,
Map<string, Set<number>>
>();
allBookings.forEach((booking) => {
if (booking.activityXid) {
if (!bookingsByActivityAndDate.has(booking.activityXid)) {
bookingsByActivityAndDate.set(booking.activityXid, new Map());
}
const dateStr = new Date(booking.occurenceDate)
.toISOString()
.split('T')[0];
const dateMap = bookingsByActivityAndDate.get(booking.activityXid)!;
if (!dateMap.has(dateStr)) {
dateMap.set(dateStr, new Set());
}
dateMap.get(dateStr)!.add(booking.itineraryHeaderXid);
}
});
// Convert map to array format with date breakdown
const activities: ActivitySummaryDTO[] = Array.from(
activityMap.values(),
).map((activity) => {
const activityBookings = bookingsByActivityAndDate.get(
activity.activityId,
);
// Get bookings for each scheduled date
const dateBreakdown = Array.from(allScheduledDates)
.sort()
.map((dateStr) => {
const count = activityBookings?.get(dateStr)?.size || 0;
return {
date: dateStr,
count,
};
});
// Count for the query date only
const queryDateStr = queryDate.toISOString().split('T')[0];
const countForQueryDate =
activityBookings?.get(queryDateStr)?.size || 0;
return {
activityName: activity.activityTitle,
activityImage: activity.coverImage,
activityImagePreSignedUrl: activity.coverImageUrl,
count: countForQueryDate,
dateBreakdown,
};
});
// Total count is bookings for the requested date only
const queryDateStr = queryDate.toISOString().split('T')[0];
const totalCount = allBookings.filter(
(b) =>
new Date(b.occurenceDate).toISOString().split('T')[0] ===
queryDateStr,
).length;
return {
date: queryDateStr,
activities,
totalCount,
};
} catch (error) {
if (error instanceof ApiError) {
throw error;
}
throw new ApiError(
500,
error instanceof Error ? error.message : 'Error fetching activities',
);
}
}
async getReservationByCheckInCode(
operatorId: number,
checkInCode: string,
): Promise<OperatorReservationByCheckInCodeDTO> {
try {
const normalizedCheckInCode = checkInCode.trim();
if (!normalizedCheckInCode) {
throw new ApiError(400, 'checkInCode is required');
}
const hostMembers = await this.prisma.hostMembers.findMany({
where: {
userXid: operatorId,
isActive: true,
memberStatus: 'accepted',
deletedAt: null,
},
select: {
hostXid: true,
},
});
const hostXids = hostMembers.map((member) => member.hostXid);
if (hostXids.length === 0) {
throw new ApiError(404, 'Reservation not found for this check-in code');
}
const reservation = await this.prisma.itineraryDetails.findFirst({
where: {
offlineCode: normalizedCheckInCode,
itineraryKind: 'ACTIVITY',
isActive: true,
deletedAt: null,
itineraryActivity: {
itineraryType: 'ACTIVITY',
isActive: true,
deletedAt: null,
activity: {
hostXid: {
in: hostXids,
},
isActive: true,
deletedAt: null,
},
},
},
select: {
id: true,
itineraryMemberXid: true,
offlineCode: true,
activityStatus: true,
createdAt: true,
paidOn: true,
itineraryMember: {
select: {
id: true,
memberRole: true,
member: {
select: {
id: true,
firstName: true,
lastName: true,
mobileNumber: true,
profileImage: true,
},
},
},
},
itineraryActivity: {
select: {
id: true,
occurenceDate: true,
startTime: true,
endTime: true,
bookingStatus: true,
venue: {
select: {
id: true,
venueName: true,
venueLabel: true,
},
},
itineraryHeader: {
select: {
id: true,
itineraryNo: true,
createdAt: true,
},
},
activity: {
select: {
id: true,
activityTitle: true,
checkInAddress: true,
ActivityPickUpDetails: {
where: {
isActive: true,
deletedAt: null,
},
select: {
id: true,
isPickUp: true,
locationAddress: true,
},
},
},
},
itineraryActivitySelections: {
where: {
isActive: true,
deletedAt: null,
},
select: {
id: true,
itineraryMemberXid: true,
isFoodOpted: true,
isTrainerOpted: true,
selectedFoodTypes: {
where: {
isActive: true,
deletedAt: null,
},
select: {
id: true,
activityFoodType: {
select: {
foodType: {
select: {
foodTypeName: true,
},
},
},
},
},
},
selectedEquipments: {
where: {
isActive: true,
deletedAt: null,
},
select: {
id: true,
activityEquipment: {
select: {
equipmentName: true,
},
},
},
},
},
},
},
},
},
});
if (!reservation) {
throw new ApiError(404, 'Reservation not found for this check-in code');
}
const activitySelection =
reservation.itineraryActivity.itineraryActivitySelections.find(
(selection) =>
selection.itineraryMemberXid === reservation.itineraryMemberXid,
) ?? null;
const selectedFoodTypes = (activitySelection?.selectedFoodTypes ?? [])
.map(
(selectedFoodType) =>
selectedFoodType.activityFoodType.foodType.foodTypeName,
)
.filter(Boolean);
const selectedEquipments = (activitySelection?.selectedEquipments ?? [])
.map(
(selectedEquipment) =>
selectedEquipment.activityEquipment.equipmentName,
)
.filter(Boolean);
const pickupLocation =
reservation.itineraryActivity.activity.ActivityPickUpDetails.find(
(detail) => detail.isPickUp && detail.locationAddress,
)?.locationAddress ??
reservation.itineraryActivity.activity.ActivityPickUpDetails.find(
(detail) => detail.locationAddress,
)?.locationAddress ??
reservation.itineraryActivity.activity.checkInAddress ??
null;
const member = reservation.itineraryMember.member;
const fullName =
buildFullName(member.firstName, member.lastName) || 'Guest';
const profileImagePreSignedUrl = await this.attachPresignedUrl(
member.profileImage,
);
const occurenceDate = new Date(
reservation.itineraryActivity.occurenceDate,
);
const bookedOnDate =
reservation.paidOn ??
reservation.createdAt ??
reservation.itineraryActivity.itineraryHeader.createdAt;
return {
itineraryHeaderXid: reservation.itineraryActivity.itineraryHeader.id,
itineraryActivityXid: reservation.itineraryActivity.id,
bookingId: reservation.itineraryActivity.itineraryHeader.itineraryNo,
checkInCode: reservation.offlineCode,
reservationStatus:
reservation.activityStatus ??
reservation.itineraryActivity.bookingStatus,
personalDetails: {
fullName,
firstName: member.firstName,
lastName: member.lastName,
role: reservation.itineraryMember.memberRole,
mobileNumber: member.mobileNumber,
profileImage: member.profileImage,
profileImagePreSignedUrl,
tags: [],
},
bookingInformation: {
activityName: reservation.itineraryActivity.activity.activityTitle,
slot:
reservation.itineraryActivity.startTime &&
reservation.itineraryActivity.endTime
? `${reservation.itineraryActivity.startTime} - ${reservation.itineraryActivity.endTime}`
: null,
startTime: reservation.itineraryActivity.startTime,
endTime: reservation.itineraryActivity.endTime,
track: reservation.itineraryActivity.venue?.venueName ?? null,
trackLabel: reservation.itineraryActivity.venue?.venueLabel ?? null,
date: formatDateOnly(occurenceDate),
dateLabel: getDateLabel(occurenceDate),
bookedOn: bookedOnDate ? bookedOnDate.toISOString() : null,
bookedOnLabel: bookedOnDate
? formatReadableDateTime(bookedOnDate)
: null,
},
bookingIncluded: {
food:
selectedFoodTypes.length > 0 ? selectedFoodTypes.join(', ') : 'No',
selectedFoodTypes,
equipment: selectedEquipments.length > 0 ? 'Yes' : 'No',
selectedEquipments,
trainerOrGuide: activitySelection?.isTrainerOpted ? 'Yes' : 'No',
pickupLocation,
},
// description1: null,
// description2: null,
};
} catch (error) {
if (error instanceof ApiError) {
throw error;
}
throw new ApiError(
500,
error instanceof Error
? error.message
: 'Error fetching reservation details',
);
}
}
async sendOtpCheckIn(
operatorId: number,
checkInCode: string,
): Promise<OperatorReservationVerificationOtpResponseDTO> {
return this.requestReservationVerificationOtp(operatorId, checkInCode, {
otpType: 'CheckInVerify',
verificationType: 'check-in',
requestedChannel: 'email',
});
}
async sendOtpCheckout(
operatorId: number,
checkInCode: string,
): Promise<OperatorReservationVerificationOtpResponseDTO> {
return this.requestReservationVerificationOtp(operatorId, checkInCode, {
otpType: 'CheckOutVerify',
verificationType: 'checkout',
requestedChannel: 'mobile',
});
}
async verifyOtpCheckIn(
operatorId: number,
checkInCode: string,
otp: string,
): Promise<OperatorReservationVerifyOtpResponseDTO> {
return this.verifyReservationOtp(operatorId, checkInCode, otp, {
otpType: 'CheckInVerify',
verificationType: 'check-in',
nextStatus: 'checked_in',
});
}
async verifyOtpCheckout(
operatorId: number,
checkInCode: string,
otp: string,
): Promise<OperatorReservationVerifyOtpResponseDTO> {
return this.verifyReservationOtp(operatorId, checkInCode, otp, {
otpType: 'CheckOutVerify',
verificationType: 'checkout',
nextStatus: 'checked_out',
});
}
}

View File

@@ -0,0 +1,286 @@
import { Injectable } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import * as bcrypt from 'bcryptjs';
import { ROLE, USER_STATUS } from '../../../common/utils/constants/common.constant';
import ApiError from '../../../common/utils/helper/ApiError';
import { OtpGeneratorSixDigit } from '../../../common/utils/helper/OtpGenerator';
type OperatorSignupInput = {
firstName?: string;
lastName?: string;
emailAddress?: string;
isdCode?: string;
mobileNumber?: string;
};
@Injectable()
export class OperatorAuthService {
constructor(private prisma: PrismaClient) {}
async findInvitedOperator(input: {
emailAddress?: string;
mobileNumber?: string;
}) {
const emailAddress = input.emailAddress?.trim().toLowerCase();
const mobileNumber = input.mobileNumber?.trim();
if (!emailAddress && !mobileNumber) {
throw new ApiError(400, 'Email address or mobile number is required');
}
const invitedOperator = await this.prisma.user.findFirst({
where: {
roleXid: ROLE.OPERATOR,
isActive: true,
OR: [
...(emailAddress ? [{ emailAddress }] : []),
...(mobileNumber ? [{ mobileNumber }] : []),
],
},
select: {
id: true,
firstName: true,
lastName: true,
emailAddress: true,
isdCode: true,
mobileNumber: true,
roleXid: true,
userPassword: true,
isEmailVerfied: true,
userStatus: true,
},
});
if (!invitedOperator) {
throw new ApiError(
403,
'Operator record not found. Please contact your host.',
);
}
return invitedOperator;
}
async signUpOperator(input: OperatorSignupInput) {
const emailAddress = input.emailAddress?.trim().toLowerCase();
const mobileNumber = input.mobileNumber?.trim();
const invitedOperator = await this.findInvitedOperator({
emailAddress,
mobileNumber,
});
if (invitedOperator.userPassword) {
return {
userId: invitedOperator.id,
emailAddress: emailAddress || invitedOperator.emailAddress,
mobileNumber: mobileNumber || invitedOperator.mobileNumber,
otp: null,
expiresOn: null,
isNewOperator: false,
};
}
const otp = OtpGeneratorSixDigit.generateOtp();
const hashedOtp = await bcrypt.hash(otp, 10);
const expiry = new Date(Date.now() + 5 * 60 * 1000);
await this.prisma.$transaction(async (tx) => {
await tx.user.update({
where: { id: invitedOperator.id },
data: {
firstName: input.firstName?.trim() || invitedOperator.firstName || null,
lastName: input.lastName?.trim() || invitedOperator.lastName || null,
emailAddress: emailAddress || invitedOperator.emailAddress,
isdCode: input.isdCode?.trim() || invitedOperator.isdCode || null,
mobileNumber: mobileNumber || invitedOperator.mobileNumber,
userStatus: USER_STATUS.INVITED,
},
});
await tx.userOtp.updateMany({
where: {
userXid: invitedOperator.id,
otpType: 'Register',
isActive: true,
},
data: {
isActive: false,
isVerified: true,
},
});
await tx.userOtp.create({
data: {
userXid: invitedOperator.id,
otpType: 'Register',
otpCode: hashedOtp,
expiresOn: expiry,
isVerified: false,
isActive: true,
},
});
});
return {
isNewOperator: true,
};
}
async verifyOperatorOtp(input: { emailAddress?: string; mobileNumber?: string; otp: string }) {
const emailAddress = input.emailAddress?.trim().toLowerCase();
const mobileNumber = input.mobileNumber?.trim();
const otp = input.otp.trim();
const invitedOperator = await this.findInvitedOperator({
emailAddress,
mobileNumber,
});
const latestOtp = await this.prisma.userOtp.findFirst({
where: {
userXid: invitedOperator.id,
otpType: 'Register',
isActive: true,
isVerified: false,
},
orderBy: { createdAt: 'desc' },
});
if (!latestOtp) {
throw new ApiError(400, 'No OTP found.');
}
if (new Date() > latestOtp.expiresOn) {
throw new ApiError(400, 'OTP has expired.');
}
const isMatch = await bcrypt.compare(otp, latestOtp.otpCode);
if (!isMatch) {
throw new ApiError(400, 'Invalid OTP.');
}
await this.prisma.userOtp.update({
where: { id: latestOtp.id },
data: {
isVerified: true,
verifiedOn: new Date(),
isActive: false,
},
});
await this.prisma.user.update({
where: { id: invitedOperator.id },
data: {
isEmailVerfied: emailAddress ? true : invitedOperator.isEmailVerfied,
},
});
return invitedOperator;
}
async createOperatorPassword(userId: number, password: string) {
const user = await this.prisma.user.findFirst({
where: {
id: userId,
roleXid: ROLE.OPERATOR,
isActive: true,
},
select: {
id: true,
emailAddress: true,
userPassword: true,
},
});
if (!user) {
throw new ApiError(404, 'Operator not found');
}
if (user.userPassword) {
throw new ApiError(400, 'Password already created. Please login.');
}
const saltRounds = parseInt(process.env.SALT_ROUNDS || '10', 10);
const hashedPassword = await bcrypt.hash(password, saltRounds);
await this.prisma.user.update({
where: { id: user.id },
data: {
userPassword: hashedPassword,
userStatus: USER_STATUS.ACTIVE,
isEmailVerfied: true,
},
});
return {
id: user.id,
emailAddress: user.emailAddress,
};
}
async loginForOperator(emailAddress: string, userPassword: string) {
const existingOperator = await this.prisma.user.findFirst({
where: {
emailAddress,
roleXid: ROLE.OPERATOR,
isActive: true,
userStatus: USER_STATUS.ACTIVE,
},
select: {
id: true,
firstName: true,
lastName: true,
emailAddress: true,
mobileNumber: true,
roleXid: true,
isActive: true,
userStatus: true,
userPassword: true,
},
});
if (!existingOperator) {
throw new ApiError(404, 'Operator not found');
}
const isPasswordMatched = await bcrypt.compare(
userPassword,
existingOperator.userPassword || '',
);
if (!isPasswordMatched) {
throw new ApiError(401, 'Invalid credentials');
}
delete existingOperator.userPassword;
return existingOperator;
}
async verifyPasswordForOperator(emailAddress: string, userPassword: string) {
const existingOperator = await this.prisma.user.findFirst({
where: {
emailAddress,
roleXid: ROLE.OPERATOR,
isActive: true,
userStatus: USER_STATUS.ACTIVE,
},
select: {
id: true,
userPassword: true,
},
});
if (!existingOperator) {
throw new ApiError(404, 'Operator not found');
}
const isPasswordMatched = await bcrypt.compare(
userPassword,
existingOperator.userPassword || '',
);
return isPasswordMatched;
}
}

View File

@@ -0,0 +1,91 @@
import { APIGatewayProxyEvent, APIGatewayProxyResult, Context } from 'aws-lambda';
import { safeHandler } from '../../../common/utils/handlers/safeHandler';
import { PrismaService } from '../../../common/database/prisma.service';
import { MinglarService } from '../services/minglar.service';
import ApiError from '../../../common/utils/helper/ApiError';
import { verifyMinglarAdminToken } from '../../../common/middlewares/jwt/authForMinglarAdmin';
import { HOST_SUGGESTION_TITLES } from '../../../common/utils/constants/minglar.constant';
const prismaService = new PrismaService();
const minglarService = new MinglarService(prismaService);
interface AddSuggestionBody {
hostXid: number;
title: string;
comments: string;
activity_pqq_header_xid:number
}
/**
* Add suggestion handler for host applications
* Allows Minglar Admin, Co_Admin, and Account Manager to add suggestions
* Types: Setup Profile, Review Account, Add Payment Details, Agreement
*/
export const handler = safeHandler(async (
event: APIGatewayProxyEvent,
context?: Context
): Promise<APIGatewayProxyResult> => {
// Verify authentication token
const token = event.headers['x-auth-token'] || event.headers['X-Auth-Token'];
if (!token) {
throw new ApiError(401, 'This is a protected route. Please provide a valid token.');
}
// Verify token and get user info
const userInfo = await verifyMinglarAdminToken(token);
// Get user details
const user = await prismaService.user.findUnique({
where: { id: userInfo.id },
select: { id: true, roleXid: true }
});
if (!user) {
throw new ApiError(404, 'User not found');
}
// Parse request body
let body: AddSuggestionBody;
try {
body = event.body ? JSON.parse(event.body) : {};
} catch (error) {
throw new ApiError(400, 'Invalid JSON in request body');
}
const { title, comments , activity_pqq_header_xid} = body;
if (!title) {
throw new ApiError(400, 'Title is required');
}
if (!comments) {
throw new ApiError(400, 'Comments are required');
}
if(!activity_pqq_header_xid){
throw new ApiError(400 , "Activity Pqq HeaderXid Required");
}
// Validate title is one of the allowed types
const allowedTitles = Object.values(HOST_SUGGESTION_TITLES);
if (!allowedTitles.includes(title)) {
throw new ApiError(400, `Invalid title. Allowed values: ${allowedTitles.join(', ')}`);
}
// Add suggestion using service
await minglarService.addPqqSuggestion(title, comments, activity_pqq_header_xid);
return {
statusCode: 200,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
},
body: JSON.stringify({
success: true,
message: 'Suggestion added successfully',
data: null,
}),
};
});

View File

@@ -0,0 +1,62 @@
import { APIGatewayProxyEvent, APIGatewayProxyResult, Context } from 'aws-lambda';
import { prismaClient } from '../../../../common/database/prisma.lambda.service';
import { verifyUserToken } from '../../../../common/middlewares/jwt/authForUser';
import { safeHandler } from '../../../../common/utils/handlers/safeHandler';
import ApiError from '../../../../common/utils/helper/ApiError';
import { ItineraryService } from '../../services/itinerary.service';
const itineraryService = new ItineraryService(prismaClient);
export const handler = safeHandler(async (
event: APIGatewayProxyEvent,
context?: Context,
): Promise<APIGatewayProxyResult> => {
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.',
);
}
const userInfo = await verifyUserToken(token);
const userId = Number(userInfo.id);
if (!userId || isNaN(userId)) {
throw new ApiError(400, 'Invalid user ID');
}
let body: {
itineraryHeaderXid?: number;
};
try {
body = event.body ? JSON.parse(event.body) : {};
} catch (error) {
throw new ApiError(400, 'Invalid JSON in request body');
}
const { itineraryHeaderXid } = body;
if (!itineraryHeaderXid) {
throw new ApiError(400, 'itineraryHeaderXid is required');
}
const result = await itineraryService.getActivityDetailsAfterBooking(
userId,
itineraryHeaderXid,
);
return {
statusCode: 200,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
},
body: JSON.stringify({
success: true,
message: 'Activity details retrieved successfully',
data: result,
}),
};
});

View File

@@ -60,7 +60,6 @@ export const handler = safeHandler(async (
const itineraryHeaderXidRaw =
event.queryStringParameters?.itineraryHeaderXid ?? null;
const startDateRaw = event.queryStringParameters?.startDate ?? null;
const endDateRaw = event.queryStringParameters?.endDate ?? null;
let itineraryHeaderXid: number | undefined;
if (
@@ -79,38 +78,17 @@ export const handler = safeHandler(async (
startDateRaw !== null &&
startDateRaw !== undefined &&
startDateRaw.trim() !== '';
const hasEndDate =
endDateRaw !== null &&
endDateRaw !== undefined &&
endDateRaw.trim() !== '';
if (hasStartDate !== hasEndDate) {
throw new ApiError(
400,
'startDate and endDate must be provided together',
);
}
let startDate: Date | undefined;
let endDate: Date | undefined;
if (hasStartDate && hasEndDate) {
if (hasStartDate) {
startDate = parseQueryDate(startDateRaw, 'startDate');
endDate = parseQueryDate(endDateRaw, 'endDate');
if (startDate > endDate) {
throw new ApiError(
400,
'startDate must be earlier than or equal to endDate',
);
}
}
const result = await itineraryService.getAllUserSavedItineraries(
userId,
itineraryHeaderXid,
startDate,
endDate,
);
return {

View File

@@ -75,6 +75,14 @@ export const handler = safeHandler(async (
);
}
if (!Number.isInteger(payload.page) || payload.page <= 0) {
throw new ApiError(400, 'page must be a positive integer.');
}
if (!Number.isInteger(payload.limit) || payload.limit <= 0) {
throw new ApiError(400, 'limit must be a positive integer.');
}
const result = await itineraryService.getMatchingBucketInterestedActivities(
userId,
payload,

View File

@@ -2,11 +2,11 @@ import { Injectable } from '@nestjs/common';
import { Prisma, PrismaClient } from '@prisma/client';
import crypto from 'crypto';
import { getPresignedUrl } from '../../../common/middlewares/aws/getPreSignedUrl';
import ApiError from '../../../common/utils/helper/ApiError';
import {
ACTIVITY_AM_INTERNAL_STATUS,
ACTIVITY_INTERNAL_STATUS,
} from '../../../common/utils/constants/host.constant';
import ApiError from '../../../common/utils/helper/ApiError';
import config from '@/config/config';
@@ -269,6 +269,28 @@ const formatDateKey = (date: Date) => {
const addMinutes = (date: Date, minutes: number) =>
new Date(date.getTime() + minutes * 60 * 1000);
const formatTicketTime = (value?: string | null) => {
if (!value) {
return null;
}
return value.trim().toUpperCase().replace(/\s+(AM|PM)$/i, '$1');
};
const formatTicketTimeRange = (
startTime?: string | null,
endTime?: string | null,
) => {
const formattedStartTime = formatTicketTime(startTime);
const formattedEndTime = formatTicketTime(endTime);
if (formattedStartTime && formattedEndTime) {
return `${formattedStartTime} - ${formattedEndTime}`;
}
return formattedStartTime ?? formattedEndTime ?? null;
};
const getDateRange = (fromDate: Date, toDate: Date) => {
const dates: Date[] = [];
const cursor = startOfDay(fromDate);
@@ -1779,19 +1801,15 @@ export class ItineraryService {
userXid: number,
itineraryHeaderXid?: number,
startDate?: Date,
endDate?: Date,
) {
const itineraries = await this.prisma.itineraryHeader.findMany({
where: {
...(itineraryHeaderXid ? { id: itineraryHeaderXid } : {}),
...(startDate && endDate
...(startDate
? {
fromDate: {
gte: startDate,
},
toDate: {
lte: endDate,
},
}
: {}),
isActive: true,
@@ -4466,8 +4484,8 @@ export class ItineraryService {
.filter((item): item is number => typeof item === 'number');
const totalCount = activities.length;
const sanitizedLimit = Math.min(Math.max(payload.limit, 1), 20);
const sanitizedPage = Math.max(payload.page, 1);
const sanitizedLimit = Math.min(Math.max(Math.floor(payload.limit || 20), 1), 20);
const sanitizedPage = Math.max(Math.floor(payload.page), 1);
const totalPages = totalCount ? Math.ceil(totalCount / sanitizedLimit) : 0;
const startIndex = (sanitizedPage - 1) * sanitizedLimit;
const paginatedActivities = activities.slice(
@@ -4511,4 +4529,517 @@ export class ItineraryService {
activities: paginatedActivities,
};
}
async getActivityDetailsAfterBooking(
userXid: number,
itineraryHeaderXid: number,
) {
const itineraryHeader = await this.prisma.itineraryHeader.findFirst({
where: {
id: itineraryHeaderXid,
isActive: true,
deletedAt: null,
OR: [
{
ownerXid: userXid,
},
{
ItineraryMembers: {
some: {
memberXid: userXid,
isActive: true,
deletedAt: null,
},
},
},
],
},
select: {
id: true,
itineraryNo: true,
title: true,
fromDate: true,
fromTime: true,
toDate: true,
toTime: true,
itineraryStatus: true,
ownerXid: true,
owner: {
select: {
id: true,
firstName: true,
lastName: true,
emailAddress: true,
mobileNumber: true,
},
},
ItineraryMembers: {
where: {
memberXid: userXid,
isActive: true,
deletedAt: null,
},
select: {
id: true,
memberXid: true,
memberRole: true,
memberStatus: true,
member: {
select: {
id: true,
firstName: true,
lastName: true,
emailAddress: true,
mobileNumber: true,
profileImage: true,
},
},
},
},
ItineraryActivities: {
where: {
activityXid: {
not: null,
},
isActive: true,
deletedAt: null,
},
orderBy: [
{
occurenceDate: 'asc',
},
{
startTime: 'asc',
},
{
displayOrder: 'asc',
},
],
select: {
id: true,
displayOrder: true,
occurenceDate: true,
startTime: true,
endTime: true,
paxCount: true,
totalAmount: true,
bookingStatus: true,
venue: {
select: {
id: true,
venueName: true,
venueLabel: true,
},
},
ItineraryDetails: {
where: {
itineraryKind: 'ACTIVITY',
isActive: true,
deletedAt: null,
itineraryMember: {
is: {
memberXid: userXid,
isActive: true,
deletedAt: null,
},
},
},
orderBy: {
createdAt: 'desc',
},
select: {
id: true,
itineraryMemberXid: true,
offlineCode: true,
description1: true,
description2: true,
activityStatus: true,
itineraryStatus: true,
isPaid: true,
paidOn: true,
createdAt: true,
},
},
itineraryActivitySelections: {
where: {
isActive: true,
deletedAt: null,
itineraryMember: {
is: {
memberXid: userXid,
isActive: true,
deletedAt: null,
},
},
},
select: {
id: true,
itineraryMemberXid: true,
isFoodOpted: true,
isTrainerOpted: true,
isInActivityNavigationOpted: true,
activityNavigationMode: {
select: {
id: true,
navigationModeName: true,
},
},
selectedFoodTypes: {
where: {
isActive: true,
deletedAt: null,
},
select: {
id: true,
activityFoodTypeXid: true,
activityFoodType: {
select: {
id: true,
foodTypeXid: true,
foodType: {
select: {
id: true,
foodTypeName: true,
},
},
},
},
},
},
selectedEquipments: {
where: {
isActive: true,
deletedAt: null,
},
select: {
id: true,
activityEquipmentXid: true,
activityEquipment: {
select: {
id: true,
equipmentName: true,
},
},
},
},
},
},
activity: {
select: {
id: true,
activityTitle: true,
activityDescription: true,
checkInAddress: true,
checkInLat: true,
checkInLong: true,
activityDurationMins: true,
foodAvailable: true,
trainerAvailable: true,
equipmentAvailable: true,
pickUpDropAvailable: true,
inActivityAvailable: true,
ActivitiesMedia: {
where: {
isActive: true,
deletedAt: null,
},
orderBy: {
displayOrder: 'asc',
},
select: {
id: true,
mediaType: true,
mediaFileName: true,
isCoverImage: true,
displayOrder: true,
},
},
ActivityPickUpDetails: {
where: {
isActive: true,
deletedAt: null,
},
select: {
id: true,
isPickUp: true,
locationLat: true,
locationLong: true,
locationAddress: true,
},
},
ActivityOtherDetails: {
where: {
isActive: true,
deletedAt: null,
},
select: {
exclusiveNotes: true,
SafetyInstruction: true,
Cancellations: true,
dosNotes: true,
dontsNotes: true,
tipsNotes: true,
termsAndCondition: true,
},
take: 1,
},
},
},
},
},
},
});
if (!itineraryHeader) {
throw new ApiError(404, 'Itinerary not found');
}
const itineraryMember = itineraryHeader.ItineraryMembers[0] ?? null;
if (itineraryHeader.ItineraryActivities.length === 0) {
throw new ApiError(404, 'No activities found for this itinerary');
}
const activities = await Promise.all(
itineraryHeader.ItineraryActivities.map(async (itineraryActivity) => {
const itineraryDetail = itineraryActivity.ItineraryDetails[0] ?? null;
const activitySelection =
itineraryActivity.itineraryActivitySelections[0] ?? null;
const selectedFoodTypes = (
activitySelection?.selectedFoodTypes ?? []
).map(
(selectedFoodType) =>
selectedFoodType.activityFoodType.foodType.foodTypeName,
);
const selectedEquipments = (
activitySelection?.selectedEquipments ?? []
).map(
(selectedEquipment) =>
selectedEquipment.activityEquipment.equipmentName,
);
const selectedNavigationMode =
activitySelection?.activityNavigationMode?.navigationModeName ?? null;
const mediaWithUrls = await Promise.all(
(itineraryActivity.activity?.ActivitiesMedia ?? []).map(
async (media) => ({
...media,
mediaUrl: await attachPresignedUrl(media.mediaFileName),
}),
),
);
const coverImage =
mediaWithUrls.find((media) => media.isCoverImage) ??
mediaWithUrls[0] ??
null;
const pickUpDetail =
itineraryActivity.activity?.ActivityPickUpDetails.find(
(detail) => detail.isPickUp && detail.locationAddress,
) ??
itineraryActivity.activity?.ActivityPickUpDetails.find(
(detail) => detail.locationAddress,
) ??
null;
const dropDetail =
itineraryActivity.activity?.ActivityPickUpDetails.find(
(detail) => !detail.isPickUp && detail.locationAddress,
) ?? null;
const bookingDate = formatDateKey(itineraryActivity.occurenceDate);
const ticketTime = formatTicketTimeRange(
itineraryActivity.startTime,
itineraryActivity.endTime,
);
const venueName =
itineraryActivity.venue?.venueLabel ??
itineraryActivity.venue?.venueName ??
null;
const foodIncluded = selectedFoodTypes.length > 0 ? 'Yes' : 'No';
const equipmentIncluded = selectedEquipments.length > 0 ? 'Yes' : 'No';
const pickAndDropIncluded = itineraryActivity.activity?.pickUpDropAvailable
? 'Yes'
: 'No';
const inActivityNavigation =
selectedNavigationMode ??
(activitySelection?.isInActivityNavigationOpted ? 'Yes' : 'No');
return {
itineraryActivityId: itineraryActivity.id,
bookingId: itineraryHeader.itineraryNo,
activityId: itineraryActivity.activity?.id ?? null,
activityTitle: itineraryActivity.activity?.activityTitle ?? null,
activityDescription:
itineraryActivity.activity?.activityDescription ?? null,
occurenceDate: itineraryActivity.occurenceDate,
startTime: itineraryActivity.startTime,
endTime: itineraryActivity.endTime,
duration: itineraryActivity.activity?.activityDurationMins ?? null,
checkInCode: itineraryDetail?.offlineCode ?? null,
qrCodeValue: itineraryDetail?.offlineCode ?? null,
checkInAddress: itineraryActivity.activity?.checkInAddress ?? null,
checkInLat: itineraryActivity.activity?.checkInLat ?? null,
checkInLong: itineraryActivity.activity?.checkInLong ?? null,
bookingStatus:
itineraryDetail?.activityStatus ??
itineraryActivity.bookingStatus ??
null,
description1: itineraryDetail?.description1 ?? null,
description2: itineraryDetail?.description2 ?? null,
itineraryStatus: itineraryDetail?.itineraryStatus ?? null,
isPaid: itineraryDetail?.isPaid ?? false,
paidOn: itineraryDetail?.paidOn ?? null,
paxCount: itineraryActivity.paxCount ?? null,
totalAmount: itineraryActivity.totalAmount ?? null,
venue: {
id: itineraryActivity.venue?.id ?? null,
venueName: itineraryActivity.venue?.venueName ?? null,
venueLabel: itineraryActivity.venue?.venueLabel ?? null,
displayName: venueName,
},
images: mediaWithUrls.map((media) => ({
id: media.id,
type: media.mediaType,
fileName: media.mediaFileName,
url: media.mediaUrl,
isCover: media.isCoverImage,
order: media.displayOrder,
})),
coverImage: coverImage
? {
id: coverImage.id,
fileName: coverImage.mediaFileName,
url: coverImage.mediaUrl,
}
: null,
bookingInformation: {
activityName: itineraryActivity.activity?.activityTitle ?? null,
date: bookingDate,
venue: venueName,
venueName: itineraryActivity.venue?.venueName ?? null,
venueLabel: itineraryActivity.venue?.venueLabel ?? null,
time: ticketTime,
startTime: formatTicketTime(itineraryActivity.startTime),
endTime: formatTicketTime(itineraryActivity.endTime),
},
bookingIncluded: {
food: foodIncluded,
selectedFoodTypes,
equipment: equipmentIncluded,
selectedEquipments,
pickAndDrop: pickAndDropIncluded,
pickupLocation:
pickUpDetail?.locationAddress ??
itineraryActivity.activity?.checkInAddress ??
null,
dropLocation: dropDetail?.locationAddress ?? null,
inActivityNavigation: inActivityNavigation ?? 'No',
trainerOrGuide: activitySelection?.isTrainerOpted ? 'Yes' : 'No',
},
ticketCard: {
checkInCode: itineraryDetail?.offlineCode ?? null,
activityTitle: itineraryActivity.activity?.activityTitle ?? null,
date: bookingDate,
venue: venueName,
time: ticketTime,
food: foodIncluded,
equipments: equipmentIncluded,
pickAndDrop: pickAndDropIncluded,
inActivityNavigation: inActivityNavigation ?? 'No',
},
userSelections: {
isFoodOpted: activitySelection?.isFoodOpted ?? false,
selectedFoodTypes,
isTrainerOpted: activitySelection?.isTrainerOpted ?? false,
isInActivityNavigationOpted:
activitySelection?.isInActivityNavigationOpted ?? false,
selectedNavigationMode: selectedNavigationMode,
selectedEquipments,
},
pickupDetails: {
pickUpDropAvailable:
itineraryActivity.activity?.pickUpDropAvailable ?? false,
pickUpLocation: pickUpDetail
? {
id: pickUpDetail.id,
locationAddress: pickUpDetail.locationAddress,
locationLat: pickUpDetail.locationLat,
locationLong: pickUpDetail.locationLong,
}
: null,
dropLocation: dropDetail
? {
id: dropDetail.id,
locationAddress: dropDetail.locationAddress,
locationLat: dropDetail.locationLat,
locationLong: dropDetail.locationLong,
}
: null,
},
activityInfo: {
exclusiveNotes:
itineraryActivity.activity?.ActivityOtherDetails[0]
?.exclusiveNotes || null,
safetyInstructions:
itineraryActivity.activity?.ActivityOtherDetails[0]
?.SafetyInstruction || null,
cancellationPolicy:
itineraryActivity.activity?.ActivityOtherDetails[0]
?.Cancellations || null,
dosAndDonts: {
dos:
itineraryActivity.activity?.ActivityOtherDetails[0]?.dosNotes ||
null,
donts:
itineraryActivity.activity?.ActivityOtherDetails[0]
?.dontsNotes || null,
tips:
itineraryActivity.activity?.ActivityOtherDetails[0]?.tipsNotes ||
null,
},
termsAndConditions:
itineraryActivity.activity?.ActivityOtherDetails[0]
?.termsAndCondition || null,
},
};
}),
);
// Return itinerary object with activities inside
return {
itinerary: {
id: itineraryHeader.id,
itineraryNo: itineraryHeader.itineraryNo,
title: itineraryHeader.title,
status: itineraryHeader.itineraryStatus,
fromDate: itineraryHeader.fromDate,
fromTime: itineraryHeader.fromTime,
toDate: itineraryHeader.toDate,
toTime: itineraryHeader.toTime,
owner: {
id: itineraryHeader.owner?.id,
firstName: itineraryHeader.owner?.firstName,
lastName: itineraryHeader.owner?.lastName,
email: itineraryHeader.owner?.emailAddress,
phone: itineraryHeader.owner?.mobileNumber,
},
viewer: itineraryMember
? {
itineraryMemberXid: itineraryMember.id,
memberXid: itineraryMember.memberXid,
memberRole: itineraryMember.memberRole,
memberStatus: itineraryMember.memberStatus,
firstName: itineraryMember.member.firstName,
lastName: itineraryMember.member.lastName,
email: itineraryMember.member.emailAddress,
phone: itineraryMember.member.mobileNumber,
profileImage: itineraryMember.member.profileImage,
profileImagePresignedUrl: await attachPresignedUrl(
itineraryMember.member.profileImage,
),
}
: null,
totalActivities: activities.length,
ticketCards: activities.map((activity) => activity.ticketCard),
activities,
},
};
}
}