Przeglądaj źródła

Admin dashboard: fix file management bugs and implement real-time unread count notifications

unknown 6 dni temu
rodzic
commit
192dffddd1

+ 3 - 1
backend/preview_utils.py

@@ -1,7 +1,9 @@
+import os
+import matplotlib
+matplotlib.use('Agg')
 import matplotlib.pyplot as plt
 from stl import mesh
 from mpl_toolkits import mplot3d
-import os
 
 def generate_stl_preview(stl_path: str, output_path: str):
     """

+ 25 - 19
backend/routers/auth.py

@@ -1,4 +1,5 @@
-from fastapi import APIRouter, Request, Depends, HTTPException
+from fastapi import APIRouter, Request, Depends, HTTPException, WebSocket, WebSocketDisconnect, Query
+from services.global_manager import global_manager
 import auth_utils
 import db
 import schemas
@@ -108,23 +109,28 @@ async def update_me(data: schemas.UserUpdate, token: str = Depends(auth_utils.oa
     user = db.execute_query("SELECT id, email, first_name, last_name, phone, shipping_address, preferred_language, role, ip_address, created_at FROM users WHERE id = %s", (user_id,))
     return user[0]
 
-@router.post("/ping")
-async def track_ping(token: str = Depends(auth_utils.oauth2_scheme)):
+@router.websocket("/ws/global")
+async def ws_global(websocket: WebSocket, token: str = Query(...)):
     payload = auth_utils.decode_token(token)
-    unread_count = 0
-    if payload and payload.get("id"):
-        user_id = payload.get("id")
-        role = payload.get("role")
-        session_utils.track_user_ping(user_id)
+    if not payload:
+        await websocket.close(code=4001)
+        return
+    user_id = payload.get("id")
+    role = payload.get("role")
+    if not user_id:
+        await websocket.close(code=4001)
+        return
         
-        if role == 'admin':
-            row = db.execute_query("SELECT count(*) as cnt FROM order_messages WHERE is_from_admin = FALSE AND is_read = FALSE")
-        else:
-            row = db.execute_query(
-                "SELECT count(*) as cnt FROM order_messages om JOIN orders o ON om.order_id = o.id WHERE o.user_id = %s AND om.is_from_admin = TRUE AND om.is_read = FALSE",
-                (user_id,)
-            )
-        if row:
-            unread_count = row[0]['cnt']
-            
-    return {"status": "ok", "unread_count": unread_count}
+    await global_manager.connect(websocket, user_id, role)
+    session_utils.track_user_ping(user_id)
+    
+    # Send initial unread count
+    await global_manager.notify_user(user_id) if role != 'admin' else await global_manager.notify_admins()
+    
+    try:
+        while True:
+            data = await websocket.receive_text()
+            if data == "ping":
+                session_utils.track_user_ping(user_id)
+    except WebSocketDisconnect:
+        global_manager.disconnect(websocket, user_id)

+ 9 - 0
backend/routers/chat.py

@@ -1,5 +1,6 @@
 from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Depends, Query, HTTPException
 from services.chat_manager import manager
+from services.global_manager import global_manager
 import db
 import auth_utils
 import datetime
@@ -23,8 +24,10 @@ async def get_order_messages(order_id: int, token: str = Depends(auth_utils.oaut
     # Mark messages as read
     if role == 'admin':
         db.execute_commit("UPDATE order_messages SET is_read = TRUE WHERE order_id = %s AND is_from_admin = FALSE AND is_read = FALSE", (order_id,))
+        await global_manager.notify_admins()
     else:
         db.execute_commit("UPDATE order_messages SET is_read = TRUE WHERE order_id = %s AND is_from_admin = TRUE AND is_read = FALSE", (order_id,))
+        await global_manager.notify_user(user_id)
         
     return messages
 
@@ -44,6 +47,10 @@ async def post_order_message(order_id: int, data: schemas.MessageCreate, token:
     msg_id = db.execute_commit(query, (order_id, user_id, is_admin, message))
     now = datetime.datetime.utcnow().isoformat()
     await manager.broadcast_to_order(order_id, {"id": msg_id, "is_from_admin": is_admin, "message": message, "created_at": now})
+    if is_admin:
+        await global_manager.notify_user(order[0]['user_id'])
+    else:
+        await global_manager.notify_admins()
     return {"id": msg_id, "status": "sent"}
 
 @router.websocket("/ws/chat/{order_id}")
@@ -72,7 +79,9 @@ async def ws_chat(websocket: WebSocket, order_id: int, token: str = Query(...)):
             elif data == "read":
                 if role == 'admin':
                     db.execute_commit("UPDATE order_messages SET is_read = TRUE WHERE order_id = %s AND is_from_admin = FALSE AND is_read = FALSE", (order_id,))
+                    await global_manager.notify_admins()
                 else:
                     db.execute_commit("UPDATE order_messages SET is_read = TRUE WHERE order_id = %s AND is_from_admin = TRUE AND is_read = FALSE", (order_id,))
+                    await global_manager.notify_user(user_id)
     except WebSocketDisconnect:
         manager.disconnect(websocket, order_id)

+ 7 - 4
backend/routers/files.py

@@ -17,7 +17,8 @@ async def upload_files(files: List[UploadFile] = File(...)):
         if not file.filename: continue
         file_ext = os.path.splitext(file.filename)[1]
         unique_filename = f"{uuid.uuid4()}{file_ext}"
-        file_path = os.path.join(config.UPLOAD_DIR, unique_filename).replace("\\", "/")
+        file_path = os.path.join(config.UPLOAD_DIR, unique_filename)
+        db_file_path = f"uploads/{unique_filename}"
         
         sha256_hash = hashlib.sha256()
         with open(file_path, "wb") as buffer:
@@ -35,16 +36,18 @@ async def upload_files(files: List[UploadFile] = File(...)):
                 print_time = result.get('print_time_str')
         
         preview_path = None
+        db_preview_path = None
         if file_ext.lower() == ".stl":
             preview_filename = f"{uuid.uuid4()}.png"
-            preview_path = os.path.join(config.PREVIEW_DIR, preview_filename).replace("\\", "/")
+            preview_path = os.path.join(config.PREVIEW_DIR, preview_filename)
+            db_preview_path = f"uploads/previews/{preview_filename}"
             preview_utils.generate_stl_preview(file_path, preview_path)
 
         query = "INSERT INTO order_files (order_id, filename, file_path, file_size, quantity, file_hash, print_time, filament_g, preview_path) VALUES (NULL, %s, %s, %s, 1, %s, %s, %s, %s)"
-        f_id = db.execute_commit(query, (file.filename, file_path, file.size, sha256_hash.hexdigest(), print_time, filament_g, preview_path))
+        f_id = db.execute_commit(query, (file.filename, db_file_path, file.size, sha256_hash.hexdigest(), print_time, filament_g, db_preview_path))
         
         uploaded_data.append({
             "id": f_id, "filename": file.filename, "size": file.size,
-            "print_time": print_time, "filament_g": filament_g, "preview_path": preview_path
+            "print_time": print_time, "filament_g": filament_g, "preview_path": db_preview_path
         })
     return {"uploaded": uploaded_data}

+ 37 - 6
backend/routers/orders.py

@@ -94,7 +94,8 @@ async def get_my_orders(token: str = Depends(auth_utils.oauth2_scheme)):
     user_id = payload.get("id")
     query = """
     SELECT o.*, 
