From 9cbdc35903ede42b272457a2329dccafc4c3c41d Mon Sep 17 00:00:00 2001 From: echo Date: Fri, 3 Apr 2026 03:25:11 +0200 Subject: [PATCH] chat implemented need testing --- apps/admin/data/fitai.db | Bin 286720 -> 360448 bytes apps/admin/package-lock.json | 66 +- apps/admin/package.json | 1 + .../api/chat/__tests__/route-authz.test.ts | 108 + .../src/app/api/chat/dm/my-thread/route.ts | 61 + apps/admin/src/app/api/chat/dm/start/route.ts | 70 + apps/admin/src/app/api/chat/gym-room/route.ts | 42 + .../messages/__tests__/route-authz.test.ts | 181 ++ .../messages/__tests__/route-params.test.ts | 133 + .../api/chat/threads/[id]/messages/route.ts | 148 ++ .../[id]/read/__tests__/route-authz.test.ts | 134 ++ .../app/api/chat/threads/[id]/read/route.ts | 60 + .../src/app/api/chat/threads/[id]/route.ts | 52 + apps/admin/src/app/api/chat/threads/route.ts | 57 + apps/admin/src/app/chat/page.tsx | 575 +++++ apps/admin/src/components/ui/Sidebar.tsx | 2 + apps/admin/src/lib/chat.ts | 824 +++++++ apps/mobile/.env.example | 5 + apps/mobile/package-lock.json | 86 + apps/mobile/package.json | 1 + apps/mobile/src/api/chat.ts | 118 + apps/mobile/src/api/index.ts | 1 + apps/mobile/src/app/(tabs)/_layout.tsx | 6 + apps/mobile/src/app/(tabs)/chat.tsx | 385 +++ apps/mobile/src/app/_layout.tsx | 13 +- apps/mobile/src/components/CustomTabBar.tsx | 31 + apps/mobile/src/config/api.ts | 10 + apps/mobile/src/contexts/ChatContext.tsx | 723 ++++++ apps/realtime/.env.example | 19 + apps/realtime/README.md | 44 + apps/realtime/package-lock.json | 2132 +++++++++++++++++ apps/realtime/package.json | 29 + apps/realtime/src/index.ts | 692 ++++++ apps/realtime/src/types.d.ts | 11 + apps/realtime/tsconfig.json | 15 + package.json | 8 +- packages/database/src/index.ts | 37 +- packages/database/src/schema.ts | 140 ++ packages/shared/src/constants/index.ts | 7 + packages/shared/src/types/index.ts | 51 + 40 files changed, 7068 insertions(+), 10 deletions(-) create mode 100644 apps/admin/src/app/api/chat/__tests__/route-authz.test.ts create mode 100644 apps/admin/src/app/api/chat/dm/my-thread/route.ts create mode 100644 apps/admin/src/app/api/chat/dm/start/route.ts create mode 100644 apps/admin/src/app/api/chat/gym-room/route.ts create mode 100644 apps/admin/src/app/api/chat/threads/[id]/messages/__tests__/route-authz.test.ts create mode 100644 apps/admin/src/app/api/chat/threads/[id]/messages/__tests__/route-params.test.ts create mode 100644 apps/admin/src/app/api/chat/threads/[id]/messages/route.ts create mode 100644 apps/admin/src/app/api/chat/threads/[id]/read/__tests__/route-authz.test.ts create mode 100644 apps/admin/src/app/api/chat/threads/[id]/read/route.ts create mode 100644 apps/admin/src/app/api/chat/threads/[id]/route.ts create mode 100644 apps/admin/src/app/api/chat/threads/route.ts create mode 100644 apps/admin/src/app/chat/page.tsx create mode 100644 apps/admin/src/lib/chat.ts create mode 100644 apps/mobile/src/api/chat.ts create mode 100644 apps/mobile/src/app/(tabs)/chat.tsx create mode 100644 apps/mobile/src/contexts/ChatContext.tsx create mode 100644 apps/realtime/.env.example create mode 100644 apps/realtime/README.md create mode 100644 apps/realtime/package-lock.json create mode 100644 apps/realtime/package.json create mode 100644 apps/realtime/src/index.ts create mode 100644 apps/realtime/src/types.d.ts create mode 100644 apps/realtime/tsconfig.json diff --git a/apps/admin/data/fitai.db b/apps/admin/data/fitai.db index e7a2c90d4895fb783ec12be9dd2753f251d236c5..c54499709f2608451c7515d9d33321c7da881e37 100644 GIT binary patch delta 4364 zcmb_fYit`;7M?pZp7A5j#7&yXgOo|z)G>8v+z?VjL+jWRZ)lRXiPN-y*6YMW;t;!$ z$Enh4sbd~Rtw1Vkbw{WIRg2X9vHU2C===g&A;hjKN`+`asH^tJO02Yn@(>_EqTD-k z$M!f*Xtj*wk>{Lq@A=N-o^$Tv1=r$Y{mEumgdm9h@Mk;^z2xm`j1Ui=sIx#4&D}zb z{oK#o_uSXqtv41j90v)#+u^Hi3w*hnmwx7$uQtQKHDC|HU37fuShD?MyK0@Xdf9Vq zFY_k-F8vpJBlQjy0egUpIC{zpMpShdd5t{v7#LS-X;7y=T?LL(ikk+v8UGrFA9Cl< z-Dyz%#();J-U3p9an~^XqiYe>F?Bd8M}6pBtFk0`6EObj!jdZ=P*!!=3jRyw_t86! z)qwLgBaC6nStM^rXR`pud!}bgIoW0Nbj`bK@%Y4k7veeP~@onv`TYHYLf?%ye3c#iNt(osr^E$V$Y`BKv|PVjv<41H+-LxL>%7<-+b^ zvsgRUl8AeSq9%_NWcZ#o@Kh0&E(cK^o|6hdFmW_Ek~M%BILhSKGWyWvq?Y8cXff|AQPLIl{BV~7Y02!A*JOCqycPXB+xe~>ajDg z5!&s7_9Wth(h(vX7@eO#`E8@}{AH@8I(={!hAf2>N(r!_zKoW4edDKR4kl8{*CBFU z)2#GF208%dRYVMnBVMc^IiFEprNDj4B|ETXHE(GdiZd2^QizEABZhcUVEI=4yGO)< z@UXB;JRlTP&?Ah9{o;r?927@OO_kddaSsY!NE{Slz6S%N!Te3ikv~gq%6~wu+cY*j zuy;%>ZsZl_Who5-c{J!A@ll39(1p(OmI^M8>hUzYb(i>vv1X>y8a=k=HIF}6-NW*2 zZQyvcJi?`+mBTB=R90F;%O4zFqwJc4JyueEOvPuVJqpz)Yu>0G=PE2idc}un zn_C}Z`7u}*$IB7I3XJ2816-*fM=&=1T%i6?T9(;ZT-yaT#ZfZ!>=WBr-t7j*9mS+I znvmu_<704n+8Qh^Z%YafD@(AUrK8GXAK5}r9E@w}R!=_(&CxPjLjNEzq1~O47BX(H z;Fb@|842Y&Jm&p@vB8LdS-AYfGMU)qbP^`D9G#I5rp$S*XM0&GLV3J-LW(E!23nrL zacNen%rT{2F)S<9CzP&rrZw39{2!f-h`9wLy9LKN-8yBpzSE!SAC!7%gcbec% zFS+Jk;(8~6BV5}-5JLn_6Bv}Qr>EQ34z@Z?R;cOB|{=!iTk z&vZ-aR5Jg@=~mZ=YX=hWXV*5qL2x%z*BNk?+bmFF+o~JtP+lOo7a)|Ux&Ab;hr>gy zG+`wOy>~@RkodZzGn0q5F3ipNSKSq25*ple0R@I;z^ESaLtpfeZ zk}%i6tq;)Qq16pSs?VMQo#(#-bSDG2&k62xUIDVIKOxP>|dMIo|>esF68@me5fIIj972<1h*nT3o_qY?) zr1LH3Le=%EdB+vU7Q15mX{oj5x-FY)3d1qdS80rlq7E(V7t<1 z_B>X{Th=9(Yu>$LgVtAQR|RELh`gWCk5K;Nw@um|fr5us0TgKQnYx%! m0nqqeKAIbc5VbdB5O?D?S6o*k5sVTwfSk*S#%!PzG?I1g%*Ksp;8IJQFfbv!OkbKRLMb^ z9RW!4zjmlXNcs1!b{5%zrxI194i2&IuEQ^vv0*EUQx(<~0I2Qq*BtM+o-XMGM?3Xg9Qk zgFF06LH;fc$j`+$Fqr$^ar7z0dSCk-iNO2&lQ z#9*frD6y3&p5(PSUISK);~-y6;3Z(A3CywnF`VX~$M7mheO;1qXxH{seGJ?AiNn|d z7A|%fF%#zcEMT^{tYY+pnwU;3M+Z7-nGk3qbHXATcg-! Jd+?5w{sGC-l~(`& diff --git a/apps/admin/package-lock.json b/apps/admin/package-lock.json index 5c52997..9f29664 100644 --- a/apps/admin/package-lock.json +++ b/apps/admin/package-lock.json @@ -45,6 +45,7 @@ "react-hook-form": "^7.66.0", "recharts": "^3.3.0", "resend": "^6.9.3", + "socket.io-client": "^4.8.1", "sonner": "^2.0.7", "sqlite": "^5.1.1", "sqlite3": "^5.1.7", @@ -3506,6 +3507,12 @@ "@sinonjs/commons": "^3.0.1" } }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, "node_modules/@stablelib/base64": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", @@ -6691,6 +6698,28 @@ "once": "^1.4.0" } }, + "node_modules/engine.io-client": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz", + "integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.18.3", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/entities": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", @@ -13241,6 +13270,34 @@ "npm": ">= 3.0.0" } }, + "node_modules/socket.io-client": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz", + "integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz", + "integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/socks": { "version": "2.8.7", "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", @@ -15132,7 +15189,6 @@ "version": "8.18.3", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", - "dev": true, "license": "MIT", "engines": { "node": ">=10.0.0" @@ -15167,6 +15223,14 @@ "dev": true, "license": "MIT" }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/apps/admin/package.json b/apps/admin/package.json index 9b5d374..f670942 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -49,6 +49,7 @@ "react": "^19.2.0", "react-dom": "^19.2.0", "react-hook-form": "^7.66.0", + "socket.io-client": "^4.8.1", "recharts": "^3.3.0", "resend": "^6.9.3", "sonner": "^2.0.7", diff --git a/apps/admin/src/app/api/chat/__tests__/route-authz.test.ts b/apps/admin/src/app/api/chat/__tests__/route-authz.test.ts new file mode 100644 index 0000000..67c27f2 --- /dev/null +++ b/apps/admin/src/app/api/chat/__tests__/route-authz.test.ts @@ -0,0 +1,108 @@ +/** + * @jest-environment node + */ + +import { NextRequest } from "next/server"; +import { GET as GET_THREADS } from "../threads/route"; +import { POST as START_DM } from "../dm/start/route"; +import { GET as GET_MY_DM } from "../dm/my-thread/route"; + +jest.mock("@clerk/nextjs/server", () => ({ + auth: jest.fn(), +})); + +jest.mock("@/lib/database", () => ({ + getDatabase: jest.fn(), +})); + +jest.mock("@/lib/sync-user", () => ({ + ensureUserSynced: jest.fn(), +})); + +jest.mock("@/lib/chat", () => ({ + getOrCreateGymRoomThread: jest.fn(), + listThreadsForUser: jest.fn(), + getOrCreateDmThread: jest.fn(), + isChatError: jest.fn(() => false), +})); + +jest.mock("@/lib/logger", () => ({ + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), +})); + +describe("/api/chat authz", () => { + const mockAuth = require("@clerk/nextjs/server").auth as jest.Mock; + const mockGetDatabase = require("@/lib/database").getDatabase as jest.Mock; + const mockEnsureUserSynced = require("@/lib/sync-user") + .ensureUserSynced as jest.Mock; + const mockListThreadsForUser = require("@/lib/chat") + .listThreadsForUser as jest.Mock; + const mockGetOrCreateDmThread = require("@/lib/chat") + .getOrCreateDmThread as jest.Mock; + + const mockDb = { + getClientTrainerAssignment: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockGetDatabase.mockResolvedValue(mockDb); + mockListThreadsForUser.mockResolvedValue([]); + }); + + it("returns 401 for unauthenticated threads list", async () => { + mockAuth.mockResolvedValue({ userId: null }); + + const req = new NextRequest("http://localhost/api/chat/threads"); + const res = await GET_THREADS(req); + + expect(res.status).toBe(401); + }); + + it("returns 403 when non participant/non staff starts DM", async () => { + mockAuth.mockResolvedValue({ userId: "user_1" }); + mockEnsureUserSynced.mockResolvedValue({ + id: "user_1", + role: "trainer", + gymId: "gym_1", + }); + + const req = new NextRequest("http://localhost/api/chat/dm/start", { + method: "POST", + body: JSON.stringify({ trainerId: "trainer_2", clientId: "client_2" }), + }); + + const res = await START_DM(req); + + expect(res.status).toBe(403); + expect(mockGetOrCreateDmThread).not.toHaveBeenCalled(); + }); + + it("returns 403 when non-client requests my-thread endpoint", async () => { + mockAuth.mockResolvedValue({ userId: "trainer_1" }); + mockEnsureUserSynced.mockResolvedValue({ + id: "trainer_1", + role: "trainer", + gymId: "gym_1", + }); + + const res = await GET_MY_DM(); + expect(res.status).toBe(403); + }); + + it("returns 404 when client has no active assignment", async () => { + mockAuth.mockResolvedValue({ userId: "client_1" }); + mockEnsureUserSynced.mockResolvedValue({ + id: "client_1", + role: "client", + gymId: "gym_1", + }); + mockDb.getClientTrainerAssignment.mockResolvedValue(null); + + const res = await GET_MY_DM(); + expect(res.status).toBe(404); + }); +}); diff --git a/apps/admin/src/app/api/chat/dm/my-thread/route.ts b/apps/admin/src/app/api/chat/dm/my-thread/route.ts new file mode 100644 index 0000000..e823d7e --- /dev/null +++ b/apps/admin/src/app/api/chat/dm/my-thread/route.ts @@ -0,0 +1,61 @@ +import { NextResponse } from "next/server"; +import { auth } from "@clerk/nextjs/server"; +import { ensureUserSynced } from "@/lib/sync-user"; +import { getDatabase } from "@/lib/database"; +import { getOrCreateDmThread, isChatError } from "@/lib/chat"; +import log from "@/lib/logger"; + +export async function GET() { + try { + const { userId } = await auth(); + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const db = await getDatabase(); + const currentUser = await ensureUserSynced(userId, db); + if (!currentUser) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + if (currentUser.role !== "client") { + return NextResponse.json( + { error: "Only clients can use this endpoint" }, + { status: 403 }, + ); + } + + const assignment = await db.getClientTrainerAssignment(currentUser.id); + if (!assignment || !assignment.isActive) { + return NextResponse.json( + { error: "No active trainer assignment found" }, + { status: 404 }, + ); + } + + const thread = await getOrCreateDmThread({ + trainerId: assignment.trainerId, + clientId: assignment.clientId, + createdBy: { + id: currentUser.id, + role: currentUser.role, + gymId: currentUser.gymId, + }, + }); + + return NextResponse.json({ thread }); + } catch (error) { + if (isChatError(error)) { + return NextResponse.json( + { error: error.message }, + { status: error.status }, + ); + } + + log.error("Failed to get client DM thread", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); + } +} diff --git a/apps/admin/src/app/api/chat/dm/start/route.ts b/apps/admin/src/app/api/chat/dm/start/route.ts new file mode 100644 index 0000000..06ba29b --- /dev/null +++ b/apps/admin/src/app/api/chat/dm/start/route.ts @@ -0,0 +1,70 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@clerk/nextjs/server"; +import { ensureUserSynced } from "@/lib/sync-user"; +import { getDatabase } from "@/lib/database"; +import { getOrCreateDmThread, isChatError } from "@/lib/chat"; +import log from "@/lib/logger"; + +export async function POST(request: NextRequest) { + try { + const { userId } = await auth(); + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await request.json().catch(() => null); + if (!body || typeof body !== "object") { + return NextResponse.json({ error: "Invalid body" }, { status: 400 }); + } + + const trainerId = String(body.trainerId ?? "").trim(); + const clientId = String(body.clientId ?? "").trim(); + + if (!trainerId || !clientId) { + return NextResponse.json( + { error: "trainerId and clientId are required" }, + { status: 400 }, + ); + } + + const db = await getDatabase(); + const currentUser = await ensureUserSynced(userId, db); + if (!currentUser) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const isStaff = + currentUser.role === "superAdmin" || currentUser.role === "admin"; + const isParticipant = + currentUser.id === trainerId || currentUser.id === clientId; + + if (!isStaff && !isParticipant) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const thread = await getOrCreateDmThread({ + trainerId, + clientId, + createdBy: { + id: currentUser.id, + role: currentUser.role, + gymId: currentUser.gymId, + }, + }); + + return NextResponse.json({ thread }, { status: 201 }); + } catch (error) { + if (isChatError(error)) { + return NextResponse.json( + { error: error.message }, + { status: error.status }, + ); + } + + log.error("Failed to start direct message thread", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); + } +} diff --git a/apps/admin/src/app/api/chat/gym-room/route.ts b/apps/admin/src/app/api/chat/gym-room/route.ts new file mode 100644 index 0000000..03a92be --- /dev/null +++ b/apps/admin/src/app/api/chat/gym-room/route.ts @@ -0,0 +1,42 @@ +import { NextResponse } from "next/server"; +import { auth } from "@clerk/nextjs/server"; +import { ensureUserSynced } from "@/lib/sync-user"; +import { getDatabase } from "@/lib/database"; +import { getOrCreateGymRoomThread, isChatError } from "@/lib/chat"; +import log from "@/lib/logger"; + +export async function GET() { + try { + const { userId } = await auth(); + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const db = await getDatabase(); + const currentUser = await ensureUserSynced(userId, db); + if (!currentUser) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const thread = await getOrCreateGymRoomThread({ + id: currentUser.id, + role: currentUser.role, + gymId: currentUser.gymId, + }); + + return NextResponse.json({ thread }); + } catch (error) { + if (isChatError(error)) { + return NextResponse.json( + { error: error.message }, + { status: error.status }, + ); + } + + log.error("Failed to get gym room thread", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); + } +} diff --git a/apps/admin/src/app/api/chat/threads/[id]/messages/__tests__/route-authz.test.ts b/apps/admin/src/app/api/chat/threads/[id]/messages/__tests__/route-authz.test.ts new file mode 100644 index 0000000..4b60fe9 --- /dev/null +++ b/apps/admin/src/app/api/chat/threads/[id]/messages/__tests__/route-authz.test.ts @@ -0,0 +1,181 @@ +/** + * @jest-environment node + */ + +import { NextRequest } from "next/server"; +import { GET, POST } from "../route"; + +jest.mock("@clerk/nextjs/server", () => ({ + auth: jest.fn(), +})); + +jest.mock("@/lib/database", () => ({ + getDatabase: jest.fn(), +})); + +jest.mock("@/lib/sync-user", () => ({ + ensureUserSynced: jest.fn(), +})); + +jest.mock("@/lib/chat", () => ({ + getThreadMessages: jest.fn(), + createThreadMessage: jest.fn(), + isChatError: jest.fn((error: unknown) => + Boolean( + error && typeof error === "object" && "status" in (error as object), + ), + ), +})); + +jest.mock("@/lib/logger", () => ({ + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), +})); + +describe("/api/chat/threads/[id]/messages authz", () => { + const mockAuth = require("@clerk/nextjs/server").auth as jest.Mock; + const mockGetDatabase = require("@/lib/database").getDatabase as jest.Mock; + const mockEnsureUserSynced = require("@/lib/sync-user") + .ensureUserSynced as jest.Mock; + const mockGetThreadMessages = require("@/lib/chat") + .getThreadMessages as jest.Mock; + const mockCreateThreadMessage = require("@/lib/chat") + .createThreadMessage as jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + mockGetDatabase.mockResolvedValue({}); + }); + + it("returns 401 for unauthenticated GET", async () => { + mockAuth.mockResolvedValue({ userId: null }); + + const request = new NextRequest( + "http://localhost/api/chat/threads/thread_1/messages", + ); + const response = await GET(request, { + params: Promise.resolve({ id: "thread_1" }), + }); + + expect(response.status).toBe(401); + }); + + it("returns 403 when synced user is missing on GET", async () => { + mockAuth.mockResolvedValue({ userId: "user_1" }); + mockEnsureUserSynced.mockResolvedValue(null); + + const request = new NextRequest( + "http://localhost/api/chat/threads/thread_1/messages", + ); + const response = await GET(request, { + params: Promise.resolve({ id: "thread_1" }), + }); + + expect(response.status).toBe(403); + expect(mockGetThreadMessages).not.toHaveBeenCalled(); + }); + + it("maps ChatError to status code on GET", async () => { + mockAuth.mockResolvedValue({ userId: "user_1" }); + mockEnsureUserSynced.mockResolvedValue({ + id: "user_1", + role: "client", + gymId: "gym_1", + }); + mockGetThreadMessages.mockRejectedValue({ + status: 403, + message: "Forbidden", + }); + + const request = new NextRequest( + "http://localhost/api/chat/threads/thread_1/messages", + ); + const response = await GET(request, { + params: Promise.resolve({ id: "thread_1" }), + }); + + expect(response.status).toBe(403); + }); + + it("returns 401 for unauthenticated POST", async () => { + mockAuth.mockResolvedValue({ userId: null }); + + const request = new NextRequest( + "http://localhost/api/chat/threads/thread_1/messages", + { + method: "POST", + body: JSON.stringify({ body: "Hello" }), + }, + ); + const response = await POST(request, { + params: Promise.resolve({ id: "thread_1" }), + }); + + expect(response.status).toBe(401); + }); + + it("returns 400 when POST body is invalid", async () => { + mockAuth.mockResolvedValue({ userId: "user_1" }); + + const request = new NextRequest( + "http://localhost/api/chat/threads/thread_1/messages", + { + method: "POST", + body: "not json", + headers: { "Content-Type": "application/json" }, + }, + ); + const response = await POST(request, { + params: Promise.resolve({ id: "thread_1" }), + }); + + expect(response.status).toBe(400); + }); + + it("returns 403 when synced user is missing on POST", async () => { + mockAuth.mockResolvedValue({ userId: "user_1" }); + mockEnsureUserSynced.mockResolvedValue(null); + + const request = new NextRequest( + "http://localhost/api/chat/threads/thread_1/messages", + { + method: "POST", + body: JSON.stringify({ body: "Hi" }), + }, + ); + const response = await POST(request, { + params: Promise.resolve({ id: "thread_1" }), + }); + + expect(response.status).toBe(403); + expect(mockCreateThreadMessage).not.toHaveBeenCalled(); + }); + + it("maps ChatError to status code on POST", async () => { + mockAuth.mockResolvedValue({ userId: "user_1" }); + mockEnsureUserSynced.mockResolvedValue({ + id: "user_1", + role: "client", + gymId: "gym_1", + }); + mockCreateThreadMessage.mockRejectedValue({ + status: 403, + message: "Forbidden", + }); + + const request = new NextRequest( + "http://localhost/api/chat/threads/thread_1/messages", + { + method: "POST", + body: JSON.stringify({ body: "Hello" }), + }, + ); + const response = await POST(request, { + params: Promise.resolve({ id: "thread_1" }), + }); + + expect(response.status).toBe(403); + }); +}); diff --git a/apps/admin/src/app/api/chat/threads/[id]/messages/__tests__/route-params.test.ts b/apps/admin/src/app/api/chat/threads/[id]/messages/__tests__/route-params.test.ts new file mode 100644 index 0000000..32687c5 --- /dev/null +++ b/apps/admin/src/app/api/chat/threads/[id]/messages/__tests__/route-params.test.ts @@ -0,0 +1,133 @@ +/** + * @jest-environment node + */ + +import { NextRequest } from "next/server"; +import { GET, POST } from "../route"; + +jest.mock("@clerk/nextjs/server", () => ({ + auth: jest.fn(), +})); + +jest.mock("@/lib/database", () => ({ + getDatabase: jest.fn(), +})); + +jest.mock("@/lib/sync-user", () => ({ + ensureUserSynced: jest.fn(), +})); + +jest.mock("@/lib/chat", () => ({ + getThreadMessages: jest.fn(), + createThreadMessage: jest.fn(), + isChatError: jest.fn(() => false), +})); + +jest.mock("@/lib/logger", () => ({ + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), +})); + +describe("/api/chat/threads/[id]/messages params", () => { + const mockAuth = require("@clerk/nextjs/server").auth as jest.Mock; + const mockGetDatabase = require("@/lib/database").getDatabase as jest.Mock; + const mockEnsureUserSynced = require("@/lib/sync-user") + .ensureUserSynced as jest.Mock; + const mockGetThreadMessages = require("@/lib/chat") + .getThreadMessages as jest.Mock; + const mockCreateThreadMessage = require("@/lib/chat") + .createThreadMessage as jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + mockAuth.mockResolvedValue({ userId: "user_1" }); + mockGetDatabase.mockResolvedValue({}); + mockEnsureUserSynced.mockResolvedValue({ + id: "user_1", + role: "client", + gymId: "gym_1", + }); + }); + + it("parses numeric cursor and limit", async () => { + mockGetThreadMessages.mockResolvedValue({ messages: [], nextCursor: null }); + + const request = new NextRequest( + "http://localhost/api/chat/threads/thread_1/messages?cursor=12345&limit=20", + ); + + const response = await GET(request, { + params: Promise.resolve({ id: "thread_1" }), + }); + + expect(response.status).toBe(200); + expect(mockGetThreadMessages).toHaveBeenCalledWith( + expect.objectContaining({ + threadId: "thread_1", + cursor: 12345, + limit: 20, + }), + ); + }); + + it("ignores invalid cursor and limit query values", async () => { + mockGetThreadMessages.mockResolvedValue({ messages: [], nextCursor: null }); + + const request = new NextRequest( + "http://localhost/api/chat/threads/thread_1/messages?cursor=abc&limit=xyz", + ); + + const response = await GET(request, { + params: Promise.resolve({ id: "thread_1" }), + }); + + expect(response.status).toBe(200); + expect(mockGetThreadMessages).toHaveBeenCalledWith( + expect.objectContaining({ + threadId: "thread_1", + cursor: undefined, + limit: undefined, + }), + ); + }); + + it("filters malformed attachments before create", async () => { + mockCreateThreadMessage.mockResolvedValue({ id: "msg_1" }); + + const request = new NextRequest( + "http://localhost/api/chat/threads/thread_1/messages", + { + method: "POST", + body: JSON.stringify({ + body: "Hello", + attachments: [ + { url: "https://example.com/file.png", type: "image/png" }, + { url: 123, type: "image/png" }, + { foo: "bar" }, + ], + }), + }, + ); + + const response = await POST(request, { + params: Promise.resolve({ id: "thread_1" }), + }); + + expect(response.status).toBe(201); + expect(mockCreateThreadMessage).toHaveBeenCalledWith( + expect.objectContaining({ + threadId: "thread_1", + body: "Hello", + attachments: [ + { + url: "https://example.com/file.png", + type: "image/png", + name: undefined, + }, + ], + }), + ); + }); +}); diff --git a/apps/admin/src/app/api/chat/threads/[id]/messages/route.ts b/apps/admin/src/app/api/chat/threads/[id]/messages/route.ts new file mode 100644 index 0000000..ceb22e8 --- /dev/null +++ b/apps/admin/src/app/api/chat/threads/[id]/messages/route.ts @@ -0,0 +1,148 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@clerk/nextjs/server"; +import { ensureUserSynced } from "@/lib/sync-user"; +import { getDatabase } from "@/lib/database"; +import { + createThreadMessage, + getThreadMessages, + isChatError, +} from "@/lib/chat"; +import log from "@/lib/logger"; + +interface Params { + params: Promise<{ id: string }>; +} + +export async function GET(request: NextRequest, { params }: Params) { + try { + const { userId } = await auth(); + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id: threadId } = await params; + const db = await getDatabase(); + const currentUser = await ensureUserSynced(userId, db); + if (!currentUser) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const cursorParam = request.nextUrl.searchParams.get("cursor"); + const limitParam = request.nextUrl.searchParams.get("limit"); + + const cursor = + cursorParam && Number.isFinite(Number(cursorParam)) + ? Number(cursorParam) + : undefined; + const limit = + limitParam && Number.isFinite(Number(limitParam)) + ? Number(limitParam) + : undefined; + + const result = await getThreadMessages({ + currentUser: { + id: currentUser.id, + role: currentUser.role, + gymId: currentUser.gymId, + }, + threadId, + cursor, + limit, + }); + + return NextResponse.json(result); + } catch (error) { + if (isChatError(error)) { + return NextResponse.json( + { error: error.message }, + { status: error.status }, + ); + } + + log.error("Failed to fetch chat messages", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); + } +} + +export async function POST(request: NextRequest, { params }: Params) { + try { + const { userId } = await auth(); + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id: threadId } = await params; + const body = await request.json().catch(() => null); + if (!body || typeof body !== "object") { + return NextResponse.json({ error: "Invalid body" }, { status: 400 }); + } + + const messageBody = String(body.body ?? "").trim(); + if (!messageBody) { + return NextResponse.json( + { error: "Message body is required" }, + { status: 400 }, + ); + } + + const clientMessageIdRaw = body.clientMessageId; + const clientMessageId = + typeof clientMessageIdRaw === "string" && + clientMessageIdRaw.trim().length > 0 + ? clientMessageIdRaw.trim() + : undefined; + + const attachmentsRaw = body.attachments; + const attachments = Array.isArray(attachmentsRaw) + ? attachmentsRaw + .filter( + (item): item is { url: string; type: string; name?: string } => + !!item && + typeof item === "object" && + typeof item.url === "string" && + typeof item.type === "string", + ) + .map((item) => ({ + url: item.url, + type: item.type, + name: typeof item.name === "string" ? item.name : undefined, + })) + : undefined; + + const db = await getDatabase(); + const currentUser = await ensureUserSynced(userId, db); + if (!currentUser) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const message = await createThreadMessage({ + currentUser: { + id: currentUser.id, + role: currentUser.role, + gymId: currentUser.gymId, + }, + threadId, + body: messageBody, + clientMessageId, + attachments, + }); + + return NextResponse.json({ message }, { status: 201 }); + } catch (error) { + if (isChatError(error)) { + return NextResponse.json( + { error: error.message }, + { status: error.status }, + ); + } + + log.error("Failed to create chat message", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); + } +} diff --git a/apps/admin/src/app/api/chat/threads/[id]/read/__tests__/route-authz.test.ts b/apps/admin/src/app/api/chat/threads/[id]/read/__tests__/route-authz.test.ts new file mode 100644 index 0000000..765c43b --- /dev/null +++ b/apps/admin/src/app/api/chat/threads/[id]/read/__tests__/route-authz.test.ts @@ -0,0 +1,134 @@ +/** + * @jest-environment node + */ + +import { NextRequest } from "next/server"; +import { POST } from "../route"; + +jest.mock("@clerk/nextjs/server", () => ({ + auth: jest.fn(), +})); + +jest.mock("@/lib/database", () => ({ + getDatabase: jest.fn(), +})); + +jest.mock("@/lib/sync-user", () => ({ + ensureUserSynced: jest.fn(), +})); + +jest.mock("@/lib/chat", () => ({ + markThreadAsRead: jest.fn(), + isChatError: jest.fn((error: unknown) => + Boolean( + error && typeof error === "object" && "status" in (error as object), + ), + ), +})); + +jest.mock("@/lib/logger", () => ({ + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), +})); + +describe("/api/chat/threads/[id]/read authz", () => { + const mockAuth = require("@clerk/nextjs/server").auth as jest.Mock; + const mockGetDatabase = require("@/lib/database").getDatabase as jest.Mock; + const mockEnsureUserSynced = require("@/lib/sync-user") + .ensureUserSynced as jest.Mock; + const mockMarkThreadAsRead = require("@/lib/chat") + .markThreadAsRead as jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + mockGetDatabase.mockResolvedValue({}); + }); + + it("returns 401 for unauthenticated request", async () => { + mockAuth.mockResolvedValue({ userId: null }); + + const request = new NextRequest( + "http://localhost/api/chat/threads/thread_1/read", + { + method: "POST", + body: JSON.stringify({ lastReadMessageId: "msg_1" }), + }, + ); + const response = await POST(request, { + params: Promise.resolve({ id: "thread_1" }), + }); + + expect(response.status).toBe(401); + }); + + it("returns 403 when synced user is missing", async () => { + mockAuth.mockResolvedValue({ userId: "user_1" }); + mockEnsureUserSynced.mockResolvedValue(null); + + const request = new NextRequest( + "http://localhost/api/chat/threads/thread_1/read", + { + method: "POST", + body: JSON.stringify({ lastReadMessageId: "msg_1" }), + }, + ); + const response = await POST(request, { + params: Promise.resolve({ id: "thread_1" }), + }); + + expect(response.status).toBe(403); + expect(mockMarkThreadAsRead).not.toHaveBeenCalled(); + }); + + it("maps ChatError to status code", async () => { + mockAuth.mockResolvedValue({ userId: "user_1" }); + mockEnsureUserSynced.mockResolvedValue({ + id: "user_1", + role: "client", + gymId: "gym_1", + }); + mockMarkThreadAsRead.mockRejectedValue({ + status: 403, + message: "Forbidden", + }); + + const request = new NextRequest( + "http://localhost/api/chat/threads/thread_1/read", + { + method: "POST", + body: JSON.stringify({ lastReadMessageId: "msg_1" }), + }, + ); + const response = await POST(request, { + params: Promise.resolve({ id: "thread_1" }), + }); + + expect(response.status).toBe(403); + }); + + it("returns success for authorized user", async () => { + mockAuth.mockResolvedValue({ userId: "user_1" }); + mockEnsureUserSynced.mockResolvedValue({ + id: "user_1", + role: "client", + gymId: "gym_1", + }); + mockMarkThreadAsRead.mockResolvedValue(undefined); + + const request = new NextRequest( + "http://localhost/api/chat/threads/thread_1/read", + { + method: "POST", + body: JSON.stringify({ lastReadMessageId: "msg_1" }), + }, + ); + const response = await POST(request, { + params: Promise.resolve({ id: "thread_1" }), + }); + + expect(response.status).toBe(200); + expect(mockMarkThreadAsRead).toHaveBeenCalled(); + }); +}); diff --git a/apps/admin/src/app/api/chat/threads/[id]/read/route.ts b/apps/admin/src/app/api/chat/threads/[id]/read/route.ts new file mode 100644 index 0000000..5137d27 --- /dev/null +++ b/apps/admin/src/app/api/chat/threads/[id]/read/route.ts @@ -0,0 +1,60 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@clerk/nextjs/server"; +import { ensureUserSynced } from "@/lib/sync-user"; +import { getDatabase } from "@/lib/database"; +import { isChatError, markThreadAsRead } from "@/lib/chat"; +import log from "@/lib/logger"; + +interface Params { + params: Promise<{ id: string }>; +} + +export async function POST(request: NextRequest, { params }: Params) { + try { + const { userId } = await auth(); + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id: threadId } = await params; + const body = await request.json().catch(() => ({})); + + const lastReadMessageId = + body && + typeof body === "object" && + typeof body.lastReadMessageId === "string" + ? body.lastReadMessageId + : undefined; + + const db = await getDatabase(); + const currentUser = await ensureUserSynced(userId, db); + if (!currentUser) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + await markThreadAsRead({ + currentUser: { + id: currentUser.id, + role: currentUser.role, + gymId: currentUser.gymId, + }, + threadId, + lastReadMessageId, + }); + + return NextResponse.json({ success: true }); + } catch (error) { + if (isChatError(error)) { + return NextResponse.json( + { error: error.message }, + { status: error.status }, + ); + } + + log.error("Failed to mark chat thread as read", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); + } +} diff --git a/apps/admin/src/app/api/chat/threads/[id]/route.ts b/apps/admin/src/app/api/chat/threads/[id]/route.ts new file mode 100644 index 0000000..aede227 --- /dev/null +++ b/apps/admin/src/app/api/chat/threads/[id]/route.ts @@ -0,0 +1,52 @@ +import { NextResponse } from "next/server"; +import { auth } from "@clerk/nextjs/server"; +import { ensureUserSynced } from "@/lib/sync-user"; +import { getDatabase } from "@/lib/database"; +import { isChatError, listThreadsForUser } from "@/lib/chat"; +import log from "@/lib/logger"; + +interface Params { + params: Promise<{ id: string }>; +} + +export async function GET(_request: Request, { params }: Params) { + try { + const { userId } = await auth(); + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id: threadId } = await params; + const db = await getDatabase(); + const currentUser = await ensureUserSynced(userId, db); + if (!currentUser) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const threads = await listThreadsForUser({ + id: currentUser.id, + role: currentUser.role, + gymId: currentUser.gymId, + }); + + const thread = threads.find((item) => item.id === threadId); + if (!thread) { + return NextResponse.json({ error: "Thread not found" }, { status: 404 }); + } + + return NextResponse.json({ thread }); + } catch (error) { + if (isChatError(error)) { + return NextResponse.json( + { error: error.message }, + { status: error.status }, + ); + } + + log.error("Failed to fetch chat thread", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); + } +} diff --git a/apps/admin/src/app/api/chat/threads/route.ts b/apps/admin/src/app/api/chat/threads/route.ts new file mode 100644 index 0000000..442d070 --- /dev/null +++ b/apps/admin/src/app/api/chat/threads/route.ts @@ -0,0 +1,57 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@clerk/nextjs/server"; +import { ensureUserSynced } from "@/lib/sync-user"; +import { getDatabase } from "@/lib/database"; +import { + getOrCreateGymRoomThread, + isChatError, + listThreadsForUser, +} from "@/lib/chat"; +import log from "@/lib/logger"; + +export async function GET(request: NextRequest) { + try { + const { userId } = await auth(); + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const db = await getDatabase(); + const currentUser = await ensureUserSynced(userId, db); + if (!currentUser) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const includeGymRoom = + request.nextUrl.searchParams.get("includeGymRoom") !== "false"; + + if (includeGymRoom && currentUser.gymId) { + await getOrCreateGymRoomThread({ + id: currentUser.id, + role: currentUser.role, + gymId: currentUser.gymId, + }); + } + + const threads = await listThreadsForUser({ + id: currentUser.id, + role: currentUser.role, + gymId: currentUser.gymId, + }); + + return NextResponse.json({ threads }); + } catch (error) { + if (isChatError(error)) { + return NextResponse.json( + { error: error.message }, + { status: error.status }, + ); + } + + log.error("Failed to fetch chat threads", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); + } +} diff --git a/apps/admin/src/app/chat/page.tsx b/apps/admin/src/app/chat/page.tsx new file mode 100644 index 0000000..842ede3 --- /dev/null +++ b/apps/admin/src/app/chat/page.tsx @@ -0,0 +1,575 @@ +"use client"; + +import { useAuth } from "@clerk/nextjs"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { io, type Socket } from "socket.io-client"; +import axios from "axios"; +import { PageHeader } from "@/components/ui/PageHeader"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; + +interface ChatThread { + id: string; + type: "gym" | "dm"; + unreadCount: number; + lastMessageBody: string | null; + lastMessageAt: string | null; +} + +interface ChatMessage { + id: string; + threadId: string; + senderUserId: string; + body: string; + createdAt: string; + clientMessageId: string | null; + kind?: "text" | "system"; + attachments?: Array<{ url: string; type: string; name?: string }>; + editedAt?: string | null; + deletedAt?: string | null; +} + +interface ReadEvent { + threadId?: string; + userId?: string; + lastReadMessageId?: string | null; +} + +export default function AdminChatPage() { + const { getToken, userId } = useAuth(); + + const [threads, setThreads] = useState([]); + const [activeThreadId, setActiveThreadId] = useState(null); + const [messagesByThreadId, setMessagesByThreadId] = useState< + Record + >({}); + const [draft, setDraft] = useState(""); + const [loadingThreads, setLoadingThreads] = useState(false); + const [loadingMessages, setLoadingMessages] = useState(false); + const [socketConnected, setSocketConnected] = useState(false); + const [typingByThreadId, setTypingByThreadId] = useState< + Record + >({}); + const [readByThreadId, setReadByThreadId] = useState< + Record> + >({}); + + const typingTimeoutRef = useRef | null>(null); + const socketRef = useRef(null); + + const activeMessages = useMemo( + () => (activeThreadId ? (messagesByThreadId[activeThreadId] ?? []) : []), + [activeThreadId, messagesByThreadId], + ); + + const typingUsers = useMemo( + () => (activeThreadId ? (typingByThreadId[activeThreadId] ?? []) : []), + [activeThreadId, typingByThreadId], + ); + + useEffect(() => { + const loadThreads = async () => { + const token = await getToken(); + if (!token) { + return; + } + + try { + setLoadingThreads(true); + const response = await axios.get<{ threads: ChatThread[] }>( + "/api/chat/threads", + { + headers: { Authorization: `Bearer ${token}` }, + }, + ); + + setThreads(response.data.threads); + setActiveThreadId( + (prev) => prev ?? response.data.threads[0]?.id ?? null, + ); + setReadByThreadId({}); + } finally { + setLoadingThreads(false); + } + }; + + void loadThreads(); + }, [getToken]); + + useEffect(() => { + if (!activeThreadId) { + return; + } + + const loadMessages = async () => { + const token = await getToken(); + if (!token) { + return; + } + + try { + setLoadingMessages(true); + const response = await axios.get<{ messages: ChatMessage[] }>( + `/api/chat/threads/${activeThreadId}/messages`, + { + headers: { Authorization: `Bearer ${token}` }, + }, + ); + + setMessagesByThreadId((prev) => ({ + ...prev, + [activeThreadId]: response.data.messages, + })); + } finally { + setLoadingMessages(false); + } + }; + + void loadMessages(); + }, [activeThreadId, getToken]); + + useEffect(() => { + let mounted = true; + + const setupSocket = async () => { + const token = await getToken(); + if (!token || !mounted) { + return; + } + + const realtimeBase = + process.env.NEXT_PUBLIC_REALTIME_URL ?? window.location.origin; + + const socket = io(realtimeBase, { + path: process.env.NEXT_PUBLIC_REALTIME_PATH ?? "/socket.io", + transports: ["websocket"], + auth: { token }, + }); + + socketRef.current = socket; + + socket.on("connect", () => { + setSocketConnected(true); + if (activeThreadId) { + socket.emit("chat:subscribe", { threadId: activeThreadId }); + } + }); + + socket.on("disconnect", () => { + setSocketConnected(false); + setTypingByThreadId({}); + }); + + socket.on( + "chat:message:new", + (event: { threadId: string; message: ChatMessage }) => { + setMessagesByThreadId((prev) => { + const current = prev[event.threadId] ?? []; + if (current.some((message) => message.id === event.message.id)) { + return prev; + } + + return { + ...prev, + [event.threadId]: [...current, event.message], + }; + }); + + setThreads((prev) => + prev + .map((thread) => + thread.id === event.threadId + ? { + ...thread, + lastMessageAt: event.message.createdAt, + lastMessageBody: event.message.body, + unreadCount: + event.threadId === activeThreadId || + event.message.senderUserId === userId + ? thread.unreadCount + : thread.unreadCount + 1, + } + : thread, + ) + .sort((a, b) => { + const aTime = a.lastMessageAt ?? "1970-01-01T00:00:00.000Z"; + const bTime = b.lastMessageAt ?? "1970-01-01T00:00:00.000Z"; + return new Date(bTime).getTime() - new Date(aTime).getTime(); + }), + ); + }, + ); + + socket.on( + "chat:message:ack", + (event: { + threadId: string; + message: ChatMessage; + clientMessageId?: string | null; + }) => { + setMessagesByThreadId((prev) => ({ + ...prev, + [event.threadId]: (prev[event.threadId] ?? []).map((message) => + event.clientMessageId + ? message.clientMessageId === event.clientMessageId + ? event.message + : message + : message.id === event.message.id + ? event.message + : message, + ), + })); + }, + ); + + socket.on( + "chat:typing", + (event: { threadId?: string; userId?: string; isTyping?: boolean }) => { + if (!event.threadId || !event.userId || event.userId === userId) { + return; + } + + setTypingByThreadId((prev) => { + const current = prev[event.threadId!] ?? []; + if (event.isTyping) { + if (current.includes(event.userId!)) { + return prev; + } + + return { + ...prev, + [event.threadId!]: [...current, event.userId!], + }; + } + + return { + ...prev, + [event.threadId!]: current.filter((id) => id !== event.userId), + }; + }); + }, + ); + + socket.on("chat:read:update", (event: ReadEvent) => { + if (!event.threadId || !event.userId || !event.lastReadMessageId) { + return; + } + + setReadByThreadId((prev) => ({ + ...prev, + [event.threadId!]: { + ...(prev[event.threadId!] ?? {}), + [event.userId!]: event.lastReadMessageId!, + }, + })); + }); + + socket.on("chat:error", (event: { code?: string; message?: string }) => { + if (event.code === "rate_limited") { + console.warn(event.message ?? "Chat action rate-limited"); + } + }); + }; + + void setupSocket(); + + return () => { + mounted = false; + if (typingTimeoutRef.current) { + clearTimeout(typingTimeoutRef.current); + } + if (socketRef.current) { + socketRef.current.disconnect(); + socketRef.current = null; + } + }; + }, [activeThreadId, getToken, userId]); + + useEffect(() => { + if (!activeThreadId || !socketRef.current || !socketConnected) { + return; + } + + socketRef.current.emit("chat:subscribe", { threadId: activeThreadId }); + setThreads((prev) => + prev.map((thread) => + thread.id === activeThreadId ? { ...thread, unreadCount: 0 } : thread, + ), + ); + + return () => { + if (socketRef.current) { + socketRef.current.emit("chat:unsubscribe", { + threadId: activeThreadId, + }); + } + }; + }, [activeThreadId, socketConnected]); + + useEffect(() => { + if (!activeThreadId) { + return; + } + + const messages = messagesByThreadId[activeThreadId] ?? []; + const lastMessage = messages[messages.length - 1]; + if (!lastMessage || lastMessage.senderUserId === userId) { + return; + } + + const markRead = async () => { + const token = await getToken(); + if (!token) { + return; + } + + await axios.post( + `/api/chat/threads/${activeThreadId}/read`, + { lastReadMessageId: lastMessage.id }, + { + headers: { Authorization: `Bearer ${token}` }, + }, + ); + + if (socketRef.current && socketConnected) { + socketRef.current.emit("chat:read", { + threadId: activeThreadId, + lastReadMessageId: lastMessage.id, + }); + } + + if (userId) { + setReadByThreadId((prev) => ({ + ...prev, + [activeThreadId]: { + ...(prev[activeThreadId] ?? {}), + [userId]: lastMessage.id, + }, + })); + } + }; + + void markRead(); + }, [activeThreadId, getToken, messagesByThreadId, socketConnected, userId]); + + const onChangeDraft = (value: string) => { + setDraft(value); + + if (!activeThreadId || !socketRef.current || !socketConnected) { + return; + } + + const isTyping = value.trim().length > 0; + socketRef.current.emit("chat:typing", { + threadId: activeThreadId, + isTyping, + }); + + if (typingTimeoutRef.current) { + clearTimeout(typingTimeoutRef.current); + } + + typingTimeoutRef.current = setTimeout(() => { + if (activeThreadId && socketRef.current) { + socketRef.current.emit("chat:typing", { + threadId: activeThreadId, + isTyping: false, + }); + } + }, 1200); + }; + + const onSend = async () => { + if (!activeThreadId) { + return; + } + + const body = draft.trim(); + if (!body) { + return; + } + + const clientMessageId = `admin_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + const optimistic: ChatMessage = { + id: clientMessageId, + threadId: activeThreadId, + senderUserId: userId ?? "", + body, + createdAt: new Date().toISOString(), + clientMessageId, + kind: "text", + attachments: [], + editedAt: null, + deletedAt: null, + }; + + setMessagesByThreadId((prev) => ({ + ...prev, + [activeThreadId]: [...(prev[activeThreadId] ?? []), optimistic], + })); + setDraft(""); + + if (socketRef.current && socketConnected) { + socketRef.current.emit("chat:send", { + threadId: activeThreadId, + body, + clientMessageId, + }); + return; + } + + const token = await getToken(); + if (!token) { + return; + } + + try { + const response = await axios.post<{ message: ChatMessage }>( + `/api/chat/threads/${activeThreadId}/messages`, + { body, clientMessageId }, + { + headers: { Authorization: `Bearer ${token}` }, + }, + ); + + setMessagesByThreadId((prev) => ({ + ...prev, + [activeThreadId]: (prev[activeThreadId] ?? []).map((message) => + message.id === clientMessageId ? response.data.message : message, + ), + })); + } catch { + setMessagesByThreadId((prev) => ({ + ...prev, + [activeThreadId]: (prev[activeThreadId] ?? []).filter( + (message) => message.id !== clientMessageId, + ), + })); + } + }; + + return ( +
+ + +
+ {socketConnected ? "Realtime connected" : "Realtime disconnected"} +
+ +
+ + + Threads + + + {loadingThreads ? ( +
Loading threads...
+ ) : ( + threads.map((thread) => ( + + )) + )} +
+
+ + + + Messages + + + {!activeThreadId ? ( +

+ Select a thread to start chatting. +

+ ) : ( +
+
+ {loadingMessages ? ( +

+ Loading messages... +

+ ) : ( + activeMessages.map((message) => { + const own = message.senderUserId === userId; + const threadReads = activeThreadId + ? (readByThreadId[activeThreadId] ?? {}) + : {}; + const seen = Object.entries(threadReads).some( + ([readerUserId, readMessageId]) => + readerUserId !== userId && + readMessageId === message.id, + ); + + return ( +
+
{message.body}
+ {own && seen && ( +
+ Seen +
+ )} +
+ ); + }) + )} +
+ + {typingUsers.length > 0 && ( +

+ {typingUsers.length === 1 + ? "Someone is typing..." + : `${typingUsers.length} people are typing...`} +

+ )} + +
+ onChangeDraft(event.target.value)} + placeholder="Type a message" + /> + +
+
+ )} +
+
+
+
+ ); +} diff --git a/apps/admin/src/components/ui/Sidebar.tsx b/apps/admin/src/components/ui/Sidebar.tsx index 716b96c..7c140c8 100644 --- a/apps/admin/src/components/ui/Sidebar.tsx +++ b/apps/admin/src/components/ui/Sidebar.tsx @@ -12,6 +12,7 @@ import { Brain, ChevronLeft, Activity, + MessageCircle, } from "lucide-react"; import { UserButton, useUser } from "@clerk/nextjs"; import { usePendingRecommendationsCount } from "@/hooks/use-api"; @@ -52,6 +53,7 @@ export function Sidebar() { href: "/recommendations", badge: pendingCount > 0 ? pendingCount : undefined, }, + { icon: MessageCircle, label: "Chat", href: "/chat" }, { icon: CalendarCheck, label: "Attendance", href: "/attendance" }, { icon: CreditCard, label: "Payments", href: "/payments" }, { icon: Settings, label: "Settings", href: "/settings" }, diff --git a/apps/admin/src/lib/chat.ts b/apps/admin/src/lib/chat.ts new file mode 100644 index 0000000..11c2017 --- /dev/null +++ b/apps/admin/src/lib/chat.ts @@ -0,0 +1,824 @@ +import { db, sql } from "@fitai/database"; +import type { UserRole } from "@fitai/shared"; +import log from "@/lib/logger"; + +export interface ChatUserContext { + id: string; + role: UserRole; + gymId?: string | null; +} + +export interface ChatThreadSummary { + id: string; + type: "gym" | "dm"; + gymId: string | null; + trainerId: string | null; + clientId: string | null; + createdBy: string; + lastMessageAt: Date | null; + createdAt: Date; + updatedAt: Date; + archivedAt: Date | null; + unreadCount: number; + lastMessageBody: string | null; +} + +export interface ChatMessageRecord { + id: string; + threadId: string; + senderUserId: string; + body: string; + kind: "text" | "system"; + attachments: Array<{ url: string; type: string; name?: string }>; + clientMessageId: string | null; + createdAt: Date; + editedAt: Date | null; + deletedAt: Date | null; +} + +class ChatError extends Error { + status: number; + + constructor(message: string, status: number) { + super(message); + this.status = status; + } +} + +let tablesEnsured = false; + +export async function ensureChatTables(): Promise { + if (tablesEnsured) { + return; + } + + await db.run(sql` + CREATE TABLE IF NOT EXISTS chat_threads ( + id TEXT PRIMARY KEY, + type TEXT NOT NULL CHECK (type IN ('gym', 'dm')), + gym_id TEXT, + trainer_id TEXT, + client_id TEXT, + created_by TEXT NOT NULL, + last_message_at INTEGER, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + archived_at INTEGER, + FOREIGN KEY (gym_id) REFERENCES gyms(id) ON DELETE CASCADE, + FOREIGN KEY (trainer_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (client_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE + ) + `); + + await db.run(sql` + CREATE TABLE IF NOT EXISTS chat_thread_members ( + id TEXT PRIMARY KEY, + thread_id TEXT NOT NULL, + user_id TEXT NOT NULL, + role_in_thread TEXT NOT NULL CHECK (role_in_thread IN ('superAdmin', 'admin', 'trainer', 'client')), + joined_at INTEGER NOT NULL, + left_at INTEGER, + muted INTEGER NOT NULL DEFAULT 0, + last_read_message_id TEXT, + last_read_at INTEGER, + FOREIGN KEY (thread_id) REFERENCES chat_threads(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(thread_id, user_id) + ) + `); + + await db.run(sql` + CREATE TABLE IF NOT EXISTS chat_messages ( + id TEXT PRIMARY KEY, + thread_id TEXT NOT NULL, + sender_user_id TEXT NOT NULL, + body TEXT NOT NULL, + kind TEXT NOT NULL CHECK (kind IN ('text', 'system')) DEFAULT 'text', + attachments_json TEXT, + client_message_id TEXT, + created_at INTEGER NOT NULL, + edited_at INTEGER, + deleted_at INTEGER, + FOREIGN KEY (thread_id) REFERENCES chat_threads(id) ON DELETE CASCADE, + FOREIGN KEY (sender_user_id) REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(sender_user_id, client_message_id) + ) + `); + + await db.run(sql` + CREATE TABLE IF NOT EXISTS chat_message_reads ( + id TEXT PRIMARY KEY, + message_id TEXT NOT NULL, + user_id TEXT NOT NULL, + read_at INTEGER NOT NULL, + FOREIGN KEY (message_id) REFERENCES chat_messages(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(message_id, user_id) + ) + `); + + await db.run( + sql`CREATE INDEX IF NOT EXISTS chat_threads_gym_type_last_msg_idx ON chat_threads(gym_id, type, last_message_at)`, + ); + await db.run( + sql`CREATE INDEX IF NOT EXISTS chat_threads_type_idx ON chat_threads(type)`, + ); + await db.run( + sql`CREATE UNIQUE INDEX IF NOT EXISTS chat_threads_trainer_client_type_idx ON chat_threads(trainer_id, client_id, type)`, + ); + await db.run( + sql`CREATE INDEX IF NOT EXISTS chat_thread_members_user_thread_idx ON chat_thread_members(user_id, thread_id)`, + ); + await db.run( + sql`CREATE INDEX IF NOT EXISTS chat_messages_thread_created_at_idx ON chat_messages(thread_id, created_at)`, + ); + await db.run( + sql`CREATE INDEX IF NOT EXISTS chat_message_reads_user_read_at_idx ON chat_message_reads(user_id, read_at)`, + ); + + tablesEnsured = true; +} + +export async function getOrCreateGymRoomThread( + currentUser: ChatUserContext, +): Promise { + await ensureChatTables(); + + if (!currentUser.gymId) { + throw new ChatError("No gym assigned", 400); + } + + const rows = await db.all(sql` + SELECT + id, + type, + gym_id as gymId, + trainer_id as trainerId, + client_id as clientId + FROM chat_threads + WHERE type = 'gym' + AND gym_id = ${currentUser.gymId} + AND archived_at IS NULL + LIMIT 1 + `); + + let thread = rows?.[0] as ThreadRow | undefined; + + if (!thread) { + const now = nowEpochSeconds(); + const newThreadId = generateId("thread"); + + await db.run(sql` + INSERT INTO chat_threads ( + id, + type, + gym_id, + created_by, + created_at, + updated_at + ) VALUES ( + ${newThreadId}, + ${"gym"}, + ${currentUser.gymId}, + ${currentUser.id}, + ${now}, + ${now} + ) + `); + + const createdRows = await db.all(sql` + SELECT + id, + type, + gym_id as gymId, + trainer_id as trainerId, + client_id as clientId + FROM chat_threads + WHERE id = ${newThreadId} + LIMIT 1 + `); + thread = createdRows?.[0] as ThreadRow | undefined; + } + + if (!thread) { + throw new ChatError("Failed to initialize gym room", 500); + } + + await ensureThreadMember(thread.id, currentUser.id, currentUser.role); + return await getThreadSummaryForUser(currentUser.id, thread.id); +} + +export async function getOrCreateDmThread(params: { + trainerId: string; + clientId: string; + createdBy: ChatUserContext; +}): Promise { + await ensureChatTables(); + + const trainerRows = await db.all(sql` + SELECT id, role, gym_id as gymId + FROM users + WHERE id = ${params.trainerId} + LIMIT 1 + `); + const clientRows = await db.all(sql` + SELECT id, role, gym_id as gymId + FROM users + WHERE id = ${params.clientId} + LIMIT 1 + `); + + const trainer = trainerRows?.[0] as + | { id: string; role: UserRole; gymId: string | null } + | undefined; + const client = clientRows?.[0] as + | { id: string; role: UserRole; gymId: string | null } + | undefined; + + if (!trainer || !client) { + throw new ChatError("Trainer or client not found", 404); + } + + if (client.role !== "client") { + throw new ChatError("Direct message target must be a client", 400); + } + + if (trainer.role !== "trainer" && trainer.role !== "admin") { + throw new ChatError("Direct message trainer is invalid", 400); + } + + if (!trainer.gymId || trainer.gymId !== client.gymId) { + throw new ChatError("Trainer and client must belong to same gym", 400); + } + + const assignmentRows = await db.all(sql` + SELECT id + FROM trainer_client_assignments + WHERE trainer_id = ${params.trainerId} + AND client_id = ${params.clientId} + AND is_active = 1 + LIMIT 1 + `); + + if (!assignmentRows?.[0]) { + throw new ChatError("Trainer is not assigned to this client", 403); + } + + const rows = await db.all(sql` + SELECT + id, + type, + gym_id as gymId, + trainer_id as trainerId, + client_id as clientId + FROM chat_threads + WHERE type = 'dm' + AND trainer_id = ${params.trainerId} + AND client_id = ${params.clientId} + AND archived_at IS NULL + LIMIT 1 + `); + + let thread = rows?.[0] as ThreadRow | undefined; + + if (!thread) { + const now = nowEpochSeconds(); + const threadId = generateId("thread"); + + await db.run(sql` + INSERT INTO chat_threads ( + id, + type, + gym_id, + trainer_id, + client_id, + created_by, + created_at, + updated_at + ) VALUES ( + ${threadId}, + ${"dm"}, + ${trainer.gymId}, + ${params.trainerId}, + ${params.clientId}, + ${params.createdBy.id}, + ${now}, + ${now} + ) + `); + + await ensureThreadMember(threadId, params.trainerId, trainer.role); + await ensureThreadMember(threadId, params.clientId, client.role); + + const createdRows = await db.all(sql` + SELECT + id, + type, + gym_id as gymId, + trainer_id as trainerId, + client_id as clientId + FROM chat_threads + WHERE id = ${threadId} + LIMIT 1 + `); + thread = createdRows?.[0] as ThreadRow | undefined; + } + + if (!thread) { + throw new ChatError("Failed to initialize direct message", 500); + } + + await ensureThreadMember( + thread.id, + params.createdBy.id, + params.createdBy.role, + ); + return await getThreadSummaryForUser(params.createdBy.id, thread.id); +} + +export async function listThreadsForUser( + currentUser: ChatUserContext, +): Promise { + await ensureChatTables(); + + const rows = await db.all(sql` + SELECT + t.id, + t.type, + t.gym_id as gymId, + t.trainer_id as trainerId, + t.client_id as clientId, + t.created_by as createdBy, + t.last_message_at as lastMessageAt, + t.created_at as createdAt, + t.updated_at as updatedAt, + t.archived_at as archivedAt, + ( + SELECT m.body + FROM chat_messages m + WHERE m.thread_id = t.id + AND m.deleted_at IS NULL + ORDER BY m.created_at DESC + LIMIT 1 + ) as lastMessageBody, + ( + SELECT COUNT(*) + FROM chat_messages m + WHERE m.thread_id = t.id + AND m.deleted_at IS NULL + AND m.sender_user_id != ${currentUser.id} + AND m.created_at > COALESCE(mem.last_read_at, 0) + ) as unreadCount + FROM chat_threads t + JOIN chat_thread_members mem + ON mem.thread_id = t.id + WHERE mem.user_id = ${currentUser.id} + AND mem.left_at IS NULL + AND t.archived_at IS NULL + ORDER BY COALESCE(t.last_message_at, t.created_at) DESC + `); + + return (rows as ThreadSummaryRow[]).map(mapThreadSummaryRow); +} + +export async function getThreadMessages(params: { + currentUser: ChatUserContext; + threadId: string; + cursor?: number; + limit?: number; +}): Promise<{ messages: ChatMessageRecord[]; nextCursor: number | null }> { + await ensureChatTables(); + await assertThreadAccess(params.currentUser, params.threadId); + + const pageSize = Math.max(1, Math.min(100, params.limit ?? 30)); + const cursor = params.cursor; + + const rows = await db.all(sql` + SELECT + id, + thread_id as threadId, + sender_user_id as senderUserId, + body, + kind, + attachments_json as attachmentsJson, + client_message_id as clientMessageId, + created_at as createdAt, + edited_at as editedAt, + deleted_at as deletedAt + FROM chat_messages + WHERE thread_id = ${params.threadId} + AND deleted_at IS NULL + ${cursor ? sql`AND created_at < ${cursor}` : sql``} + ORDER BY created_at DESC + LIMIT ${pageSize + 1} + `); + + const data = rows as MessageRow[]; + const hasNextPage = data.length > pageSize; + const pageRows = hasNextPage ? data.slice(0, pageSize) : data; + const messages = pageRows.map(mapMessageRow).reverse(); + + const nextCursor = + hasNextPage && pageRows.length > 0 + ? Number(pageRows[pageRows.length - 1].createdAt) + : null; + + return { messages, nextCursor }; +} + +export async function createThreadMessage(params: { + currentUser: ChatUserContext; + threadId: string; + body: string; + clientMessageId?: string; + attachments?: Array<{ url: string; type: string; name?: string }>; +}): Promise { + await ensureChatTables(); + await assertThreadAccess(params.currentUser, params.threadId); + + const body = params.body.trim(); + if (!body) { + throw new ChatError("Message body is required", 400); + } + + if (body.length > 2000) { + throw new ChatError("Message is too long", 400); + } + + if (params.clientMessageId) { + const existingRows = await db.all(sql` + SELECT + id, + thread_id as threadId, + sender_user_id as senderUserId, + body, + kind, + attachments_json as attachmentsJson, + client_message_id as clientMessageId, + created_at as createdAt, + edited_at as editedAt, + deleted_at as deletedAt + FROM chat_messages + WHERE sender_user_id = ${params.currentUser.id} + AND client_message_id = ${params.clientMessageId} + LIMIT 1 + `); + + if (existingRows?.[0]) { + return mapMessageRow(existingRows[0] as MessageRow); + } + } + + const messageId = generateId("msg"); + const now = nowEpochSeconds(); + + await db.run(sql` + INSERT INTO chat_messages ( + id, + thread_id, + sender_user_id, + body, + kind, + attachments_json, + client_message_id, + created_at + ) VALUES ( + ${messageId}, + ${params.threadId}, + ${params.currentUser.id}, + ${body}, + ${"text"}, + ${params.attachments?.length ? JSON.stringify(params.attachments) : null}, + ${params.clientMessageId ?? null}, + ${now} + ) + `); + + await db.run(sql` + UPDATE chat_threads + SET + last_message_at = ${now}, + updated_at = ${now} + WHERE id = ${params.threadId} + `); + + const messageRows = await db.all(sql` + SELECT + id, + thread_id as threadId, + sender_user_id as senderUserId, + body, + kind, + attachments_json as attachmentsJson, + client_message_id as clientMessageId, + created_at as createdAt, + edited_at as editedAt, + deleted_at as deletedAt + FROM chat_messages + WHERE id = ${messageId} + LIMIT 1 + `); + + const message = messageRows?.[0] as MessageRow | undefined; + if (!message) { + throw new ChatError("Failed to send message", 500); + } + + return mapMessageRow(message); +} + +export async function markThreadAsRead(params: { + currentUser: ChatUserContext; + threadId: string; + lastReadMessageId?: string; +}): Promise { + await ensureChatTables(); + await assertThreadAccess(params.currentUser, params.threadId); + + let targetMessageId = params.lastReadMessageId ?? null; + + if (!targetMessageId) { + const latestRows = await db.all(sql` + SELECT id + FROM chat_messages + WHERE thread_id = ${params.threadId} + AND deleted_at IS NULL + ORDER BY created_at DESC + LIMIT 1 + `); + const latest = latestRows?.[0] as { id?: string } | undefined; + targetMessageId = latest?.id ?? null; + } + + const now = nowEpochSeconds(); + await db.run(sql` + UPDATE chat_thread_members + SET + last_read_message_id = ${targetMessageId}, + last_read_at = ${now} + WHERE thread_id = ${params.threadId} + AND user_id = ${params.currentUser.id} + `); +} + +export function isChatError(error: unknown): error is ChatError { + return error instanceof ChatError; +} + +async function assertThreadAccess( + currentUser: ChatUserContext, + threadId: string, +): Promise { + const rows = await db.all(sql` + SELECT + id, + type, + gym_id as gymId, + trainer_id as trainerId, + client_id as clientId + FROM chat_threads + WHERE id = ${threadId} + AND archived_at IS NULL + LIMIT 1 + `); + const thread = rows?.[0] as ThreadRow | undefined; + + if (!thread) { + throw new ChatError("Thread not found", 404); + } + + if (currentUser.role === "superAdmin") { + return thread; + } + + if (thread.type === "gym") { + if (!currentUser.gymId || currentUser.gymId !== thread.gymId) { + throw new ChatError("Forbidden", 403); + } + + await ensureThreadMember(thread.id, currentUser.id, currentUser.role); + return thread; + } + + const isParticipant = + thread.trainerId === currentUser.id || thread.clientId === currentUser.id; + + if (!isParticipant) { + throw new ChatError("Forbidden", 403); + } + + if (thread.trainerId && thread.clientId) { + const assignmentRows = await db.all(sql` + SELECT id + FROM trainer_client_assignments + WHERE trainer_id = ${thread.trainerId} + AND client_id = ${thread.clientId} + AND is_active = 1 + LIMIT 1 + `); + + if (!assignmentRows?.[0]) { + throw new ChatError("Trainer-client assignment is not active", 403); + } + } + + await ensureThreadMember(thread.id, currentUser.id, currentUser.role); + return thread; +} + +async function getThreadSummaryForUser( + userId: string, + threadId: string, +): Promise { + const rows = await db.all(sql` + SELECT + t.id, + t.type, + t.gym_id as gymId, + t.trainer_id as trainerId, + t.client_id as clientId, + t.created_by as createdBy, + t.last_message_at as lastMessageAt, + t.created_at as createdAt, + t.updated_at as updatedAt, + t.archived_at as archivedAt, + ( + SELECT m.body + FROM chat_messages m + WHERE m.thread_id = t.id + AND m.deleted_at IS NULL + ORDER BY m.created_at DESC + LIMIT 1 + ) as lastMessageBody, + ( + SELECT COUNT(*) + FROM chat_messages m + WHERE m.thread_id = t.id + AND m.deleted_at IS NULL + AND m.sender_user_id != ${userId} + AND m.created_at > COALESCE(mem.last_read_at, 0) + ) as unreadCount + FROM chat_threads t + JOIN chat_thread_members mem + ON mem.thread_id = t.id + AND mem.user_id = ${userId} + AND mem.left_at IS NULL + WHERE t.id = ${threadId} + LIMIT 1 + `); + + const row = rows?.[0] as ThreadSummaryRow | undefined; + if (!row) { + throw new ChatError("Thread not found", 404); + } + + return mapThreadSummaryRow(row); +} + +async function ensureThreadMember( + threadId: string, + userId: string, + role: UserRole, +): Promise { + const now = nowEpochSeconds(); + await db.run(sql` + INSERT OR IGNORE INTO chat_thread_members ( + id, + thread_id, + user_id, + role_in_thread, + joined_at, + muted + ) VALUES ( + ${generateId("member")}, + ${threadId}, + ${userId}, + ${role}, + ${now}, + ${0} + ) + `); + + await db.run(sql` + UPDATE chat_thread_members + SET left_at = NULL + WHERE thread_id = ${threadId} + AND user_id = ${userId} + `); +} + +function mapThreadSummaryRow(row: ThreadSummaryRow): ChatThreadSummary { + return { + id: row.id, + type: row.type, + gymId: row.gymId ?? null, + trainerId: row.trainerId ?? null, + clientId: row.clientId ?? null, + createdBy: row.createdBy, + lastMessageAt: toDate(row.lastMessageAt), + createdAt: toDate(row.createdAt) ?? new Date(), + updatedAt: toDate(row.updatedAt) ?? new Date(), + archivedAt: toDate(row.archivedAt), + unreadCount: Number(row.unreadCount ?? 0), + lastMessageBody: row.lastMessageBody ?? null, + }; +} + +function mapMessageRow(row: MessageRow): ChatMessageRecord { + return { + id: row.id, + threadId: row.threadId, + senderUserId: row.senderUserId, + body: row.body, + kind: row.kind, + attachments: parseAttachments(row.attachmentsJson), + clientMessageId: row.clientMessageId ?? null, + createdAt: toDate(row.createdAt) ?? new Date(), + editedAt: toDate(row.editedAt), + deletedAt: toDate(row.deletedAt), + }; +} + +function parseAttachments( + value: string | null, +): Array<{ url: string; type: string; name?: string }> { + if (!value) { + return []; + } + + try { + const parsed = JSON.parse(value); + if (!Array.isArray(parsed)) { + return []; + } + + return parsed.filter( + (item): item is { url: string; type: string; name?: string } => + item && + typeof item === "object" && + typeof item.url === "string" && + typeof item.type === "string", + ); + } catch (error) { + log.warn("Failed to parse chat attachments JSON", { value, error }); + return []; + } +} + +function toDate(value: number | string | null | undefined): Date | null { + if (value === null || value === undefined) { + return null; + } + + const numberValue = Number(value); + if (!Number.isFinite(numberValue)) { + return null; + } + + return new Date(numberValue * 1000); +} + +function generateId(prefix: string): string { + return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`; +} + +function nowEpochSeconds(): number { + return Math.floor(Date.now() / 1000); +} + +interface ThreadRow { + id: string; + type: "gym" | "dm"; + gymId: string | null; + trainerId: string | null; + clientId: string | null; +} + +interface ThreadSummaryRow { + id: string; + type: "gym" | "dm"; + gymId: string | null; + trainerId: string | null; + clientId: string | null; + createdBy: string; + lastMessageAt: number | null; + createdAt: number; + updatedAt: number; + archivedAt: number | null; + unreadCount: number; + lastMessageBody: string | null; +} + +interface MessageRow { + id: string; + threadId: string; + senderUserId: string; + body: string; + kind: "text" | "system"; + attachmentsJson: string | null; + clientMessageId: string | null; + createdAt: number; + editedAt: number | null; + deletedAt: number | null; +} diff --git a/apps/mobile/.env.example b/apps/mobile/.env.example index f6c3860..c16a59e 100644 --- a/apps/mobile/.env.example +++ b/apps/mobile/.env.example @@ -34,6 +34,11 @@ EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_your_publishable_key_here # EXPO_PUBLIC_API_URL=http://localhost:3000 +# Optional realtime override (defaults to EXPO_PUBLIC_API_URL host) +# Set this when your Socket.IO service runs on a different origin/port. +EXPO_PUBLIC_REALTIME_URL=http://localhost:3001 +EXPO_PUBLIC_REALTIME_PATH=/socket.io + # ============================================================================= # App Configuration (Optional) # ============================================================================= diff --git a/apps/mobile/package-lock.json b/apps/mobile/package-lock.json index 2e5890f..d7cb4f7 100644 --- a/apps/mobile/package-lock.json +++ b/apps/mobile/package-lock.json @@ -46,6 +46,7 @@ "react-native-screens": "~4.16.0", "react-native-svg": "^15.15.3", "react-native-web": "^0.21.2", + "socket.io-client": "^4.8.1", "zod": "^3.22.0" }, "devDependencies": { @@ -4602,6 +4603,12 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, "node_modules/@stripe/stripe-js": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-5.6.0.tgz", @@ -6806,6 +6813,49 @@ "node": ">= 0.8" } }, + "node_modules/engine.io-client": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz", + "integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.18.3", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "node_modules/engine.io-client/node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/entities": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", @@ -12740,6 +12790,34 @@ "node": ">=8.0.0" } }, + "node_modules/socket.io-client": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz", + "integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz", + "integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/source-map": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", @@ -14037,6 +14115,14 @@ "node": ">=8.0" } }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/apps/mobile/package.json b/apps/mobile/package.json index f29a1a5..559f69c 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -52,6 +52,7 @@ "react-native-screens": "~4.16.0", "react-native-svg": "^15.15.3", "react-native-web": "^0.21.2", + "socket.io-client": "^4.8.1", "zod": "^3.22.0" }, "devDependencies": { diff --git a/apps/mobile/src/api/chat.ts b/apps/mobile/src/api/chat.ts new file mode 100644 index 0000000..8d62ee0 --- /dev/null +++ b/apps/mobile/src/api/chat.ts @@ -0,0 +1,118 @@ +import { io, type Socket } from "socket.io-client"; +import { API_BASE_URL, API_ENDPOINTS } from "../config/api"; +import { apiClient, withAuth } from "./client"; + +export interface ChatThread { + id: string; + type: "gym" | "dm"; + gymId: string | null; + trainerId: string | null; + clientId: string | null; + createdBy: string; + lastMessageAt: string | null; + createdAt: string; + updatedAt: string; + archivedAt: string | null; + unreadCount: number; + lastMessageBody: string | null; +} + +export interface ChatMessage { + id: string; + threadId: string; + senderUserId: string; + body: string; + kind: "text" | "system"; + attachments: Array<{ url: string; type: string; name?: string }>; + clientMessageId: string | null; + createdAt: string; + editedAt: string | null; + deletedAt: string | null; +} + +export interface ThreadMessagesResponse { + messages: ChatMessage[]; + nextCursor: number | null; +} + +export const chatApi = { + getThreads: async (token: string): Promise => { + const response = await apiClient.get<{ threads: ChatThread[] }>( + API_ENDPOINTS.CHAT.THREADS, + withAuth(token), + ); + return response.data.threads; + }, + + getGymRoomThread: async (token: string): Promise => { + const response = await apiClient.get<{ thread: ChatThread }>( + API_ENDPOINTS.CHAT.GYM_ROOM, + withAuth(token), + ); + return response.data.thread; + }, + + getMyDmThread: async (token: string): Promise => { + const response = await apiClient.get<{ thread: ChatThread }>( + API_ENDPOINTS.CHAT.MY_DM_THREAD, + withAuth(token), + ); + return response.data.thread; + }, + + getThreadMessages: async ( + threadId: string, + token: string, + cursor?: number, + ): Promise => { + const endpoint = + cursor === undefined + ? API_ENDPOINTS.CHAT.THREAD_MESSAGES(threadId) + : `${API_ENDPOINTS.CHAT.THREAD_MESSAGES(threadId)}?cursor=${cursor}`; + + const response = await apiClient.get( + endpoint, + withAuth(token), + ); + return response.data; + }, + + sendMessage: async ( + threadId: string, + body: string, + token: string, + clientMessageId?: string, + ): Promise => { + const response = await apiClient.post<{ message: ChatMessage }>( + API_ENDPOINTS.CHAT.THREAD_MESSAGES(threadId), + { body, clientMessageId }, + withAuth(token), + ); + return response.data.message; + }, + + markThreadRead: async ( + threadId: string, + token: string, + lastReadMessageId?: string, + ): Promise => { + await apiClient.post( + API_ENDPOINTS.CHAT.THREAD_READ(threadId), + { lastReadMessageId }, + withAuth(token), + ); + }, +}; + +export function createChatSocket(token: string): Socket { + const baseUrl = new URL(API_BASE_URL); + const realtimeBase = + process.env.EXPO_PUBLIC_REALTIME_URL ?? + `${baseUrl.protocol}//${baseUrl.host}`; + + return io(realtimeBase, { + path: process.env.EXPO_PUBLIC_REALTIME_PATH ?? "/socket.io", + transports: ["websocket"], + auth: { token }, + }); +} diff --git a/apps/mobile/src/api/index.ts b/apps/mobile/src/api/index.ts index a91c11f..039e870 100644 --- a/apps/mobile/src/api/index.ts +++ b/apps/mobile/src/api/index.ts @@ -17,3 +17,4 @@ export * from "./helpers"; export * from "./membership"; export * from "./food"; export * from "./gyms"; +export * from "./chat"; diff --git a/apps/mobile/src/app/(tabs)/_layout.tsx b/apps/mobile/src/app/(tabs)/_layout.tsx index 407aa3e..51681f8 100644 --- a/apps/mobile/src/app/(tabs)/_layout.tsx +++ b/apps/mobile/src/app/(tabs)/_layout.tsx @@ -83,6 +83,12 @@ export default function TabLayout() { title: "Plans", }} /> + | null>(null); + const activeMessages = useMemo( + () => (activeThreadId ? (messagesByThreadId[activeThreadId] ?? []) : []), + [activeThreadId, messagesByThreadId], + ); + const typingUsers = useMemo( + () => (activeThreadId ? (typingByThreadId[activeThreadId] ?? []) : []), + [activeThreadId, typingByThreadId], + ); + + const handleSend = async () => { + if (!activeThreadId) { + return; + } + + const body = draft.trim(); + if (!body) { + return; + } + + setDraft(""); + try { + await sendMessage(activeThreadId, body); + setTyping(activeThreadId, false); + } catch { + setDraft(body); + } + }; + + const handleDraftChange = (value: string) => { + setDraft(value); + + if (!activeThreadId) { + return; + } + + const isTyping = value.trim().length > 0; + setTyping(activeThreadId, isTyping); + + if (typingTimeoutRef.current) { + clearTimeout(typingTimeoutRef.current); + } + + typingTimeoutRef.current = setTimeout(() => { + if (activeThreadId) { + setTyping(activeThreadId, false); + } + }, 1200); + }; + + const handleLoadOlder = () => { + if (!activeThreadId) { + return; + } + + void loadOlderMessages(activeThreadId); + }; + + useEffect(() => { + return () => { + if (typingTimeoutRef.current) { + clearTimeout(typingTimeoutRef.current); + } + + if (activeThreadId) { + setTyping(activeThreadId, false); + } + }; + }, [activeThreadId, setTyping]); + + return ( + + + + {socketConnected ? "Realtime connected" : "Realtime disconnected"} + + + {totalUnreadCount} unread total + + + + + + {loadingThreads ? ( + + ) : ( + item.id} + renderItem={({ item }) => { + const selected = item.id === activeThreadId; + return ( + setActiveThread(item.id)} + style={[ + styles.threadItem, + { + backgroundColor: selected + ? colors.surface + : "transparent", + borderColor: colors.border, + }, + ]} + > + + {item.type === "gym" ? "Gym Room" : "Trainer Chat"} + + + {item.lastMessageBody ?? "No messages yet"} + + {item.unreadCount > 0 && ( + + {item.unreadCount} unread + + )} + + ); + }} + /> + )} + + + + {!activeThreadId ? ( + + + Select a thread to start chatting + + + ) : ( + <> + {loadingMessages ? ( + + ) : ( + item.id} + contentContainerStyle={styles.messagesList} + refreshControl={ + + activeThreadId + ? void refreshMessages(activeThreadId) + : void refreshThreads() + } + tintColor={colors.primary} + /> + } + ListHeaderComponent={ + activeThreadId && hasMoreMessages(activeThreadId) ? ( + + {isLoadingOlderMessages(activeThreadId) ? ( + + ) : ( + + Load older messages + + )} + + ) : null + } + renderItem={({ item }) => { + const own = item.senderUserId === userId; + const threadReads = activeThreadId + ? (readByThreadId[activeThreadId] ?? {}) + : {}; + const readers = Object.entries(threadReads) + .filter( + ([readerUserId, messageId]) => + readerUserId !== userId && messageId === item.id, + ) + .map(([readerUserId]) => readerUserId); + + return ( + + + {item.body} + + {own && readers.length > 0 && ( + + Seen + + )} + + ); + }} + /> + )} + + {typingUsers.length > 0 && ( + + + {typingUsers.length === 1 + ? "Someone is typing..." + : `${typingUsers.length} people are typing...`} + + + )} + + + + + + Send + + + + + )} + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + connection: { + borderBottomWidth: 1, + paddingHorizontal: 16, + paddingVertical: 8, + }, + layout: { + flex: 1, + flexDirection: "row", + }, + sidebar: { + width: 160, + borderRightWidth: 1, + padding: 8, + }, + threadItem: { + borderWidth: 1, + borderRadius: 12, + padding: 10, + marginBottom: 8, + }, + chatPane: { + flex: 1, + borderLeftWidth: 0, + }, + emptyState: { + flex: 1, + alignItems: "center", + justifyContent: "center", + }, + messagesList: { + padding: 12, + gap: 8, + }, + loadOlder: { + borderWidth: 1, + borderRadius: 10, + paddingHorizontal: 10, + paddingVertical: 8, + alignSelf: "center", + marginBottom: 10, + }, + messageBubble: { + borderRadius: 12, + borderWidth: 1, + paddingHorizontal: 10, + paddingVertical: 8, + maxWidth: "80%", + }, + composer: { + borderTopWidth: 1, + padding: 10, + flexDirection: "row", + gap: 8, + alignItems: "center", + }, + typingRow: { + paddingHorizontal: 14, + paddingBottom: 6, + }, + input: { + flex: 1, + borderWidth: 1, + borderRadius: 10, + paddingHorizontal: 10, + paddingVertical: 8, + }, + sendButton: { + borderRadius: 10, + paddingHorizontal: 14, + paddingVertical: 10, + }, +}); diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index 54394eb..eceb4a6 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -12,6 +12,7 @@ import { FitnessGoalsProvider } from "../contexts/FitnessGoalsContext"; import { RecommendationsProvider } from "../contexts/RecommendationsContext"; import { NotificationsProvider } from "../contexts/NotificationsContext"; import { MembershipProvider } from "../contexts/MembershipContext"; +import { ChatProvider } from "../contexts/ChatContext"; import { queryClient } from "../lib/query-client"; import { useAutoWorkoutGeofence } from "../hooks/useAutoWorkoutGeofence"; import log from "../utils/logger"; @@ -184,11 +185,13 @@ export default function RootLayout() { - - - - - + + + + + + + diff --git a/apps/mobile/src/components/CustomTabBar.tsx b/apps/mobile/src/components/CustomTabBar.tsx index c6bc00f..fa715cb 100644 --- a/apps/mobile/src/components/CustomTabBar.tsx +++ b/apps/mobile/src/components/CustomTabBar.tsx @@ -4,6 +4,7 @@ import { BottomTabBarProps } from "@react-navigation/bottom-tabs"; import { Ionicons } from "@expo/vector-icons"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useTheme } from "../contexts/ThemeContext"; +import { useChat } from "../contexts/ChatContext"; export function CustomTabBar({ state, @@ -12,6 +13,7 @@ export function CustomTabBar({ }: BottomTabBarProps) { const { colors } = useTheme(); const insets = useSafeAreaInsets(); + const { totalUnreadCount } = useChat(); return ( + {route.name === "chat" && totalUnreadCount > 0 && ( + + + {totalUnreadCount > 99 ? "99+" : totalUnreadCount} + + + )} {isFocused && ( `/api/hydration?startDate=${startDate}&endDate=${endDate}`, }, + CHAT: { + THREADS: "/api/chat/threads", + GYM_ROOM: "/api/chat/gym-room", + START_DM: "/api/chat/dm/start", + MY_DM_THREAD: "/api/chat/dm/my-thread", + THREAD: (threadId: string) => `/api/chat/threads/${threadId}`, + THREAD_MESSAGES: (threadId: string) => + `/api/chat/threads/${threadId}/messages`, + THREAD_READ: (threadId: string) => `/api/chat/threads/${threadId}/read`, + }, FITNESS_GOALS: { LIST: "/api/fitness-goals", CREATE: "/api/fitness-goals", diff --git a/apps/mobile/src/contexts/ChatContext.tsx b/apps/mobile/src/contexts/ChatContext.tsx new file mode 100644 index 0000000..e86f271 --- /dev/null +++ b/apps/mobile/src/contexts/ChatContext.tsx @@ -0,0 +1,723 @@ +import React, { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { useAuth, useUser } from "@clerk/clerk-expo"; +import type { Socket } from "socket.io-client"; +import { + chatApi, + createChatSocket, + type ChatMessage, + type ChatThread, +} from "../api/chat"; +import log from "../utils/logger"; + +interface ChatContextValue { + threads: ChatThread[]; + totalUnreadCount: number; + activeThreadId: string | null; + messagesByThreadId: Record; + readByThreadId: Record>; + loadingThreads: boolean; + loadingMessages: boolean; + socketConnected: boolean; + typingByThreadId: Record; + setActiveThread: (threadId: string | null) => void; + refreshThreads: () => Promise; + refreshMessages: (threadId: string) => Promise; + loadOlderMessages: (threadId: string) => Promise; + hasMoreMessages: (threadId: string) => boolean; + isLoadingOlderMessages: (threadId: string) => boolean; + sendMessage: (threadId: string, body: string) => Promise; + setTyping: (threadId: string, isTyping: boolean) => void; + markThreadRead: ( + threadId: string, + lastReadMessageId?: string, + ) => Promise; +} + +const ChatContext = createContext(undefined); + +export function ChatProvider({ children }: { children: React.ReactNode }) { + const { isSignedIn, getToken } = useAuth(); + const { user } = useUser(); + const userRole = + typeof user?.publicMetadata?.role === "string" + ? user.publicMetadata.role + : undefined; + const isClientUser = userRole === "client"; + + const [threads, setThreads] = useState([]); + const [activeThreadId, setActiveThreadId] = useState(null); + const [messagesByThreadId, setMessagesByThreadId] = useState< + Record + >({}); + const [readByThreadId, setReadByThreadId] = useState< + Record> + >({}); + const [loadingThreads, setLoadingThreads] = useState(false); + const [loadingMessages, setLoadingMessages] = useState(false); + const [socketConnected, setSocketConnected] = useState(false); + const [typingByThreadId, setTypingByThreadId] = useState< + Record + >({}); + const [nextCursorByThreadId, setNextCursorByThreadId] = useState< + Record + >({}); + const [loadingOlderByThreadId, setLoadingOlderByThreadId] = useState< + Record + >({}); + + const socketRef = useRef(null); + const lastMarkedReadByThreadRef = useRef>({}); + const activeThreadIdRef = useRef(null); + const currentUserIdRef = useRef(undefined); + const refreshThreadsRef = useRef<() => Promise>(async () => {}); + const markThreadReadRef = useRef< + (threadId: string, lastReadMessageId?: string) => Promise + >(async () => {}); + + const clearAll = useCallback(() => { + setThreads([]); + setActiveThreadId(null); + setMessagesByThreadId({}); + setReadByThreadId({}); + setNextCursorByThreadId({}); + setLoadingOlderByThreadId({}); + setLoadingThreads(false); + setLoadingMessages(false); + setSocketConnected(false); + setTypingByThreadId({}); + lastMarkedReadByThreadRef.current = {}; + + if (socketRef.current) { + socketRef.current.disconnect(); + socketRef.current = null; + } + }, []); + + const refreshThreads = useCallback(async () => { + if (!isSignedIn) { + return; + } + + const token = await getToken(); + if (!token) { + return; + } + + try { + setLoadingThreads(true); + if (isClientUser) { + try { + await chatApi.getMyDmThread(token); + } catch { + // ignore bootstrap errors for now + } + } + + const data = await chatApi.getThreads(token); + setThreads(data); + + setActiveThreadId((prev) => { + if (prev && data.some((thread) => thread.id === prev)) { + return prev; + } + + return data.length > 0 ? data[0].id : null; + }); + } catch (error) { + log.warn("Failed to refresh chat threads", { error }); + } finally { + setLoadingThreads(false); + } + }, [getToken, isSignedIn, isClientUser]); + + const refreshMessages = useCallback( + async (threadId: string) => { + if (!isSignedIn) { + return; + } + + const token = await getToken(); + if (!token) { + return; + } + + try { + setLoadingMessages(true); + const response = await chatApi.getThreadMessages(threadId, token); + setMessagesByThreadId((prev) => ({ + ...prev, + [threadId]: response.messages, + })); + setNextCursorByThreadId((prev) => ({ + ...prev, + [threadId]: response.nextCursor, + })); + + setThreads((prev) => + prev.map((thread) => + thread.id === threadId ? { ...thread, unreadCount: 0 } : thread, + ), + ); + } catch (error) { + log.warn("Failed to refresh chat messages", { threadId, error }); + } finally { + setLoadingMessages(false); + } + }, + [getToken, isSignedIn], + ); + + const loadOlderMessages = useCallback( + async (threadId: string) => { + if (!isSignedIn) { + return; + } + + const nextCursor = nextCursorByThreadId[threadId]; + if (nextCursor === null || nextCursor === undefined) { + return; + } + + if (loadingOlderByThreadId[threadId]) { + return; + } + + const token = await getToken(); + if (!token) { + return; + } + + try { + setLoadingOlderByThreadId((prev) => ({ ...prev, [threadId]: true })); + + const response = await chatApi.getThreadMessages( + threadId, + token, + nextCursor, + ); + + setMessagesByThreadId((prev) => { + const existing = prev[threadId] ?? []; + const merged = [...response.messages, ...existing]; + const dedupedById = new Map(); + merged.forEach((message) => { + dedupedById.set(message.id, message); + }); + + return { + ...prev, + [threadId]: Array.from(dedupedById.values()).sort( + (a, b) => + new Date(a.createdAt).getTime() - + new Date(b.createdAt).getTime(), + ), + }; + }); + + setNextCursorByThreadId((prev) => ({ + ...prev, + [threadId]: response.nextCursor, + })); + } catch (error) { + log.warn("Failed to load older chat messages", { threadId, error }); + } finally { + setLoadingOlderByThreadId((prev) => ({ ...prev, [threadId]: false })); + } + }, + [getToken, isSignedIn, loadingOlderByThreadId, nextCursorByThreadId], + ); + + const markThreadRead = useCallback( + async (threadId: string, lastReadMessageId?: string) => { + if (!isSignedIn) { + return; + } + + const token = await getToken(); + if (!token) { + return; + } + + try { + await chatApi.markThreadRead(threadId, token, lastReadMessageId); + if (socketRef.current?.connected) { + socketRef.current.emit("chat:read", { + threadId, + lastReadMessageId, + }); + } + + if (lastReadMessageId && user?.id) { + setReadByThreadId((prev) => ({ + ...prev, + [threadId]: { + ...(prev[threadId] ?? {}), + [user.id]: lastReadMessageId, + }, + })); + } + } catch (error) { + log.warn("Failed to mark thread read", { threadId, error }); + } + }, + [getToken, isSignedIn, user?.id], + ); + + useEffect(() => { + activeThreadIdRef.current = activeThreadId; + }, [activeThreadId]); + + useEffect(() => { + currentUserIdRef.current = user?.id; + }, [user?.id]); + + useEffect(() => { + refreshThreadsRef.current = refreshThreads; + }, [refreshThreads]); + + useEffect(() => { + markThreadReadRef.current = markThreadRead; + }, [markThreadRead]); + + const sendMessage = useCallback( + async (threadId: string, body: string) => { + const text = body.trim(); + if (!text || !isSignedIn) { + return; + } + + const token = await getToken(); + if (!token) { + return; + } + + const clientMessageId = `mobile_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + + const optimistic: ChatMessage = { + id: clientMessageId, + threadId, + senderUserId: user?.id ?? "", + body: text, + kind: "text", + attachments: [], + clientMessageId, + createdAt: new Date().toISOString(), + editedAt: null, + deletedAt: null, + }; + + setMessagesByThreadId((prev) => ({ + ...prev, + [threadId]: [...(prev[threadId] ?? []), optimistic], + })); + + try { + if (socketRef.current && socketConnected) { + socketRef.current.emit("chat:send", { + threadId, + body: text, + clientMessageId, + }); + } else { + const persisted = await chatApi.sendMessage( + threadId, + text, + token, + clientMessageId, + ); + + setMessagesByThreadId((prev) => ({ + ...prev, + [threadId]: (prev[threadId] ?? []).map((msg) => + msg.id === clientMessageId ? persisted : msg, + ), + })); + } + } catch (error) { + setMessagesByThreadId((prev) => ({ + ...prev, + [threadId]: (prev[threadId] ?? []).filter( + (msg) => msg.id !== clientMessageId, + ), + })); + + log.warn("Failed to send chat message", { threadId, error }); + throw error; + } + }, + [getToken, isSignedIn, socketConnected, user?.id], + ); + + useEffect(() => { + if (!isSignedIn) { + clearAll(); + return; + } + + let mounted = true; + + const setupSocket = async () => { + const token = await getToken(); + if (!token || !mounted) { + return; + } + + const socket = createChatSocket(token); + socketRef.current = socket; + + socket.on("connect", () => { + setSocketConnected(true); + void refreshThreadsRef.current(); + + if (activeThreadIdRef.current) { + socket.emit("chat:subscribe", { + threadId: activeThreadIdRef.current, + }); + } + }); + + socket.on("disconnect", () => { + setSocketConnected(false); + setTypingByThreadId({}); + }); + + socket.on( + "chat:message:new", + (event: { threadId: string; message: ChatMessage }) => { + setMessagesByThreadId((prev) => { + const current = prev[event.threadId] ?? []; + if (current.some((msg) => msg.id === event.message.id)) { + return prev; + } + + const deduped = current.filter( + (msg) => + !( + msg.clientMessageId && + event.message.clientMessageId && + msg.clientMessageId === event.message.clientMessageId + ), + ); + + return { + ...prev, + [event.threadId]: [...deduped, event.message], + }; + }); + + setThreads((prev) => + prev + .map((thread) => { + if (thread.id !== event.threadId) { + return thread; + } + + const isIncoming = + event.message.senderUserId !== currentUserIdRef.current; + const incrementUnread = + isIncoming && event.threadId !== activeThreadIdRef.current; + + return { + ...thread, + lastMessageAt: event.message.createdAt, + lastMessageBody: event.message.body, + unreadCount: incrementUnread + ? thread.unreadCount + 1 + : thread.unreadCount, + }; + }) + .sort((a, b) => { + const aTime = a.lastMessageAt ?? a.createdAt; + const bTime = b.lastMessageAt ?? b.createdAt; + return new Date(bTime).getTime() - new Date(aTime).getTime(); + }), + ); + + if ( + event.threadId === activeThreadIdRef.current && + event.message.senderUserId !== currentUserIdRef.current + ) { + void markThreadReadRef.current(event.threadId, event.message.id); + setThreads((prev) => + prev.map((thread) => + thread.id === event.threadId + ? { ...thread, unreadCount: 0 } + : thread, + ), + ); + } + }, + ); + + socket.on( + "chat:message:ack", + (event: { + threadId: string; + message: ChatMessage; + clientMessageId?: string | null; + }) => { + setMessagesByThreadId((prev) => ({ + ...prev, + [event.threadId]: (prev[event.threadId] ?? []).map((msg) => + event.clientMessageId + ? msg.clientMessageId === event.clientMessageId + ? event.message + : msg + : msg.id === event.message.id + ? event.message + : msg, + ), + })); + + setThreads((prev) => + prev + .map((thread) => + thread.id === event.threadId + ? { + ...thread, + lastMessageAt: event.message.createdAt, + lastMessageBody: event.message.body, + } + : thread, + ) + .sort((a, b) => { + const aTime = a.lastMessageAt ?? a.createdAt; + const bTime = b.lastMessageAt ?? b.createdAt; + return new Date(bTime).getTime() - new Date(aTime).getTime(); + }), + ); + }, + ); + + socket.on( + "chat:read:update", + (event: { + threadId?: string; + userId?: string; + lastReadMessageId?: string | null; + }) => { + if (!event.threadId || !event.userId || !event.lastReadMessageId) { + return; + } + + setReadByThreadId((prev) => ({ + ...prev, + [event.threadId!]: { + ...(prev[event.threadId!] ?? {}), + [event.userId!]: event.lastReadMessageId!, + }, + })); + + void refreshThreadsRef.current(); + }, + ); + + socket.on( + "chat:typing", + (event: { threadId?: string; userId?: string; isTyping?: boolean }) => { + const threadId = event?.threadId; + const typingUserId = event?.userId; + if ( + !threadId || + !typingUserId || + typingUserId === currentUserIdRef.current + ) { + return; + } + + setTypingByThreadId((prev) => { + const current = prev[threadId] ?? []; + if (event.isTyping) { + if (current.includes(typingUserId)) { + return prev; + } + + return { + ...prev, + [threadId]: [...current, typingUserId], + }; + } + + return { + ...prev, + [threadId]: current.filter((id) => id !== typingUserId), + }; + }); + }, + ); + + socket.on("chat:error", (event) => { + log.warn("Chat socket error event", { event }); + }); + }; + + void setupSocket(); + + return () => { + mounted = false; + if (socketRef.current) { + socketRef.current.disconnect(); + socketRef.current = null; + } + }; + }, [clearAll, getToken, isSignedIn]); + + useEffect(() => { + if (!isSignedIn) { + return; + } + + void refreshThreadsRef.current(); + }, [isSignedIn, userRole]); + + useEffect(() => { + if (!activeThreadId) { + return; + } + + if (socketRef.current && socketConnected) { + socketRef.current.emit("chat:subscribe", { threadId: activeThreadId }); + } + + void refreshMessages(activeThreadId); + + setThreads((prev) => + prev.map((thread) => + thread.id === activeThreadId ? { ...thread, unreadCount: 0 } : thread, + ), + ); + + return () => { + if (socketRef.current) { + socketRef.current.emit("chat:unsubscribe", { + threadId: activeThreadId, + }); + } + }; + }, [activeThreadId, refreshMessages, socketConnected]); + + useEffect(() => { + if (!activeThreadId) { + return; + } + + const messages = messagesByThreadId[activeThreadId] ?? []; + if (messages.length === 0) { + return; + } + + const lastMessage = messages[messages.length - 1]; + if (!lastMessage || lastMessage.senderUserId === user?.id) { + return; + } + + if (lastMarkedReadByThreadRef.current[activeThreadId] === lastMessage.id) { + return; + } + + lastMarkedReadByThreadRef.current[activeThreadId] = lastMessage.id; + void markThreadRead(activeThreadId, lastMessage.id); + }, [activeThreadId, markThreadRead, messagesByThreadId, user?.id]); + + const setActiveThread = useCallback((threadId: string | null) => { + setActiveThreadId(threadId); + if (!threadId) { + return; + } + + setThreads((prev) => + prev.map((thread) => + thread.id === threadId ? { ...thread, unreadCount: 0 } : thread, + ), + ); + }, []); + + const setTyping = useCallback( + (threadId: string, isTyping: boolean) => { + if (!socketRef.current || !socketConnected) { + return; + } + + socketRef.current.emit("chat:typing", { threadId, isTyping }); + }, + [socketConnected], + ); + + const hasMoreMessages = useCallback( + (threadId: string) => + nextCursorByThreadId[threadId] !== null && + nextCursorByThreadId[threadId] !== undefined, + [nextCursorByThreadId], + ); + + const isLoadingOlderMessages = useCallback( + (threadId: string) => Boolean(loadingOlderByThreadId[threadId]), + [loadingOlderByThreadId], + ); + + const totalUnreadCount = useMemo( + () => threads.reduce((sum, thread) => sum + thread.unreadCount, 0), + [threads], + ); + + const value = useMemo( + () => ({ + threads, + totalUnreadCount, + activeThreadId, + messagesByThreadId, + readByThreadId, + loadingThreads, + loadingMessages, + socketConnected, + typingByThreadId, + setActiveThread, + refreshThreads, + refreshMessages, + loadOlderMessages, + hasMoreMessages, + isLoadingOlderMessages, + sendMessage, + setTyping, + markThreadRead, + }), + [ + threads, + totalUnreadCount, + activeThreadId, + messagesByThreadId, + readByThreadId, + loadingThreads, + loadingMessages, + socketConnected, + typingByThreadId, + setActiveThread, + refreshThreads, + refreshMessages, + loadOlderMessages, + hasMoreMessages, + isLoadingOlderMessages, + sendMessage, + setTyping, + markThreadRead, + ], + ); + + return {children}; +} + +export function useChat(): ChatContextValue { + const context = useContext(ChatContext); + if (!context) { + throw new Error("useChat must be used within ChatProvider"); + } + return context; +} diff --git a/apps/realtime/.env.example b/apps/realtime/.env.example new file mode 100644 index 0000000..047ab08 --- /dev/null +++ b/apps/realtime/.env.example @@ -0,0 +1,19 @@ +# Realtime service port +REALTIME_PORT=3001 + +# Comma-separated allowed origins for Socket.IO CORS +# Example: http://localhost:3000,http://localhost:8081 +REALTIME_CORS_ORIGIN=http://localhost:3000,http://localhost:8081 + +# Socket.IO path (must match clients) +REALTIME_SOCKET_PATH=/socket.io + +# Clerk secret key (required for token verification) +CLERK_SECRET_KEY=sk_test_your_secret_key_here + +# Redis URL (optional but recommended for multi-instance scaling + distributed rate limits) +REDIS_URL=redis://localhost:6379 + +# Optional database path override (shared with @fitai/database) +# FITAI_DATABASE_PATH=../../apps/admin/data/fitai.db +# DATABASE_PATH=../../apps/admin/data/fitai.db diff --git a/apps/realtime/README.md b/apps/realtime/README.md new file mode 100644 index 0000000..eaff38c --- /dev/null +++ b/apps/realtime/README.md @@ -0,0 +1,44 @@ +# FitAI Realtime Service + +Socket.IO service for gym room and trainer-client chat. + +## Setup + +1. Copy env template: + +```bash +cp .env.example .env +``` + +2. Fill required values in `.env` (`CLERK_SECRET_KEY` at minimum). + +3. Build dependencies from monorepo root (if needed) and run: + +```bash +npm --prefix packages/database run build +npm --prefix apps/realtime run dev +``` + +For production: + +```bash +npm --prefix apps/realtime run build +npm --prefix apps/realtime run start +``` + +## Required environment variables + +- `CLERK_SECRET_KEY`: verifies client session token in socket handshake. +- `REALTIME_CORS_ORIGIN`: comma-separated client origins. + +## Recommended environment variables + +- `REDIS_URL`: enables Socket.IO Redis adapter and distributed rate limiting. +- `FITAI_DATABASE_PATH` or `DATABASE_PATH`: explicit DB path. +- `REALTIME_PORT`: server port (default `3001`). +- `REALTIME_SOCKET_PATH`: Socket.IO path (default `/socket.io`). + +## Notes + +- If `REDIS_URL` is missing, service still works with single-instance in-memory rate limits. +- Database path resolution falls back to shared `apps/admin/data/fitai.db` when available. diff --git a/apps/realtime/package-lock.json b/apps/realtime/package-lock.json new file mode 100644 index 0000000..63a1282 --- /dev/null +++ b/apps/realtime/package-lock.json @@ -0,0 +1,2132 @@ +{ + "name": "@fitai/realtime", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@fitai/realtime", + "version": "1.0.0", + "dependencies": { + "@clerk/backend": "^2.5.4", + "@fitai/database": "file:../../packages/database", + "@fitai/shared": "file:../../packages/shared", + "@socket.io/redis-adapter": "^8.3.0", + "dotenv": "^17.2.3", + "express": "^4.21.2", + "redis": "^4.7.0", + "socket.io": "^4.8.1" + }, + "devDependencies": { + "@types/express": "^4.17.23", + "@types/node": "^24.10.0", + "tsx": "^4.20.6", + "typescript": "^5.9.3" + } + }, + "../../packages/database": { + "name": "@fitai/database", + "version": "1.0.0", + "dependencies": { + "@types/better-sqlite3": "^7.6.13", + "better-sqlite3": "12.4.1", + "drizzle-orm": "^0.44.7" + }, + "devDependencies": { + "drizzle-kit": "^0.31.6", + "typescript": "^5.9.3" + } + }, + "../../packages/shared": { + "name": "@fitai/shared", + "version": "1.0.0", + "dependencies": { + "clsx": "^2.1.1", + "tailwind-merge": "^3.4.0", + "zod": "^4.1.12" + }, + "devDependencies": { + "typescript": "^5.9.3" + } + }, + "node_modules/@clerk/backend": { + "version": "2.33.1", + "resolved": "https://registry.npmjs.org/@clerk/backend/-/backend-2.33.1.tgz", + "integrity": "sha512-DRwmFu6gEmzHRUeXXB5y02QxMihHDEgetSQrb0ME6KaYe29+LnenBUQAmlASXmsovIi9cBqk4hE4WHWNRXX+Bw==", + "license": "MIT", + "dependencies": { + "@clerk/shared": "^3.47.3", + "@clerk/types": "^4.101.21", + "standardwebhooks": "^1.0.0", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=18.17.0" + } + }, + "node_modules/@clerk/shared": { + "version": "3.47.3", + "resolved": "https://registry.npmjs.org/@clerk/shared/-/shared-3.47.3.tgz", + "integrity": "sha512-jG0wMIZuuc8zaKieg9Os8ocTphG+llluRukUUdyVnu4+ZI1syVf+dkpDP3ZK69yLavTX3D0KAmkmQqTPzQV/Nw==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "csstype": "3.1.3", + "dequal": "2.0.3", + "glob-to-regexp": "0.4.1", + "js-cookie": "3.0.5", + "std-env": "^3.9.0", + "swr": "2.3.4" + }, + "engines": { + "node": ">=18.17.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0", + "react-dom": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/@clerk/types": { + "version": "4.101.21", + "resolved": "https://registry.npmjs.org/@clerk/types/-/types-4.101.21.tgz", + "integrity": "sha512-/70W603A6bRv1n24dDNAs3kWHLSIgXebEyzXZ46IuROWcq0+guSqqLa+nKekxxIdk6I/vnI9SWjBvBRuZVMnhQ==", + "license": "MIT", + "dependencies": { + "@clerk/shared": "^3.47.3" + }, + "engines": { + "node": ">=18.17.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@fitai/database": { + "resolved": "../../packages/database", + "link": true + }, + "node_modules/@fitai/shared": { + "resolved": "../../packages/shared", + "link": true + }, + "node_modules/@redis/bloom": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz", + "integrity": "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/client": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz", + "integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==", + "license": "MIT", + "peer": true, + "dependencies": { + "cluster-key-slot": "1.1.2", + "generic-pool": "3.9.0", + "yallist": "4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@redis/graph": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.1.1.tgz", + "integrity": "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/json": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@redis/json/-/json-1.0.7.tgz", + "integrity": "sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/search": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@redis/search/-/search-1.2.0.tgz", + "integrity": "sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/time-series": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.1.0.tgz", + "integrity": "sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, + "node_modules/@socket.io/redis-adapter": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/@socket.io/redis-adapter/-/redis-adapter-8.3.0.tgz", + "integrity": "sha512-ly0cra+48hDmChxmIpnESKrc94LjRL80TEmZVscuQ/WWkRP81nNj8W8cCGMqbI4L6NCuAaPRSzZF1a9GlAxxnA==", + "license": "MIT", + "dependencies": { + "debug": "~4.3.1", + "notepack.io": "~3.0.1", + "uid2": "1.0.0" + }, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "socket.io-adapter": "^2.5.4" + } + }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.8", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz", + "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.12.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz", + "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/base64id": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", + "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", + "license": "MIT", + "engines": { + "node": "^4.5.0 || >= 5.9" + } + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dotenv": { + "version": "17.4.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.0.tgz", + "integrity": "sha512-kCKF62fwtzwYm0IGBNjRUjtJgMfGapII+FslMHIjMR5KTnwEmBmWLDRSnc3XSNP8bNy34tekgQyDT0hr7pERRQ==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/engine.io": { + "version": "6.6.6", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.6.tgz", + "integrity": "sha512-U2SN0w3OpjFRVlrc17E6TMDmH58Xl9rai1MblNjAdwWp07Kk+llmzX0hjDpQdrDGzwmvOtgM5yI+meYX6iZ2xA==", + "license": "MIT", + "dependencies": { + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "@types/ws": "^8.5.12", + "accepts": "~1.3.4", + "base64id": "2.0.0", + "cookie": "~0.7.2", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.18.3" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/engine.io/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generic-pool": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz", + "integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.7", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", + "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "license": "BSD-2-Clause" + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/js-cookie": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", + "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/notepack.io": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/notepack.io/-/notepack.io-3.0.1.tgz", + "integrity": "sha512-TKC/8zH5pXIAMVQio2TvVDTtPRX+DJPHDqjRbxogtFiByHyzKmy96RA0JtCQJ+WouyyL4A10xomQzgbUT+1jCg==", + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/redis": { + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/redis/-/redis-4.7.1.tgz", + "integrity": "sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==", + "license": "MIT", + "workspaces": [ + "./packages/*" + ], + "dependencies": { + "@redis/bloom": "1.2.0", + "@redis/client": "1.6.1", + "@redis/graph": "1.1.1", + "@redis/json": "1.0.7", + "@redis/search": "1.2.0", + "@redis/time-series": "1.1.0" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/socket.io": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.3.tgz", + "integrity": "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.4", + "base64id": "~2.0.0", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io": "~6.6.0", + "socket.io-adapter": "~2.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/socket.io-adapter": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.6.tgz", + "integrity": "sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "debug": "~4.4.1", + "ws": "~8.18.3" + } + }, + "node_modules/socket.io-adapter/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz", + "integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "license": "MIT" + }, + "node_modules/swr": { + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/swr/-/swr-2.3.4.tgz", + "integrity": "sha512-bYd2lrhc+VarcpkgWclcUi92wYCpOgMws9Sd1hG1ntAu0NEy+14CbotuFjshBU2kt9rYj9TSmDcybpxpeTU1fg==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uid2": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/uid2/-/uid2-1.0.0.tgz", + "integrity": "sha512-+I6aJUv63YAcY9n4mQreLUt0d4lvwkkopDNmpomkAUz0fAkEMV9pRWxN0EjhW1YfRhcuyHg2v3mwddCDW1+LFQ==", + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + } + } +} diff --git a/apps/realtime/package.json b/apps/realtime/package.json new file mode 100644 index 0000000..6e5f928 --- /dev/null +++ b/apps/realtime/package.json @@ -0,0 +1,29 @@ +{ + "name": "@fitai/realtime", + "version": "1.0.0", + "private": true, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc -p tsconfig.json", + "start": "node dist/index.js", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@clerk/backend": "^2.5.4", + "@fitai/database": "file:../../packages/database", + "@fitai/shared": "file:../../packages/shared", + "@socket.io/redis-adapter": "^8.3.0", + "dotenv": "^17.2.3", + "express": "^4.21.2", + "redis": "^4.7.0", + "socket.io": "^4.8.1" + }, + "devDependencies": { + "@types/express": "^4.17.23", + "@types/node": "^24.10.0", + "tsx": "^4.20.6", + "typescript": "^5.9.3" + } +} diff --git a/apps/realtime/src/index.ts b/apps/realtime/src/index.ts new file mode 100644 index 0000000..804e047 --- /dev/null +++ b/apps/realtime/src/index.ts @@ -0,0 +1,692 @@ +import "dotenv/config"; +import http from "node:http"; +import express, { type Request, type Response } from "express"; +import { Server as SocketIOServer } from "socket.io"; +import { createClient } from "redis"; +import { createAdapter } from "@socket.io/redis-adapter"; +import { verifyToken } from "@clerk/backend"; +import { db, sql } from "@fitai/database"; + +interface ThreadAccess { + id: string; + type: "gym" | "dm"; + gymId: string | null; + trainerId: string | null; + clientId: string | null; +} + +interface IncomingChatMessagePayload { + threadId?: string; + body?: string; + clientMessageId?: string; + attachments?: unknown; +} + +interface AuthenticatedSocketData { + user: { + id: string; + role: "superAdmin" | "admin" | "trainer" | "client"; + gymId: string | null; + }; +} + +const app = express(); +const server = http.createServer(app); +const sendRateWindowByUser = new Map(); +const typingRateWindowByKey = new Map(); +const readRateWindowByKey = new Map(); +const SEND_LIMIT_PER_MINUTE = 30; +const TYPING_LIMIT_PER_10_SECONDS = 20; +const READ_LIMIT_PER_10_SECONDS = 30; +let redisRateClient: ReturnType | null = null; + +const io = new SocketIOServer< + Record, + Record, + Record, + AuthenticatedSocketData +>(server, { + cors: { + origin: process.env.REALTIME_CORS_ORIGIN?.split(",") ?? "*", + credentials: true, + }, + path: process.env.REALTIME_SOCKET_PATH ?? "/socket.io", +}); + +app.get("/healthz", (_req: Request, res: Response) => { + res.json({ ok: true, service: "realtime" }); +}); + +async function attachRedisAdapter(): Promise { + const redisUrl = process.env.REDIS_URL; + if (!redisUrl) { + return; + } + + const pubClient = createClient({ url: redisUrl }); + const subClient = pubClient.duplicate(); + await pubClient.connect(); + await subClient.connect(); + + redisRateClient = pubClient; + io.adapter(createAdapter(pubClient, subClient)); +} + +io.use(async (socket: any, next: (err?: Error) => void) => { + try { + const token = + typeof socket.handshake.auth?.token === "string" + ? socket.handshake.auth.token + : null; + + const userId = await verifyAndExtractUserId(token); + if (!userId) { + return next(new Error("Unauthorized")); + } + + const rows = await db.all(sql` + SELECT + id, + role, + gym_id as gymId + FROM users + WHERE id = ${userId} + LIMIT 1 + `); + + const user = rows?.[0] as + | { + id: string; + role: "superAdmin" | "admin" | "trainer" | "client"; + gymId: string | null; + } + | undefined; + + if (!user) { + return next(new Error("Unauthorized")); + } + + socket.data.user = user; + return next(); + } catch { + return next(new Error("Unauthorized")); + } +}); + +io.on("connection", (socket: any) => { + socket.on("chat:subscribe", async (payload: { threadId?: string }) => { + const threadId = payload?.threadId; + if (!threadId) { + socket.emit("chat:error", { code: "invalid_payload" }); + return; + } + + const allowed = await canAccessThread(socket.data.user, threadId); + if (!allowed) { + socket.emit("chat:error", { code: "forbidden" }); + return; + } + + await socket.join(`thread:${threadId}`); + socket.emit("chat:subscribed", { threadId }); + }); + + socket.on("chat:unsubscribe", async (payload: { threadId?: string }) => { + const threadId = payload?.threadId; + if (!threadId) { + return; + } + + await socket.leave(`thread:${threadId}`); + }); + + socket.on( + "chat:typing", + async (payload: { threadId?: string; isTyping?: boolean }) => { + const threadId = payload?.threadId; + if (!threadId) { + return; + } + + const typingAllowed = await checkRateLimit( + typingRateWindowByKey, + `typing:${socket.data.user.id}:${threadId}`, + TYPING_LIMIT_PER_10_SECONDS, + 10, + ); + if (!typingAllowed) { + return; + } + + const allowed = await canAccessThread(socket.data.user, threadId); + if (!allowed) { + return; + } + + socket.to(`thread:${threadId}`).emit("chat:typing", { + threadId, + userId: socket.data.user.id, + isTyping: Boolean(payload?.isTyping), + }); + }, + ); + + socket.on("chat:send", async (payload: IncomingChatMessagePayload) => { + try { + const threadId = + typeof payload?.threadId === "string" ? payload.threadId : null; + const body = typeof payload?.body === "string" ? payload.body.trim() : ""; + + if (!threadId || !body) { + socket.emit("chat:error", { + code: "invalid_payload", + message: "threadId and body are required", + }); + return; + } + + const sendAllowed = await checkRateLimit( + sendRateWindowByUser, + `send:${socket.data.user.id}`, + SEND_LIMIT_PER_MINUTE, + 60, + ); + if (!sendAllowed) { + socket.emit("chat:error", { + code: "rate_limited", + message: "Too many messages. Please slow down.", + }); + return; + } + + if (body.length > 2000) { + socket.emit("chat:error", { + code: "message_too_long", + message: "Message is too long", + }); + return; + } + + const access = await getThreadAccess(socket.data.user, threadId); + if (!access) { + socket.emit("chat:error", { code: "forbidden", message: "Forbidden" }); + return; + } + + await ensureThreadMembership( + threadId, + socket.data.user.id, + socket.data.user.role, + ); + + const clientMessageId = + typeof payload.clientMessageId === "string" && + payload.clientMessageId.trim().length > 0 + ? payload.clientMessageId.trim() + : null; + + if (clientMessageId) { + const existingRows = await db.all(sql` + SELECT + id, + thread_id as threadId, + sender_user_id as senderUserId, + body, + kind, + attachments_json as attachmentsJson, + client_message_id as clientMessageId, + created_at as createdAt, + edited_at as editedAt, + deleted_at as deletedAt + FROM chat_messages + WHERE sender_user_id = ${socket.data.user.id} + AND client_message_id = ${clientMessageId} + LIMIT 1 + `); + + if (existingRows?.[0]) { + const message = mapMessageRow(existingRows[0] as MessageRow); + socket.emit("chat:message:ack", { + threadId, + clientMessageId, + message, + duplicate: true, + }); + return; + } + } + + const attachments = normalizeAttachments(payload.attachments); + const now = nowEpochSeconds(); + const messageId = generateId("msg"); + + await db.run(sql` + INSERT INTO chat_messages ( + id, + thread_id, + sender_user_id, + body, + kind, + attachments_json, + client_message_id, + created_at + ) VALUES ( + ${messageId}, + ${threadId}, + ${socket.data.user.id}, + ${body}, + ${"text"}, + ${attachments.length > 0 ? JSON.stringify(attachments) : null}, + ${clientMessageId}, + ${now} + ) + `); + + await db.run(sql` + UPDATE chat_threads + SET + last_message_at = ${now}, + updated_at = ${now} + WHERE id = ${threadId} + `); + + const createdRows = await db.all(sql` + SELECT + id, + thread_id as threadId, + sender_user_id as senderUserId, + body, + kind, + attachments_json as attachmentsJson, + client_message_id as clientMessageId, + created_at as createdAt, + edited_at as editedAt, + deleted_at as deletedAt + FROM chat_messages + WHERE id = ${messageId} + LIMIT 1 + `); + + const createdRow = createdRows?.[0] as MessageRow | undefined; + if (!createdRow) { + socket.emit("chat:error", { + code: "send_failed", + message: "Failed to persist message", + }); + return; + } + + const message = mapMessageRow(createdRow); + + await socket.join(`thread:${threadId}`); + + io.to(`thread:${threadId}`).emit("chat:message:new", { + threadId, + message, + }); + + io.to(`thread:${threadId}`).emit("chat:read:update", { + threadId, + userId: socket.data.user.id, + lastReadMessageId: message.id, + }); + + socket.emit("chat:message:ack", { + threadId, + clientMessageId, + message, + }); + } catch (error) { + socket.emit("chat:error", { + code: "internal_error", + message: error instanceof Error ? error.message : "Failed to send", + }); + } + }); + + socket.on( + "chat:read", + async (payload: { threadId?: string; lastReadMessageId?: string }) => { + const threadId = payload?.threadId; + if (!threadId) { + return; + } + + const readAllowed = await checkRateLimit( + readRateWindowByKey, + `read:${socket.data.user.id}:${threadId}`, + READ_LIMIT_PER_10_SECONDS, + 10, + ); + if (!readAllowed) { + return; + } + + const allowed = await canAccessThread(socket.data.user, threadId); + if (!allowed) { + return; + } + + await ensureThreadMembership( + threadId, + socket.data.user.id, + socket.data.user.role, + ); + + const now = Math.floor(Date.now() / 1000); + await db.run(sql` + UPDATE chat_thread_members + SET + last_read_message_id = ${payload?.lastReadMessageId ?? null}, + last_read_at = ${now} + WHERE thread_id = ${threadId} + AND user_id = ${socket.data.user.id} + `); + + io.to(`thread:${threadId}`).emit("chat:read:update", { + threadId, + userId: socket.data.user.id, + lastReadMessageId: payload?.lastReadMessageId ?? null, + }); + }, + ); +}); + +async function canAccessThread( + user: AuthenticatedSocketData["user"], + threadId: string, +): Promise { + const access = await getThreadAccess(user, threadId); + return Boolean(access); +} + +async function getThreadAccess( + user: AuthenticatedSocketData["user"], + threadId: string, +): Promise { + const rows = await db.all(sql` + SELECT + id, + type, + gym_id as gymId, + trainer_id as trainerId, + client_id as clientId, + archived_at as archivedAt + FROM chat_threads + WHERE id = ${threadId} + LIMIT 1 + `); + + const thread = rows?.[0] as + | { + id: string; + type: "gym" | "dm"; + gymId: string | null; + trainerId: string | null; + clientId: string | null; + archivedAt: number | null; + } + | undefined; + + if (!thread || thread.archivedAt) { + return null; + } + + if (user.role === "superAdmin") { + return { + id: thread.id, + type: thread.type, + gymId: thread.gymId, + trainerId: thread.trainerId, + clientId: thread.clientId, + }; + } + + if (thread.type === "gym") { + if (!user.gymId || user.gymId !== thread.gymId) { + return null; + } + + return { + id: thread.id, + type: thread.type, + gymId: thread.gymId, + trainerId: thread.trainerId, + clientId: thread.clientId, + }; + } + + const isParticipant = + user.id === thread.trainerId || user.id === thread.clientId; + if (!isParticipant) { + return null; + } + + if (thread.trainerId && thread.clientId) { + const assignmentRows = await db.all(sql` + SELECT id + FROM trainer_client_assignments + WHERE trainer_id = ${thread.trainerId} + AND client_id = ${thread.clientId} + AND is_active = 1 + LIMIT 1 + `); + + if (!assignmentRows?.[0]) { + return null; + } + } + + return { + id: thread.id, + type: thread.type, + gymId: thread.gymId, + trainerId: thread.trainerId, + clientId: thread.clientId, + }; +} + +async function verifyAndExtractUserId( + token: string | null, +): Promise { + if (!token) { + return null; + } + + const secretKey = process.env.CLERK_SECRET_KEY; + if (!secretKey) { + return null; + } + + try { + const verified = await verifyToken(token, { + secretKey, + clockSkewInMs: 5000, + }); + if (typeof verified.sub === "string" && verified.sub.trim().length > 0) { + return verified.sub; + } + + const fallback = + typeof verified.sid === "string" && verified.sid.trim().length > 0 + ? verified.sid + : null; + if (fallback) { + const rows = await db.all(sql` + SELECT id + FROM users + WHERE id = ${fallback} + LIMIT 1 + `); + if (rows?.[0]) { + return fallback; + } + } + + const payload = JSON.parse( + Buffer.from(token.split(".")[1], "base64url").toString(), + ); + if (typeof payload.sub === "string" && payload.sub.trim().length > 0) { + return payload.sub; + } + + return null; + } catch { + return null; + } +} + +async function main() { + await attachRedisAdapter(); + const port = Number(process.env.REALTIME_PORT ?? 3001); + server.listen(port, () => { + console.log(`[realtime] listening on :${port}`); + }); +} + +async function ensureThreadMembership( + threadId: string, + userId: string, + role: AuthenticatedSocketData["user"]["role"], +): Promise { + const now = nowEpochSeconds(); + await db.run(sql` + INSERT OR IGNORE INTO chat_thread_members ( + id, + thread_id, + user_id, + role_in_thread, + joined_at, + muted + ) VALUES ( + ${generateId("member")}, + ${threadId}, + ${userId}, + ${role}, + ${now}, + ${0} + ) + `); + + await db.run(sql` + UPDATE chat_thread_members + SET left_at = NULL + WHERE thread_id = ${threadId} + AND user_id = ${userId} + `); +} + +function normalizeAttachments( + raw: unknown, +): Array<{ url: string; type: string; name?: string }> { + if (!Array.isArray(raw)) { + return []; + } + + return raw + .filter( + (item): item is { url: string; type: string; name?: string } => + item !== null && + typeof item === "object" && + typeof (item as { url?: unknown }).url === "string" && + typeof (item as { type?: unknown }).type === "string", + ) + .map((item) => ({ + url: item.url, + type: item.type, + name: typeof item.name === "string" ? item.name : undefined, + })); +} + +function parseAttachments( + value: string | null, +): Array<{ url: string; type: string; name?: string }> { + if (!value) { + return []; + } + + try { + return normalizeAttachments(JSON.parse(value)); + } catch { + return []; + } +} + +function mapMessageRow(row: MessageRow) { + return { + id: row.id, + threadId: row.threadId, + senderUserId: row.senderUserId, + body: row.body, + kind: row.kind, + attachments: parseAttachments(row.attachmentsJson), + clientMessageId: row.clientMessageId, + createdAt: new Date(Number(row.createdAt) * 1000).toISOString(), + editedAt: row.editedAt + ? new Date(Number(row.editedAt) * 1000).toISOString() + : null, + deletedAt: row.deletedAt + ? new Date(Number(row.deletedAt) * 1000).toISOString() + : null, + }; +} + +function generateId(prefix: string): string { + return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`; +} + +function nowEpochSeconds(): number { + return Math.floor(Date.now() / 1000); +} + +export async function checkRateLimit( + windows: Map, + key: string, + limit: number, + windowSeconds: number, +): Promise { + if (redisRateClient) { + try { + const redisKey = `fitai:chat:rl:${key}`; + const count = await redisRateClient.incr(redisKey); + if (count === 1) { + await redisRateClient.expire(redisKey, windowSeconds); + } + + return count <= limit; + } catch { + // Continue with in-memory fallback below. + } + } + + const now = Date.now(); + const current = windows.get(key) ?? []; + const windowMs = windowSeconds * 1000; + const withinWindow = current.filter( + (timestamp) => now - timestamp < windowMs, + ); + + if (withinWindow.length >= limit) { + windows.set(key, withinWindow); + return false; + } + + withinWindow.push(now); + windows.set(key, withinWindow); + return true; +} + +interface MessageRow { + id: string; + threadId: string; + senderUserId: string; + body: string; + kind: "text" | "system"; + attachmentsJson: string | null; + clientMessageId: string | null; + createdAt: number; + editedAt: number | null; + deletedAt: number | null; +} + +void main(); diff --git a/apps/realtime/src/types.d.ts b/apps/realtime/src/types.d.ts new file mode 100644 index 0000000..e9c2643 --- /dev/null +++ b/apps/realtime/src/types.d.ts @@ -0,0 +1,11 @@ +declare module "@fitai/database" { + export const db: { + all: (query: unknown) => Promise; + run: (query: unknown) => Promise; + }; + + export function sql( + strings: TemplateStringsArray, + ...values: unknown[] + ): unknown; +} diff --git a/apps/realtime/tsconfig.json b/apps/realtime/tsconfig.json new file mode 100644 index 0000000..7b79206 --- /dev/null +++ b/apps/realtime/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "CommonJS", + "moduleResolution": "Node", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/package.json b/package.json index 755b0cf..ef7ebb4 100644 --- a/package.json +++ b/package.json @@ -5,10 +5,13 @@ "private": true, "scripts": { "dev": "concurrently \"npm run dev:admin\" \"npm run dev:mobile\"", + "dev:chat-stack": "concurrently \"npm run dev:admin\" \"npm run dev:realtime\" \"npm run dev:mobile\"", "dev:admin": "cd apps/admin && npx next dev", + "dev:realtime": "npm --prefix apps/realtime run dev", "dev:mobile": "cd apps/mobile && npx expo start", "build": "npm run build:admin && npm run build:mobile", "build:admin": "cd apps/admin && npx next build", + "build:realtime": "npm --prefix apps/realtime run build", "build:mobile": "cd apps/mobile && npx expo build", "test": "npm run test:admin && npm run test:mobile", "test:admin": "cd apps/admin && npx jest", @@ -16,9 +19,10 @@ "lint": "npm run lint:admin && npm run lint:mobile", "lint:admin": "cd apps/admin && npx eslint . --ext .js,.jsx,.ts,.tsx", "lint:mobile": "cd apps/mobile && npx eslint . --ext .js,.jsx,.ts,.tsx", - "typecheck": "npm run typecheck:admin && npm run typecheck:mobile", + "typecheck": "npm run typecheck:admin && npm run typecheck:mobile && npm run typecheck:realtime", "typecheck:admin": "cd apps/admin && npx tsc --noEmit", - "typecheck:mobile": "cd apps/mobile && npx tsc --noEmit" + "typecheck:mobile": "cd apps/mobile && npx tsc --noEmit", + "typecheck:realtime": "npm --prefix apps/realtime run typecheck" }, "devDependencies": { "@typescript-eslint/eslint-plugin": "^8.46.3", diff --git a/packages/database/src/index.ts b/packages/database/src/index.ts index 347ed44..f417db1 100644 --- a/packages/database/src/index.ts +++ b/packages/database/src/index.ts @@ -1,9 +1,16 @@ import Database from "better-sqlite3"; import { drizzle } from "drizzle-orm/better-sqlite3"; +import { existsSync, mkdirSync } from "node:fs"; +import path from "node:path"; import * as schema from "./schema"; -// Configurable database path with intelligent defaults -const dbPath = "./data/fitai.db"; +// Configurable database path with intelligent defaults. +// Priority: +// 1) FITAI_DATABASE_PATH / DATABASE_PATH +// 2) ./data/fitai.db from current working directory +// 3) monorepo shared admin database path +const dbPath = resolveDatabasePath(); +mkdirSync(path.dirname(dbPath), { recursive: true }); const sqlite = new Database(dbPath); export const db = drizzle(sqlite, { schema }); @@ -21,3 +28,29 @@ export { lte, like, } from "drizzle-orm"; + +function resolveDatabasePath(): string { + const envPath = process.env.FITAI_DATABASE_PATH ?? process.env.DATABASE_PATH; + if (envPath && envPath.trim().length > 0) { + return path.resolve(envPath); + } + + const cwdDefault = path.resolve(process.cwd(), "data", "fitai.db"); + if (existsSync(cwdDefault)) { + return cwdDefault; + } + + const repoRoot = path.resolve(__dirname, "..", "..", ".."); + const sharedAdminDb = path.join( + repoRoot, + "apps", + "admin", + "data", + "fitai.db", + ); + if (existsSync(sharedAdminDb)) { + return sharedAdminDb; + } + + return cwdDefault; +} diff --git a/packages/database/src/schema.ts b/packages/database/src/schema.ts index 2d803f5..b6ed737 100644 --- a/packages/database/src/schema.ts +++ b/packages/database/src/schema.ts @@ -8,6 +8,9 @@ import { uniqueIndex, } from "drizzle-orm/sqlite-core"; +const chatThreadTypeValues = ["gym", "dm"] as const; +const chatMessageKindValues = ["text", "system"] as const; + export const users = sqliteTable( "users", { @@ -574,6 +577,135 @@ export const trainerClientAssignments = sqliteTable( }), ); +export const chatThreads = sqliteTable( + "chat_threads", + { + id: text("id").primaryKey(), + type: text("type", { enum: chatThreadTypeValues }).notNull(), + gymId: text("gym_id").references(() => gyms.id, { onDelete: "cascade" }), + trainerId: text("trainer_id").references(() => users.id, { + onDelete: "cascade", + }), + clientId: text("client_id").references(() => users.id, { + onDelete: "cascade", + }), + createdBy: text("created_by") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + lastMessageAt: integer("last_message_at", { mode: "timestamp" }), + createdAt: integer("created_at", { mode: "timestamp" }) + .notNull() + .$defaultFn(() => new Date()), + updatedAt: integer("updated_at", { mode: "timestamp" }) + .notNull() + .$defaultFn(() => new Date()), + archivedAt: integer("archived_at", { mode: "timestamp" }), + }, + (table) => ({ + typeIdx: index("chat_threads_type_idx").on(table.type), + gymTypeLastMessageIdx: index("chat_threads_gym_type_last_msg_idx").on( + table.gymId, + table.type, + table.lastMessageAt, + ), + trainerClientTypeIdx: uniqueIndex( + "chat_threads_trainer_client_type_idx", + ).on(table.trainerId, table.clientId, table.type), + }), +); + +export const chatThreadMembers = sqliteTable( + "chat_thread_members", + { + id: text("id").primaryKey(), + threadId: text("thread_id") + .notNull() + .references(() => chatThreads.id, { onDelete: "cascade" }), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + roleInThread: text("role_in_thread", { + enum: ["superAdmin", "admin", "trainer", "client"], + }).notNull(), + joinedAt: integer("joined_at", { mode: "timestamp" }) + .notNull() + .$defaultFn(() => new Date()), + leftAt: integer("left_at", { mode: "timestamp" }), + muted: integer("muted", { mode: "boolean" }).notNull().default(false), + lastReadMessageId: text("last_read_message_id"), + lastReadAt: integer("last_read_at", { mode: "timestamp" }), + }, + (table) => ({ + threadUserUniqueIdx: uniqueIndex("chat_thread_members_thread_user_idx").on( + table.threadId, + table.userId, + ), + userThreadIdx: index("chat_thread_members_user_thread_idx").on( + table.userId, + table.threadId, + ), + }), +); + +export const chatMessages = sqliteTable( + "chat_messages", + { + id: text("id").primaryKey(), + threadId: text("thread_id") + .notNull() + .references(() => chatThreads.id, { onDelete: "cascade" }), + senderUserId: text("sender_user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + body: text("body").notNull(), + kind: text("kind", { enum: chatMessageKindValues }) + .notNull() + .default("text"), + attachmentsJson: text("attachments_json"), + clientMessageId: text("client_message_id"), + createdAt: integer("created_at", { mode: "timestamp" }) + .notNull() + .$defaultFn(() => new Date()), + editedAt: integer("edited_at", { mode: "timestamp" }), + deletedAt: integer("deleted_at", { mode: "timestamp" }), + }, + (table) => ({ + threadCreatedAtIdx: index("chat_messages_thread_created_at_idx").on( + table.threadId, + table.createdAt, + ), + senderClientMessageUniqueIdx: uniqueIndex( + "chat_messages_sender_client_msg_idx", + ).on(table.senderUserId, table.clientMessageId), + }), +); + +export const chatMessageReads = sqliteTable( + "chat_message_reads", + { + id: text("id").primaryKey(), + messageId: text("message_id") + .notNull() + .references(() => chatMessages.id, { onDelete: "cascade" }), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + readAt: integer("read_at", { mode: "timestamp" }) + .notNull() + .$defaultFn(() => new Date()), + }, + (table) => ({ + messageUserUniqueIdx: uniqueIndex("chat_message_reads_message_user_idx").on( + table.messageId, + table.userId, + ), + userReadAtIdx: index("chat_message_reads_user_read_at_idx").on( + table.userId, + table.readAt, + ), + }), +); + export type User = typeof users.$inferSelect; export type NewUser = typeof users.$inferInsert; export type Client = typeof clients.$inferSelect; @@ -603,3 +735,11 @@ export type TrainerClientAssignment = typeof trainerClientAssignments.$inferSelect; export type NewTrainerClientAssignment = typeof trainerClientAssignments.$inferInsert; +export type ChatThread = typeof chatThreads.$inferSelect; +export type NewChatThread = typeof chatThreads.$inferInsert; +export type ChatThreadMember = typeof chatThreadMembers.$inferSelect; +export type NewChatThreadMember = typeof chatThreadMembers.$inferInsert; +export type ChatMessage = typeof chatMessages.$inferSelect; +export type NewChatMessage = typeof chatMessages.$inferInsert; +export type ChatMessageRead = typeof chatMessageReads.$inferSelect; +export type NewChatMessageRead = typeof chatMessageReads.$inferInsert; diff --git a/packages/shared/src/constants/index.ts b/packages/shared/src/constants/index.ts index ff3ce46..aa0873e 100644 --- a/packages/shared/src/constants/index.ts +++ b/packages/shared/src/constants/index.ts @@ -128,6 +128,13 @@ export const RECOMMENDATION_STATUSES = [ ] as const; export type RecommendationStatus = (typeof RECOMMENDATION_STATUSES)[number]; +// Chat constants +export const CHAT_THREAD_TYPES = ["gym", "dm"] as const; +export type ChatThreadType = (typeof CHAT_THREAD_TYPES)[number]; + +export const CHAT_MESSAGE_KINDS = ["text", "system"] as const; +export type ChatMessageKind = (typeof CHAT_MESSAGE_KINDS)[number]; + // Helper functions to check enum values export function isValidUserRole(role: string): role is UserRole { return USER_ROLES.includes(role as UserRole); diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 3261152..f4e7e09 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -13,6 +13,8 @@ import type { NotificationType, GymStatus, RecommendationStatus, + ChatThreadType, + ChatMessageKind, } from "../constants"; export interface User { @@ -239,6 +241,55 @@ export interface TrainerClientAssignment { updatedAt: Date; } +export interface ChatThread { + id: string; + type: ChatThreadType; + gymId?: string; + trainerId?: string; + clientId?: string; + createdBy: string; + lastMessageAt?: Date; + createdAt: Date; + updatedAt: Date; + archivedAt?: Date; +} + +export interface ChatThreadMember { + id: string; + threadId: string; + userId: string; + roleInThread: UserRole; + joinedAt: Date; + leftAt?: Date; + muted: boolean; + lastReadMessageId?: string; + lastReadAt?: Date; +} + +export interface ChatMessage { + id: string; + threadId: string; + senderUserId: string; + body: string; + kind: ChatMessageKind; + attachments?: Array<{ + url: string; + type: string; + name?: string; + }>; + clientMessageId?: string; + createdAt: Date; + editedAt?: Date; + deletedAt?: Date; +} + +export interface ChatMessageRead { + id: string; + messageId: string; + userId: string; + readAt: Date; +} + // User Report Types export interface WeeklyCheckInStats { weekStart: string; // ISO 8601 date (YYYY-MM-DD)