Sebebeb commited on
Commit
2f18a0c
·
verified ·
1 Parent(s): 882e778

Upload 5 files

Browse files
Files changed (5) hide show
  1. .gitignore +3 -0
  2. Dockerfile +15 -0
  3. README.md +46 -0
  4. package.json +16 -0
  5. server.js +270 -0
.gitignore ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ node_modules/
2
+ data/db.json
3
+ .DS_Store
Dockerfile ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM node:20-alpine
2
+
3
+ WORKDIR /app
4
+
5
+ COPY package.json ./
6
+ RUN npm install --omit=dev
7
+
8
+ COPY server.js ./
9
+
10
+ RUN mkdir -p /app/data
11
+
12
+ EXPOSE 7860
13
+ ENV PORT=7860
14
+
15
+ CMD ["node", "server.js"]
README.md ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: House Management
3
+ emoji: 🏠
4
+ colorFrom: gray
5
+ colorTo: blue
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ ---
10
+
11
+ # House Management Backend
12
+
13
+ Routes messages between household members (Au Pair / Child roles), keeps an
14
+ inbox history, and sends APNs push notifications to offline devices.
15
+
16
+ ## Endpoints
17
+
18
+ - `POST /register` — `{ userId, role, name, pushToken, language }`
19
+ - `POST /message` — `{ fromUserId, toRole?, toUserId?, text }`
20
+ - `GET /inbox/:userId`
21
+ - `POST /inbox/:userId/read/:messageId`
22
+ - `DELETE /inbox/:userId/:messageId`
23
+ - `POST /translate` — `{ text, target, source? }`
24
+ - Socket.io events: `identify`, `message`
25
+
26
+ ## Space secrets to configure
27
+
28
+ - `APNS_KEY` — contents of the `.p8` push key
29
+ - `APNS_KEY_ID`
30
+ - `APNS_TEAM_ID`
31
+ - `APNS_BUNDLE_ID` — `com.sebastienhall.HouseManagement`
32
+ - `APNS_PRODUCTION` — `true` once shipping, `false` for dev builds
33
+
34
+ ## Deploy
35
+
36
+ Add these files through the Space's web interface (Files tab → Add file), or:
37
+
38
+ ```bash
39
+ git init
40
+ git remote add origin https://huggingface.co/spaces/sebebeb/System
41
+ git add .
42
+ git commit -m "House Management backend"
43
+ git push origin main
44
+ ```
45
+
46
+ Space URL once live: `https://sebebeb-System.hf.space`
package.json ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "housebackend",
3
+ "version": "1.0.0",
4
+ "description": "House Management routing backend",
5
+ "main": "server.js",
6
+ "type": "commonjs",
7
+ "scripts": {
8
+ "start": "node server.js"
9
+ },
10
+ "dependencies": {
11
+ "cors": "^2.8.5",
12
+ "express": "^4.19.2",
13
+ "jsonwebtoken": "^9.0.2",
14
+ "socket.io": "^4.7.5"
15
+ }
16
+ }
server.js ADDED
@@ -0,0 +1,270 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // House Management backend
2
+ // Routes messages between household members (Au Pair <-> Child roles),
3
+ // persists an inbox history, and pushes APNs notifications to offline devices.
4
+
5
+ const fs = require("fs");
6
+ const path = require("path");
7
+ const http = require("http");
8
+ const http2 = require("http2");
9
+ const crypto = require("crypto");
10
+ const express = require("express");
11
+ const cors = require("cors");
12
+ const jwt = require("jsonwebtoken");
13
+ const { Server } = require("socket.io");
14
+
15
+ const PORT = process.env.PORT || 7860;
16
+ const DB_PATH = path.join(__dirname, "data", "db.json");
17
+
18
+ // ---------- Persistence ----------
19
+ function loadDB() {
20
+ try {
21
+ return JSON.parse(fs.readFileSync(DB_PATH, "utf8"));
22
+ } catch {
23
+ return { users: {}, messages: [] };
24
+ }
25
+ }
26
+ function saveDB(db) {
27
+ fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
28
+ fs.writeFileSync(DB_PATH, JSON.stringify(db, null, 2));
29
+ }
30
+ let db = loadDB();
31
+
32
+ // ---------- App / Socket setup ----------
33
+ const app = express();
34
+ app.use(cors());
35
+ app.use(express.json());
36
+
37
+ const server = http.createServer(app);
38
+ const io = new Server(server, { cors: { origin: "*" } });
39
+
40
+ // userId -> socket.id (only while connected)
41
+ const liveSockets = new Map();
42
+
43
+ app.get("/health", (_req, res) => res.json({ ok: true }));
44
+
45
+ // ---------- User registration ----------
46
+ // body: { userId, role: "aupair"|"child", name, pushToken, language }
47
+ app.post("/register", (req, res) => {
48
+ const { userId, role, name, pushToken, language } = req.body || {};
49
+ if (!userId || !role) return res.status(400).json({ error: "userId and role required" });
50
+ db.users[userId] = {
51
+ ...(db.users[userId] || {}),
52
+ userId,
53
+ role,
54
+ name: name || db.users[userId]?.name || role,
55
+ pushToken: pushToken ?? db.users[userId]?.pushToken ?? null,
56
+ language: language ?? db.users[userId]?.language ?? "en",
57
+ updatedAt: Date.now(),
58
+ };
59
+ saveDB(db);
60
+ res.json({ ok: true, user: db.users[userId] });
61
+ });
62
+
63
+ // ---------- Message send (REST fallback to socket) ----------
64
+ // body: { fromUserId, toRole? , toUserId?, text }
65
+ // Routing rule: if toUserId given, deliver directly. Else deliver to everyone
66
+ // with the opposite role of the sender (broadcast to household).
67
+ app.post("/message", async (req, res) => {
68
+ const { fromUserId, toRole, toUserId, text } = req.body || {};
69
+ const sender = db.users[fromUserId];
70
+ if (!sender || !text) return res.status(400).json({ error: "fromUserId (registered) and text required" });
71
+
72
+ const targets = resolveTargets(sender, toRole, toUserId);
73
+ const message = {
74
+ id: crypto.randomUUID(),
75
+ fromUserId,
76
+ fromName: sender.name,
77
+ fromRole: sender.role,
78
+ text,
79
+ targets: targets.map((t) => t.userId),
80
+ createdAt: Date.now(),
81
+ readBy: [],
82
+ };
83
+ db.messages.push(message);
84
+ saveDB(db);
85
+
86
+ await deliver(message, targets);
87
+ res.json({ ok: true, message });
88
+ });
89
+
90
+ // ---------- Inbox ----------
91
+ app.get("/inbox/:userId", (req, res) => {
92
+ const { userId } = req.params;
93
+ const msgs = db.messages.filter((m) => m.targets.includes(userId));
94
+ res.json({ messages: msgs.sort((a, b) => b.createdAt - a.createdAt) });
95
+ });
96
+
97
+ app.post("/inbox/:userId/read/:messageId", (req, res) => {
98
+ const { userId, messageId } = req.params;
99
+ const msg = db.messages.find((m) => m.id === messageId);
100
+ if (!msg) return res.status(404).json({ error: "not found" });
101
+ if (!msg.readBy.includes(userId)) msg.readBy.push(userId);
102
+ saveDB(db);
103
+ res.json({ ok: true });
104
+ });
105
+
106
+ app.delete("/inbox/:userId/:messageId", (req, res) => {
107
+ const { userId, messageId } = req.params;
108
+ const msg = db.messages.find((m) => m.id === messageId);
109
+ if (!msg) return res.status(404).json({ error: "not found" });
110
+ msg.targets = msg.targets.filter((t) => t !== userId);
111
+ db.messages = db.messages.filter((m) => m.id !== messageId || m.targets.length > 0);
112
+ saveDB(db);
113
+ res.json({ ok: true });
114
+ });
115
+
116
+ // ---------- Simple free-tier translation passthrough ----------
117
+ // body: { text, target, source? }
118
+ app.post("/translate", async (req, res) => {
119
+ const { text, target, source = "auto" } = req.body || {};
120
+ if (!text || !target) return res.status(400).json({ error: "text and target required" });
121
+ try {
122
+ const url = `https://api.mymemory.translated.net/get?q=${encodeURIComponent(text)}&langpair=${source}|${target}`;
123
+ const r = await fetch(url);
124
+ const data = await r.json();
125
+ res.json({ translated: data?.responseData?.translatedText || text });
126
+ } catch (e) {
127
+ res.status(502).json({ error: "translation failed", detail: String(e) });
128
+ }
129
+ });
130
+
131
+ // ---------- Routing helpers ----------
132
+ function resolveTargets(sender, toRole, toUserId) {
133
+ if (toUserId) {
134
+ const u = db.users[toUserId];
135
+ return u ? [u] : [];
136
+ }
137
+ const targetRole = toRole || (sender.role === "aupair" ? "child" : "aupair");
138
+ return Object.values(db.users).filter((u) => u.role === targetRole && u.userId !== sender.userId);
139
+ }
140
+
141
+ async function deliver(message, targets) {
142
+ for (const target of targets) {
143
+ const socketId = liveSockets.get(target.userId);
144
+ if (socketId) {
145
+ io.to(socketId).emit("message", message);
146
+ }
147
+ if (target.pushToken) {
148
+ pushAPNs(target.pushToken, message).catch((e) => console.error("APNs push failed:", e.message));
149
+ }
150
+ }
151
+ }
152
+
153
+ // ---------- APNs push (token-based auth, .p8 key) ----------
154
+ // Configure via env: APNS_KEY (PEM contents), APNS_KEY_ID, APNS_TEAM_ID,
155
+ // APNS_BUNDLE_ID, APNS_PRODUCTION ("true"/"false")
156
+ let cachedAPNsJWT = null;
157
+ let cachedAPNsJWTExpiry = 0;
158
+
159
+ function getAPNsJWT() {
160
+ const now = Math.floor(Date.now() / 1000);
161
+ if (cachedAPNsJWT && now < cachedAPNsJWTExpiry - 60) return cachedAPNsJWT;
162
+ const key = process.env.APNS_KEY;
163
+ const keyId = process.env.APNS_KEY_ID;
164
+ const teamId = process.env.APNS_TEAM_ID;
165
+ if (!key || !keyId || !teamId) throw new Error("APNs env vars not configured");
166
+ cachedAPNsJWT = jwt.sign({ iss: teamId, iat: now }, key.replace(/\\n/g, "\n"), {
167
+ algorithm: "ES256",
168
+ header: { alg: "ES256", kid: keyId },
169
+ });
170
+ cachedAPNsJWTExpiry = now + 3600;
171
+ return cachedAPNsJWT;
172
+ }
173
+
174
+ function pushAPNs(deviceToken, message) {
175
+ return new Promise((resolve, reject) => {
176
+ const bundleId = process.env.APNS_BUNDLE_ID || "com.sebastienhall.HouseManagement";
177
+ const production = process.env.APNS_PRODUCTION === "true";
178
+ const host = production ? "api.push.apple.com" : "api.sandbox.push.apple.com";
179
+
180
+ let token;
181
+ try {
182
+ token = getAPNsJWT();
183
+ } catch (e) {
184
+ return reject(e);
185
+ }
186
+
187
+ const client = http2.connect(`https://${host}`);
188
+ client.on("error", reject);
189
+
190
+ const payload = JSON.stringify({
191
+ aps: {
192
+ alert: { title: `${message.fromName} (${message.fromRole})`, body: message.text },
193
+ sound: "default",
194
+ category: "HOUSE_MESSAGE",
195
+ },
196
+ messageId: message.id,
197
+ deepLink: "housemanagement://inbox",
198
+ });
199
+
200
+ const req = client.request({
201
+ ":method": "POST",
202
+ ":path": `/3/device/${deviceToken}`,
203
+ authorization: `bearer ${token}`,
204
+ "apns-topic": bundleId,
205
+ "apns-push-type": "alert",
206
+ "content-type": "application/json",
207
+ });
208
+
209
+ req.setEncoding("utf8");
210
+ let data = "";
211
+ req.on("data", (chunk) => (data += chunk));
212
+ req.on("end", () => {
213
+ client.close();
214
+ resolve(data);
215
+ });
216
+ req.on("error", (e) => {
217
+ client.close();
218
+ reject(e);
219
+ });
220
+ req.end(payload);
221
+ });
222
+ }
223
+
224
+ // ---------- Socket.io realtime ----------
225
+ io.on("connection", (socket) => {
226
+ socket.on("identify", ({ userId, role, name }) => {
227
+ if (!userId) return;
228
+ liveSockets.set(userId, socket.id);
229
+ socket.data.userId = userId;
230
+ if (role) {
231
+ db.users[userId] = {
232
+ ...(db.users[userId] || {}),
233
+ userId,
234
+ role,
235
+ name: name || db.users[userId]?.name || role,
236
+ updatedAt: Date.now(),
237
+ };
238
+ saveDB(db);
239
+ }
240
+ });
241
+
242
+ socket.on("message", async ({ fromUserId, toRole, toUserId, text }) => {
243
+ const sender = db.users[fromUserId];
244
+ if (!sender || !text) return;
245
+ const targets = resolveTargets(sender, toRole, toUserId);
246
+ const message = {
247
+ id: crypto.randomUUID(),
248
+ fromUserId,
249
+ fromName: sender.name,
250
+ fromRole: sender.role,
251
+ text,
252
+ targets: targets.map((t) => t.userId),
253
+ createdAt: Date.now(),
254
+ readBy: [],
255
+ };
256
+ db.messages.push(message);
257
+ saveDB(db);
258
+ await deliver(message, targets);
259
+ });
260
+
261
+ socket.on("disconnect", () => {
262
+ if (socket.data.userId && liveSockets.get(socket.data.userId) === socket.id) {
263
+ liveSockets.delete(socket.data.userId);
264
+ }
265
+ });
266
+ });
267
+
268
+ server.listen(PORT, () => {
269
+ console.log(`House Management backend listening on :${PORT}`);
270
+ });