fitlien-services/functions/src/memberCache/memberCache.ts
Sharon Dcruz bf94866fbf
All checks were successful
Deploy FitLien services to Dev / Deploy to Dev (push) Successful in 3m29s
feature/fitlien-828 (#119)
Reviewed-on: #119
Co-authored-by: Sharon Dcruz <sharondcruz@cosq.net>
Co-committed-by: Sharon Dcruz <sharondcruz@cosq.net>
2025-09-19 11:08:57 +00:00

527 lines
15 KiB
TypeScript

import { onRequest } from "firebase-functions/v2/https";
import { getLogger, getAdmin } from "../shared/config";
import * as admin from "firebase-admin";
const app = getAdmin();
const logger = getLogger();
const CACHE_FOLDER = "gym_member_cache";
interface MembershipData {
gymId?: string;
userId?: string;
status?: string;
subscription?: {
hasPersonalTraining?: boolean;
frequency?: string;
[key: string]: any;
};
isPartialPayment?: boolean;
remainingAmount?: number;
createdAt?: admin.firestore.Timestamp;
updatedAt?: admin.firestore.Timestamp;
[key: string]: any;
}
interface ClientFields {
fields?: { [key: string]: any };
[key: string]: any;
}
interface PaymentData {
dateTimestamp?: admin.firestore.Timestamp;
[key: string]: any;
}
interface TrainerAssignment {
id?: string;
membershipId?: string;
timeSlot?: any[];
createdAt?: admin.firestore.Timestamp;
[key: string]: any;
}
interface CacheEntry {
userId: string;
membershipId: string;
memberData: {
daysUntilExpiry?: number | null;
hasPartialPayment: boolean;
createdAt?: string | null;
updatedAt?: string | null;
[key: string]: any;
};
fields: { [key: string]: any };
renewalDate?: string | null;
trainerAssignments: any[];
timeSlots: any[];
lastUpdated: string;
}
interface JsonCacheData {
gymId: string;
members: CacheEntry[];
totalMembers: number;
lastUpdated: string;
cacheVersion: string;
}
export const getMemberCache = onRequest(
{
region: "#{SERVICES_RGN}#",
cors: true,
},
async (req, res) => {
try {
if (req.method === "OPTIONS") {
res.set("Access-Control-Allow-Origin", "*");
res.set("Access-Control-Allow-Methods", "GET, POST");
res.set("Access-Control-Allow-Headers", "Content-Type, Authorization");
res.status(204).send("");
return;
}
res.set("Access-Control-Allow-Origin", "*");
res.set("Access-Control-Allow-Headers", "Content-Type, Authorization");
let requestData: any = {};
if (req.method === "GET") {
requestData = req.query;
} else if (req.method === "POST") {
requestData = req.body;
}
const { gymId } = requestData;
if (!gymId) {
res.status(400).json({ error: "gymId is required" });
return;
}
const fileName = `${CACHE_FOLDER}/${gymId}.json`;
const file = app.storage().bucket().file(fileName);
try {
const [fileBuffer] = await file.download();
const jsonData: JsonCacheData = JSON.parse(fileBuffer.toString());
logger.info(
`Retrieved ${jsonData.totalMembers} members from cache for gym ${gymId}`
);
res.status(200).json(jsonData);
} catch (error) {
logger.error(`Error reading cache file for gym ${gymId}:`, error);
res.status(404).json({
error: "Cache not found for this gym. Please update cache first.",
gymId,
members: [],
totalMembers: 0,
lastUpdated: new Date().toISOString(),
cacheVersion: "2.0",
});
}
} catch (error) {
logger.error("Error getting member cache:", error);
res.status(500).json({ error: "Error retrieving cached data" });
}
}
);
export const updateMemberCache = onRequest(
{
region: "#{SERVICES_RGN}#",
cors: true,
timeoutSeconds: 540,
},
async (req, res) => {
try {
if (req.method === "OPTIONS") {
res.set("Access-Control-Allow-Origin", "*");
res.set("Access-Control-Allow-Methods", "POST");
res.set("Access-Control-Allow-Headers", "Content-Type, Authorization");
res.status(204).send("");
return;
}
res.set("Access-Control-Allow-Origin", "*");
res.set("Access-Control-Allow-Headers", "Content-Type, Authorization");
if (req.method !== "POST") {
res.status(405).json({ error: "Method not allowed. Use POST." });
return;
}
const { gymId, membershipIds } = req.body;
let incrementalUpdate = req.body.incrementalUpdate || false;
if (!gymId) {
res.status(400).json({ error: "gymId is required" });
return;
}
logger.info(
`Starting cache ${
incrementalUpdate ? "incremental update" : "full refresh"
} for gym: ${gymId}`
);
let members: CacheEntry[] = [];
let existingData: JsonCacheData | null = null;
if (incrementalUpdate) {
try {
const fileName = `${CACHE_FOLDER}/${gymId}.json`;
const file = app.storage().bucket().file(fileName);
const [fileBuffer] = await file.download();
existingData = JSON.parse(fileBuffer.toString());
if (existingData) {
members = [...existingData.members];
logger.info(
`Loaded existing cache with ${members.length} members for incremental update`
);
} else {
logger.warn(
"Existing cache data is invalid, performing full refresh"
);
incrementalUpdate = false;
}
} catch (error) {
logger.warn(
`Could not load existing cache for incremental update, performing full refresh: ${error}`
);
incrementalUpdate = false;
}
}
if (incrementalUpdate && membershipIds && Array.isArray(membershipIds)) {
await updateSpecificMembers(gymId, membershipIds, members);
} else {
members = await fetchAllMembers(gymId);
}
const jsonData: JsonCacheData = {
gymId,
members,
totalMembers: members.length,
lastUpdated: new Date().toISOString(),
cacheVersion: "2.0",
};
await saveCacheToStorage(gymId, jsonData);
const updateType = incrementalUpdate
? "incremental update"
: "full refresh";
logger.info(
`Cache ${updateType} completed successfully for gym ${gymId} with ${members.length} members`
);
res.status(200).json({
success: true,
message: `Cache ${updateType} completed successfully for gym ${gymId}`,
totalMembers: members.length,
lastUpdated: jsonData.lastUpdated,
updateType: updateType,
});
} catch (error) {
logger.error("Error updating member cache:", error);
res.status(500).json({
error: "Error updating cache",
details: error instanceof Error ? error.message : String(error),
});
}
}
);
async function fetchAllMembers(gymId: string): Promise<CacheEntry[]> {
const members: CacheEntry[] = [];
const membershipsSnapshot = await app
.firestore()
.collection("memberships")
.where("gymId", "==", gymId)
.orderBy("createdAt", "desc")
.get();
const batchSize = 10;
for (let i = 0; i < membershipsSnapshot.docs.length; i += batchSize) {
const batch = membershipsSnapshot.docs.slice(i, i + batchSize);
const batchPromises = batch.map(async (doc) => {
try {
const membershipData = doc.data() as MembershipData;
const userId = membershipData.userId;
if (!userId) {
logger.warn(
`Skipping member with ID ${doc.id} due to missing userId`
);
return null;
}
return await generateCacheEntry(userId, doc.id, membershipData);
} catch (error) {
logger.error(`Error processing member ${doc.id}:`, error);
return null;
}
});
const batchResults = await Promise.all(batchPromises);
const validResults = batchResults.filter(
(member): member is CacheEntry => member !== null
);
members.push(...validResults);
}
return members;
}
async function updateSpecificMembers(
gymId: string,
membershipIds: string[],
existingMembers: CacheEntry[]
): Promise<void> {
logger.info(`Updating ${membershipIds.length} specific members`);
const existingMembersMap = new Map<string, number>();
existingMembers.forEach((member, index) => {
existingMembersMap.set(member.membershipId, index);
});
const batchSize = 10;
for (let i = 0; i < membershipIds.length; i += batchSize) {
const batch = membershipIds.slice(i, i + batchSize);
const membershipDocs = await Promise.all(
batch.map(async (membershipId) => {
try {
const doc = await app
.firestore()
.collection("memberships")
.doc(membershipId)
.get();
return doc.exists ? { id: doc.id, data: doc.data() } : null;
} catch (error) {
logger.error(`Error fetching membership ${membershipId}:`, error);
return null;
}
})
);
const batchPromises = membershipDocs
.filter((doc): doc is { id: string; data: any } => doc !== null)
.map(async (doc) => {
try {
const membershipData = doc.data as MembershipData;
const userId = membershipData.userId;
if (!userId) {
logger.warn(
`Skipping member with ID ${doc.id} due to missing userId`
);
return null;
}
return await generateCacheEntry(userId, doc.id, membershipData);
} catch (error) {
logger.error(`Error processing member ${doc.id}:`, error);
return null;
}
});
const batchResults = await Promise.all(batchPromises);
batchResults.forEach((updatedMember) => {
if (updatedMember) {
const existingIndex = existingMembersMap.get(
updatedMember.membershipId
);
if (existingIndex !== undefined) {
existingMembers[existingIndex] = updatedMember;
} else {
existingMembers.push(updatedMember);
}
}
});
}
}
async function saveCacheToStorage(
gymId: string,
jsonData: JsonCacheData
): Promise<void> {
const fileName = `${CACHE_FOLDER}/${gymId}.json`;
const file = app.storage().bucket().file(fileName);
await file.save(JSON.stringify(jsonData, null, 2), {
metadata: {
contentType: "application/json",
metadata: {
gymId: gymId,
totalMembers: jsonData.totalMembers.toString(),
generatedAt: jsonData.lastUpdated,
cacheVersion: jsonData.cacheVersion,
},
},
});
}
async function generateCacheEntry(
userId: string,
membershipId: string,
membershipData: MembershipData
): Promise<CacheEntry> {
try {
let fields: { [key: string]: string } = {};
try {
const clientFieldsSnapshot = await app
.firestore()
.collection("client_profiles")
.where("uid", "==", userId)
.get();
if (!clientFieldsSnapshot.empty) {
const fieldDoc = clientFieldsSnapshot.docs[0];
const fieldData = fieldDoc.data() as ClientFields;
fields = { ...fieldData.fields };
Object.keys(fieldData).forEach((key) => {
if (key !== "fields" && !fields[key]) {
fields[key] = fieldData[key];
}
});
}
} catch (error) {
logger.error(`Error getting fields for user ${userId}:`, error);
}
let renewalDate: Date | null = null;
let daysUntilExpiry: number | null = null;
if (membershipData.subscription) {
try {
const paymentsSnapshot = await app
.firestore()
.collection("membership_payments")
.where("membershipId", "==", membershipId)
.orderBy("dateTimestamp", "desc")
.limit(1)
.get();
if (!paymentsSnapshot.empty) {
const latestPayment = paymentsSnapshot.docs[0].data() as PaymentData;
if (latestPayment.dateTimestamp) {
const paymentDate = latestPayment.dateTimestamp.toDate();
renewalDate = calculateRenewalDateTimeFromPayment(
membershipData.subscription,
paymentDate
);
if (renewalDate && membershipData.status === "ACTIVE") {
const now = new Date();
const difference = Math.floor(
(renewalDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)
);
daysUntilExpiry = difference > 0 ? difference : null;
}
}
}
} catch (error) {
logger.error(
`Error getting renewal date for membership ${membershipId}:`,
error
);
}
}
const isPartial = membershipData.isPartialPayment === true;
const remaining = membershipData.remainingAmount || 0;
const hasPartialPayment = isPartial && remaining > 0;
let trainerAssignments: any[] = [];
let timeSlots: any[] = [];
if (membershipData.subscription?.hasPersonalTraining) {
try {
const assignmentsSnapshot = await app
.firestore()
.collection("personal_trainer_assignments")
.where("membershipId", "==", membershipId)
.get();
assignmentsSnapshot.docs.forEach((doc) => {
const data = doc.data() as TrainerAssignment;
if (data.timeSlot && Array.isArray(data.timeSlot)) {
timeSlots.push(...data.timeSlot);
}
trainerAssignments.push({ id: doc.id, ...data });
});
} catch (error) {
logger.error(
`Error getting trainer data for membership ${membershipId}:`,
error
);
}
}
const cacheEntry: CacheEntry = {
userId,
membershipId,
memberData: {
...membershipData,
hasPartialPayment,
daysUntilExpiry,
createdAt: membershipData.createdAt
? membershipData.createdAt.toDate().toISOString()
: null,
updatedAt: membershipData.updatedAt
? membershipData.updatedAt.toDate().toISOString()
: null,
},
fields,
renewalDate: renewalDate ? renewalDate.toISOString() : null,
trainerAssignments,
timeSlots,
lastUpdated: new Date().toISOString(),
};
return cacheEntry;
} catch (error) {
logger.error("Error generating cache entry:", error);
throw error;
}
}
function calculateRenewalDateTimeFromPayment(
subscription: { frequency?: string },
paymentDate: Date
): Date {
const frequency = subscription.frequency || "Monthly";
switch (frequency) {
case "Monthly":
return new Date(
paymentDate.getFullYear(),
paymentDate.getMonth() + 1,
paymentDate.getDate()
);
case "Quarterly":
return new Date(
paymentDate.getFullYear(),
paymentDate.getMonth() + 3,
paymentDate.getDate()
);
case "Half-yearly":
return new Date(
paymentDate.getFullYear(),
paymentDate.getMonth() + 6,
paymentDate.getDate()
);
case "Yearly":
return new Date(
paymentDate.getFullYear() + 1,
paymentDate.getMonth(),
paymentDate.getDate()
);
default:
return new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
}
}