-           GROUP_CONCAT(JSON_OBJECT('filename', f.filename, 'file_path', f.file_path, 'quantity', f.quantity, 'preview_path', f.preview_path, 'print_time', f.print_time, 'filament_g', f.filament_g)) as files
+           (SELECT count(*) FROM order_messages om WHERE om.order_id = o.id AND om.is_from_admin = TRUE AND om.is_read = FALSE) as unread_count,
+           GROUP_CONCAT(JSON_OBJECT('file_id', f.id, 'filename', f.filename, 'file_path', f.file_path, 'quantity', f.quantity, 'preview_path', f.preview_path, 'print_time', f.print_time, 'filament_g', f.filament_g)) as files
     FROM orders o
     LEFT JOIN order_files f ON o.id = f.order_id
     WHERE o.user_id = %s
@@ -135,7 +136,8 @@ async def get_admin_orders(token: str = Depends(auth_utils.oauth2_scheme)):
     
     query = """
     SELECT o.*, 
-           GROUP_CONCAT(JSON_OBJECT('filename', f.filename, 'file_path', f.file_path, 'file_size', f.file_size, 'quantity', f.quantity, 'preview_path', f.preview_path, 'print_time', f.print_time, 'filament_g', f.filament_g)) as files
+           (SELECT count(*) FROM order_messages om WHERE om.order_id = o.id AND om.is_from_admin = FALSE AND om.is_read = FALSE) as unread_count,
+           GROUP_CONCAT(JSON_OBJECT('file_id', f.id, 'filename', f.filename, 'file_path', f.file_path, 'file_size', f.file_size, 'quantity', f.quantity, 'preview_path', f.preview_path, 'print_time', f.print_time, 'filament_g', f.filament_g)) as files
     FROM orders o
     LEFT JOIN order_files f ON o.id = f.order_id
     GROUP BY o.id
@@ -215,7 +217,8 @@ async def admin_attach_file(
         raise HTTPException(status_code=403, detail="Admin role required")
         
     unique_filename = f"{uuid.uuid4()}{os.path.splitext(file.filename)[1]}"
-    file_path = os.path.join(config.UPLOAD_DIR, unique_filename).replace("\\", "/")
+    file_path = os.path.join(config.UPLOAD_DIR, unique_filename)
+    db_file_path = f"uploads/{unique_filename}"
     
     sha256_hash = hashlib.sha256()
     with open(file_path, "wb") as buffer:
@@ -224,9 +227,11 @@ async def admin_attach_file(
             buffer.write(chunk)
     
     preview_path = None
+    db_preview_path = None
     if file_path.lower().endswith(".stl"):
         preview_filename = f"{uuid.uuid4()}.png"
-        preview_path = os.path.join(config.PREVIEW_DIR, preview_filename).replace("\\", "/")
+        preview_path = os.path.join(config.PREVIEW_DIR, preview_filename)
+        db_preview_path = f"uploads/previews/{preview_filename}"
         preview_utils.generate_stl_preview(file_path, preview_path)
 
     filament_g = None
@@ -238,6 +243,32 @@ async def admin_attach_file(
             print_time = result.get('print_time_str')
 
     query = "INSERT INTO order_files (order_id, filename, file_path, file_size, quantity, file_hash, print_time, filament_g, preview_path) VALUES (%s, %s, %s, %s, 1, %s, %s, %s, %s)"
-    f_id = db.execute_commit(query, (order_id, file.filename, file_path, file.size, sha256_hash.hexdigest(), print_time, filament_g, preview_path))
+    f_id = db.execute_commit(query, (order_id, file.filename, db_file_path, file.size, sha256_hash.hexdigest(), print_time, filament_g, db_preview_path))
     
-    return {"id": f_id, "filename": file.filename, "preview_path": preview_path, "filament_g": filament_g, "print_time": print_time}
+    return {"file_id": f_id, "filename": file.filename, "preview_path": db_preview_path, "filament_g": filament_g, "print_time": print_time}
+
+@router.delete("/{order_id}/files/{file_id}")
+async def admin_delete_file(
+    order_id: int,
+    file_id: int,
+    token: str = Depends(auth_utils.oauth2_scheme)
+):
+    payload = auth_utils.decode_token(token)
+    if not payload or payload.get("role") != 'admin':
+        raise HTTPException(status_code=403, detail="Admin role required")
+        
+    file_record = db.execute_query("SELECT file_path, preview_path FROM order_files WHERE id = %s AND order_id = %s", (file_id, order_id))
+    if not file_record:
+        raise HTTPException(status_code=404, detail="File not found")
+        
+    base_dir = config.BASE_DIR
+    try:
+        if file_record[0]['file_path']:
+            os.remove(os.path.join(base_dir, file_record[0]['file_path']))
+        if file_record[0]['preview_path']:
+            os.remove(os.path.join(base_dir, file_record[0]['preview_path']))
+    except Exception as e:
+        print(f"Error removing file from disk: {e}")
+        
+    db.execute_commit("DELETE FROM order_files WHERE id = %s AND order_id = %s", (file_id, order_id))
+    return {"status": "success"}

+ 56 - 0
backend/services/global_manager.py

@@ -0,0 +1,56 @@
+import json
+from typing import Dict, List
+from fastapi import WebSocket
+
+class GlobalConnectionManager:
+    def __init__(self):
+        self.active_connections: Dict[int, List[WebSocket]] = {}
+        self.user_roles: Dict[int, str] = {}
+        
+    async def connect(self, websocket: WebSocket, user_id: int, role: str):
+        await websocket.accept()
+        if user_id not in self.active_connections:
+            self.active_connections[user_id] = []
+        self.active_connections[user_id].append(websocket)
+        self.user_roles[user_id] = role
+
+    def disconnect(self, websocket: WebSocket, user_id: int):
+        if user_id in self.active_connections:
+            self.active_connections[user_id] = [ws for ws in self.active_connections[user_id] if ws != websocket]
+            if not self.active_connections[user_id]:
+                del self.active_connections[user_id]
+                if user_id in self.user_roles:
+                    del self.user_roles[user_id]
+
+    async def send_unread_count(self, user_id: int, count: int):
+        if user_id in self.active_connections:
+            payload = json.dumps({"type": "unread_count", "count": count})
+            for ws in self.active_connections[user_id]:
+                try:
+                    await ws.send_text(payload)
+                except:
+                    pass
+
+    async def notify_user(self, user_id: int):
+        import db
+        row = db.execute_query(
+            "SELECT count(*) as cnt FROM order_messages om JOIN orders o ON om.order_id = o.id WHERE o.user_id = %s AND om.is_from_admin = TRUE AND om.is_read = FALSE",
+            (user_id,)
+        )
+        count = row[0]['cnt'] if row else 0
+        await self.send_unread_count(user_id, count)
+
+    async def notify_admins(self):
+        import db
+        row = db.execute_query("SELECT count(*) as cnt FROM order_messages WHERE is_from_admin = FALSE AND is_read = FALSE")
+        count = row[0]['cnt'] if row else 0
+        payload = json.dumps({"type": "unread_count", "count": count})
+        for uid, role in self.user_roles.items():
+            if role == 'admin' and uid in self.active_connections:
+                for ws in self.active_connections[uid]:
+                    try:
+                        await ws.send_text(payload)
+                    except:
+                        pass
+
+global_manager = GlobalConnectionManager()

+ 1 - 1
src/components/Footer.vue

@@ -40,7 +40,7 @@
       </div>
 
       <!-- Bottom -->
-      <div class="pt-6 border-t border-black/[0.04] flex flex-col sm:flex-row justify-between items-center gap-4">
+      <div class="pt-6 border-t border-black/[0.04] flex flex-col md:flex-row justify-between items-center gap-4">
         <p class="text-[10px] font-bold text-foreground/30 uppercase tracking-widest">© 2024 Radionica 3D. {{ t("footer.allRightsReserved") }}</p>
         <div class="flex gap-4">
           <router-link to="/privacy" class="text-[10px] font-bold text-foreground/30 hover:text-primary transition-colors uppercase tracking-widest">{{ t("footer.privacy") }}</router-link>

+ 13 - 14
src/components/Header.vue

@@ -19,9 +19,8 @@
         </nav>
 
         <!-- Right controls -->
-        <div class="hidden lg:flex items-center gap-2">
-          <LanguageSwitcher />
-
+        <div class="flex items-center gap-2">
+          <div class="hidden lg:flex items-center gap-2">
           <RouterLink
             v-if="isAdmin"
             to="/admin"
@@ -34,7 +33,7 @@
           <!-- Unread Messages Badge -->
           <RouterLink
             v-if="isLoggedIn && authStore.unreadMessagesCount > 0"
-            to="/orders"
+            :to="isAdmin ? '/admin' : '/orders'"
             class="inline-flex items-center gap-1.5 px-3 py-1 bg-red-500 hover:bg-red-600 text-white text-xs font-bold rounded-full transition-all shadow-md animate-in fade-in"
             title="Unread messages in chat"
           >
@@ -65,13 +64,17 @@
               {{ t("nav.register") }}
             </Button>
           </template>
-        </div>
+          </div>
 
-        <!-- Hamburger -->
-        <Button variant="ghost" size="icon" class="lg:hidden" @click="mobileOpen = !mobileOpen">
-          <X v-if="mobileOpen" class="w-5 h-5" />
-          <Menu v-else class="w-5 h-5" />
-        </Button>
+          <!-- Language Switcher visible everywhere -->
+          <LanguageSwitcher />
+
+          <!-- Hamburger -->
+          <Button variant="ghost" size="icon" class="lg:hidden" @click="mobileOpen = !mobileOpen">
+            <X v-if="mobileOpen" class="w-5 h-5" />
+            <Menu v-else class="w-5 h-5" />
+          </Button>
+        </div>
       </div>
     </div>
 
@@ -141,10 +144,6 @@
               </Button>
             </template>
           </div>
-
-          <div class="pt-4">
-            <LanguageSwitcher @select="mobileOpen = false" />
-          </div>
         </nav>
       </div>
     </Transition>

+ 12 - 0
src/lib/api.ts

@@ -362,6 +362,18 @@ export const adminAttachFile = async (orderId: number, formData: FormData) => {
   return response.json();
 };
 
+export const adminDeleteFile = async (orderId: number, fileId: number) => {
+  const token = localStorage.getItem("token");
+  const response = await fetch(`${API_BASE_URL}/orders/${orderId}/files/${fileId}?lang=${i18n.global.locale.value}`, {
+    method: 'DELETE',
+    headers: { 
+      'Authorization': `Bearer ${token}`
+    }
+  });
+  if (!response.ok) throw new Error("Failed to delete file");
+  return response.json();
+};
+
 export const adminUpdatePhotoStatus = async (photoId: number, data: { is_public: boolean }) => {
   const token = localStorage.getItem("token");
   const response = await fetch(`${API_BASE_URL}/admin/photos/${photoId}?lang=${i18n.global.locale.value}`, {

+ 15 - 4
src/pages/Admin.vue

@@ -137,8 +137,13 @@
                         </div>
                       </div>
                     </div>
-                    <!-- Quantity Badge -->
-                    <div class="absolute top-2 right-2 px-1.5 py-0.5 bg-background/80 backdrop-blur-md rounded-md border border-border/50 text-[10px] font-bold">x{{ f.quantity || 1 }}</div>
+                    <!-- Delete Actions & Quantity -->
+                    <div class="absolute top-2 right-2 flex flex-col items-end gap-1">
+                      <div class="px-1.5 py-0.5 bg-background/80 backdrop-blur-md rounded-md border border-border/50 text-[10px] font-bold">x{{ f.quantity || 1 }}</div>
+                      <button @click.prevent="handleDeleteFile(order.id, f.file_id, f.filename)" class="p-1 bg-background/80 backdrop-blur-md border border-border/50 text-rose-500 hover:bg-rose-500 hover:text-white rounded-md transition-colors shadow-sm" title="Delete attached file">
+                        <Trash2 class="w-2.5 h-2.5" />
+                      </button>
+                    </div>
                   </div>
                 </div>
               <div class="pt-4 border-t border-border/50">
@@ -195,7 +200,8 @@
                   <FileText class="w-4 h-4" /> Print Uplatnica
                 </a>
 
-                <button @click="toggleAdminChat(order.id)" :class="['w-full flex items-center justify-center gap-2 py-2 rounded-xl border border-border/50 hover:border-primary/30 text-sm font-bold transition-all', order.invoice_path ? 'mt-2' : 'mt-4', adminChatId === order.id ? 'bg-primary text-primary-foreground' : 'bg-background/50 text-muted-foreground hover:text-foreground']">
+                <button @click="toggleAdminChat(order.id)" :class="['w-full flex items-center justify-center gap-2 py-2 rounded-xl border border-border/50 hover:border-primary/30 text-sm font-bold transition-all relative', order.invoice_path ? 'mt-2' : 'mt-4', adminChatId === order.id ? 'bg-primary text-primary-foreground' : 'bg-background/50 text-muted-foreground hover:text-foreground']">
+                  <span v-if="order.unread_count > 0" class="absolute -top-2 -right-2 bg-red-500 text-white text-[10px] w-5 h-5 flex items-center justify-center rounded-full shadow-sm animate-pulse z-10">{{ order.unread_count }}</span>
                   <MessageCircle class="w-4 h-4" />{{ t("chat.open") }}
                 </button>
               </div>
@@ -458,7 +464,7 @@ import { useAuthStore } from "@/stores/auth";
 import { 
   adminGetOrders, adminUpdateOrder, adminGetMaterials, adminCreateMaterial, adminUpdateMaterial, adminDeleteMaterial, 
   adminGetServices, adminCreateService, adminUpdateService, adminDeleteService, adminUploadOrderPhoto, 
-  adminUpdatePhotoStatus, adminAttachFile, getBlogPosts, adminCreatePost, adminUpdatePost, adminDeletePost 
+  adminUpdatePhotoStatus, adminAttachFile, adminDeleteFile, getBlogPosts, adminCreatePost, adminUpdatePost, adminDeletePost 
 } from "@/lib/api";
 
 const { t } = useI18n();
@@ -571,6 +577,11 @@ async function handleAttachFile(orderId: number, file?: File) {
   try { await adminAttachFile(orderId, fd); toast.success("File attached and preview generated"); fetchData(); }
   catch (e: any) { toast.error(e.message); }
 }
+async function handleDeleteFile(orderId: number, fileId: number, filename: string) {
+  if (!window.confirm(`Delete attached file "${filename}"?`)) return;
+  try { await adminDeleteFile(orderId, fileId); toast.success("File deleted successfully"); fetchData(); }
+  catch { toast.error("Failed to delete file"); }
+}
 async function handleDeleteMaterial(id: number, name: string) {
   if (!window.confirm(`Delete material "${name}"?`)) return;
   try { await adminDeleteMaterial(id); toast.success("Deleted"); fetchData(); }

+ 15 - 4
src/pages/Orders.vue

@@ -72,10 +72,9 @@
                     class="p-2 hover:bg-secondary rounded-lg transition-colors">
                     <ExternalLink class="w-4 h-4" />
                   </a>
-                  <RouterLink :to="`/order-success?id=${order.id}`">
-                    <Button variant="outline" size="sm">Details</Button>
-                  </RouterLink>
+                  <!-- Removed obsolete routing link -->
                   <Button variant="ghost" size="sm" @click="toggleChat(order.id)" class="relative">
+                    <span v-if="order.unread_count > 0" class="absolute -top-2 -right-2 bg-red-500 text-white text-[10px] w-5 h-5 flex items-center justify-center rounded-full shadow-sm animate-pulse z-10">{{ order.unread_count }}</span>
                     <MessageCircle class="w-4 h-4" :class="openChatId === order.id ? 'text-primary' : ''" />
                     <span class="ml-1 text-xs">{{ t("chat.open") }}</span>
                   </Button>
@@ -157,7 +156,7 @@
 </template>
 
 <script setup lang="ts">
-import { ref, onMounted, defineComponent, h } from "vue";
+import { ref, onMounted, defineComponent, h, watch } from "vue";
 import { RouterLink, useRouter } from "vue-router";
 import { useI18n } from "vue-i18n";
 import { Package, Clock, ShieldCheck, Truck, XCircle, ArrowLeft, Loader2, ExternalLink, Hash, FileText, Image as ImageIcon, MessageCircle, FileBox, Download } from "lucide-vue-next";
@@ -167,9 +166,11 @@ import Footer from "@/components/Footer.vue";
 import OrderChat from "@/components/OrderChat.vue";
 import OrderTracker from "@/components/OrderTracker.vue";
 import { getMyOrders } from "@/lib/api";
+import { useAuthStore } from "@/stores/auth";
 
 const { t } = useI18n();
 const router = useRouter();
+const authStore = useAuthStore();
 const orders = ref<any[]>([]);
 const isLoading = ref(true);
 const openChatId = ref<number | null>(null);
@@ -213,4 +214,14 @@ onMounted(async () => {
   catch (e) { console.error("Failed to fetch orders:", e); }
   finally { isLoading.value = false; }
 });
+
+watch(() => authStore.unreadMessagesCount, async (newVal, oldVal) => {
+  if (newVal !== oldVal) {
+    try {
+      orders.value = await getMyOrders();
+    } catch (e) {
+      console.error("Failed to auto-refresh orders:", e);
+    }
+  }
+});
 </script>

+ 49 - 16
src/stores/auth.ts

@@ -53,26 +53,62 @@ export const useAuthStore = defineStore("auth", () => {
     }
   }
 
+  let globalWs: WebSocket | null = null;
+  let reconnectTimer: number | null = null;
+  const WS_BASE_URL = "ws://localhost:8000";
+
   function startPing() {
-    import("@/lib/api").then(({ authPing }) => {
-      const doPing = async () => {
-        const res = await authPing();
-        if (res && res.unread_count !== undefined) {
-          if (res.unread_count > unreadMessagesCount.value) {
+    stopPing(); // Ensure fresh start
+    const token = localStorage.getItem("token");
+    if (!token) return;
+
+    globalWs = new WebSocket(`${WS_BASE_URL}/api/auth/ws/global?token=${encodeURIComponent(token)}`);
+
+    globalWs.onopen = () => {
+      // Send ping every 25 seconds to keep connection alive and update online status in Redis
+      pingInterval = window.setInterval(() => {
+        if (globalWs && globalWs.readyState === WebSocket.OPEN) {
+          globalWs.send("ping");
+        }
+      }, 25000);
+    };
+
+    globalWs.onmessage = (event) => {
+      try {
+        const msg = JSON.parse(event.data);
+        if (msg.type === "unread_count" && msg.count !== undefined) {
+          if (msg.count > unreadMessagesCount.value) {
             playNotificationSound();
           }
-          unreadMessagesCount.value = res.unread_count;
+          unreadMessagesCount.value = msg.count;
         }
-      };
-      doPing(); // ping immediately
-      pingInterval = window.setInterval(doPing, 30000);
-    });
+      } catch (e) {
+        console.error("WS Parse error", e);
+      }
+    };
+
+    globalWs.onclose = () => {
+      if (pingInterval) clearInterval(pingInterval);
+      if (user.value) { // If still logged in, try reconnecting
+        reconnectTimer = window.setTimeout(startPing, 5000);
+      }
+    };
   }
+
   function stopPing() {
     if (pingInterval) {
       clearInterval(pingInterval);
       pingInterval = null;
     }
+    if (reconnectTimer) {
+      clearTimeout(reconnectTimer);
+      reconnectTimer = null;
+    }
+    if (globalWs) {
+      globalWs.onclose = null; // Disable auto reconnect
+      globalWs.close();
+      globalWs = null;
+    }
   }
 
   function init() {
@@ -102,12 +138,9 @@ export const useAuthStore = defineStore("auth", () => {
   }
 
   async function refreshUnreadCount() {
-    import("@/lib/api").then(async ({ authPing }) => {
-      const res = await authPing();
-      if (res && res.unread_count !== undefined) {
-        unreadMessagesCount.value = res.unread_count;
-      }
-    });
+    // Left empty or removed, as WS handles updates now.
+    // If you need manual force, you could re-trigger connect or add an endpoint, 
+    // but the WS pushes update automatically.
   }
 
   return {