add new chat logic and message service

This commit is contained in:
JCEEE
2026-08-05 11:20:27 +01:00
parent 3378e1dce7
commit fed2f32258
11 changed files with 1013 additions and 14 deletions
+37
View File
@@ -349,6 +349,43 @@ async function main() {
],
});
// Chat collections (global family messages + transient typing presence)
ids.messages = await createCollection(token, {
name: "messages",
type: "base",
listRule: "",
viewRule: "",
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
rel("famId", ids.fams!, true),
select("authorType", ["admin", "member"], true),
text("authorId", true),
text("authorName", true),
text("authorColor"),
text("content", true),
],
});
ids.chat_typing = await createCollection(token, {
name: "chat_typing",
type: "base",
listRule: "",
viewRule: "",
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
rel("famId", ids.fams!, true),
text("actorId", true),
select("actorType", ["admin", "member"], true),
text("authorName", true),
text("authorColor"),
bool("typing"),
],
});
console.log("\n✅ All collections created successfully");
console.log("Collection IDs:", ids);
}
+138
View File
@@ -2313,6 +2313,144 @@ app.post("/api/admin/:famId/debug/generate-data", requireAdmin, async (c) => {
}
});
// ── Chat ────────────────────────────────────────────────
// Resolve the acting user for a chat write. Admins authenticate via the
// httpOnly `session` cookie (parsed server-side) or explicit session headers
// (server-to-server); members via device token.
async function resolveChatActor(c: any) {
// Server-side admin (layout load forwards session headers).
const hsFamId = c.req.header("x-session-famid");
const hsUserId = c.req.header("x-session-userid");
if (hsFamId && hsUserId) {
const admins = await pb.getList(
"fam_admins",
`famId = '${hsFamId}' && userId = '${hsUserId}'`,
);
const admin = admins.items?.[0];
if (admin) {
return {
famId: hsFamId,
actor: {
id: admin.id,
type: "admin",
name: admin.name,
color: admin.color,
},
};
}
}
const cookieHeader = c.req.header("cookie") || "";
const cookieMatch = cookieHeader.match(/session=([^;]+)/);
if (cookieMatch) {
try {
const s = JSON.parse(decodeURIComponent(cookieMatch[1]));
if (s?.famId && s?.userId) {
const admins = await pb.getList(
"fam_admins",
`famId = '${s.famId}' && userId = '${s.userId}'`,
);
const admin = admins.items?.[0];
if (admin) {
return {
famId: s.famId,
actor: {
id: admin.id,
type: "admin",
name: admin.name,
color: admin.color,
},
};
}
}
} catch {}
}
const famId = c.req.header("x-device-famid");
const deviceToken = c.req.header("x-device-token");
if (famId && deviceToken) {
const hash = crypto.createHash("sha256").update(deviceToken).digest("hex");
const members = await pb.getList(
"members",
`famId = '${famId}' && deviceToken = '${hash}'`,
);
const member = members.items?.[0];
if (member) {
return {
famId,
actor: {
id: member.id,
type: "member",
name: member.name,
color: member.color,
},
};
}
}
return null;
}
app.post("/api/chat/:famId/messages", async (c) => {
try {
const auth = await resolveChatActor(c);
if (!auth) return c.json({ error: "Unauthorized" }, 401);
const { content } = await c.req.json();
if (!content || !content.trim()) {
return c.json({ error: "content required" }, 400);
}
const record = await pb.create("messages", {
famId: auth.famId,
authorType: auth.actor.type,
authorId: auth.actor.id,
authorName: auth.actor.name,
authorColor: auth.actor.color,
content: content.trim(),
createdAt: new Date().toISOString(),
});
return c.json(record);
} catch (err) {
return handleError(c, err);
}
});
app.post("/api/chat/:famId/typing", async (c) => {
try {
const auth = await resolveChatActor(c);
if (!auth) return c.json({ error: "Unauthorized" }, 401);
const { typing } = await c.req.json();
const existing = await pb.getList(
"chat_typing",
`famId = '${auth.famId}' && actorId = '${auth.actor.id}' && actorType = '${auth.actor.type}'`,
);
const row = {
famId: auth.famId,
actorId: auth.actor.id,
actorType: auth.actor.type,
authorName: auth.actor.name,
authorColor: auth.actor.color,
typing: !!typing,
};
if (existing.items?.length) {
await pb.update("chat_typing", existing.items[0].id, row);
} else {
await pb.create("chat_typing", row);
}
return c.json({ ok: true });
} catch (err) {
return handleError(c, err);
}
});
// Current user's chat identity (name, color, actorId) + famId.
app.get("/api/chat/me", async (c) => {
try {
const auth = await resolveChatActor(c);
if (!auth) return c.json({ error: "Unauthorized" }, 401);
return c.json({ famId: auth.famId, actor: auth.actor });
} catch (err) {
return handleError(c, err);
}
});
// ── Start server ─────────────────────────────────────────
const port = parseInt(process.env.PROXY_PORT || "3456", 10);
+73
View File
@@ -1383,5 +1383,78 @@ export async function migrate(): Promise<void> {
);
}
// ── 9. Create chat collections (messages + chat_typing) ──
const famsColChat = await getCollection("fams");
if (famsColChat) {
// 9a. messages
const msgsCol = await getCollection("messages");
if (!msgsCol) {
console.log("[migrate] Creating messages collection...");
await createCollection({
name: "messages",
type: "base",
listRule: "",
viewRule: "",
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
{
name: "famId",
type: "relation",
required: true,
collectionId: famsColChat.id,
maxSelect: 1,
cascadeDelete: true,
},
{ name: "authorType", type: "select", required: true, values: ["admin", "member"], maxSelect: 1 },
{ name: "authorId", type: "text", required: true },
{ name: "authorName", type: "text", required: true },
{ name: "authorColor", type: "text", required: false },
{ name: "content", type: "text", required: true },
{ name: "createdAt", type: "date", required: false },
],
});
} else {
console.log(` ↳ messages already exists`);
await updateCollection("messages", { listRule: "", viewRule: "" });
}
// 9b. chat_typing (transient presence rows, one per actor)
const typingCol = await getCollection("chat_typing");
if (!typingCol) {
console.log("[migrate] Creating chat_typing collection...");
await createCollection({
name: "chat_typing",
type: "base",
listRule: "",
viewRule: "",
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
{
name: "famId",
type: "relation",
required: true,
collectionId: famsColChat.id,
maxSelect: 1,
cascadeDelete: true,
},
{ name: "actorId", type: "text", required: true },
{ name: "actorType", type: "select", required: true, values: ["admin", "member"], maxSelect: 1 },
{ name: "authorName", type: "text", required: true },
{ name: "authorColor", type: "text", required: false },
{ name: "typing", type: "bool", required: false },
],
});
} else {
console.log(` ↳ chat_typing already exists`);
await updateCollection("chat_typing", { listRule: "", viewRule: "" });
}
} else {
console.log(` ↳ fams collection not found — chat collections deferred`);
}
console.log("[migrate] Done");
}