diff --git a/functions/src/notifications/filterMembersRequest.ts b/functions/src/notifications/filterMembersRequest.ts new file mode 100644 index 0000000..0b4e610 --- /dev/null +++ b/functions/src/notifications/filterMembersRequest.ts @@ -0,0 +1,453 @@ +import { onCall } from "firebase-functions/v2/https"; +import { getLogger, getAdmin } from "../shared/config"; + +const app = getAdmin(); +const logger = getLogger(); + +interface FilterMembersRequest { + gymId: string; + filter: string; + sortBy: string; + sortAscending: boolean; + searchQuery?: string; + limit?: number; + lastDocumentId?: string; + role?: string; + trainerAssignedMembershipIds?: string[]; +} + +interface MembershipWithDetails { + membershipId: string; + memberData: any; + fields: { [key: string]: string }; + renewalDate?: string; + daysUntilExpiry?: number; + hasPersonalTraining: boolean; + trainerAssignments?: any[]; + timeSlots?: any[]; +} + +interface FilterMembersResponse { + members: MembershipWithDetails[]; + hasMore: boolean; + lastDocumentId?: string; + totalCount: number; +} + +export const filterMembers = onCall( + { + region: "#{SERVICES_RGN}#", + cors: true, + }, + async (request): Promise => { + try { + const { + gymId, + filter, + sortBy, + sortAscending, + searchQuery, + limit = 20, + lastDocumentId, + role, + trainerAssignedMembershipIds, + } = request.data as FilterMembersRequest; + + logger.info(`Filtering members for gym ${gymId} with filter: ${filter}`); + + if (!gymId) { + throw new Error("gymId is required"); + } + + let query = app + .firestore() + .collection("memberships") + .where("gymId", "==", gymId); + + if (filter === "Active") { + query = query.where("status", "==", "ACTIVE"); + } else if (filter === "Pending") { + query = query.where("status", "==", "PENDING"); + } else if (filter === "Expired") { + query = query.where("status", "==", "EXPIRED"); + } + + let orderByField = "createdAt"; + let orderByDirection: "asc" | "desc" = sortAscending ? "asc" : "desc"; + + switch (sortBy) { + case "Name": + orderByField = "createdAt"; + break; + case "Expiry Date": + orderByField = "expirationDate"; + break; + case "Join Date": + orderByField = "createdAt"; + break; + default: + orderByField = "createdAt"; + } + + query = query.orderBy(orderByField, orderByDirection); + + if (lastDocumentId) { + const lastDoc = await app + .firestore() + .collection("memberships") + .doc(lastDocumentId) + .get(); + if (lastDoc.exists) { + query = query.startAfter(lastDoc); + } + } + + const fetchLimit = ["All", "Active", "Pending", "Expired"].includes( + filter + ) + ? limit + : limit * 3; + query = query.limit(fetchLimit); + + const snapshot = await query.get(); + const allMembers: MembershipWithDetails[] = []; + + const batchSize = 10; + const docs = snapshot.docs; + + for (let i = 0; i < docs.length; i += batchSize) { + const batch = docs.slice(i, i + batchSize); + const batchResults = await Promise.allSettled( + batch.map(async (doc) => { + const memberData = doc.data(); + const membershipId = doc.id; + + const fields = await getClientFieldsByMembershipId(membershipId); + + let renewalDate: Date | undefined; + let daysUntilExpiry: number | undefined; + + if (memberData.subscription) { + const payments = await getPaymentsForMembership(membershipId); + if (payments.length > 0) { + const latestPayment = payments[0]; + renewalDate = calculateRenewalDateFromPayment( + memberData.subscription, + latestPayment.dateTimestamp + ); + + if (memberData.status === "ACTIVE") { + const now = new Date(); + const timeDiff = renewalDate.getTime() - now.getTime(); + daysUntilExpiry = Math.max( + 0, + Math.floor(timeDiff / (1000 * 3600 * 24)) + ); + } + } + } + + const hasPersonalTraining = + memberData.subscription?.hasPersonalTraining === true; + + let trainerAssignments: any[] = []; + let timeSlots: any[] = []; + + if (hasPersonalTraining) { + trainerAssignments = await getTrainerAssignmentsForMembership( + membershipId + ); + + if (trainerAssignments.length > 0) { + const latestAssignment = trainerAssignments[0]; + timeSlots = latestAssignment.timeSlot || []; + } + } + + return { + membershipId, + memberData, + fields, + renewalDate: renewalDate?.toISOString(), + daysUntilExpiry, + hasPersonalTraining, + trainerAssignments, + timeSlots, + }; + }) + ); + + batchResults.forEach((result) => { + if (result.status === "fulfilled" && result.value) { + allMembers.push(result.value); + } + }); + } + + let filteredMembers = allMembers; + if (role === "Trainer" && trainerAssignedMembershipIds) { + filteredMembers = allMembers.filter((member) => + trainerAssignedMembershipIds.includes(member.membershipId) + ); + } + + if (searchQuery && searchQuery.length >= 2) { + const searchLower = searchQuery.toLowerCase(); + filteredMembers = filteredMembers.filter((member) => { + const firstName = member.fields["first-name"]?.toLowerCase() || ""; + const lastName = member.fields["last-name"]?.toLowerCase() || ""; + const phone = member.fields["phone-number"]?.toLowerCase() || ""; + const email = member.fields["email"]?.toLowerCase() || ""; + + return ( + firstName.includes(searchLower) || + lastName.includes(searchLower) || + phone.includes(searchLower) || + email.includes(searchLower) + ); + }); + } + + if ( + filter !== "All" && + filter !== "Active" && + filter !== "Pending" && + filter !== "Expired" + ) { + filteredMembers = filteredMembers.filter((member) => { + switch (filter) { + case "Personal Training": + return member.hasPersonalTraining; + + case "Expiring in 10 Days": + return ( + member.memberData.status === "ACTIVE" && + member.daysUntilExpiry != null && + member.daysUntilExpiry > 0 && + member.daysUntilExpiry <= 10 + ); + + case "Expiring in 30 Days": + return ( + member.memberData.status === "ACTIVE" && + member.daysUntilExpiry != null && + member.daysUntilExpiry > 10 && + member.daysUntilExpiry <= 30 + ); + + case "Expired in 30 days": + return applyExpiredDateFilter(member.memberData, 30); + + case "Expired in 60 days": + return applyExpiredDateFilter(member.memberData, 60); + + default: + return true; + } + }); + } + + if (sortBy === "Name") { + filteredMembers.sort((a, b) => { + const aName = `${a.fields["first-name"] || ""} ${ + a.fields["last-name"] || "" + }`.trim(); + const bName = `${b.fields["first-name"] || ""} ${ + b.fields["last-name"] || "" + }`.trim(); + const comparison = aName.localeCompare(bName); + return sortAscending ? comparison : -comparison; + }); + } else if (sortBy === "Expiry Date") { + filteredMembers.sort((a, b) => { + const aDate = a.renewalDate ? new Date(a.renewalDate) : new Date(0); + const bDate = b.renewalDate ? new Date(b.renewalDate) : new Date(0); + const comparison = aDate.getTime() - bDate.getTime(); + return sortAscending ? comparison : -comparison; + }); + } + + const startIndex = 0; + const endIndex = Math.min(startIndex + limit, filteredMembers.length); + const paginatedMembers = filteredMembers.slice(startIndex, endIndex); + + const hasMore = endIndex < filteredMembers.length; + const lastMember = + paginatedMembers.length > 0 + ? paginatedMembers[paginatedMembers.length - 1] + : null; + + logger.info( + `Returning ${paginatedMembers.length} members out of ${filteredMembers.length} filtered` + ); + + return { + members: paginatedMembers, + hasMore, + lastDocumentId: lastMember?.membershipId, + totalCount: filteredMembers.length, + }; + } catch (error) { + logger.error("Error filtering members:", error); + throw new Error( + `Failed to filter members: ${ + error instanceof Error ? error.message : "Unknown error" + }` + ); + } + } +); + +async function getClientFieldsByMembershipId( + membershipId: string +): Promise<{ [key: string]: string }> { + try { + const membershipDoc = await app + .firestore() + .collection("memberships") + .doc(membershipId) + .get(); + + if (!membershipDoc.exists) { + return {}; + } + + const membershipData = membershipDoc.data(); + const userId = membershipData?.userId; + + if (!userId) { + return {}; + } + + const clientDoc = await app + .firestore() + .collection("client_profiles") + .doc(userId) + .get(); + + if (!clientDoc.exists) { + return {}; + } + + return clientDoc.data()?.fields || {}; + } catch (error) { + logger.error( + `Error getting client fields for membership ${membershipId}:`, + error + ); + return {}; + } +} + +async function getPaymentsForMembership(membershipId: string): Promise { + try { + const docSnapshot = await app + .firestore() + .collection("membership_payments") + .doc(membershipId) + .get(); + + if (!docSnapshot.exists) { + return []; + } + + const data = docSnapshot.data(); + const paymentsData = data?.payments || []; + + const payments = paymentsData.map((payment: any) => ({ + ...payment, + dateTimestamp: payment.dateTimestamp.toDate + ? payment.dateTimestamp.toDate() + : new Date(payment.dateTimestamp), + createdAt: payment.createdAt.toDate + ? payment.createdAt.toDate() + : new Date(payment.createdAt), + })); + + payments.sort( + (a: any, b: any) => b.createdAt.getTime() - a.createdAt.getTime() + ); + return payments; + } catch (error) { + logger.error( + `Error getting payments for membership ${membershipId}:`, + error + ); + return []; + } +} + +function calculateRenewalDateFromPayment( + subscription: any, + paymentDate: Date +): Date { + const renewalDate = new Date(paymentDate); + const frequency = subscription.frequency || "Monthly"; + + switch (frequency.toLowerCase()) { + case "monthly": + renewalDate.setMonth(renewalDate.getMonth() + 1); + break; + case "quarterly": + renewalDate.setMonth(renewalDate.getMonth() + 3); + break; + case "half-yearly": + renewalDate.setMonth(renewalDate.getMonth() + 6); + break; + case "yearly": + renewalDate.setFullYear(renewalDate.getFullYear() + 1); + break; + default: + renewalDate.setMonth(renewalDate.getMonth() + 1); + } + + return renewalDate; +} + +async function getTrainerAssignmentsForMembership( + membershipId: string +): Promise { + try { + const querySnapshot = await app + .firestore() + .collection("personal_trainer_assignments") + .where("membershipId", "==", membershipId) + .get(); + + return querySnapshot.docs.map((doc) => ({ + id: doc.id, + ...doc.data(), + })); + } catch (error) { + logger.error( + `Error getting trainer assignments for membership ${membershipId}:`, + error + ); + return []; + } +} + +function applyExpiredDateFilter(memberData: any, days: number): boolean { + const status = memberData.status; + const expirationDate = memberData.expirationDate; + + if (status !== "EXPIRED" || !expirationDate) { + return false; + } + + try { + const expiredDate = expirationDate.toDate + ? expirationDate.toDate() + : new Date(expirationDate); + const now = new Date(); + const targetDaysAgo = new Date(now.getTime() - days * 24 * 60 * 60 * 1000); + + return ( + expiredDate.getTime() < now.getTime() && + expiredDate.getTime() > targetDaysAgo.getTime() + ); + } catch (error) { + logger.error("Error parsing expiration date:", error); + return false; + } +}