Gustavo Zanatta 1 tydzień temu
rodzic
commit
1fcf97df13

+ 1 - 1
.env.app.dev

@@ -2,7 +2,7 @@ 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=
 PAGARME_PUBLIC_KEY=pk_test_1VRWkbvu43Tyk7qG

+ 3 - 2
quasar.config.js

@@ -35,7 +35,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,
     PAGARME_PUBLIC_KEY:  fileEnv.PAGARME_PUBLIC_KEY
@@ -89,7 +89,8 @@ export default defineConfig((ctx) => {
       "i18n",
       "defaultPropsComponents",
       "push-notifications",
-      // "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 - 16
src/components/dashboard/DashboardHeaderBar.vue

@@ -54,36 +54,26 @@
 <script setup>
 import { computed } from 'vue'
 import { formatRating } from 'src/helpers/utils'
+import { notificationsStore } from 'src/stores/notifications'
 import { useRouter } from 'vue-router'
 
 import LogoDiariaColorida from 'src/assets/logo_diaria_colorido_sem_texto.svg'
 
-const props = defineProps({
+defineProps({
   data: {
     type: Object,
     default: () => null
-  },
-
-  notifications: {
-    type: Array,
-    default: () => []
   }
 })
 
 const router = useRouter()
 
-//vai para dashboard as notificações tem que ser mocada no backend
-const unreadNotifications = computed(() => {
-  return props.notifications.filter((notification) => !notification.read).length
-})
+const notifications = notificationsStore()
+
+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);
+  });
+};

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

@@ -10,7 +10,6 @@
       <q-pull-to-refresh color="primary" @refresh="onRefresh">
         <DashboardHeaderBar
           :data="headerBar"
-          :notifications="notifications"
         />
 
         <DashboardRegistrationIncomplete v-if="!registrationComplete" />
@@ -68,7 +67,9 @@ import { dadosDashboard } from 'src/api/dashboard';
 import { LocalStorage, useQuasar } from 'quasar';
 import { updateMe } from 'src/api/user';
 import { useI18n } from 'vue-i18n';
+import { REALTIME_EVENT } from 'src/realtime/events';
 import { usePaymentStore } from 'src/stores/payment';
+import { useRealtimeSync } from 'src/composables/useRealtime';
 import { useRoute, useRouter } from 'vue-router'
 import { userStore } from 'src/stores/user';
 
@@ -109,7 +110,6 @@ const headerBar = ref({});
 const lastDoneSchedules = ref([]);
 const loading = ref(true);
 const nextSchedules = ref([]);
-const notifications = ref([]);
 const pendingSchedules = ref([]);
 const pendingServicePackages = ref([]);
 const providersClose = ref([]);
@@ -246,7 +246,6 @@ const reloadDashboard = async (showLoader = true) => {
     clientProposals.value = response.schedulesProposals ?? [];
     customSchedulesNoProposals.value = response.customSchedulesNoProposals ?? [];
     todaySchedules.value = response.todaySchedules ?? [];
-    notifications.value = response.notifications ?? [];
     hasPaymentMethods.value = response.has_payment_methods ?? true;
     hasLocation.value = response.has_location ?? true;
   }
@@ -288,6 +287,17 @@ const openRatingDialog = (schedule) => {
   })
 }
 
+useRealtimeSync({
+  events: [
+    REALTIME_EVENT.SCHEDULE_CREATED,
+    REALTIME_EVENT.SCHEDULE_STATUS_CHANGED,
+    REALTIME_EVENT.PROPOSAL_CREATED,
+    REALTIME_EVENT.PROPOSAL_REFUSED,
+    REALTIME_EVENT.PACKAGE_STATUS_CHANGED,
+  ],
+  onSync: () => reloadDashboard(false),
+});
+
 onMounted(async () => {
   await reloadDashboard();
   maybeOpenSuccessModal();

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

@@ -93,43 +93,27 @@
 </template>
 
 <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([])
+const store = notificationsStore()
 
-onMounted(() => {
-  loadNotifications()
-})
+const notifications = computed(() => store.items)
 
-const unreadCount = computed(() => {
-  return notifications.value.filter((n) => !n.read).length
-})
+const unreadCount = computed(() => store.unreadCount)
 
-const loadNotifications = async () => {
-  try {
-    const response = await api.get('/notifications')
-    notifications.value = response.data.payload || []
-  } catch (error) {
-    console.error(error)
-  }
-}
+onMounted(() => {
+  store.load()
+})
 
 const markAsRead = async (id) => {
   try {
-    await api.put(`/notifications/${id}/read`)
-    notifications.value = notifications.value.map((notification) => {
-      if (notification.id === id) {
-        return { ...notification, read: true }
-      }
-      return notification
-    })
+    await store.markAsRead(id)
   } catch (error) {
     console.error(error)
   }
@@ -137,11 +121,7 @@ const markAsRead = async (id) => {
 
 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",
+});

+ 33 - 0
src/realtime/handlers.js

@@ -0,0 +1,33 @@
+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,
+  };
+});