// House Management backend // Routes messages between household members (Au Pair <-> Child roles), // persists an inbox history, and pushes APNs notifications to offline devices. const fs = require("fs"); const path = require("path"); const http = require("http"); const http2 = require("http2"); const crypto = require("crypto"); const express = require("express"); const cors = require("cors"); const jwt = require("jsonwebtoken"); const { Server } = require("socket.io"); const PORT = process.env.PORT || 7860; const DB_PATH = path.join(__dirname, "data", "db.json"); // ---------- Persistence ---------- function loadDB() { try { return JSON.parse(fs.readFileSync(DB_PATH, "utf8")); } catch { return { users: {}, messages: [] }; } } function saveDB(db) { fs.mkdirSync(path.dirname(DB_PATH), { recursive: true }); fs.writeFileSync(DB_PATH, JSON.stringify(db, null, 2)); } let db = loadDB(); // ---------- App / Socket setup ---------- const app = express(); app.use(cors()); app.use(express.json()); const server = http.createServer(app); const io = new Server(server, { cors: { origin: "*" } }); // userId -> socket.id (only while connected) const liveSockets = new Map(); app.get("/health", (_req, res) => res.json({ ok: true })); // ---------- User registration ---------- // body: { userId, role: "aupair"|"child", name, pushToken, language } app.post("/register", (req, res) => { const { userId, role, name, pushToken, language } = req.body || {}; if (!userId || !role) return res.status(400).json({ error: "userId and role required" }); db.users[userId] = { ...(db.users[userId] || {}), userId, role, name: name || db.users[userId]?.name || role, pushToken: pushToken ?? db.users[userId]?.pushToken ?? null, language: language ?? db.users[userId]?.language ?? "en", updatedAt: Date.now(), }; saveDB(db); res.json({ ok: true, user: db.users[userId] }); }); // ---------- Message send (REST fallback to socket) ---------- // body: { fromUserId, toRole? , toUserId?, text } // Routing rule: if toUserId given, deliver directly. Else deliver to everyone // with the opposite role of the sender (broadcast to household). app.post("/message", async (req, res) => { const { fromUserId, toRole, toUserId, text } = req.body || {}; const sender = db.users[fromUserId]; if (!sender || !text) return res.status(400).json({ error: "fromUserId (registered) and text required" }); const targets = resolveTargets(sender, toRole, toUserId); const message = { id: crypto.randomUUID(), fromUserId, fromName: sender.name, fromRole: sender.role, text, targets: targets.map((t) => t.userId), createdAt: Date.now(), readBy: [], }; db.messages.push(message); saveDB(db); await deliver(message, targets); res.json({ ok: true, message }); }); // ---------- Inbox ---------- app.get("/inbox/:userId", (req, res) => { const { userId } = req.params; const msgs = db.messages.filter((m) => m.targets.includes(userId)); res.json({ messages: msgs.sort((a, b) => b.createdAt - a.createdAt) }); }); app.post("/inbox/:userId/read/:messageId", (req, res) => { const { userId, messageId } = req.params; const msg = db.messages.find((m) => m.id === messageId); if (!msg) return res.status(404).json({ error: "not found" }); if (!msg.readBy.includes(userId)) msg.readBy.push(userId); saveDB(db); res.json({ ok: true }); }); app.delete("/inbox/:userId/:messageId", (req, res) => { const { userId, messageId } = req.params; const msg = db.messages.find((m) => m.id === messageId); if (!msg) return res.status(404).json({ error: "not found" }); msg.targets = msg.targets.filter((t) => t !== userId); db.messages = db.messages.filter((m) => m.id !== messageId || m.targets.length > 0); saveDB(db); res.json({ ok: true }); }); // ---------- Simple free-tier translation passthrough ---------- // body: { text, target, source? } app.post("/translate", async (req, res) => { const { text, target, source = "auto" } = req.body || {}; if (!text || !target) return res.status(400).json({ error: "text and target required" }); try { const url = `https://api.mymemory.translated.net/get?q=${encodeURIComponent(text)}&langpair=${source}|${target}`; const r = await fetch(url); const data = await r.json(); res.json({ translated: data?.responseData?.translatedText || text }); } catch (e) { res.status(502).json({ error: "translation failed", detail: String(e) }); } }); // ---------- Routing helpers ---------- function resolveTargets(sender, toRole, toUserId) { if (toUserId) { const u = db.users[toUserId]; return u ? [u] : []; } const targetRole = toRole || (sender.role === "aupair" ? "child" : "aupair"); return Object.values(db.users).filter((u) => u.role === targetRole && u.userId !== sender.userId); } async function deliver(message, targets) { for (const target of targets) { const socketId = liveSockets.get(target.userId); if (socketId) { io.to(socketId).emit("message", message); } if (target.pushToken) { pushAPNs(target.pushToken, message).catch((e) => console.error("APNs push failed:", e.message)); } } } // ---------- APNs push (token-based auth, .p8 key) ---------- // Configure via env: APNS_KEY (PEM contents), APNS_KEY_ID, APNS_TEAM_ID, // APNS_BUNDLE_ID, APNS_PRODUCTION ("true"/"false") let cachedAPNsJWT = null; let cachedAPNsJWTExpiry = 0; function getAPNsJWT() { const now = Math.floor(Date.now() / 1000); if (cachedAPNsJWT && now < cachedAPNsJWTExpiry - 60) return cachedAPNsJWT; const key = process.env.APNS_KEY; const keyId = process.env.APNS_KEY_ID; const teamId = process.env.APNS_TEAM_ID; if (!key || !keyId || !teamId) throw new Error("APNs env vars not configured"); cachedAPNsJWT = jwt.sign({ iss: teamId, iat: now }, key.replace(/\\n/g, "\n"), { algorithm: "ES256", header: { alg: "ES256", kid: keyId }, }); cachedAPNsJWTExpiry = now + 3600; return cachedAPNsJWT; } function pushAPNs(deviceToken, message) { return new Promise((resolve, reject) => { const bundleId = process.env.APNS_BUNDLE_ID || "com.sebastienhall.HouseManagement"; const production = process.env.APNS_PRODUCTION === "true"; const host = production ? "api.push.apple.com" : "api.sandbox.push.apple.com"; let token; try { token = getAPNsJWT(); } catch (e) { return reject(e); } const client = http2.connect(`https://${host}`); client.on("error", reject); const payload = JSON.stringify({ aps: { alert: { title: `${message.fromName} (${message.fromRole})`, body: message.text }, sound: "default", category: "HOUSE_MESSAGE", }, messageId: message.id, deepLink: "housemanagement://inbox", }); const req = client.request({ ":method": "POST", ":path": `/3/device/${deviceToken}`, authorization: `bearer ${token}`, "apns-topic": bundleId, "apns-push-type": "alert", "content-type": "application/json", }); req.setEncoding("utf8"); let data = ""; req.on("data", (chunk) => (data += chunk)); req.on("end", () => { client.close(); resolve(data); }); req.on("error", (e) => { client.close(); reject(e); }); req.end(payload); }); } // ---------- Socket.io realtime ---------- io.on("connection", (socket) => { socket.on("identify", ({ userId, role, name }) => { if (!userId) return; liveSockets.set(userId, socket.id); socket.data.userId = userId; if (role) { db.users[userId] = { ...(db.users[userId] || {}), userId, role, name: name || db.users[userId]?.name || role, updatedAt: Date.now(), }; saveDB(db); } }); socket.on("message", async ({ fromUserId, toRole, toUserId, text }) => { const sender = db.users[fromUserId]; if (!sender || !text) return; const targets = resolveTargets(sender, toRole, toUserId); const message = { id: crypto.randomUUID(), fromUserId, fromName: sender.name, fromRole: sender.role, text, targets: targets.map((t) => t.userId), createdAt: Date.now(), readBy: [], }; db.messages.push(message); saveDB(db); await deliver(message, targets); }); socket.on("disconnect", () => { if (socket.data.userId && liveSockets.get(socket.data.userId) === socket.id) { liveSockets.delete(socket.data.userId); } }); }); server.listen(PORT, () => { console.log(`House Management backend listening on :${PORT}`); });