UNPKG

pulse-ai-utils

Version:

Utility functions and helpers for AI-powered applications

362 lines 15.7 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.normalizeQueryFilters = normalizeQueryFilters; const firebase_admin_1 = __importDefault(require("firebase-admin")); const sanitizeId_1 = require("../utils/sanitizeId"); const load_service_account_1 = require("../config/load-service-account"); // Lazy initialization variables let serviceAccount = null; let firebaseInitialized = false; let initializationPromise = null; // Initialize Firebase Admin only when needed - now async with proper waiting async function initializeFirebaseIfNeeded() { // If already initialized, return immediately if (firebaseInitialized || firebase_admin_1.default.apps.length > 0) { return; } // If initialization is already in progress, wait for it if (initializationPromise) { return initializationPromise; } // Start initialization initializationPromise = (async () => { // In test mode, skip Firebase initialization if (process.env.NODE_ENV === 'test') { console.warn('Skipping Firebase initialization in test mode (firestore)'); firebaseInitialized = true; return; } try { console.log('[Firestore] Initializing Firebase Admin...'); const startTime = Date.now(); serviceAccount = (0, load_service_account_1.loadServiceAccount)(); // Ensure project ID has no stray whitespace / control chars that break gRPC metadata const rawProjectId = process.env.GOOGLE_PROJECT_ID || serviceAccount.project_id; const googleProjectId = rawProjectId ? rawProjectId.trim().replace(/[\r\n\t]/g, '') : undefined; firebase_admin_1.default.initializeApp({ credential: firebase_admin_1.default.credential.cert(serviceAccount), // projectId must be a clean string; otherwise Firestore client will embed illegal chars projectId: googleProjectId, }); // Test the connection to ensure it's ready const testDb = firebase_admin_1.default.firestore(); await testDb.settings({ ignoreUndefinedProperties: true }); const initTime = Date.now() - startTime; console.log(`[Firestore] Firebase Admin initialized successfully in ${initTime}ms`); firebaseInitialized = true; } catch (error) { console.error('[Firestore] Firebase initialization failed:', error); firebaseInitialized = false; initializationPromise = null; // Reset so we can retry throw error; } })(); return initializationPromise; } async function getFirestore(dbId) { // Initialize Firebase if needed and wait for it to complete await initializeFirebaseIfNeeded(); // Sanitize dbId to prevent metadata string errors const sanitizedDbId = dbId ? dbId.trim().replace(/[\r\n\t]/g, '') : 'pulse'; // In test mode, return a mock Firestore instance if (process.env.NODE_ENV === 'test') { console.warn('Returning mock Firestore instance in test mode'); return {}; } // Use the constructor directly, passing the original service account JSON const cleanProjectId = firebase_admin_1.default.app().options.projectId. toString().trim().replace(/[\r\n\t]/g, ''); return new firebase_admin_1.default.firestore.Firestore({ projectId: cleanProjectId, // Ensure no illegal characters databaseId: sanitizedDbId, credentials: serviceAccount, // Pass the loaded service account JSON here }); } function normalizeQueryFilters(filters, defaults = {}) { const mergedFilters = { ...filters }; for (const [key, value] of Object.entries(defaults)) { if (mergedFilters[key] === undefined) { mergedFilters[key] = value; } } if (mergedFilters.type !== undefined) { if (mergedFilters.category === undefined) { mergedFilters.category = mergedFilters.type; } delete mergedFilters.type; } if (mergedFilters.area !== undefined) { if (mergedFilters.region === undefined) { mergedFilters.region = mergedFilters.area; } delete mergedFilters.area; } return mergedFilters; } class FirestoreService { async getDocuments(collection, id = null, dbId, timestamp = null, intervalMinutes = null, fields = null) { const db = await getFirestore(dbId); // If intervalMinutes is provided, calculate the upper bound let lowerBound = null; if (timestamp && intervalMinutes) { const baseTime = new Date(timestamp); lowerBound = new Date(baseTime.getTime() - intervalMinutes * 60 * 1000).toISOString(); } if (id) { id = (0, sanitizeId_1.sanitizeId)(id); const docRef = db.collection(collection).doc(id); const docSnapshot = await docRef.get(); if (!docSnapshot.exists) throw new Error('Document not found'); let data = docSnapshot.data(); if (fields && fields.length > 0) { data = Object.fromEntries(Object.entries(data ?? {}).filter(([k]) => fields.includes(k))); } if (timestamp && intervalMinutes) { const docTimestamp = data.timestamp; if (!docTimestamp || docTimestamp < lowerBound) { if (!docTimestamp) { //log docTimeStamp, lowerBound, basetime, id console.log(`Document ${id}`); console.log(`Lower bound: ${lowerBound}`); console.log(`Timestamp: ${timestamp}`); } return null; } } return data; } else { let query = db.collection(collection); if (timestamp && intervalMinutes) { query = query.where('timestamp', '>=', lowerBound); } else if (timestamp) { query = query.where('timestamp', '>', timestamp); } if (fields && fields.length > 0) { query = query.select(...fields); } const snapshot = await query.get(); return snapshot.docs.map((doc) => ({ id: doc.id, ...doc.data() })); } } async createOrUpdateDocument(collection, id, data, dbId) { const db = await getFirestore(dbId); // For content collection, use smart ID generation if ID not provided or invalid if (collection === 'content' && data.title && (!id || id.trim() === '')) { id = (0, sanitizeId_1.generateContentId)({ title: data.title, location: data.location, source_url: data.source_url, page: data.page, category: data.category, type: data.type }); } else { id = (0, sanitizeId_1.sanitizeId)(id); } // Add or update the timestamp field data.id = id; if (!data.timestamp) { data.timestamp = new Date().toISOString(); } await db.collection(collection).doc(id).set(data, { merge: true }); return { message: 'Document created/updated successfully' }; } async updateDocument(collection, id, data, dbId) { if (!collection) { throw new Error('Collection is required'); } if (!id) { throw new Error('id is required'); } const db = await getFirestore(dbId); id = (0, sanitizeId_1.sanitizeId)(id); data.timestamp = data.timestamp || new Date().toISOString(); await db.collection(collection).doc(id).set(data, { merge: true }); return { message: 'Document updated successfully' }; } async batchUpsertDocuments(collection, docs, dbId) { // docs should be an array of objects, each with an `id` and `data` property const upserts = docs.map((doc) => this.createOrUpdateDocument(collection, doc.id, doc, dbId)); await Promise.all(upserts); return { message: 'Batch upsert completed successfully' }; } async getDocumentsByFields(collection, dbId, filters = {}, defaults = {}) { if (!collection) { throw new Error('Collection is required'); } const db = await getFirestore(dbId); const mergedFilters = normalizeQueryFilters(filters, defaults); let query = db.collection(collection); for (const [field, value] of Object.entries(mergedFilters)) { query = query.where(field, '==', value); } const snapshot = await query.get(); return snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() })); } /** * Delete documents from a collection using arbitrary field filters. * A collection and at least one filter are required to avoid mass deletions. * @param {string} dbId Firestore database ID * @param {string} collection Firestore collection name * @param {object} filters - key/value pairs to filter on * @returns {Promise<number>} - Number of deleted documents */ async deleteDocumentsByFields(dbId, collection, filters = {}) { if (!collection) { throw new Error('Collection is required'); } if (!filters || Object.keys(filters).length === 0) { throw new Error('At least one filter must be provided'); } const db = await getFirestore(dbId); let query = db.collection(collection); for (const [field, value] of Object.entries(filters)) { query = query.where(field, '==', value); } const snapshot = await query.get(); const batch = db.batch(); snapshot.forEach((doc) => batch.delete(doc.ref)); await batch.commit(); return snapshot.size; } /** * Delete documents older than the specified age in minutes. * Optionally filters by page field if provided. * @param {string} dbId Firestore database ID * @param {string} collection Firestore collection name * @param {number} maxAgeMinutes Age in minutes * @param {string} [page] Optional page filter * @returns {Promise<number>} Number of deleted documents */ async deleteStaleDocuments(dbId, collection, maxAgeMinutes, page) { if (!collection) { throw new Error('Collection is required'); } if (!maxAgeMinutes || maxAgeMinutes <= 0) { throw new Error('maxAgeMinutes must be greater than 0'); } const db = await getFirestore(dbId); const thresholdDate = new Date(Date.now() - maxAgeMinutes * 60 * 1000); const thresholdDateField = new Date(Date.now() - maxAgeMinutes * 2 * 60 * 1000); // date field gets double threshold let query = db.collection(collection); if (page) { query = query.where('page', '==', page); } // Remove invalid documentId query boundary, just fetch all docs (optionally filtered by page) const snapshot = await query.get(); const batch = db.batch(); let deleteCount = 0; snapshot.forEach(doc => { const data = doc.data(); // Only check date if present, otherwise just use timestamp const timestampExpired = data.timestamp && new Date(data.timestamp) < thresholdDate; const dateExpired = data.date ? (new Date(data.date) < thresholdDateField) : false; if (timestampExpired || dateExpired) { batch.delete(doc.ref); deleteCount++; } }); if (deleteCount > 0) { await batch.commit(); } return deleteCount; } /** * Cleanup stale contents based on refresh rate from Remote Config. * @param {string} dbId Firestore database ID * @param {string} page Page name used for config parameter and filtering * @param {Function} [fetchConfig] optional injection for tests * @returns {Promise<number>} Number of deleted documents */ async cleanupStaleContents(dbId, collection, page, fetchConfig) { if (!fetchConfig) { // Dynamically load to avoid TypeScript path issues const mod = await Promise.resolve().then(() => __importStar(require('../config/firebase-config'))); fetchConfig = mod.getRemoteConfigParam; } const paramName = `recommended_sources`; const configJson = await fetchConfig(paramName); if (!configJson) { throw new Error('Config not found'); } // Parse the JSON string into an array of configs let configs = JSON.parse(configJson); if (page) { configs = configs.filter(cfg => cfg.url === page); } // For each config, call deleteStaleDocuments with its refresh_rate const deleteCounts = await Promise.all(configs.map(cfg => this.deleteStaleDocuments(dbId, collection, cfg.refresh_rate, cfg.url))); // Sum the deleted counts return deleteCounts.reduce((sum, count) => sum + count, 0); } async deleteContents(dbId, collection, page) { if (!collection) { throw new Error('Collection is required'); } if (!page) { throw new Error('page is required'); } const db = await getFirestore(dbId); const snapshot = await db.collection(collection).get(); const batch = db.batch(); const encodedTarget = encodeURIComponent(page); let deleted = 0; snapshot.forEach(doc => { const docPage = doc.get('page'); if (docPage && encodeURIComponent(docPage) === encodedTarget) { batch.delete(doc.ref); deleted++; } }); if (deleted > 0) { await batch.commit(); } return deleted; } } const firestoreService = new FirestoreService(); exports.default = firestoreService; //# sourceMappingURL=firestore.js.map