| |
| |
| |
|
|
| 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"); |
|
|
| |
| 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(); |
|
|
| |
| const app = express(); |
| app.use(cors()); |
| app.use(express.json()); |
|
|
| const server = http.createServer(app); |
| const io = new Server(server, { cors: { origin: "*" } }); |
|
|
| |
| const liveSockets = new Map(); |
|
|
| app.get("/health", (_req, res) => res.json({ ok: true })); |
|
|
| |
| |
| 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] }); |
| }); |
|
|
| |
| |
| |
| |
| 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 }); |
| }); |
|
|
| |
| 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 }); |
| }); |
|
|
| |
| |
| 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) }); |
| } |
| }); |
|
|
| |
| 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)); |
| } |
| } |
| } |
|
|
| |
| |
| |
| 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); |
| }); |
| } |
|
|
| |
| 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}`); |
| }); |
|
|