Gustavo Zanatta 1 săptămână în urmă
părinte
comite
9ac8d78be5

+ 1 - 1
.env.app.dev

@@ -2,6 +2,6 @@ API_URL=http://localhost:3000
 PASSWORD=S@ft2080.
 WEBSOCKET_API=http://localhost:4321/
 WEBSOCKET_PATH=/socket.io
-WEBSOCKET_ROOM=LARAVEL
+WEBSOCKET_PROJECT=diaria
 WEBSOCKET_API_KEY=7wArC/kl0nTbt4zBu0agw.NXLyjA96I6x1XmBcuokwPqfo3/CIxzqYw.PTthh5eqa08Uf4ubFlOqatpShoz1CRRID9pZReEFvBk3il6E9u
 GOOGLE_MAPS_API_KEY=

+ 3 - 2
quasar.config.js

@@ -36,7 +36,7 @@ const loadAppEnv = (ctx) => {
     PASSWORD:            fileEnv.PASSWORD,
     WEBSOCKET_API:       fileEnv.WEBSOCKET_API,
     WEBSOCKET_PATH:      fileEnv.WEBSOCKET_PATH,
-    WEBSOCKET_ROOM:      fileEnv.WEBSOCKET_ROOM,
+    WEBSOCKET_PROJECT:   fileEnv.WEBSOCKET_PROJECT,
     WEBSOCKET_API_KEY:   fileEnv.WEBSOCKET_API_KEY,
     GOOGLE_MAPS_API_KEY: fileEnv.GOOGLE_MAPS_API_KEY,
   };
