From 0594fc4fcae2ce911629f682517f56b46afefdf0 Mon Sep 17 00:00:00 2001 From: Antherix Date: Fri, 5 Jun 2026 00:46:22 +0530 Subject: [PATCH 1/3] fix: prevent NaN token totals by coalescing null tokens to 0 --- backend/controllers/chat.controller.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/controllers/chat.controller.js b/backend/controllers/chat.controller.js index 477b946..d95166b 100644 --- a/backend/controllers/chat.controller.js +++ b/backend/controllers/chat.controller.js @@ -293,8 +293,8 @@ const listAllChats = asyncHandler(async (req, res) => { const chatsWithUsage = chats.map((chat) => { const totals = chat.usageEvents.reduce( (acc, curr) => { - acc.inputTokens += curr.inputTokens; - acc.outputTokens += curr.outputTokens; + acc.inputTokens += curr.inputTokens ?? 0; + acc.outputTokens += curr.outputTokens ?? 0; return acc; }, { inputTokens: 0, outputTokens: 0 }, @@ -342,8 +342,8 @@ const recentChats = asyncHandler(async (req, res) => { const chatsWithUsage = chats.map((chat) => { const totals = chat.usageEvents.reduce( (acc, curr) => { - acc.inputTokens += curr.inputTokens; - acc.outputTokens += curr.outputTokens; + acc.inputTokens += curr.inputTokens ?? 0; + acc.outputTokens += curr.outputTokens ?? 0; return acc; }, { inputTokens: 0, outputTokens: 0 }, From d6e963139d7d21e944a886d322e611c3848384b6 Mon Sep 17 00:00:00 2001 From: Antherix Date: Fri, 5 Jun 2026 16:27:13 +0530 Subject: [PATCH 2/3] feat: add delete-my-data endpoint and profile UI --- backend/controllers/user.controller.js | 80 +++++++++++++++++++++++++ backend/routers/user.route.js | 2 + src/lib/api.ts | 3 + src/pages/Profile.tsx | 83 +++++++++++++++++++++++++- 4 files changed, 165 insertions(+), 3 deletions(-) diff --git a/backend/controllers/user.controller.js b/backend/controllers/user.controller.js index f54fd61..fac5bf7 100644 --- a/backend/controllers/user.controller.js +++ b/backend/controllers/user.controller.js @@ -311,6 +311,85 @@ const resetPassword = asyncHandler(async (req, res) => { res.status(200).json(new ApiResponse(200, { reset: true }, "Password reset successfully !!")); }); +const deleteMyData = asyncHandler(async (req, res) => { + const userId = req.user.id; // set by your auth middleware + const { confirm } = req.query; + + // Acceptance criteria: require explicit confirmation flag + if (confirm !== "true") { + return res.status(400).json({ + success: false, + message: 'Pass confirm=true as a query param to confirm deletion.', + }); + } + + try { + await prisma.$transaction(async (tx) => { + // 1. Delete ChatMessageSources (deepest dependency first) + await tx.chatMessageSource.deleteMany({ + where: { chatMessage: { chat: { userId } } }, + }); + + // 2. Delete ChatMessages + await tx.chatMessage.deleteMany({ + where: { chat: { userId } }, + }); + + // 3. Delete UsageEvents (onDelete: SetNull means we must handle these) + await tx.usageEvent.deleteMany({ where: { userId } }); + + // 4. Delete ApiKeys + await tx.apiKey.deleteMany({ where: { userId } }); + + // 5. Delete ChatSources that belong ONLY to this user's chats + // Safe detach: only remove if no other user's chat references them + const userChatIds = ( + await tx.chat.findMany({ + where: { userId }, + select: { id: true }, + }) + ).map((c) => c.id); + + // Find ChatSource IDs used by this user's chats + const userChatSources = await tx.chatSource.findMany({ + where: { chatId: { in: userChatIds } }, + select: { id: true, docsUrl: true }, + }); + + // Only delete ChatSources not referenced by any other chat + for (const cs of userChatSources) { + const otherChatCount = await tx.chat.count({ + where: { + chatSources: { some: { docsUrl: cs.docsUrl } }, + userId: { not: userId }, + }, + }); + if (otherChatCount === 0) { + await tx.chatSource.delete({ where: { id: cs.id } }); + } + } + + // 6. Delete Chats + await tx.chat.deleteMany({ where: { userId } }); + }); + + return res.status(200).json({ + success: true, + message: 'All your data has been deleted.', + }); + } catch (error) { + // Idempotent: if already deleted, return success + if (error.code === 'P2025') { + return res.status(200).json({ + success: true, + message: 'Data already deleted or not found.', + }); + } + console.error('deleteMyData error:', error); + return res.status(500).json({ success: false, message: 'Server error.' }); + } +}); + export { userRegister, userLogIn, @@ -321,4 +400,5 @@ export { currentUserProfile, resetPassword, sendResetCode, + deleteMyData, }; \ No newline at end of file diff --git a/backend/routers/user.route.js b/backend/routers/user.route.js index 165e91a..0be5559 100644 --- a/backend/routers/user.route.js +++ b/backend/routers/user.route.js @@ -19,6 +19,7 @@ import { currentUserProfile, resetPassword, sendResetCode, + deleteMyData, } from "../controllers/user.controller.js"; const userRouter = Router(); @@ -32,5 +33,6 @@ userRouter.route("/refresh-tokens").patch(verifyJWT, refreshTokens); userRouter.route("/profile").get(verifyStrictJWT, currentUserProfile); userRouter.route("/send-reset-code").post(validate(sendResetCodeSchema), sendResetCode); userRouter.route("/reset-password").patch(validate(resetPasswordSchema), resetPassword); +userRouter.route("/delete-my-data").delete(verifyStrictJWT, deleteMyData); export default userRouter; diff --git a/src/lib/api.ts b/src/lib/api.ts index fec8e6f..cae5629 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -418,3 +418,6 @@ export const getSharedChatMessages = (shareToken: string) => export const forkSharedChat = (shareToken: string) => apiRequest<{ chatId: string }>(`/chat/shared/${shareToken}/fork`, { method: "POST" }); + +export const deleteMyData = () => + apiRequest<{ message: string }>("/user/delete-my-data?confirm=true", { method: "DELETE" }); \ No newline at end of file diff --git a/src/pages/Profile.tsx b/src/pages/Profile.tsx index f82738c..23ddad3 100644 --- a/src/pages/Profile.tsx +++ b/src/pages/Profile.tsx @@ -1,9 +1,9 @@ import { useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; import { Sidebar } from "../components/Sidebar"; -import { User, Mail, LogOut, Save, Key, CheckCircle2 } from "lucide-react"; -import { logoutUser } from "../lib/auth"; -import { getUserProfile } from "../lib/api"; +import { User, Mail, LogOut, Save, Key, CheckCircle2, Trash2 } from "lucide-react"; +import { logoutUser, forceSignOut } from "../lib/auth"; +import { getUserProfile, deleteMyData } from "../lib/api"; const Profile = () => { const navigate = useNavigate(); @@ -14,6 +14,9 @@ const Profile = () => { const [saved, setSaved] = useState(false); const [showLogoutConfirm, setShowLogoutConfirm] = useState(false); const [error, setError] = useState(""); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [deleteError, setDeleteError] = useState(""); useEffect(() => { const loadProfile = async () => { @@ -40,6 +43,19 @@ const Profile = () => { }, 800); }; + const handleDeleteData = async () => { + setIsDeleting(true); + setDeleteError(""); + try { + await deleteMyData(); + forceSignOut(); + } catch (err) { + setDeleteError(err instanceof Error ? err.message : "Failed to delete data."); + setIsDeleting(false); + setShowDeleteConfirm(false); + } + }; + const handleLogout = async () => { setShowLogoutConfirm(false); await logoutUser(); @@ -149,6 +165,28 @@ const Profile = () => { + {/* Danger Zone — Delete Data */} +
+
+
+

Delete all my data

+

+ Permanently deletes your chats, messages, API keys, and usage history. +

+
+ +
+ {deleteError && ( +

{deleteError}

+ )} +
+ {/* Logout */}
@@ -172,6 +210,45 @@ const Profile = () => {
+ {/* Delete Data Confirmation Modal */} + {showDeleteConfirm && ( +
+
!isDeleting && setShowDeleteConfirm(false)} + /> +
+
+ +
+

Delete all data?

+

+ This will permanently delete all your chats, messages, API keys, and usage history. This cannot be undone. +

+
+ + +
+
+
+ )} + {/* Logout Confirmation Modal */} {showLogoutConfirm && (
From 45b9f02026a41243b64cb11664f15ea33615f52f Mon Sep 17 00:00:00 2001 From: Antherix Date: Fri, 12 Jun 2026 09:01:40 +0530 Subject: [PATCH 3/3] fix: convert streaming to real SSE events (chunk/usage/done/error) --- backend/controllers/chatMessage.controller.js | 27 ++++--- src/lib/api.ts | 72 +++++++++++++------ 2 files changed, 71 insertions(+), 28 deletions(-) diff --git a/backend/controllers/chatMessage.controller.js b/backend/controllers/chatMessage.controller.js index 04425c5..c81e6ad 100644 --- a/backend/controllers/chatMessage.controller.js +++ b/backend/controllers/chatMessage.controller.js @@ -11,6 +11,11 @@ import { MemoryClient } from "mem0ai"; const memory = MEM0_ENABLED ? new MemoryClient({ apiKey: process.env.MEM0_API_KEY }) : null; +// SSE helper — writes a properly-framed SSE event +function writeSSE(res, event, data) { + res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); +} + const getAvailableModels = asyncHandler(async (req, res) => { const apikeys = await prisma.apiKey.findMany({ where: { userId: req.user.id }, @@ -125,7 +130,11 @@ if (chat.status === "FAILED") { relevantNodeIds = await treeindex.retrieveRelevantNodes(userPrompt); if(relevantNodeIds.length == 0) { - res.write("No relevant sources found, for this query"); + res.setHeader("Content-Type", "text/event-stream"); + res.setHeader("Cache-Control", "no-cache, no-transform"); + res.setHeader("Connection", "keep-alive"); + res.setHeader("X-Accel-Buffering", "no"); + writeSSE(res, "error", { message: "No relevant sources found for this query" }); res.end(); return; } @@ -210,7 +219,7 @@ if (chat.status === "FAILED") { res.setHeader("Cache-Control", "no-cache, no-transform"); res.setHeader("Connection", "keep-alive"); res.setHeader("X-Accel-Buffering", "no"); - + let llmResponse = ""; let inputTokens = 0; let outputTokens = 0; @@ -222,17 +231,19 @@ if (chat.status === "FAILED") { if (chunk.usage) { inputTokens = chunk.usage.prompt_tokens; outputTokens = chunk.usage.completion_tokens; + writeSSE(res, "usage", { inputTokens, outputTokens }); } if (content) { llmResponse += content; - res.write(content); + writeSSE(res, "chunk", { content }); } } - } catch (error) { - res.end("Stream ended with error.", error.message); - } finally { - res.end(); - } + writeSSE(res, "done", {}); + } catch (error) { + writeSSE(res, "error", { message: error.message || "Stream ended with error" }); + } finally { + res.end(); + } if (llmResponse.trim()) { if (MEM0_ENABLED && memory) { diff --git a/src/lib/api.ts b/src/lib/api.ts index cae5629..5a0a9ba 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -142,6 +142,7 @@ export const invalidateApiKeyCaches = () => { const prefix = cacheKey(""); removeMatchingFromCache(`${prefix}/apikey/list`); removeMatchingFromCache(`${prefix}/apikey/count`); + removeMatchingFromCache(`${prefix}/message/models`); }; export const invalidateChatCaches = () => { @@ -250,12 +251,15 @@ export const getMessageSources = (messageId: string) => }), ); + export const sendMessageStream = async (payload: { userPrompt: string; model: string; provider: string; chatId: string; onChunk?: (chunk: string) => void; + onError?: (message: string) => void; + signal?: AbortSignal; }) => { const token = getAccessToken(); const headers = new Headers({ "Content-Type": "application/json" }); @@ -267,7 +271,13 @@ export const sendMessageStream = async (payload: { method: "POST", headers, credentials: "include", - body: JSON.stringify(payload), + body: JSON.stringify({ + userPrompt: payload.userPrompt, + model: payload.model, + provider: payload.provider, + chatId: payload.chatId, + }), + signal: payload.signal, }); if (!response.ok || !response.body) { @@ -278,25 +288,54 @@ export const sendMessageStream = async (payload: { throw new Error(payload?.message || "Unable to send message"); } + // AFTER: const reader = response.body.getReader(); const decoder = new TextDecoder(); let text = ""; - + let buffer = ""; + while (true) { const { done, value } = await reader.read(); if (done) break; - const chunk = decoder.decode(value, { stream: true }); - if (!chunk) continue; - text += chunk; - payload.onChunk?.(chunk); - } - - const tail = decoder.decode(); - if (tail) { - text += tail; - payload.onChunk?.(tail); + buffer += decoder.decode(value, { stream: true }); + + // SSE frames are separated by double newlines + const frames = buffer.split("\n\n"); + buffer = frames.pop() ?? ""; // last partial frame stays in buffer + + for (const frame of frames) { + if (!frame.trim()) continue; + + let eventName = "message"; + let dataLine = ""; + + for (const line of frame.split("\n")) { + if (line.startsWith("event: ")) eventName = line.slice(7).trim(); + else if (line.startsWith("data: ")) dataLine = line.slice(6).trim(); + } + + if (!dataLine) continue; + + let parsed: Record; + try { + parsed = JSON.parse(dataLine); + } catch { + continue; + } + + if (eventName === "chunk" && typeof parsed.content === "string") { + text += parsed.content; + payload.onChunk?.(parsed.content); + } else if (eventName === "error") { + const msg = typeof parsed.message === "string" ? parsed.message : "Stream error"; + payload.onError?.(msg); + payload.onChunk?.(msg); // surface it in the UI + + } + // "usage" and "done" events — no UI action needed, silently consumed + } } - + invalidateChatMessages(payload.chatId); return text; }; @@ -368,13 +407,6 @@ export const getTopChatsByUsage = () => }> >("/usage/top-chats", { method: "GET" }), ); - apiRequest< - Array<{ - chatId: string; - _sum: { inputTokens: number | null; outputTokens: number | null }; - name?: string | null; - }> - >("/usage/top-chats", { method: "GET" }); export type UsageBreakdownItem = { model: string; provider: string | null;