2026-05-05 12:30:00 +00:00
|
|
|
import { useEffect, useRef } from "react";
|
2026-04-21 10:41:01 +00:00
|
|
|
import { io } from "socket.io-client";
|
|
|
|
|
import { useQueryClient } from "@tanstack/react-query";
|
|
|
|
|
import {
|
|
|
|
|
getListNotificationsQueryKey,
|
|
|
|
|
getGetHomeStatsQueryKey,
|
2026-04-21 18:47:30 +00:00
|
|
|
getListMyServiceOrdersQueryKey,
|
2026-04-21 18:58:19 +00:00
|
|
|
getListIncomingServiceOrdersQueryKey,
|
2026-04-22 10:20:04 +00:00
|
|
|
getListAppsQueryKey,
|
|
|
|
|
getGetMeQueryKey,
|
2026-04-29 13:24:16 +00:00
|
|
|
getListRolesQueryKey,
|
|
|
|
|
getListPermissionsQueryKey,
|
|
|
|
|
getGetRolePermissionsQueryKey,
|
|
|
|
|
getGetRolePermissionAuditQueryKey,
|
|
|
|
|
getGetRoleUsageQueryKey,
|
2026-04-21 10:41:01 +00:00
|
|
|
} from "@workspace/api-client-react";
|
|
|
|
|
import { useAuth } from "@/contexts/AuthContext";
|
2026-05-05 08:13:38 +00:00
|
|
|
import { notificationPlayer } from "@/lib/notification-sounds";
|
2026-04-21 10:41:01 +00:00
|
|
|
|
|
|
|
|
const BASE = import.meta.env.BASE_URL.replace(/\/$/, "");
|
|
|
|
|
|
2026-05-05 12:30:00 +00:00
|
|
|
const SOCKET_WARMUP_MS = 3000;
|
|
|
|
|
const nowMs = () =>
|
|
|
|
|
typeof performance !== "undefined" ? performance.now() : Date.now();
|
|
|
|
|
|
2026-04-21 10:41:01 +00:00
|
|
|
export function useNotificationsSocket() {
|
|
|
|
|
const { user } = useAuth();
|
|
|
|
|
const queryClient = useQueryClient();
|
2026-05-05 12:30:00 +00:00
|
|
|
const connectedAtRef = useRef<number>(0);
|
2026-04-21 10:41:01 +00:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!user) return;
|
|
|
|
|
|
2026-05-05 12:30:00 +00:00
|
|
|
// Suppress chimes for the first few seconds after the socket
|
|
|
|
|
// connects so any pending notifications that flush in on page
|
|
|
|
|
// load don't all play sounds back-to-back.
|
|
|
|
|
connectedAtRef.current = nowMs();
|
|
|
|
|
|
2026-04-21 10:41:01 +00:00
|
|
|
const socket = io(window.location.origin, {
|
|
|
|
|
path: `${BASE}/api/socket.io`,
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-05 12:30:00 +00:00
|
|
|
socket.on("connect", () => {
|
|
|
|
|
connectedAtRef.current = nowMs();
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-21 18:47:30 +00:00
|
|
|
socket.on("notification_created", (payload?: { type?: string }) => {
|
2026-04-21 10:41:01 +00:00
|
|
|
queryClient.invalidateQueries({ queryKey: getListNotificationsQueryKey() });
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: getGetHomeStatsQueryKey() });
|
2026-04-21 18:47:30 +00:00
|
|
|
if (payload?.type === "order") {
|
|
|
|
|
queryClient.invalidateQueries({
|
|
|
|
|
queryKey: getListMyServiceOrdersQueryKey(),
|
|
|
|
|
});
|
2026-04-21 18:58:19 +00:00
|
|
|
queryClient.invalidateQueries({
|
|
|
|
|
queryKey: getListIncomingServiceOrdersQueryKey(),
|
|
|
|
|
});
|
2026-04-21 18:47:30 +00:00
|
|
|
}
|
2026-05-05 08:13:38 +00:00
|
|
|
|
2026-05-05 08:31:27 +00:00
|
|
|
// Per-user sound + vibration. Plays whether the tab is in the
|
|
|
|
|
// foreground or background — users want to hear the chime even
|
|
|
|
|
// while looking at the app. Still silenced by global mute and
|
|
|
|
|
// per-channel opt-outs below.
|
2026-05-05 08:13:38 +00:00
|
|
|
if (user.notificationsMuted) return;
|
|
|
|
|
const isOrder = payload?.type === "order";
|
|
|
|
|
const isMeeting = payload?.type === "executive_meeting";
|
|
|
|
|
if (isOrder && !user.notifyOrdersEnabled) return;
|
|
|
|
|
if (isMeeting && !user.notifyMeetingsEnabled) return;
|
|
|
|
|
if (!isOrder && !isMeeting) return;
|
2026-05-05 12:30:00 +00:00
|
|
|
// Skip audio/vibration during the warmup window — UI/badge
|
|
|
|
|
// counts (above) still update, but we stay silent so a
|
|
|
|
|
// page-load flush doesn't sound like an alarm.
|
|
|
|
|
if (nowMs() - connectedAtRef.current < SOCKET_WARMUP_MS) return;
|
2026-05-05 08:13:38 +00:00
|
|
|
const soundId = isOrder
|
|
|
|
|
? user.notificationSoundOrder
|
|
|
|
|
: user.notificationSoundMeeting;
|
2026-05-05 08:20:54 +00:00
|
|
|
const vibrate = isOrder
|
|
|
|
|
? user.vibrationEnabledOrder
|
|
|
|
|
: user.vibrationEnabledMeeting;
|
2026-05-05 08:24:27 +00:00
|
|
|
notificationPlayer.play(soundId, { vibrate });
|
2026-04-21 18:47:30 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
socket.on("order_updated", () => {
|
|
|
|
|
queryClient.invalidateQueries({
|
|
|
|
|
queryKey: getListMyServiceOrdersQueryKey(),
|
|
|
|
|
});
|
2026-04-21 18:58:19 +00:00
|
|
|
queryClient.invalidateQueries({
|
|
|
|
|
queryKey: getListIncomingServiceOrdersQueryKey(),
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-22 06:37:01 +00:00
|
|
|
socket.on("order_deleted", () => {
|
|
|
|
|
queryClient.invalidateQueries({
|
|
|
|
|
queryKey: getListMyServiceOrdersQueryKey(),
|
|
|
|
|
});
|
|
|
|
|
queryClient.invalidateQueries({
|
|
|
|
|
queryKey: getListIncomingServiceOrdersQueryKey(),
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-21 18:58:19 +00:00
|
|
|
socket.on("order_incoming_changed", () => {
|
|
|
|
|
queryClient.invalidateQueries({
|
|
|
|
|
queryKey: getListIncomingServiceOrdersQueryKey(),
|
|
|
|
|
});
|
2026-04-21 10:41:01 +00:00
|
|
|
});
|
|
|
|
|
|
2026-05-05 14:53:30 +00:00
|
|
|
socket.on("note_received", () => {
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: ["notes"] });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
socket.on("note_replied", () => {
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: ["notes"] });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
socket.on("note_status_changed", () => {
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: ["notes"] });
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-22 10:20:04 +00:00
|
|
|
socket.on("apps_changed", () => {
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: getListAppsQueryKey() });
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: getGetMeQueryKey() });
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-29 14:13:07 +00:00
|
|
|
socket.on(
|
|
|
|
|
"executive_meetings_changed",
|
|
|
|
|
(payload?: { date?: string }) => {
|
|
|
|
|
if (typeof payload?.date === "string" && payload.date.length > 0) {
|
|
|
|
|
queryClient.invalidateQueries({
|
|
|
|
|
queryKey: ["/api/executive-meetings", payload.date],
|
|
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
queryClient.invalidateQueries({
|
|
|
|
|
queryKey: ["/api/executive-meetings"],
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
2026-04-29 17:22:20 +00:00
|
|
|
socket.on("executive_meeting_notifications_changed", () => {
|
|
|
|
|
queryClient.invalidateQueries({
|
|
|
|
|
queryKey: ["/api/executive-meetings/notifications"],
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-01 14:23:01 +00:00
|
|
|
// #277: Per-user push for the 5-min upcoming-meeting alert. The server
|
|
|
|
|
// only emits this to `user:${userId}`, so receiving it means *this*
|
|
|
|
|
// user (in this or another of their tabs / devices) acknowledged or
|
|
|
|
|
// dismissed an alert. Invalidate the alert-state query so the open
|
|
|
|
|
// alert card hides within ~1s instead of waiting on the 30s poll.
|
|
|
|
|
// Other users do not receive this event, so their alert stays visible.
|
|
|
|
|
socket.on(
|
|
|
|
|
"executive_meeting_alert_state_changed",
|
|
|
|
|
() => {
|
|
|
|
|
queryClient.invalidateQueries({
|
|
|
|
|
queryKey: ["/api/executive-meetings/alert-state"],
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
2026-04-29 13:24:16 +00:00
|
|
|
socket.on(
|
|
|
|
|
"role_permissions_changed",
|
|
|
|
|
(payload?: { roleId?: number }) => {
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: getGetMeQueryKey() });
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: getListPermissionsQueryKey() });
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: getListRolesQueryKey() });
|
|
|
|
|
const roleId = payload?.roleId;
|
|
|
|
|
if (typeof roleId === "number") {
|
|
|
|
|
queryClient.invalidateQueries({
|
|
|
|
|
queryKey: getGetRolePermissionsQueryKey(roleId),
|
|
|
|
|
});
|
|
|
|
|
queryClient.invalidateQueries({
|
|
|
|
|
queryKey: getGetRolePermissionAuditQueryKey(roleId),
|
|
|
|
|
});
|
|
|
|
|
queryClient.invalidateQueries({
|
|
|
|
|
queryKey: getGetRoleUsageQueryKey(roleId),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
2026-04-21 10:41:01 +00:00
|
|
|
return () => {
|
|
|
|
|
socket.disconnect();
|
|
|
|
|
};
|
|
|
|
|
}, [user, queryClient]);
|
|
|
|
|
}
|