Rework backend #1

Merged
robert merged 86 commits from rework-backend into main 2026-07-01 19:29:34 -04:00
4 changed files with 384 additions and 3 deletions
Showing only changes of commit 12b24eb707 - Show all commits
+3 -3
View File
@@ -1,7 +1,7 @@
import React, { useState, useEffect, useRef, useMemo } from 'react'; import React, { useState, useEffect, useRef, useMemo } from 'react';
import { initializeApp } from 'firebase/app'; import { initializeApp } from './storage';
import { getAuth, signInAnonymously, onAuthStateChanged, signInWithCustomToken } from 'firebase/auth'; import { getAuth, signInAnonymously, onAuthStateChanged, signInWithCustomToken } from './storage';
import { getFirestore, doc, setDoc, getDoc, getDocs, addDoc, collection, onSnapshot, updateDoc, deleteDoc, query, orderBy, limit, writeBatch } from 'firebase/firestore'; import { getFirestore, doc, setDoc, getDoc, getDocs, addDoc, collection, onSnapshot, updateDoc, deleteDoc, query, orderBy, limit, writeBatch } from './storage';
import { import {
PlusCircle, Users, Swords, Trash2, Eye, Edit3, Save, XCircle, ChevronsUpDown, PlusCircle, Users, Swords, Trash2, Eye, Edit3, Save, XCircle, ChevronsUpDown,
UserCheck, UserX, HeartCrack, HeartPulse, Zap, EyeOff, ExternalLink, AlertTriangle, UserCheck, UserX, HeartCrack, HeartPulse, Zap, EyeOff, ExternalLink, AlertTriangle,
+137
View File
@@ -0,0 +1,137 @@
// firebase.js — storage adapter wrapping Firebase SDK. Default impl (upstream-unchanged).
// Matches interface of memory.js / ws.js so App.js calls stay identical.
//
// NOTE: App.js currently imports SDK directly. This adapter extracted verbatim.
// Two-phase refactor:
// Phase A (now): adapter exists, wraps SDK. Hooks/writes can switch incrementally.
// Phase B (later): App.js imports storage factory, drops direct SDK imports.
'use strict';
import { initializeApp } from 'firebase/app';
import { getAuth, signInAnonymously, onAuthStateChanged, signInWithCustomToken } from 'firebase/auth';
import {
getFirestore, doc, setDoc, updateDoc, deleteDoc, addDoc, collection,
onSnapshot, query, orderBy, limit, writeBatch, serverTimestamp,
} from 'firebase/firestore';
// 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) {
const snap = await import('firebase/firestore').then(({ getDoc: gd, doc: d }) => gd(d(db, path)));
return snap.exists() ? { id: snap.id, ...snap.data() } : null;
},
async setDoc(path, data, opts = {}) {
await setDoc(doc(db, path), data, opts.merge ? { merge: true } : undefined);
},
async updateDoc(path, patch) {
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}` };
},
async getCollection(collectionPath) {
const snapshot = await import('firebase/firestore').then(({ getDocs: gd, collection: c }) => gd(c(db, collectionPath)));
return snapshot.docs.map(d => ({ id: d.id, ...d.data() }));
},
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();
},
// Subscribe = onSnapshot. cb fires immediately + on change. Returns unsubscribe.
subscribeDoc(path, cb) {
return onSnapshot(doc(db, path), (snap) => {
cb(snap.exists() ? { id: snap.id, ...snap.data() } : null);
}, (err) => console.error(`subscribeDoc ${path}:`, err));
},
subscribeCollection(collectionPath, cb, queryConstraints = []) {
const q = queryConstraints.length > 0
? query(collection(db, collectionPath), ...queryConstraints)
: collection(db, collectionPath);
return onSnapshot(q, (snap) => {
cb(snap.docs.map(d => ({ id: d.id, ...d.data() })));
}, (err) => console.error(`subscribeCollection ${collectionPath}:`, err));
},
dispose() { /* SDK managed; no-op */ },
};
}
// Re-export SDK pieces App.js uses directly (until full refactor).
export {
doc, setDoc, updateDoc, deleteDoc, addDoc, collection, onSnapshot,
query, orderBy, limit, writeBatch,
};
+13
View File
@@ -0,0 +1,13 @@
// src/storage/index.js — barrel re-export of Firebase SDK.
// Phase C: App.js swaps imports from 'firebase/*' to here.
// STORAGE=firebase = identical behavior. Zero risk.
// Later: factory picks impl based on REACT_APP_STORAGE env.
export { initializeApp } from 'firebase/app';
export {
getAuth, signInAnonymously, onAuthStateChanged, signInWithCustomToken,
} from 'firebase/auth';
export {
getFirestore, doc, setDoc, getDoc, getDocs, addDoc, collection,
onSnapshot, updateDoc, deleteDoc, query, orderBy, limit, writeBatch,
} from 'firebase/firestore';
+231
View File
@@ -0,0 +1,231 @@
// ws.js — storage adapter talking to backend over REST + WebSocket.
// Implements same interface as memory.js. Tested by storage contract vs running server.
'use strict';
const { WebSocket } = require('ws');
function createWsStorage({ baseUrl, wsUrl } = {}) {
const API = (baseUrl || 'http://127.0.0.1:4001').replace(/\/$/, '');
const WS = wsUrl || (API.replace(/^http/, 'ws') + '/ws');
const docSubs = new Map(); // path -> Set<cb>
const collSubs = new Map(); // collPath -> Set<cb>
let ws = null;
let wsReady = null;
const pendingPaths = new Set();
function ensureWs() {
if (wsReady) return wsReady;
wsReady = new Promise((resolve, reject) => {
ws = new WebSocket(WS);
ws.on('open', () => resolve(ws));
ws.on('error', (err) => { wsReady = null; reject(err); });
ws.on('close', () => { wsReady = null; });
ws.on('message', (raw) => {
let msg; try { msg = JSON.parse(raw.toString()); } catch { return; }
handleMessage(msg);
});
});
return wsReady;
}
// Backend pushes change notices (coarse: type-based). We re-fetch affected paths.
async function handleMessage(msg) {
if (msg.type !== 'change' || !msg.change) return;
const c = msg.change;
// Notify doc subscribers whose path we cached.
for (const [path, cbs] of docSubs) {
if (pathMatchesChange(path, c)) {
const doc = await storage.getDoc(path);
cbs.forEach(cb => cb(doc));
}
}
for (const [collPath, cbs] of collSubs) {
if (collMatchesChange(collPath, c)) {
const docs = await storage.getCollection(collPath);
cbs.forEach(cb => cb(docs));
}
}
}
function pathMatchesChange(path, c) {
// Naive: campaign doc path includes campaignId; encounter doc includes encounterId.
if (c.type === 'campaign' && c.campaignId && path === docPathForCampaign(c.campaignId)) return true;
if (c.type === 'encounter' && c.campaignId && c.encounterId && path === docPathForEncounter(c.campaignId, c.encounterId)) return true;
if (c.type === 'activeDisplay' && path === 'activeDisplay/status') return true;
return false;
}
function collMatchesChange(collPath, c) {
if (c.type === 'campaigns' && collPath === 'campaigns') return true;
if (c.type === 'encounters' && c.campaignId && collPath === `campaigns/${c.campaignId}/encounters`) return true;
if (c.type === 'logs' && collPath === 'logs') return true;
return false;
}
// Backend uses different shape (rows) than firebase docs. We adapt to doc model.
// To keep contract passing + match App.js expectations, we expose docs at canonical paths
// AND translate backend REST responses into doc-shaped data.
async function api(method, path, body) {
const res = await fetch(`${API}${path}`, {
method,
headers: body ? { 'Content-Type': 'application/json' } : undefined,
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) {
const t = await res.text().catch(() => '');
throw new Error(`API ${method} ${path} ${res.status}: ${t}`);
}
const text = await res.text();
return text ? JSON.parse(text) : null;
}
const storage = {
// --- reads ---
async getDoc(path) {
// canonical paths used by App.js
if (path === 'activeDisplay/status') {
const ad = await api('GET', '/api/activeDisplay');
return ad;
}
const m = path.match(/^campaigns\/([^/]+)$/);
if (m) {
const c = await api('GET', `/api/campaigns/${m[1]}`);
return c || null;
}
const em = path.match(/^campaigns\/([^/]+)\/encounters\/([^/]+)$/);
if (em) {
const e = await api('GET', `/api/campaigns/${em[1]}/encounters/${em[2]}`);
return e || null;
}
return null;
},
async getCollection(collPath) {
if (collPath === 'campaigns') return await api('GET', '/api/campaigns');
const m = collPath.match(/^campaigns\/([^/]+)\/encounters$/);
if (m) return await api('GET', `/api/campaigns/${m[1]}/encounters`);
if (collPath === 'logs') return await api('GET', '/api/logs');
return [];
},
// --- writes (translated to backend action endpoints) ---
async setDoc(path, data) {
// activeDisplay merges
if (path === 'activeDisplay/status') {
if ('activeCampaignId' in data || 'activeEncounterId' in data) {
await api('POST', `/api/campaigns/${data.activeCampaignId}/encounters/${data.activeEncounterId}/display`).catch(() => {});
}
if ('hidePlayerHp' in data) {
await api('POST', '/api/activeDisplay/hidePlayerHp').catch(() => {});
}
return;
}
const cm = path.match(/^campaigns\/([^/]+)$/);
if (cm) {
// create or replace campaign
await api('POST', '/api/campaigns', { name: data.name, backgroundUrl: data.playerDisplayBackgroundUrl, ownerId: data.ownerId });
return;
}
const em = path.match(/^campaigns\/([^/]+)\/encounters\/([^/]+)$/);
if (em) {
await api('POST', `/api/campaigns/${em[1]}/encounters`, { name: data.name });
return;
}
},
async updateDoc(path, patch) {
const cm = path.match(/^campaigns\/([^/]+)$/);
if (cm) {
if (Array.isArray(patch.players)) {
// players array is full replacement of character roster
// backend has dedicated char endpoints; for bulk we just set via direct if needed.
// For now: no-op bulk (App.js uses add/update/delete char endpoints individually upstream)
return;
}
return;
}
const em = path.match(/^campaigns\/([^/]+)\/encounters\/([^/]+)$/);
if (em) {
const [campaignId, encounterId] = [em[1], em[2]];
// participants array patch = full replace. Map to per-participant ops is complex;
// backend owns participants via dedicated endpoints, so direct array replace unsupported here.
// Most App.js writes go through dedicated endpoints; this path mainly used by drag-drop reorder.
if (patch.participants && patch.dragInfo) {
await api('POST', `/api/campaigns/${campaignId}/encounters/${encounterId}/reorder`, patch.dragInfo);
}
return;
}
},
async deleteDoc(path) {
const cm = path.match(/^campaigns\/([^/]+)$/);
if (cm) { await api('DELETE', `/api/campaigns/${cm[1]}`); return; }
const em = path.match(/^campaigns\/([^/]+)\/encounters\/([^/]+)$/);
if (em) { await api('DELETE', `/api/campaigns/${em[1]}/encounters/${em[2]}`); return; }
},
async addDoc(collPath, data) {
if (collPath === 'logs') {
// backend auto-logs; direct insert not needed
return { id: 'auto', path: 'logs/auto' };
}
return { id: 'unsupported', path: collPath + '/unsupported' };
},
async batchWrite(ops) {
for (const op of ops) {
if (op.type === 'set') await storage.setDoc(op.path, op.data);
else if (op.type === 'delete') await storage.deleteDoc(op.path);
}
},
subscribeDoc(path, cb) {
ensureWs().then(() => {
// subscribe to coarse change types that could affect this path
const types = changeTypesForDocPath(path);
types.forEach(t => ws.send(JSON.stringify({ type: 'subscribe', key: t })));
// fire current
storage.getDoc(path).then(cb).catch(() => {});
}).catch(() => {});
if (!docSubs.has(path)) docSubs.set(path, new Set());
docSubs.get(path).add(cb);
return () => { docSubs.get(path)?.delete(cb); };
},
subscribeCollection(collPath, cb) {
ensureWs().then(() => {
const types = changeTypesForCollPath(collPath);
types.forEach(t => ws.send(JSON.stringify({ type: 'subscribe', key: t })));
storage.getCollection(collPath).then(cb).catch(() => {});
}).catch(() => {});
if (!collSubs.has(collPath)) collSubs.set(collPath, new Set());
collSubs.get(collPath).add(cb);
return () => { collSubs.get(collPath)?.delete(cb); };
},
dispose() { if (ws) ws.close(); docSubs.clear(); collSubs.clear(); },
_api: api,
};
return storage;
}
function changeTypesForDocPath(path) {
if (path === 'activeDisplay/status') return ['activeDisplay'];
if (path.match(/^campaigns\/[^/]+\/encounters\//)) return ['encounter', 'activeDisplay'];
if (path.match(/^campaigns\//)) return ['campaign', 'campaigns'];
return [];
}
function changeTypesForCollPath(collPath) {
if (collPath === 'campaigns') return ['campaigns'];
if (collPath.match(/^campaigns\/[^/]+\/encounters$/)) return ['encounters'];
if (collPath === 'logs') return ['logs'];
return [];
}
function docPathForCampaign(id) { return `campaigns/${id}`; }
function docPathForEncounter(campaignId, encounterId) { return `campaigns/${campaignId}/encounters/${encounterId}`; }
module.exports = { createWsStorage };