From 8b076949a7f8e723bc87619f729082d2c1991b2d Mon Sep 17 00:00:00 2001 From: Blake Matthes Date: Wed, 27 May 2026 20:52:34 -0500 Subject: [PATCH] feat(warehousing): ppoo monitoring added this will monitor ppoo every 45 seconds as long as someone is on the page. closes #13 --- backend/server.ts | 5 + backend/socket.io/roomCache.socket.ts | 2 +- backend/socket.io/roomDefinitions.socket.ts | 47 ++- backend/socket.io/roomEmitter.socket.ts | 2 +- backend/socket.io/roomService.socket.ts | 23 +- backend/socket.io/serverSetup.ts | 71 ++++- .../warehousing/warehousing.ppooMonitor.ts | 29 ++ frontend/src/hooks/socket.io.hook.ts | 15 +- lstMobile/src/app/(tabs)/ppoo.tsx | 271 ++++++------------ lstMobile/src/hooks/socket.io.hook.ts | 136 +++++++++ lstMobile/src/lib/socket.io.ts | 34 +++ 11 files changed, 425 insertions(+), 210 deletions(-) create mode 100644 backend/warehousing/warehousing.ppooMonitor.ts create mode 100644 lstMobile/src/hooks/socket.io.hook.ts create mode 100644 lstMobile/src/lib/socket.io.ts diff --git a/backend/server.ts b/backend/server.ts index 9afab50..30fa3bf 100644 --- a/backend/server.ts +++ b/backend/server.ts @@ -26,6 +26,7 @@ import { } from "./utils/analyticRouteHits.utils.js"; import { createCronJob } from "./utils/croner.utils.js"; import { sendEmail } from "./utils/sendEmail.utils.js"; +import { ppooMonitoring } from "./warehousing/warehousing.ppooMonitor.js"; const port = Number(process.env.PORT) || 3000; export let systemSettings: Setting[] = []; @@ -78,6 +79,10 @@ const start = async () => { runRouteHitAnalyticsCron(), ); + createCronJob("ppooMonitor", "*/45 * * * * *", async () => + ppooMonitoring(), + ); + createCronJob("cleanHitsUp", "0 0 7 * * *", () => cleanupOldRouteHits()); // one shots only needed to run on server startups createNotifications(); diff --git a/backend/socket.io/roomCache.socket.ts b/backend/socket.io/roomCache.socket.ts index d1986ad..e3da9b0 100644 --- a/backend/socket.io/roomCache.socket.ts +++ b/backend/socket.io/roomCache.socket.ts @@ -1,4 +1,4 @@ -import type { RoomId } from "./types.socket.js"; +import type { RoomId } from "./roomDefinitions.socket.js"; export const MAX_HISTORY = 50; export const FLUSH_INTERVAL = 100; // 50ms change higher if needed diff --git a/backend/socket.io/roomDefinitions.socket.ts b/backend/socket.io/roomDefinitions.socket.ts index 5aa26fb..7f0adf7 100644 --- a/backend/socket.io/roomDefinitions.socket.ts +++ b/backend/socket.io/roomDefinitions.socket.ts @@ -1,17 +1,50 @@ import { desc } from "drizzle-orm"; import { db } from "../db/db.controller.js"; import { logs } from "../db/schema/logs.schema.js"; -import type { RoomId } from "./types.socket.js"; +import { ppoRun } from "../warehousing/warehousing.ppooMonitor.js"; type RoomDefinition = { seed: (limit: number) => Promise; }; -export const protectedRooms: any = { +export type StaticRoomId = "logs" | "labels" | "admin" | "admin:build" | "ppoo"; +export type DynamicRoomId = `dockDoorLoading:${string}`; +export type RoomId = StaticRoomId | DynamicRoomId; + +export type RoomConfig = { + requiresAuth?: boolean; + role?: string[]; + seed?: (limit: number, roomId: RoomId) => Promise; +}; + +export const protectedRooms: Record = { logs: { requiresAuth: true, role: ["admin", "systemAdmin"] }, //admin: { requiresAuth: false, role: ["admin", "systemAdmin"] }, + labels: {}, + admin: {}, + "admin:build": {}, + ppoo: {}, }; +export function getRoomConfig(roomId: string): RoomConfig | null { + if (roomId in protectedRooms) { + return protectedRooms[roomId as StaticRoomId]; + } + + if (roomId.startsWith("dockDoorLoading:")) { + const dockId = roomId.split(":")[1]; + + if (!dockId) return null; + + return { + requiresAuth: true, + role: ["admin", "systemAdmin", "dockDoor"], + }; + } + + return null; +} + export const roomDefinition: Record = { logs: { seed: async (limit) => { @@ -48,4 +81,14 @@ export const roomDefinition: Record = { return []; }, }, + ppoo: { + seed: async (limit) => { + console.log(limit); + return { + type: "snapshot", + items: await ppoRun(), + createdAt: new Date().toISOString(), + } as any; + }, + }, }; diff --git a/backend/socket.io/roomEmitter.socket.ts b/backend/socket.io/roomEmitter.socket.ts index a370e1c..afdf38a 100644 --- a/backend/socket.io/roomEmitter.socket.ts +++ b/backend/socket.io/roomEmitter.socket.ts @@ -1,6 +1,6 @@ // the emitter setup -import type { RoomId } from "./types.socket.js"; +import type { RoomId } from "./roomDefinitions.socket.js"; let addDataToRoom: ((roomId: RoomId, payload: unknown[]) => void) | null = null; diff --git a/backend/socket.io/roomService.socket.ts b/backend/socket.io/roomService.socket.ts index b44d753..f47dd11 100644 --- a/backend/socket.io/roomService.socket.ts +++ b/backend/socket.io/roomService.socket.ts @@ -7,18 +7,33 @@ import { roomFlushTimers, roomHistory, } from "./roomCache.socket.js"; -import { roomDefinition } from "./roomDefinitions.socket.js"; -import type { RoomId } from "./types.socket.js"; +import { type RoomId, roomDefinition } from "./roomDefinitions.socket.js"; // get the db data if not exiting already const log = createLogger({ module: "socket.io", subModule: "roomService" }); +let ioRef: Server | null = null; +export const registerRoomService = (io: Server) => { + ioRef = io; +}; + +export const hasRoomMembers = (roomId: string): boolean => { + if (!ioRef) return false; + + return (ioRef.sockets.adapter.rooms.get(roomId)?.size ?? 0) > 0; +}; + +export const getRoomMemberCount = (roomId: string): number => { + if (!ioRef) return 0; + + return ioRef.sockets.adapter.rooms.get(roomId)?.size ?? 0; +}; export const preseedRoom = async (roomId: RoomId) => { if (roomHistory.has(roomId)) { return roomHistory.get(roomId); } - const roomDef = roomDefinition[roomId]; + const roomDef = roomDefinition[roomId] as any; if (!roomDef) { log.error({}, `Room ${roomId} is not defined`); @@ -32,7 +47,7 @@ export const preseedRoom = async (roomId: RoomId) => { }; export const createRoomEmitter = (io: Server) => { - const addDataToRoom = (roomId: RoomId, payload: T) => { + const addDataToRoom = (roomId: RoomId, payload: T[]) => { if (!roomHistory.has(roomId)) { roomHistory.set(roomId, []); } diff --git a/backend/socket.io/serverSetup.ts b/backend/socket.io/serverSetup.ts index 1625ec6..164f2b7 100644 --- a/backend/socket.io/serverSetup.ts +++ b/backend/socket.io/serverSetup.ts @@ -7,7 +7,11 @@ import { Server } from "socket.io"; import { createLogger } from "../logger/logger.controller.js"; import { allowedOrigins } from "../utils/cors.utils.js"; import { registerEmitter } from "./roomEmitter.socket.js"; -import { createRoomEmitter, preseedRoom } from "./roomService.socket.js"; +import { + createRoomEmitter, + preseedRoom, + registerRoomService, +} from "./roomService.socket.js"; //const __filename = fileURLToPath(import.meta.url); //const __dirname = dirname(__filename); @@ -15,7 +19,7 @@ const log = createLogger({ module: "socket.io", subModule: "setup" }); import { auth } from "../utils/auth.utils.js"; //import type { Session, User } from "better-auth"; // adjust if needed -import { protectedRooms } from "./roomDefinitions.socket.js"; +import { getRoomConfig } from "./roomDefinitions.socket.js"; // declare module "socket.io" { // interface Socket { @@ -33,6 +37,9 @@ export const setupSocketIORoutes = (baseUrl: string, server: HttpServer) => { }, }); + // manage members of the rooms. + registerRoomService(io); + // ✅ Create emitter instance const { addDataToRoom } = createRoomEmitter(io); registerEmitter(addDataToRoom); @@ -78,38 +85,76 @@ export const setupSocketIORoutes = (baseUrl: string, server: HttpServer) => { version: "1.0.0", }); - s.on("join-room", async (rn) => { - const config = protectedRooms[rn]; + // s.on("join-room", async (rn) => { + // const config = protectedRooms[rn]; - if (config?.requiresAuth && !s.user) { + // if (config?.requiresAuth && !s.user) { + // return s.emit("room-error", { + // room: rn, + // message: "Authentication required", + // }); + // } + + // const roles = Array.isArray(config?.role) ? config?.role : [config?.role]; + + // //if (config?.role && s.user?.role !== config.role) { + // if (config?.role && !roles.includes(s.user?.role)) { + // return s.emit("room-error", { + // roomId: rn, + // message: `Not authorized to be in room: ${rn}`, + // }); + // } + // s.join(rn); + + // // get room seeded + // const history = await preseedRoom(rn); + // log.info({}, `User joined ${rn}: ${s.id}`); + // // send the intial data + // s.emit("room-update", { + // roomId: rn, + // payloads: history, + // initial: true, + // }); + // }); + + s.on("join-room", async (rn: string) => { + const config = getRoomConfig(rn); + + if (!config) { return s.emit("room-error", { - room: rn, + roomId: rn, + message: `Unknown room: ${rn}`, + }); + } + + if (config.requiresAuth && !s.user) { + return s.emit("room-error", { + roomId: rn, message: "Authentication required", }); } - const roles = Array.isArray(config?.role) ? config?.role : [config?.role]; + const roles = Array.isArray(config.role) ? config.role : []; - //if (config?.role && s.user?.role !== config.role) { - if (config?.role && !roles.includes(s.user?.role)) { + if (roles.length > 0 && !roles.includes(s.user?.role)) { return s.emit("room-error", { roomId: rn, message: `Not authorized to be in room: ${rn}`, }); } + s.join(rn); - // get room seeded - const history = await preseedRoom(rn); + const history = await preseedRoom(rn as any); + log.info({}, `User joined ${rn}: ${s.id}`); - // send the intial data + s.emit("room-update", { roomId: rn, payloads: history, initial: true, }); }); - s.on("leave-room", (room) => { s.leave(room); log.info({}, `${s.id} left room: ${room}`); diff --git a/backend/warehousing/warehousing.ppooMonitor.ts b/backend/warehousing/warehousing.ppooMonitor.ts new file mode 100644 index 0000000..8410e79 --- /dev/null +++ b/backend/warehousing/warehousing.ppooMonitor.ts @@ -0,0 +1,29 @@ +import { emitToRoom } from "../socket.io/roomEmitter.socket.js"; +import { hasRoomMembers } from "../socket.io/roomService.socket.js"; +import { runProdApi } from "../utils/prodEndpoint.utils.js"; + +export const ppoRun = async () => { + const laneData = await runProdApi({ + method: "post", + endpoint: "/public/v1.1/Warehousing/GetWarehouseUnits", + data: [ + { + laneIds: ["0"], + }, + ], + }); + + return laneData?.data ?? []; +}; + +export const ppooMonitoring = async () => { + if (!hasRoomMembers(`ppoo`)) { + return; + } + + emitToRoom("ppoo", { + type: "snapshot", + items: await ppoRun(), + createdAt: new Date().toISOString(), + } as any); +}; diff --git a/frontend/src/hooks/socket.io.hook.ts b/frontend/src/hooks/socket.io.hook.ts index 1db08f6..0a09de9 100644 --- a/frontend/src/hooks/socket.io.hook.ts +++ b/frontend/src/hooks/socket.io.hook.ts @@ -13,6 +13,7 @@ type RoomErrorPayload = { export function useSocketRoom( roomId: string, + enabled = true, getKey?: (item: T) => string | number, ) { const [data, setData] = useState([]); @@ -35,6 +36,7 @@ export function useSocketRoom( ); useEffect(() => { + if (!roomId || !enabled) return; function handleConnect() { socket.emit("join-room", roomId); setInfo(`Joined room: ${roomId}`); @@ -74,7 +76,18 @@ export function useSocketRoom( socket.off("room-update", handleUpdate); socket.off("room-error", handleError); }; - }, [roomId]); + }, [roomId, enabled]); return { data, info, clearRoom }; } + +/* + +const isDockDoorPage = location.pathname.startsWith("/dockdoor"); + +useSocketRoom( + dockId ? `dockdoor:${dockId}` : null, + isDockDoorPage, +); + +*/ diff --git a/lstMobile/src/app/(tabs)/ppoo.tsx b/lstMobile/src/app/(tabs)/ppoo.tsx index 800bd62..3849aa8 100644 --- a/lstMobile/src/app/(tabs)/ppoo.tsx +++ b/lstMobile/src/app/(tabs)/ppoo.tsx @@ -1,210 +1,105 @@ -import axios from "axios"; import { format } from "date-fns-tz"; +import * as Device from "expo-device"; import { useFocusEffect } from "expo-router"; import type React from "react"; -import { useCallback, useEffect, useState } from "react"; -import { ScrollView, Text, View } from "react-native"; -import { SafeAreaView } from "react-native-safe-area-context"; -import Toast from "react-native-toast-message"; -import { GlobalFooter } from "../../components/UpdateFooter"; -import { Button } from "../../components/ui/button"; -import { Card, CardContent, CardHeader } from "../../components/ui/card"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "../../components/ui/dialog"; -import { useAppStore } from "../../hooks/useAppStore"; -import { scannerFeedback } from "../../lib/feedbackScan"; -import { type ZebraScanResult, zebraScanner } from "../../lib/ZebraScanner"; + Button, + ScrollView, + Text, + useWindowDimensions, + View, +} from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import { Card, CardContent } from "../../components/ui/card"; +import { useSocketRoom } from "../../hooks/socket.io.hook"; -const InfoRow = ({ - label, - value, -}: { - label: string; - value: React.ReactNode; -}) => { - return ( - - {label} - - {value} - - - ); +type PPOO = { + type: string; + items: any; + createdAt: Date; }; - export default function PPOO() { - const [units, setUnits] = useState(null); - const serverIp = useAppStore((s) => s.serverIp); + const { data } = useSocketRoom("ppoo", undefined, "replace") as any; + const [sortDir, setSortDir] = useState<"asc" | "desc">("desc"); - const handleScan = useCallback( - async (scan: ZebraScanResult) => { - setUnits(null); - await scannerFeedback({ - type: "scan", - sound: true, - vibrate: true, - led: true, - }); - if (!scan.data.startsWith("loc")) { - Toast.show({ - type: "error", - text1: "Scan error", - text2: "The last scan was not a lane please try again", - }); + const { width } = useWindowDimensions(); + const isTablet = + Device.modelName?.toLowerCase().includes("et40") || + Device.modelName?.toLowerCase().includes("et45"); - return; - } - try { - const res = await axios.post( - `http://${serverIp.trim()}:3000/lst/api/mobile/lanecheck`, - { - lane: "loc#1#0<", - }, - { - timeout: 5000, - }, - ); - if (res.status === 200) { - setUnits(res.data); - Toast.show({ - type: "info", - text1: "Lane Data", - text2: "All Loading Units from this lane will be listed below", - }); - } - } catch (error) { - console.log(error); - Toast.show({ - type: "error", - text1: "Lane Data", - text2: "Error getting lane data please try again", - }); - } - }, - [serverIp.trim], - ); + const columns = isTablet ? 3 : 1; - useFocusEffect( - useCallback(() => { - zebraScanner.startListening(); + const gap = 8; + const cardWidth = + columns === 1 ? width - 16 : (width - gap * (columns + 1)) / columns; - const sub = zebraScanner.addScanListener((scan) => { - //console.log("SCAN:", scan); - handleScan(scan); - }); + const items = data?.items ?? []; + const sortedItems = useMemo(() => { + return [...items].sort((a, b) => { + const aDate = new Date(a.lastMovingDate).getTime(); + const bDate = new Date(b.lastMovingDate).getTime(); - return () => { - sub.remove(); - zebraScanner.stopListening(); - //setUnits(null); - }; - }, [handleScan]), - ); + return sortDir === "asc" ? aDate - bDate : bDate - aDate; + }); + }, [items, sortDir]); + + //console.log(logsInfo); return ( - - {units ? ( - // - // - // - // {units.data?.map((i: any, index: any) => ( - // - // example - // - // ))} - // - // - // - - - There Are {units.data.length} units in PPOO - - - - {units.data.map((i, index) => ( - - - - - - - {i.articleId} - {i.articleName} - + + + - - ))} + + Running Number: {i.runningNumber ?? "Non barcoded"} + + + Date: {format(i.lastMovingDate, "M/d/yyyy HH:mm")} + + + + + ); + })} - ) : ( - - - Please scan a lane to see all Units that are in the lane. - - )} - - - ); } diff --git a/lstMobile/src/hooks/socket.io.hook.ts b/lstMobile/src/hooks/socket.io.hook.ts new file mode 100644 index 0000000..afd6d7d --- /dev/null +++ b/lstMobile/src/hooks/socket.io.hook.ts @@ -0,0 +1,136 @@ +import { useFocusEffect } from "expo-router"; +import { useCallback, useEffect, useState } from "react"; +import { getSocket } from "../lib/socket.io"; + +type RoomUpdatePayload = { + roomId: string; + payloads: T[]; +}; + +type RoomErrorPayload = { + roomId?: string; + message?: string; +}; + +type UpdateMode = "append" | "replace"; + +export function useSocketRoom( + roomId: string, + getKey?: (item: T) => string | number, + updateMode: UpdateMode = "append", +) { + const [data, setData] = useState([]); + const [info, setInfo] = useState( + "No data yet — join the room to start receiving", + ); + + const clearRoom = useCallback( + (id?: string | number) => { + if (id !== undefined && getKey) { + setData((prev) => prev.filter((item) => getKey(item) !== id)); + setInfo(`Removed item ${id}`); + return; + } + + setData([]); + setInfo("Room data cleared"); + }, + [getKey], + ); + + useFocusEffect( + useCallback(() => { + const socket = getSocket(); + function handleConnect() { + socket.emit("join-room", roomId); + setInfo(`Joined room: ${roomId}`); + } + + function handleUpdate(payload: RoomUpdatePayload) { + // protects against other room updates hitting this hook + if (payload.roomId !== roomId) return; + + // resetting room data for rooms that just need updated data. + if (updateMode === "replace") { + setData(payload.payloads); + } else { + setData((prev) => [...payload.payloads, ...prev]); + } + + setInfo(""); + } + + function handleError(err: RoomErrorPayload) { + if (err.roomId && err.roomId !== roomId) return; + setInfo(err.message ?? "Room error"); + } + + socket.on("connect", handleConnect); + socket.on("room-update", handleUpdate); + socket.on("room-error", handleError); + + if (!socket.connected && socket.disconnected) { + socket.connect(); + } + + // If already connected, join immediately + if (socket.connected) { + socket.emit("join-room", roomId); + setInfo(`Joined room: ${roomId}`); + } + + return () => { + socket.emit("leave-room", roomId); + + socket.off("connect", handleConnect); + socket.off("room-update", handleUpdate); + socket.off("room-error", handleError); + }; + }, [roomId, updateMode]), + ); + + // useEffect(() => { + // const socket = getSocket(); + // function handleConnect() { + // socket.emit("join-room", roomId); + // setInfo(`Joined room: ${roomId}`); + // } + + // function handleUpdate(payload: RoomUpdatePayload) { + // // protects against other room updates hitting this hook + // if (payload.roomId !== roomId) return; + + // setData((prev) => [...payload.payloads, ...prev]); + // setInfo(""); + // } + + // function handleError(err: RoomErrorPayload) { + // if (err.roomId && err.roomId !== roomId) return; + // setInfo(err.message ?? "Room error"); + // } + + // if (!socket.connected && socket.disconnected) { + // socket.connect(); + // } + + // // If already connected, join immediately + // if (socket.connected) { + // socket.emit("join-room", roomId); + // setInfo(`Joined room: ${roomId}`); + // } + + // socket.on("connect", handleConnect); + // socket.on("room-update", handleUpdate); + // socket.on("room-error", handleError); + + // return () => { + // socket.emit("leave-room", roomId); + // console.log("leaving Room"); + // socket.off("connect", handleConnect); + // socket.off("room-update", handleUpdate); + // socket.off("room-error", handleError); + // }; + // }, [roomId]); + + return { data, info, clearRoom }; +} diff --git a/lstMobile/src/lib/socket.io.ts b/lstMobile/src/lib/socket.io.ts new file mode 100644 index 0000000..b09d395 --- /dev/null +++ b/lstMobile/src/lib/socket.io.ts @@ -0,0 +1,34 @@ +import { io, type Socket } from "socket.io-client"; +import { useAppStore } from "../hooks/useAppStore"; + +let socket: Socket | null = null; + +export function getSocket() { + const { serverIp, serverPort } = useAppStore.getState(); + + //const port = Number(serverPort) >= 50000 ? "3000" : serverPort; + const url = `http://${serverIp}:${serverPort}`; + + if (!socket) { + socket = io(url, { + path: "/lst/api/socket.io", + withCredentials: true, + autoConnect: true, + reconnection: true, + reconnectionAttempts: 5, + reconnectionDelay: 1000, + }); + } + + return socket; +} + +export function connectSocket() { + const socket = getSocket(); + + if (!socket.connected && socket.disconnected) { + socket.connect(); + } + + return socket; +}