diff --git a/serverless/functions/operator.yml b/serverless/functions/operator.yml index 203fcf2..0a2e0e7 100644 --- a/serverless/functions/operator.yml +++ b/serverless/functions/operator.yml @@ -88,3 +88,21 @@ operatorVerifyPassword: - 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 diff --git a/src/common/middlewares/jwt/authForHost.ts b/src/common/middlewares/jwt/authForHost.ts index 342d441..7cf0ea1 100644 --- a/src/common/middlewares/jwt/authForHost.ts +++ b/src/common/middlewares/jwt/authForHost.ts @@ -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'); diff --git a/src/modules/host/dto/operator.activity.dto.ts b/src/modules/host/dto/operator.activity.dto.ts new file mode 100644 index 0000000..9fbb2ca --- /dev/null +++ b/src/modules/host/dto/operator.activity.dto.ts @@ -0,0 +1,26 @@ +export class GetActivitiesByDateRequestDTO { + activityDate?: string; // ISO date format: YYYY-MM-DD (optional, defaults to today) +} + +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; + }; +} diff --git a/src/modules/host/handlers/operator/getActivitiesByDate.ts b/src/modules/host/handlers/operator/getActivitiesByDate.ts new file mode 100644 index 0000000..ccefca5 --- /dev/null +++ b/src/modules/host/handlers/operator/getActivitiesByDate.ts @@ -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 => { + 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; + } +}); diff --git a/src/modules/host/services/operatorActivity.service.ts b/src/modules/host/services/operatorActivity.service.ts new file mode 100644 index 0000000..ad406ea --- /dev/null +++ b/src/modules/host/services/operatorActivity.service.ts @@ -0,0 +1,274 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; +import { getPresignedUrl } from '../../../common/middlewares/aws/getPreSignedUrl'; +import ApiError from '../../../common/utils/helper/ApiError'; +import config from '../../../config/config'; +import { ActivitySummaryDTO } from '../dto/operator.activity.dto'; + +@Injectable() +export class OperatorActivityService { + constructor(private prisma: PrismaClient) {} + + 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(); + 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> + >(); + + 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', + ); + } + } +}