IO-3166-Global-Notifications-Part-2 - Checkpoint

This commit is contained in:
Dave Richer
2025-03-05 17:28:32 -05:00
parent 358503f9ef
commit 2a65cb5025
9 changed files with 368 additions and 24 deletions

View File

@@ -1,6 +1,6 @@
import { Badge, Layout, Menu, Spin } from "antd"; import { Badge, Layout, Menu, Spin } from "antd";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useEffect, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
@@ -95,7 +95,8 @@ function Header({
const { t } = useTranslation(); const { t } = useTranslation();
const { isConnected, scenarioNotificationsOn } = useSocket(); const { isConnected, scenarioNotificationsOn } = useSocket();
const [notificationVisible, setNotificationVisible] = useState(false); const [notificationVisible, setNotificationVisible] = useState(false);
const baseTitleRef = useRef(document.title || "");
const lastSetTitleRef = useRef("");
const userAssociationId = bodyshop?.associations?.[0]?.id; const userAssociationId = bodyshop?.associations?.[0]?.id;
const { const {
@@ -123,6 +124,40 @@ function Header({
} }
}, [isConnected, unreadLoading, refetchUnread, userAssociationId]); }, [isConnected, unreadLoading, refetchUnread, userAssociationId]);
// Keep The unread count in the title.
useEffect(() => {
const updateTitle = () => {
const currentTitle = document.title;
// Check if the current title differs from what we last set
if (currentTitle !== lastSetTitleRef.current) {
// Extract base title by removing any unread count prefix
const baseTitleMatch = currentTitle.match(/^\(\d+\)\s*(.*)$/);
baseTitleRef.current = baseTitleMatch ? baseTitleMatch[1] : currentTitle;
}
// Apply unread count to the base title
const newTitle = unreadCount > 0 ? `(${unreadCount}) ${baseTitleRef.current}` : baseTitleRef.current;
// Only update if the title has changed to avoid unnecessary DOM writes
if (document.title !== newTitle) {
document.title = newTitle;
lastSetTitleRef.current = newTitle; // Store what we set
}
};
// Initial update
updateTitle();
// Poll every 100ms to catch child component changes
const interval = setInterval(updateTitle, 100);
// Cleanup
return () => {
clearInterval(interval);
document.title = baseTitleRef.current; // Reset to base title on unmount
};
}, [unreadCount]); // Re-run when unreadCount changes
const handleNotificationClick = (e) => { const handleNotificationClick = (e) => {
setNotificationVisible(!notificationVisible); setNotificationVisible(!notificationVisible);
if (handleMenuClick) handleMenuClick(e); if (handleMenuClick) handleMenuClick(e);
@@ -656,7 +691,7 @@ function Header({
icon: unreadLoading ? ( icon: unreadLoading ? (
<Spin size="small" /> <Spin size="small" />
) : ( ) : (
<Badge size="small" count={unreadCount}> <Badge offset={[8, 0]} size="small" count={unreadCount}>
<BellFilled /> <BellFilled />
</Badge> </Badge>
), ),

View File

@@ -1,11 +1,12 @@
import { Virtuoso } from "react-virtuoso"; import { Virtuoso } from "react-virtuoso";
import { Alert, Badge, Button, Space, Spin, Switch, Tooltip, Typography } from "antd"; import { Badge, Button, Space, Spin, Switch, Tooltip, Typography } from "antd";
import { CheckCircleFilled, CheckCircleOutlined, EyeFilled, EyeOutlined } from "@ant-design/icons"; import { CheckCircleFilled, CheckCircleOutlined, EyeFilled, EyeOutlined } from "@ant-design/icons";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import "./notification-center.styles.scss"; import "./notification-center.styles.scss";
import day from "../../utils/day.js"; import day from "../../utils/day.js";
import { forwardRef } from "react"; import { forwardRef } from "react";
import { DateTimeFormat } from "../../utils/DateFormatter.jsx";
const { Text, Title } = Typography; const { Text, Title } = Typography;
@@ -52,11 +53,7 @@ const NotificationCenterComponent = forwardRef(
<span className="ro-number"> <span className="ro-number">
{t("notifications.labels.ro-number", { ro_number: notification.roNumber })} {t("notifications.labels.ro-number", { ro_number: notification.roNumber })}
</span> </span>
<Text <Text type="secondary" className="relative-time" title={DateTimeFormat(notification.created_at)}>
type="secondary"
className="relative-time"
title={day(notification.created_at).format("YYYY-MM-DD hh:mm A")}
>
{day(notification.created_at).fromNow()} {day(notification.created_at).fromNow()}
</Text> </Text>
</Title> </Title>
@@ -83,7 +80,6 @@ const NotificationCenterComponent = forwardRef(
<div className="notification-controls"> <div className="notification-controls">
<Tooltip title={t("notifications.labels.show-unread-only")}> <Tooltip title={t("notifications.labels.show-unread-only")}>
<Space size={4} align="center" className="notification-toggle"> <Space size={4} align="center" className="notification-toggle">
{" "}
{showUnreadOnly ? ( {showUnreadOnly ? (
<EyeFilled className="notification-toggle-icon" /> <EyeFilled className="notification-toggle-icon" />
) : ( ) : (
@@ -94,7 +90,7 @@ const NotificationCenterComponent = forwardRef(
</Tooltip> </Tooltip>
<Tooltip title={t("notifications.labels.mark-all-read")}> <Tooltip title={t("notifications.labels.mark-all-read")}>
<Button <Button
type={!unreadCount ? "default" : "primary"} type="link"
icon={!unreadCount ? <CheckCircleFilled /> : <CheckCircleOutlined />} icon={!unreadCount ? <CheckCircleFilled /> : <CheckCircleOutlined />}
onClick={markAllRead} onClick={markAllRead}
disabled={!unreadCount} disabled={!unreadCount}

View File

@@ -9,9 +9,10 @@ import {
GET_NOTIFICATIONS, GET_NOTIFICATIONS,
GET_UNREAD_COUNT, GET_UNREAD_COUNT,
MARK_ALL_NOTIFICATIONS_READ, MARK_ALL_NOTIFICATIONS_READ,
MARK_NOTIFICATION_READ MARK_NOTIFICATION_READ,
UPDATE_NOTIFICATIONS_READ_FRAGMENT
} from "../../graphql/notifications.queries.js"; } from "../../graphql/notifications.queries.js";
import { gql, useMutation } from "@apollo/client"; import { useMutation } from "@apollo/client";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
const SocketContext = createContext(null); const SocketContext = createContext(null);
@@ -290,7 +291,17 @@ const SocketProvider = ({ children, bodyshop, navigate, currentUser, scenarioNot
}); });
notification.info({ notification.info({
message: t("notifications.labels.notification-popup-title", { ro_number: jobRoNumber }), message: (
<div
onClick={() => {
markNotificationRead({ variables: { id: notificationId } })
.then(() => navigate(`/manage/jobs/${jobId}`))
.catch((e) => console.error(`Error marking notification read: ${e?.message || ""}`));
}}
>
{t("notifications.labels.notification-popup-title", { ro_number: jobRoNumber })}
</div>
),
description: ( description: (
<ul <ul
className="notification-alert-unordered-list" className="notification-alert-unordered-list"
@@ -327,11 +338,7 @@ const SocketProvider = ({ children, bodyshop, navigate, currentUser, scenarioNot
}); });
client.cache.writeFragment({ client.cache.writeFragment({
id: notificationRef, id: notificationRef,
fragment: gql` fragment: UPDATE_NOTIFICATIONS_READ_FRAGMENT,
fragment UpdateNotificationRead on notifications {
read
}
`,
data: { read: timestamp } data: { read: timestamp }
}); });
@@ -383,11 +390,7 @@ const SocketProvider = ({ children, bodyshop, navigate, currentUser, scenarioNot
const notifRef = client.cache.identify({ __typename: "notifications", id: notif.id }); const notifRef = client.cache.identify({ __typename: "notifications", id: notif.id });
client.cache.writeFragment({ client.cache.writeFragment({
id: notifRef, id: notifRef,
fragment: gql` fragment: UPDATE_NOTIFICATIONS_READ_FRAGMENT,
fragment UpdateNotificationRead on notifications {
read
}
`,
data: { read: timestamp } data: { read: timestamp }
}); });
} }

View File

@@ -50,3 +50,9 @@ export const MARK_NOTIFICATION_READ = gql`
} }
} }
`; `;
export const UPDATE_NOTIFICATIONS_READ_FRAGMENT = gql`
fragment UpdateNotificationRead on notifications {
read
}
`;

223
docker-compose-cluster.yml Normal file
View File

@@ -0,0 +1,223 @@
version: '3.8'
services:
# Load Balancer (NGINX) with WebSocket support and session persistence
load-balancer:
image: nginx:latest
container_name: load-balancer
ports:
- "4000:80" # External port 4000 maps to NGINX's port 80
volumes:
- ./nginx-websocket.conf:/etc/nginx/nginx.conf:ro # Mount NGINX configuration
networks:
- redis-cluster-net
depends_on:
- node-app-1
- node-app-2
- node-app-3
healthcheck:
test: [ "CMD", "curl", "-f", "http://localhost/health" ]
interval: 10s
timeout: 5s
retries: 5
# Node App Instance 1
node-app-1:
build:
context: .
container_name: node-app-1
hostname: node-app-1
networks:
- redis-cluster-net
env_file:
- .env.development
depends_on:
redis-node-1:
condition: service_healthy
redis-node-2:
condition: service_healthy
redis-node-3:
condition: service_healthy
localstack:
condition: service_healthy
aws-cli:
condition: service_completed_successfully
ports:
- "4001:4000" # Different external port for local access
volumes:
- .:/app
- node-app-npm-cache:/app/node_modules
# Node App Instance 2
node-app-2:
build:
context: .
container_name: node-app-2
hostname: node-app-2
networks:
- redis-cluster-net
env_file:
- .env.development
depends_on:
redis-node-1:
condition: service_healthy
redis-node-2:
condition: service_healthy
redis-node-3:
condition: service_healthy
localstack:
condition: service_healthy
aws-cli:
condition: service_completed_successfully
ports:
- "4002:4000" # Different external port for local access
volumes:
- .:/app
- node-app-npm-cache:/app/node_modules
# Node App Instance 3
node-app-3:
build:
context: .
container_name: node-app-3
hostname: node-app-3
networks:
- redis-cluster-net
env_file:
- .env.development
depends_on:
redis-node-1:
condition: service_healthy
redis-node-2:
condition: service_healthy
redis-node-3:
condition: service_healthy
localstack:
condition: service_healthy
aws-cli:
condition: service_completed_successfully
ports:
- "4003:4000" # Different external port for local access
volumes:
- .:/app
- node-app-npm-cache:/app/node_modules
# Redis Node 1
redis-node-1:
build:
context: ./redis
container_name: redis-node-1
hostname: redis-node-1
restart: unless-stopped
networks:
- redis-cluster-net
volumes:
- redis-node-1-data:/data
- redis-lock:/redis-lock
healthcheck:
test: [ "CMD", "redis-cli", "ping" ]
interval: 10s
timeout: 5s
retries: 10
# Redis Node 2
redis-node-2:
build:
context: ./redis
container_name: redis-node-2
hostname: redis-node-2
restart: unless-stopped
networks:
- redis-cluster-net
volumes:
- redis-node-2-data:/data
- redis-lock:/redis-lock
healthcheck:
test: [ "CMD", "redis-cli", "ping" ]
interval: 10s
timeout: 5s
retries: 10
# Redis Node 3
redis-node-3:
build:
context: ./redis
container_name: redis-node-3
hostname: redis-node-3
restart: unless-stopped
networks:
- redis-cluster-net
volumes:
- redis-node-3-data:/data
- redis-lock:/redis-lock
healthcheck:
test: [ "CMD", "redis-cli", "ping" ]
interval: 10s
timeout: 5s
retries: 10
# LocalStack
localstack:
image: localstack/localstack
container_name: localstack
hostname: localstack
networks:
- redis-cluster-net
restart: unless-stopped
volumes:
- /var/run/docker.sock:/var/run/docker.sock
environment:
- SERVICES=s3,ses,secretsmanager,cloudwatch,logs
- DEBUG=0
- AWS_ACCESS_KEY_ID=test
- AWS_SECRET_ACCESS_KEY=test
- AWS_DEFAULT_REGION=ca-central-1
- EXTRA_CORS_ALLOWED_HEADERS=Authorization,Content-Type
- EXTRA_CORS_ALLOWED_ORIGINS=*
- EXTRA_CORS_EXPOSE_HEADERS=Authorization,Content-Type
ports:
- "4566:4566"
healthcheck:
test: [ "CMD", "curl", "-f", "http://localhost:4566/_localstack/health" ]
interval: 10s
timeout: 5s
retries: 5
start_period: 20s
# AWS-CLI
aws-cli:
image: amazon/aws-cli
container_name: aws-cli
hostname: aws-cli
networks:
- redis-cluster-net
depends_on:
localstack:
condition: service_healthy
volumes:
- './localstack:/tmp/localstack'
- './certs:/tmp/certs'
environment:
- AWS_ACCESS_KEY_ID=test
- AWS_SECRET_ACCESS_KEY=test
- AWS_DEFAULT_REGION=ca-central-1
entrypoint: /bin/sh -c
command: >
"
aws --endpoint-url=http://localstack:4566 ses verify-domain-identity --domain imex.online --region ca-central-1
aws --endpoint-url=http://localstack:4566 ses verify-email-identity --email-address noreply@imex.online --region ca-central-1
aws --endpoint-url=http://localstack:4566 secretsmanager create-secret --name CHATTER_PRIVATE_KEY --secret-string file:///tmp/certs/io-ftp-test.key
aws --endpoint-url=http://localstack:4566 logs create-log-group --log-group-name development --region ca-central-1
aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket imex-large-log --create-bucket-configuration LocationConstraint=ca-central-1
"
networks:
redis-cluster-net:
driver: bridge
volumes:
node-app-npm-cache:
redis-node-1-data:
redis-node-2-data:
redis-node-3-data:
redis-lock:

View File

@@ -198,6 +198,14 @@
- name: user - name: user
using: using:
foreign_key_constraint_on: useremail foreign_key_constraint_on: useremail
array_relationships:
- name: notifications
using:
foreign_key_constraint_on:
column: associationid
table:
name: notifications
schema: public
select_permissions: select_permissions:
- role: user - role: user
permission: permission:
@@ -3484,6 +3492,13 @@
table: table:
name: notes name: notes
schema: public schema: public
- name: notifications
using:
foreign_key_constraint_on:
column: jobid
table:
name: notifications
schema: public
- name: parts_dispatches - name: parts_dispatches
using: using:
foreign_key_constraint_on: foreign_key_constraint_on:
@@ -6732,6 +6747,13 @@
table: table:
name: ioevents name: ioevents
schema: public schema: public
- name: job_watchers
using:
foreign_key_constraint_on:
column: user_email
table:
name: job_watchers
schema: public
- name: messages - name: messages
using: using:
foreign_key_constraint_on: foreign_key_constraint_on:

45
nginx-websocket.conf Normal file
View File

@@ -0,0 +1,45 @@
events {
worker_connections 1024;
}
http {
upstream node_app {
ip_hash; # Enables session persistence based on client IP
server node-app-1:4000;
server node-app-2:4000;
server node-app-3:4000;
}
# WebSocket upgrade configuration
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
location / {
proxy_pass http://node_app;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket headers
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 86400; # Keep WebSocket connections alive (24 hours)
}
# Health check endpoint
location /health {
proxy_pass http://node_app;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
}

View File

@@ -2758,6 +2758,7 @@ exports.INSERT_NOTIFICATIONS_MUTATION = ` mutation INSERT_NOTIFICATIONS($object
} }
}`; }`;
// REMEMBER: Update the cache_bodyshop event in hasura to include any added fields
exports.GET_BODYSHOP_BY_ID = ` exports.GET_BODYSHOP_BY_ID = `
query GET_BODYSHOP_BY_ID($id: uuid!) { query GET_BODYSHOP_BY_ID($id: uuid!) {
bodyshops_by_pk(id: $id) { bodyshops_by_pk(id: $id) {

View File

@@ -142,4 +142,17 @@ router.post("/alertcheck", eventAuthorizationMiddleware, alertCheck);
// Redis Cache Routes // Redis Cache Routes
router.post("/bodyshop-cache", eventAuthorizationMiddleware, updateBodyshopCache); router.post("/bodyshop-cache", eventAuthorizationMiddleware, updateBodyshopCache);
// Health Check for docker-compose-cluster load balancer, only available in development
if (process.env.NODE_ENV === "development") {
router.get("/health", (req, res) => {
const healthStatus = {
status: "healthy",
timestamp: new Date().toISOString(),
environment: process.env.NODE_ENV || "unknown",
uptime: process.uptime()
};
res.status(200).json(healthStatus);
});
}
module.exports = router; module.exports = router;