-
-
-
- {@render children()}
-
- {#if claimToast}
-
{claimToast}
+
+
+
+
+
+
+
+ {@render children()}
+
+ {#if claimToast}
+
{claimToast}
+ {/if}
+
+
+ {#if chatStore.open}
+
{/if}
-
+
diff --git a/proxy/scripts/seed.ts b/proxy/scripts/seed.ts
index 4868cd5..8678367 100644
--- a/proxy/scripts/seed.ts
+++ b/proxy/scripts/seed.ts
@@ -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);
}
diff --git a/proxy/src/index.ts b/proxy/src/index.ts
index cf44790..88b48a8 100644
--- a/proxy/src/index.ts
+++ b/proxy/src/index.ts
@@ -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);
diff --git a/proxy/src/migrate.ts b/proxy/src/migrate.ts
index f7518e2..f42db8a 100644
--- a/proxy/src/migrate.ts
+++ b/proxy/src/migrate.ts
@@ -1383,5 +1383,78 @@ export async function migrate(): Promise
{
);
}
+ // ββ 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");
}