System / server.js
Sebebeb's picture
Upload 5 files
2f18a0c verified
Raw
History Blame Contribute Delete
8.65 kB
// 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}`);
});