2026-06-28 17:51:39 -04:00
|
|
|
// firebase.js — storage adapter wrapping Firebase SDK. Default impl (upstream-unchanged).
|
|
|
|
|
// Matches interface of memory.js / ws.js so App.js calls stay identical.
|
|
|
|
|
//
|
2026-07-04 12:22:04 -04:00
|
|
|
// App.js imports SDK via this module (doc, setDoc, etc re-exported) for both
|
|
|
|
|
// the adapter (createFirebaseStorage) and direct SDK calls. ONE import path.
|
2026-06-28 17:51:39 -04:00
|
|
|
|
|
|
|
|
import { initializeApp } from 'firebase/app';
|
2026-07-06 10:31:46 -04:00
|
|
|
import { getAuth } from 'firebase/auth';
|
2026-06-28 17:51:39 -04:00
|
|
|
import {
|
2026-06-28 21:05:39 -04:00
|
|
|
getFirestore, doc, setDoc, getDoc as getDocReal, getDocs as getDocsReal, addDoc, collection,
|
2026-07-06 10:31:46 -04:00
|
|
|
onSnapshot, updateDoc, deleteDoc, query, where, orderBy, limit, writeBatch, getCountFromServer,
|
2026-06-28 17:51:39 -04:00
|
|
|
} from 'firebase/firestore';
|
|
|
|
|
|
2026-06-29 13:13:46 -04:00
|
|
|
// Adapter call recorder (instrumentation, no behavior change).
|
|
|
|
|
// Tests assert adapter.subscribeDoc called (catches raw-SDK bypass like DisplayView).
|
|
|
|
|
const ADAPTER_CALLS = [];
|
|
|
|
|
function recordAdapterCall(entry) { ADAPTER_CALLS.push({ ...entry, ts: Date.now() }); }
|
|
|
|
|
export function getAdapterCalls() { return [...ADAPTER_CALLS]; }
|
|
|
|
|
export function resetAdapterCalls() { ADAPTER_CALLS.length = 0; }
|
|
|
|
|
|
2026-06-28 17:51:39 -04:00
|
|
|
// Path helpers mirror App.js getPath object.
|
|
|
|
|
const APP_ID = process.env.REACT_APP_TRACKER_APP_ID || 'ttrpg-initiative-tracker-default';
|
|
|
|
|
const PUBLIC_DATA_PATH = `artifacts/${APP_ID}/public/data`;
|
|
|
|
|
|
|
|
|
|
export const getPath = {
|
|
|
|
|
campaigns: () => `${PUBLIC_DATA_PATH}/campaigns`,
|
|
|
|
|
campaign: (id) => `${PUBLIC_DATA_PATH}/campaigns/${id}`,
|
|
|
|
|
encounters: (campaignId) => `${PUBLIC_DATA_PATH}/campaigns/${campaignId}/encounters`,
|
|
|
|
|
encounter: (campaignId, encounterId) => `${PUBLIC_DATA_PATH}/campaigns/${campaignId}/encounters/${encounterId}`,
|
|
|
|
|
activeDisplay: () => `${PUBLIC_DATA_PATH}/activeDisplay/status`,
|
|
|
|
|
logs: () => `${PUBLIC_DATA_PATH}/logs`
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let firebaseApp = null;
|
|
|
|
|
let dbInstance = null;
|
|
|
|
|
let authInstance = null;
|
|
|
|
|
|
|
|
|
|
export function initFirebase() {
|
|
|
|
|
const config = {
|
|
|
|
|
apiKey: process.env.REACT_APP_FIREBASE_API_KEY,
|
|
|
|
|
authDomain: process.env.REACT_APP_FIREBASE_AUTH_DOMAIN,
|
|
|
|
|
projectId: process.env.REACT_APP_FIREBASE_PROJECT_ID,
|
|
|
|
|
storageBucket: process.env.REACT_APP_FIREBASE_STORAGE_BUCKET,
|
|
|
|
|
messagingSenderId: process.env.REACT_APP_FIREBASE_MESSAGING_SENDER_ID,
|
|
|
|
|
appId: process.env.REACT_APP_FIREBASE_APP_ID
|
|
|
|
|
};
|
|
|
|
|
const requiredKeys = ['apiKey', 'authDomain', 'projectId', 'appId'];
|
|
|
|
|
const missing = requiredKeys.filter(k => !config[k]);
|
|
|
|
|
if (missing.length > 0) {
|
|
|
|
|
console.error(`CRITICAL: Missing Firebase config: ${missing.join(', ')}`);
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
firebaseApp = initializeApp(config);
|
|
|
|
|
dbInstance = getFirestore(firebaseApp);
|
|
|
|
|
authInstance = getAuth(firebaseApp);
|
|
|
|
|
return true;
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('Firebase init failed:', err);
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function getDb() { return dbInstance; }
|
|
|
|
|
export function getAuthInstance() { return authInstance; }
|
|
|
|
|
|
|
|
|
|
// ============================================================================
|
|
|
|
|
// STORAGE ADAPTER
|
|
|
|
|
// ============================================================================
|
|
|
|
|
// Wraps SDK in the storage interface (getDoc/setDoc/etc).
|
|
|
|
|
// App.js can now import { storage } and call storage.setDoc(path, data).
|
|
|
|
|
// Hooks (useFirestoreDocument etc) still use SDK directly for now.
|
|
|
|
|
|
|
|
|
|
export function createFirebaseStorage() {
|
|
|
|
|
const db = dbInstance;
|
|
|
|
|
if (!db) throw new Error('Firestore not initialized. Call initFirebase() first.');
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
async getDoc(path) {
|
2026-06-28 21:05:39 -04:00
|
|
|
const snap = await getDocReal(doc(db, path));
|
2026-06-28 17:51:39 -04:00
|
|
|
return snap.exists() ? { id: snap.id, ...snap.data() } : null;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
async setDoc(path, data, opts = {}) {
|
2026-06-30 13:55:14 -04:00
|
|
|
recordAdapterCall({ fn: 'setDoc', path, data, opts });
|
2026-06-28 17:51:39 -04:00
|
|
|
await setDoc(doc(db, path), data, opts.merge ? { merge: true } : undefined);
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
async updateDoc(path, patch) {
|
2026-06-30 13:55:14 -04:00
|
|
|
recordAdapterCall({ fn: 'updateDoc', path, patch });
|
2026-06-28 17:51:39 -04:00
|
|
|
await updateDoc(doc(db, path), patch);
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
async deleteDoc(path) {
|
|
|
|
|
await deleteDoc(doc(db, path));
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
async addDoc(collectionPath, data) {
|
|
|
|
|
const ref = await addDoc(collection(db, collectionPath), data);
|
|
|
|
|
return { id: ref.id, path: `${collectionPath}/${ref.id}` };
|
|
|
|
|
},
|
|
|
|
|
|
2026-07-06 10:31:46 -04:00
|
|
|
async getCollection(collectionPath, queryConstraints = []) {
|
|
|
|
|
// Translate neutral {__type} constraints to firebase SDK builders.
|
|
|
|
|
const fbConstraints = [];
|
|
|
|
|
for (const c of queryConstraints || []) {
|
|
|
|
|
if (!c || !c.__type) continue;
|
|
|
|
|
if (c.__type === 'where') fbConstraints.push(where(c.field, c.op, c.value));
|
|
|
|
|
else if (c.__type === 'orderBy') fbConstraints.push(orderBy(c.field, c.dir || 'asc'));
|
|
|
|
|
else if (c.__type === 'limit') fbConstraints.push(limit(c.n));
|
|
|
|
|
// firebase SDK has no offset; skip (UI uses server adapter in dev).
|
|
|
|
|
}
|
|
|
|
|
const q = fbConstraints.length ? query(collection(db, collectionPath), ...fbConstraints) : collection(db, collectionPath);
|
|
|
|
|
const snapshot = await getDocsReal(q);
|
2026-06-28 17:51:39 -04:00
|
|
|
return snapshot.docs.map(d => ({ id: d.id, ...d.data() }));
|
|
|
|
|
},
|
|
|
|
|
|
2026-07-06 10:31:46 -04:00
|
|
|
async countCollection(collectionPath) {
|
|
|
|
|
const snapshot = await getCountFromServer(collection(db, collectionPath));
|
|
|
|
|
return snapshot.data().count;
|
|
|
|
|
},
|
|
|
|
|
|
2026-06-28 17:51:39 -04:00
|
|
|
async batchWrite(ops) {
|
|
|
|
|
const batch = writeBatch(db);
|
|
|
|
|
for (const op of ops) {
|
|
|
|
|
if (op.type === 'set') batch.set(doc(db, op.path), op.data);
|
|
|
|
|
else if (op.type === 'delete') batch.delete(doc(db, op.path));
|
|
|
|
|
else if (op.type === 'update') batch.update(doc(db, op.path), op.data);
|
|
|
|
|
}
|
|
|
|
|
await batch.commit();
|
|
|
|
|
},
|
|
|
|
|
|
2026-07-06 10:31:46 -04:00
|
|
|
// Transactional undo via batch (atomic in firestore). Apply updates +
|
|
|
|
|
// flip undone flag in single commit.
|
|
|
|
|
async undo({ logPath, undo, redo = false }) {
|
|
|
|
|
const batch = writeBatch(db);
|
|
|
|
|
const updates = redo ? (undo.redo || undo.updates) : undo.updates;
|
|
|
|
|
batch.update(doc(db, undo.encounterPath), updates);
|
|
|
|
|
batch.update(doc(db, logPath), { undone: !redo });
|
|
|
|
|
await batch.commit();
|
|
|
|
|
},
|
|
|
|
|
|
2026-06-28 17:51:39 -04:00
|
|
|
// Subscribe = onSnapshot. cb fires immediately + on change. Returns unsubscribe.
|
2026-07-04 12:22:04 -04:00
|
|
|
subscribeDoc(path, cb, errCb) {
|
2026-06-29 13:13:46 -04:00
|
|
|
recordAdapterCall({ fn: 'subscribeDoc', path });
|
2026-06-28 17:51:39 -04:00
|
|
|
return onSnapshot(doc(db, path), (snap) => {
|
|
|
|
|
cb(snap.exists() ? { id: snap.id, ...snap.data() } : null);
|
2026-07-04 12:22:04 -04:00
|
|
|
}, (err) => {
|
|
|
|
|
console.error(`subscribeDoc ${path}:`, err);
|
|
|
|
|
if (typeof errCb === 'function') errCb(err);
|
|
|
|
|
});
|
2026-06-28 17:51:39 -04:00
|
|
|
},
|
|
|
|
|
|
2026-07-04 12:22:04 -04:00
|
|
|
subscribeCollection(collectionPath, cb, queryConstraints = [], errCb) {
|
2026-06-29 13:13:46 -04:00
|
|
|
recordAdapterCall({ fn: 'subscribeCollection', path: collectionPath });
|
2026-07-04 16:06:28 -04:00
|
|
|
// queryConstraints = neutral {__type} builders (from index.js).
|
|
|
|
|
// Translate to SDK orderBy/limit.
|
|
|
|
|
const sdkConstraints = queryConstraints.map(c => {
|
2026-07-06 10:31:46 -04:00
|
|
|
if (c.__type === 'where') return where(c.field, c.op, c.value);
|
2026-07-04 16:06:28 -04:00
|
|
|
if (c.__type === 'orderBy') return orderBy(c.field, c.dir);
|
|
|
|
|
if (c.__type === 'limit') return limit(c.n);
|
|
|
|
|
return c; // pass-through (forward compat)
|
|
|
|
|
});
|
|
|
|
|
const q = sdkConstraints.length > 0
|
|
|
|
|
? query(collection(db, collectionPath), ...sdkConstraints)
|
2026-06-28 17:51:39 -04:00
|
|
|
: collection(db, collectionPath);
|
|
|
|
|
return onSnapshot(q, (snap) => {
|
|
|
|
|
cb(snap.docs.map(d => ({ id: d.id, ...d.data() })));
|
2026-07-04 12:22:04 -04:00
|
|
|
}, (err) => {
|
|
|
|
|
console.error(`subscribeCollection ${collectionPath}:`, err);
|
|
|
|
|
if (typeof errCb === 'function') errCb(err);
|
|
|
|
|
});
|
2026-06-28 17:51:39 -04:00
|
|
|
},
|
|
|
|
|
|
|
|
|
|
dispose() { /* SDK managed; no-op */ },
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Re-export SDK pieces App.js uses directly (until full refactor).
|
2026-07-04 16:06:28 -04:00
|
|
|
// orderBy/limit NOT re-exported: App uses neutral builders from index.js.
|
2026-06-28 17:51:39 -04:00
|
|
|
export {
|
|
|
|
|
doc, setDoc, updateDoc, deleteDoc, addDoc, collection, onSnapshot,
|
2026-07-04 16:06:28 -04:00
|
|
|
query, writeBatch,
|
2026-06-28 17:51:39 -04:00
|
|
|
};
|