@@ -90,7 +90,8 @@ export default defineConfig((ctx) => {
       "defaultPropsComponents",
       "push-notifications",
       "pwa-elements",
-      // "socket.io",
+      "socket.io",
+      "realtime",
     ],
 
     // https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#css

+ 14 - 0
src/api/notification.js

@@ -0,0 +1,14 @@
+import api from "src/api";
+
+export const getNotifications = async () => {
+  const { data } = await api.get("/notifications");
+  return data.payload;
+};
+
+export const markNotificationAsRead = async (id) => {
+  await api.put(`/notifications/${id}/read`);
+};
+
+export const markAllNotificationsAsRead = async () => {
+  await api.put("/notifications/read-all");
+};

+ 22 - 0
src/boot/realtime.js

@@ -0,0 +1,22 @@
+import { defineBoot } from "#q-app/wrappers";
+import { watch } from "vue";
+import { registerRealtimeHandlers, unregisterRealtimeHandlers } from "src/realtime/handlers";
+import { userStore } from "src/stores/user";
+
+export default defineBoot(async () => {
+  const store = userStore();
+
+  watch(
+    () => store.user?.id,
+    (userId) => {
+      if (userId) {
+        registerRealtimeHandlers();
+
+        return;
+      }
+
+      unregisterRealtimeHandlers();
+    },
+    { immediate: true },
+  );
+});

+ 44 - 51
src/boot/socket.io.js

@@ -1,18 +1,27 @@
 import { defineBoot } from "#q-app/wrappers";
 import { io } from "socket.io-client";
-import { reactive } from "vue";
+import { reactive, watch } from "vue";
+import { realtimeRoom } from "src/realtime/rooms";
+import { userStore } from "src/stores/user";
 
 const state = reactive({
   activeRooms: new Set(),
   isConnected: false,
 });
 
+const log = (...args) => {
+  if (process.env.DEV) {
+    console.log("[realtime]", ...args);
+  }
+};
+
 const socket = io(process.env.WEBSOCKET_API, {
   transport: ["websocket"],
   path: process.env.WEBSOCKET_PATH,
   auth: {
     apiKey: process.env.WEBSOCKET_API_KEY,
   },
+  autoConnect: false,
   reconnection: true,
   reconnectionDelay: 1000,
   timeout: 20000,
@@ -26,15 +35,15 @@ const socket = io(process.env.WEBSOCKET_API, {
 const joinRoom = (roomName) => {
   if (!roomName) return false;
 
-  const fullRoomName = `${process.env.WEBSOCKET_ROOM}:${roomName}`;
+  const fullRoomName = `${process.env.WEBSOCKET_PROJECT}:${roomName}`;
 
   state.activeRooms.add(fullRoomName);
 
   if (state.isConnected) {
     socket.emit("join", fullRoomName);
-    console.log(`Joined room: ${fullRoomName}`);
+    log(`Joined room: ${fullRoomName}`);
   } else {
-    console.log(`Room ${fullRoomName} will join upon connection`);
+    log(`Room ${fullRoomName} will join upon connection`);
   }
 
   return true;
@@ -47,42 +56,25 @@ const joinRoom = (roomName) => {
 const leaveRoom = (roomName) => {
   if (!roomName) return;
 
-  const fullRoomName = `${process.env.WEBSOCKET_ROOM}:${roomName}`;
+  const fullRoomName = `${process.env.WEBSOCKET_PROJECT}:${roomName}`;
 
   state.activeRooms.delete(fullRoomName);
 
   if (state.isConnected) {
     socket.emit("leave", fullRoomName);
-    console.log(`Left room: ${fullRoomName}`);
+    log(`Left room: ${fullRoomName}`);
   }
 };
 
-const sendEventToLaravel = (eventName, data) => {
-  const channel = process.env.WEBSOCKET_ROOM + ":" + eventName;
-  socket.emit("eventWrapperToLaravel", {
-    channel: channel,
-    data: data,
-  });
-};
-
-const sendEvent = (room, eventName, data) => {
-  const channel = process.env.WEBSOCKET_ROOM + ":" + room + "@" + eventName;
-  socket.emit("eventWrapperToNode", {
-    channel: channel,
-    data: data,
-  });
-};
-
 export default defineBoot(async () => {
   socket.on("connect", () => {
-    console.log("Connected to websocket server!");
+    log("Connected to websocket server!");
     state.isConnected = true;
 
     // Rejoin all active rooms after reconnection
     if (state.activeRooms.size > 0) {
-      console.log(
-        `Rejoining ${state.activeRooms.size} rooms after reconnection`,
-      );
+      log(`Rejoining ${state.activeRooms.size} rooms after reconnection`);
+
       state.activeRooms.forEach((room) => {
         socket.emit("join", room);
       });
@@ -90,31 +82,40 @@ export default defineBoot(async () => {
   });
 
   socket.on("disconnect", () => {
-    console.log("Disconnected from websocket server!");
+    log("Disconnected from websocket server!");
     state.isConnected = false;
   });
 
   socket.on("connect_error", (error) => {
-    console.error("Websocket connection error: ", error);
+    console.error("Websocket connection error: ", error.message);
     state.isConnected = false;
   });
 
-  socket.on("connect_timeout", (timeout) => {
-    console.error("Websocket connection timeout: ", timeout);
-    state.isConnected = false;
-  });
 
-  socket.on("reconnect", (attemptNumber) => {
-    console.log(
-      "Reconnected to websocket server! Attempt number: ",
-      attemptNumber,
-    );
-    state.isConnected = true;
-  });
+  const store = userStore();
 
-  socket.on("reconnect_attempt", (attemptNumber) => {
-    console.log("Reconnect attempt number: ", attemptNumber);
-  });
+  watch(
+    () => store.user?.id,
+    (userId, previousUserId) => {
+      if (previousUserId && previousUserId !== userId) {
+        leaveRoom(realtimeRoom.user(previousUserId));
+      }
+
+      if (!userId) {
+        state.activeRooms.clear();
+        socket.disconnect();
+
+        return;
+      }
+
+      joinRoom(realtimeRoom.user(userId));
+
+      if (!socket.connected) {
+        socket.connect();
+      }
+    },
+    { immediate: true },
+  );
 });
 
 /**
@@ -147,12 +148,4 @@ const onceEvent = (event, callback, roomName = null) => {
   };
 };
 
-export {
-  socket,
-  joinRoom,
-  leaveRoom,
-  sendEvent,
-  sendEventToLaravel,
-  onceEvent,
-  state,
-};
+export { socket, joinRoom, leaveRoom, onceEvent, state };

+ 6 - 14
src/components/dashboard/DashboardHeaderBar.vue

@@ -72,36 +72,28 @@
 <script setup>
 import { computed } from 'vue'
 import { useProviderApproval } from 'src/composables/useProviderApproval'
+import { notificationsStore } from 'src/stores/notifications'
 import { useRouter } from 'vue-router'
 
 import LogoDiariaColorida from 'src/assets/logo_diaria_colorido_sem_texto.svg'
 
 const router = useRouter()
 
+const notifications = notificationsStore()
+
 const { isPendingProvider } = useProviderApproval()
 
-const props = defineProps({
+defineProps({
   data: {
     type: Object,
     default: () => null
-  },
-  notifications: {
-    type: Array,
-    default: () => []
   }
 })
 
-const unreadNotifications = computed(() => {
-  return props.notifications.filter((notification) => !notification.read).length
-})
+const unreadNotifications = computed(() => notifications.unreadCount)
 
 const goToNotifications = () => {
-  router.push({
-    name: 'NotificationsPage',
-    query: {
-      notifications: JSON.stringify(props.notifications)
-    }
-  })
+  router.push({ name: 'NotificationsPage' })
 }
 </script>
 

+ 109 - 0
src/composables/useRealtime.js

@@ -0,0 +1,109 @@
+import { onBeforeUnmount, toValue, watch } from "vue";
+import { joinRoom, leaveRoom, socket, state } from "src/boot/socket.io";
+
+const RESYNC_DEBOUNCE_MS = 300;
+
+/**
+ * @param {string|string[]} events Nome(s) de REALTIME_EVENT
+ * @param {Function} handler Recebe (payload, eventName)
+ * @return {Function} Cancela a escuta manualmente
+ */
+export const useRealtime = (events, handler) => {
+  const names = Array.isArray(events) ? events : [events];
+
+  const listeners = names.map((name) => {
+    const listener = (payload) => handler(payload, name);
+
+    socket.on(name, listener);
+
+    return { name, listener };
+  });
+
+  const stop = () => {
+    listeners.forEach(({ name, listener }) => socket.off(name, listener));
+  };
+
+  onBeforeUnmount(stop);
+
+  return stop;
+};
+
+/**
+ * @param {string|string[]|Function} rooms
+ * @return {Function} Sai de todas as salas manualmente
+ */
+export const useRealtimeRoom = (rooms) => {
+  let joined = [];
+
+  const resolve = () => {
+    const value = toValue(rooms);
+
+    return (Array.isArray(value) ? value : [value]).filter(Boolean);
+  };
+
+  const leaveAll = () => {
+    joined.forEach((room) => leaveRoom(room));
+    joined = [];
+  };
+
+  watch(
+    resolve,
+    (next) => {
+      const removed = joined.filter((room) => !next.includes(room));
+      const added = next.filter((room) => !joined.includes(room));
+
+      removed.forEach((room) => leaveRoom(room));
+      added.forEach((room) => joinRoom(room));
+
+      joined = next;
+    },
+    { immediate: true },
+  );
+
+  onBeforeUnmount(leaveAll);
+
+  return leaveAll;
+};
+
+/**
+ * @param {object} options
+ * @param {string|string[]|Function} options.rooms
+ * @param {string|string[]} options.events
+ * @param {Function} options.onSync
+ */
+export const useRealtimeSync = ({ rooms, events, onSync }) => {
+  let debounceId = null;
+  let hasConnected = state.isConnected;
+
+  const sync = () => {
+    clearTimeout(debounceId);
+
+    debounceId = setTimeout(() => onSync(), RESYNC_DEBOUNCE_MS);
+  };
+
+  useRealtimeRoom(rooms);
+  useRealtime(events, sync);
+
+  const onConnect = () => {
+    if (hasConnected) {
+      sync();
+    }
+
+    hasConnected = true;
+  };
+
+  const onVisibilityChange = () => {
+    if (document.visibilityState === "visible") {
+      sync();
+    }
+  };
+
+  socket.on("connect", onConnect);
+  document.addEventListener("visibilitychange", onVisibilityChange);
+
+  onBeforeUnmount(() => {
+    clearTimeout(debounceId);
+    socket.off("connect", onConnect);
+    document.removeEventListener("visibilitychange", onVisibilityChange);
+  });
+};

+ 14 - 3
src/pages/dashboard/DashboardPage.vue

@@ -11,7 +11,7 @@
 
     <template v-else>
       <q-pull-to-refresh color="primary" @refresh="onRefresh">
-        <DashboardHeaderBar :data="headerBar" :notifications="notifications" />
+        <DashboardHeaderBar :data="headerBar" />
 
         <DashboardSummaryInfos :data="summaryInfos" />
 
@@ -63,7 +63,9 @@ import { acceptServicePackage, rejectServicePackage, updateScheduleStatus } from
 import { useI18n } from "vue-i18n";
 import { useQuasar } from "quasar";
 import { useAuth } from "src/composables/useAuth";
+import { REALTIME_EVENT } from "src/realtime/events";
 import { useProviderApproval } from "src/composables/useProviderApproval";
+import { useRealtimeSync } from "src/composables/useRealtime";
 import { useRoute, useRouter } from "vue-router";
 
 import DashboardHeaderBar from "src/components/dashboard/DashboardHeaderBar.vue";
@@ -98,7 +100,6 @@ let approvalPollId = null;
 const headerBar = ref({});
 const loading = ref(true);
 const nextSchedules = ref([]);
-const notifications = ref([]);
 const opportunities = ref([]);
 const priceSuggestion = ref({});
 const solicitations = ref([]);
@@ -132,7 +133,6 @@ const loadDashboard = async () => {
   if (response) {
     headerBar.value = response.headerBar;
     nextSchedules.value = response.nextSchedules ?? [];
-    notifications.value = response.notifications ?? [];
     opportunities.value = response.opportunities ?? [];
     priceSuggestion.value = response.priceSuggested;
     solicitations.value = response.solicitations ?? [];
@@ -293,6 +293,17 @@ watch(
   { immediate: true },
 );
 
+useRealtimeSync({
+  events: [
+    REALTIME_EVENT.SCHEDULE_CREATED,
+    REALTIME_EVENT.SCHEDULE_STATUS_CHANGED,
+    REALTIME_EVENT.PROPOSAL_ACCEPTED,
+    REALTIME_EVENT.PROPOSAL_REFUSED,
+    REALTIME_EVENT.PACKAGE_STATUS_CHANGED,
+  ],
+  onSync: () => loadDashboard(),
+});
+
 onMounted(async () => {
   await loadDashboard();
   loading.value = false;

+ 10 - 44
src/pages/notifications/NotificationsPage.vue

@@ -93,54 +93,27 @@
 
 <script setup>
 
-import { computed, ref, onMounted } from 'vue'
+import { computed, onMounted } from 'vue'
+import { notificationsStore } from 'src/stores/notifications'
 import { useRouter } from 'vue-router'
 
-import { api } from 'boot/axios'
-
 import logoDiaria from 'src/assets/logo_diaria_colorido_sem_texto.svg'
 
 const router = useRouter()
 
-const notifications = ref([])
-
-onMounted(() => {
-  loadNotifications()
-})
+const store = notificationsStore()
 
-const unreadCount = computed(() => {
-  return notifications.value.filter((n) => !n.read).length
-})
+const notifications = computed(() => store.items)
 
-const loadNotifications = async () => {
-  try {
+const unreadCount = computed(() => store.unreadCount)
 
-    const response = await api.get('/notifications')
-
-    notifications.value = response.data.payload || []
-
-  } catch (error) {
-    console.error(error)
-  }
-}
+onMounted(() => {
+  store.load()
+})
 
 const markAsRead = async (notification) => {
   try {
-
-    await api.put(`/notifications/${notification.id}/read`)
-
-    notifications.value = notifications.value.map((item) => {
-
-      if (item.id === notification.id) {
-        return {
-          ...item,
-          read: true
-        }
-      }
-
-      return item
-    })
-
+    await store.markAsRead(notification.id)
   } catch (error) {
     console.error(error)
   }
@@ -148,14 +121,7 @@ const markAsRead = async (notification) => {
 
 const markAllAsRead = async () => {
   try {
-
-    await api.put('/notifications/read-all')
-
-    notifications.value = notifications.value.map((notification) => ({
-      ...notification,
-      read: true
-    }))
-
+    await store.markAllAsRead()
   } catch (error) {
     console.error(error)
   }

+ 24 - 0
src/realtime/events.js

@@ -0,0 +1,24 @@
+export const REALTIME_EVENT = Object.freeze({
+  // Sino de notificacoes
+  NOTIFICATION_CREATED: "notification.created",
+  NOTIFICATION_READ: "notification.read",
+  NOTIFICATION_READ_ALL: "notification.read_all",
+
+  // Agendamentos
+  SCHEDULE_CREATED: "schedule.created",
+  SCHEDULE_STATUS_CHANGED: "schedule.status_changed",
+
+  // Propostas
+  PROPOSAL_CREATED: "proposal.created",
+  PROPOSAL_ACCEPTED: "proposal.accepted",
+  PROPOSAL_REFUSED: "proposal.refused",
+
+  // Pacotes e pagamento
+  PACKAGE_STATUS_CHANGED: "package.status_changed",
+  PAYMENT_STATUS_CHANGED: "payment.status_changed",
+
+  // Prestador e backoffice
+  PROVIDER_APPROVAL_CHANGED: "provider.approval_changed",
+  PROVIDER_PENDING_CREATED: "provider.pending_created",
+  SUPPORT_REQUEST_CREATED: "support_request.created",
+});

+ 34 - 0
src/realtime/handlers.js

@@ -0,0 +1,34 @@
+import { REALTIME_EVENT } from "src/realtime/events";
+import { notificationsStore } from "src/stores/notifications";
+import { socket } from "src/boot/socket.io";
+
+
+const BELL_EVENTS = [
+  REALTIME_EVENT.NOTIFICATION_CREATED,
+  REALTIME_EVENT.NOTIFICATION_READ,
+  REALTIME_EVENT.NOTIFICATION_READ_ALL,
+];
+
+let listener = null;
+
+export const registerRealtimeHandlers = () => {
+  if (listener) return;
+
+  const notifications = notificationsStore();
+
+  listener = () => notifications.load();
+
+  BELL_EVENTS.forEach((event) => socket.on(event, listener));
+
+  notifications.load();
+};
+
+export const unregisterRealtimeHandlers = () => {
+  if (!listener) return;
+
+  BELL_EVENTS.forEach((event) => socket.off(event, listener));
+
+  listener = null;
+
+  notificationsStore().reset();
+};

+ 6 - 0
src/realtime/rooms.js

@@ -0,0 +1,6 @@
+export const realtimeRoom = Object.freeze({
+  user: (userId) => `user.${userId}`,
+  schedule: (scheduleId) => `schedule.${scheduleId}`,
+  package: (servicePackageId) => `package.${servicePackageId}`,
+  provider: (providerId) => `provider.${providerId}`,
+});

+ 86 - 0
src/stores/notifications.js

@@ -0,0 +1,86 @@
+import { computed, ref } from "vue";
+import { defineStore } from "pinia";
+import {
+  getNotifications,
+  markAllNotificationsAsRead,
+  markNotificationAsRead,
+} from "src/api/notification";
+
+export const notificationsStore = defineStore("notifications", () => {
+  const items = ref([]);
+  const loading = ref(false);
+
+  const unreadCount = computed(
+    () => items.value.filter((notification) => !notification.read).length,
+  );
+
+  let inFlight = null;
+
+  const load = () => {
+    if (inFlight) return inFlight;
+
+    loading.value = true;
+
+    inFlight = getNotifications()
+      .then((payload) => {
+        items.value = payload ?? [];
+      })
+      .catch((error) => {
+        console.error(error);
+      })
+      .finally(() => {
+        loading.value = false;
+        inFlight = null;
+      });
+
+    return inFlight;
+  };
+
+  const markAsRead = async (id) => {
+    const target = items.value.find((notification) => notification.id === id);
+
+    if (!target || target.read) return;
+
+    target.read = true;
+
+    try {
+      await markNotificationAsRead(id);
+    } catch (error) {
+      target.read = false;
+
+      throw error;
+    }
+  };
+
+  const markAllAsRead = async () => {
+    const previous = items.value.map((notification) => notification.read);
+
+    items.value.forEach((notification) => {
+      notification.read = true;
+    });
+
+    try {
+      await markAllNotificationsAsRead();
+    } catch (error) {
+      items.value.forEach((notification, index) => {
+        notification.read = previous[index];
+      });
+
+      throw error;
+    }
+  };
+
+  const reset = () => {
+    items.value = [];
+  };
+
+  return {
+    items,
+    loading,
+    unreadCount,
+    load,
+    markAsRead,
+    markAllAsRead,
+    reset,
+  };
+});