refactor Notifications

This commit is contained in:
alma 2026-01-16 00:43:43 +01:00
parent 05ec62595d
commit b926597a48
4 changed files with 603 additions and 0 deletions

View File

@ -0,0 +1,118 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from "@/app/api/auth/options";
import { logger } from '@/lib/logger';
/**
* Get RocketChat user token for WebSocket connection
* This endpoint returns the user's auth token and userId for real-time connections
*/
export async function GET(request: Request) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json(
{ error: "Not authenticated" },
{ status: 401 }
);
}
const baseUrl = process.env.NEXT_PUBLIC_IFRAME_PAROLE_URL?.split('/channel')[0];
if (!baseUrl) {
logger.error('[ROCKET_CHAT_USER_TOKEN] Failed to get Rocket.Chat base URL');
return NextResponse.json(
{ error: 'Server configuration error' },
{ status: 500 }
);
}
// Use admin token to authenticate
const adminHeaders = {
'X-Auth-Token': process.env.ROCKET_CHAT_TOKEN!,
'X-User-Id': process.env.ROCKET_CHAT_USER_ID!,
'Content-Type': 'application/json'
};
// Get username from email
const username = session.user.email.split('@')[0];
if (!username) {
logger.error('[ROCKET_CHAT_USER_TOKEN] No username found in session email');
return NextResponse.json(
{ error: 'Invalid user' },
{ status: 400 }
);
}
// Get all users to find the current user
const usersResponse = await fetch(`${baseUrl}/api/v1/users.list`, {
method: 'GET',
headers: adminHeaders
});
if (!usersResponse.ok) {
logger.error('[ROCKET_CHAT_USER_TOKEN] Failed to get users list');
return NextResponse.json(
{ error: 'Failed to get user' },
{ status: 500 }
);
}
const usersData = await usersResponse.json();
const currentUser = usersData.users?.find((u: any) =>
u.username?.toLowerCase() === username.toLowerCase() ||
u.emails?.some((e: any) => e.address?.toLowerCase() === session.user.email?.toLowerCase())
);
if (!currentUser) {
logger.error('[ROCKET_CHAT_USER_TOKEN] User not found in RocketChat', { username });
return NextResponse.json(
{ error: 'User not found' },
{ status: 404 }
);
}
// Create user token
const secret = process.env.ROCKET_CHAT_CREATE_TOKEN_SECRET;
if (!secret) {
logger.error('[ROCKET_CHAT_USER_TOKEN] ROCKET_CHAT_CREATE_TOKEN_SECRET not configured');
return NextResponse.json(
{ error: 'Server configuration error' },
{ status: 500 }
);
}
const createTokenResponse = await fetch(`${baseUrl}/api/v1/users.createToken`, {
method: 'POST',
headers: adminHeaders,
body: JSON.stringify({
userId: currentUser._id,
secret: secret
})
});
if (!createTokenResponse.ok) {
logger.error('[ROCKET_CHAT_USER_TOKEN] Failed to create user token');
return NextResponse.json(
{ error: 'Failed to create token' },
{ status: 500 }
);
}
const tokenData = await createTokenResponse.json();
return NextResponse.json({
userId: currentUser._id,
authToken: tokenData.data.authToken,
username: currentUser.username,
});
} catch (error: any) {
logger.error('[ROCKET_CHAT_USER_TOKEN] Error', {
error: error instanceof Error ? error.message : String(error),
});
return NextResponse.json(
{ error: "Internal server error", message: error.message },
{ status: 500 }
);
}
}

View File

@ -8,6 +8,7 @@ import { AuthCheck } from "@/components/auth/auth-check";
import { Toaster } from "@/components/ui/toaster"; import { Toaster } from "@/components/ui/toaster";
import { useBackgroundImage } from "@/components/background-switcher"; import { useBackgroundImage } from "@/components/background-switcher";
import { clearAuthCookies, clearKeycloakCookies } from "@/lib/session"; import { clearAuthCookies, clearKeycloakCookies } from "@/lib/session";
import { useRocketChatCalls } from "@/hooks/use-rocketchat-calls";
interface LayoutWrapperProps { interface LayoutWrapperProps {
children: React.ReactNode; children: React.ReactNode;
@ -19,6 +20,9 @@ export function LayoutWrapper({ children, isSignInPage, isAuthenticated }: Layou
const { currentBackground, changeBackground } = useBackgroundImage(); const { currentBackground, changeBackground } = useBackgroundImage();
const { data: session } = useSession(); const { data: session } = useSession();
// Listen for incoming RocketChat calls
useRocketChatCalls();
// Global listener for logout messages from iframe applications // Global listener for logout messages from iframe applications
useEffect(() => { useEffect(() => {
if (isSignInPage) return; // Don't listen on signin page if (isSignInPage) return; // Don't listen on signin page

View File

@ -0,0 +1,150 @@
import { useEffect, useRef, useCallback } from 'react';
import { useSession } from 'next-auth/react';
import { RocketChatCallListener, CallEvent } from '@/lib/services/rocketchat-call-listener';
import { useWidgetNotification } from './use-widget-notification';
import { logger } from '@/lib/logger';
/**
* Hook to listen for incoming RocketChat calls and trigger notifications
*/
export function useRocketChatCalls() {
const { data: session } = useSession();
const { triggerNotification } = useWidgetNotification();
const listenerRef = useRef<RocketChatCallListener | null>(null);
const unsubscribeRef = useRef<(() => void) | null>(null);
const initializedRef = useRef(false);
/**
* Get RocketChat credentials for the current user
*/
const getRocketChatCredentials = useCallback(async () => {
if (!session?.user?.id) {
return null;
}
try {
// Get user token from RocketChat API
const response = await fetch('/api/rocket-chat/messages?refresh=true', {
credentials: 'include',
});
if (!response.ok) {
logger.error('[useRocketChatCalls] Failed to get RocketChat credentials');
return null;
}
// Extract base URL from environment
const baseUrl = process.env.NEXT_PUBLIC_IFRAME_PAROLE_URL?.split('/channel')[0];
if (!baseUrl) {
logger.error('[useRocketChatCalls] RocketChat base URL not configured');
return null;
}
// Get user's RocketChat ID and token
// We need to call the API to get the user token
const tokenResponse = await fetch('/api/rocket-chat/user-token', {
credentials: 'include',
});
if (!tokenResponse.ok) {
logger.error('[useRocketChatCalls] Failed to get user token');
return null;
}
const tokenData = await tokenResponse.json();
return {
userId: tokenData.userId,
authToken: tokenData.authToken,
baseUrl,
};
} catch (error) {
logger.error('[useRocketChatCalls] Error getting credentials', { error });
return null;
}
}, [session?.user?.id]);
/**
* Initialize call listener
*/
const initializeListener = useCallback(async () => {
if (initializedRef.current || listenerRef.current) {
return;
}
const credentials = await getRocketChatCredentials();
if (!credentials) {
logger.warn('[useRocketChatCalls] Could not get credentials, skipping initialization');
return;
}
try {
const listener = RocketChatCallListener.getInstance();
const success = await listener.initialize(
credentials.userId,
credentials.authToken,
credentials.baseUrl
);
if (success) {
listenerRef.current = listener;
initializedRef.current = true;
// Subscribe to call events
const unsubscribe = listener.onCall((callEvent: CallEvent) => {
logger.info('[useRocketChatCalls] Incoming call detected', {
from: callEvent.from.username,
roomId: callEvent.roomId,
});
// Trigger notification
triggerNotification({
source: 'rocketchat',
count: 1, // Increment count for call
items: [
{
id: `call-${callEvent.roomId}-${Date.now()}`,
title: `Appel entrant de ${callEvent.from.name || callEvent.from.username}`,
message: `Appel vidéo/audio dans ${callEvent.roomName || 'chat'}`,
link: `/parole?room=${callEvent.roomId}`,
timestamp: callEvent.timestamp,
metadata: {
type: 'call',
from: callEvent.from,
roomId: callEvent.roomId,
},
},
],
});
});
unsubscribeRef.current = unsubscribe;
logger.debug('[useRocketChatCalls] Call listener initialized');
}
} catch (error) {
logger.error('[useRocketChatCalls] Error initializing listener', { error });
}
}, [getRocketChatCredentials, triggerNotification]);
/**
* Cleanup on unmount
*/
useEffect(() => {
if (session?.user?.id) {
initializeListener();
}
return () => {
if (unsubscribeRef.current) {
unsubscribeRef.current();
unsubscribeRef.current = null;
}
if (listenerRef.current) {
listenerRef.current.disconnect();
listenerRef.current = null;
}
initializedRef.current = false;
};
}, [session?.user?.id, initializeListener]);
}

View File

@ -0,0 +1,331 @@
/**
* RocketChat Call Listener Service
*
* Listens for incoming calls via RocketChat's DDP/WebSocket real-time API
* Uses stream-notify-user with webrtc events to detect incoming calls
*/
import { logger } from '@/lib/logger';
export interface CallEvent {
type: 'call-incoming' | 'call-answered' | 'call-ended';
from: {
userId: string;
username: string;
name?: string;
};
roomId: string;
roomName?: string;
timestamp: Date;
}
type CallEventHandler = (event: CallEvent) => void;
export class RocketChatCallListener {
private static instance: RocketChatCallListener;
private ws: WebSocket | null = null;
private reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
private reconnectAttempts = 0;
private maxReconnectAttempts = 10;
private reconnectDelay = 3000;
private isConnecting = false;
private isConnected = false;
private callHandlers: Set<CallEventHandler> = new Set();
private userId: string | null = null;
private authToken: string | null = null;
private baseUrl: string | null = null;
private subscriptionId: string | null = null;
private constructor() {}
public static getInstance(): RocketChatCallListener {
if (!RocketChatCallListener.instance) {
RocketChatCallListener.instance = new RocketChatCallListener();
}
return RocketChatCallListener.instance;
}
/**
* Initialize the listener with user credentials
*/
public async initialize(userId: string, authToken: string, baseUrl: string): Promise<boolean> {
this.userId = userId;
this.authToken = authToken;
this.baseUrl = baseUrl;
if (this.isConnected || this.isConnecting) {
logger.debug('[ROCKETCHAT_CALL_LISTENER] Already connected or connecting');
return true;
}
return this.connect();
}
/**
* Connect to RocketChat WebSocket
*/
private async connect(): Promise<boolean> {
if (this.isConnecting) {
return false;
}
if (!this.baseUrl || !this.userId || !this.authToken) {
logger.error('[ROCKETCHAT_CALL_LISTENER] Missing credentials for connection');
return false;
}
this.isConnecting = true;
try {
// Convert HTTP URL to WebSocket URL
const wsUrl = this.baseUrl
.replace('http://', 'ws://')
.replace('https://', 'wss://')
.replace(/\/$/, '') + '/websocket';
logger.debug('[ROCKETCHAT_CALL_LISTENER] Connecting to WebSocket', { wsUrl });
this.ws = new WebSocket(wsUrl);
this.ws.onopen = () => {
logger.debug('[ROCKETCHAT_CALL_LISTENER] WebSocket connected');
this.isConnecting = false;
this.isConnected = true;
this.reconnectAttempts = 0;
this.authenticate();
};
this.ws.onmessage = (event) => {
this.handleMessage(JSON.parse(event.data));
};
this.ws.onerror = (error) => {
logger.error('[ROCKETCHAT_CALL_LISTENER] WebSocket error', { error });
this.isConnecting = false;
this.isConnected = false;
};
this.ws.onclose = () => {
logger.debug('[ROCKETCHAT_CALL_LISTENER] WebSocket closed');
this.isConnected = false;
this.isConnecting = false;
this.subscriptionId = null;
this.attemptReconnect();
};
return true;
} catch (error) {
logger.error('[ROCKETCHAT_CALL_LISTENER] Connection error', {
error: error instanceof Error ? error.message : String(error),
});
this.isConnecting = false;
this.attemptReconnect();
return false;
}
}
/**
* Authenticate with RocketChat
*/
private authenticate(): void {
if (!this.ws || !this.authToken || !this.userId) {
return;
}
const loginMessage = {
msg: 'method',
method: 'login',
id: `login-${Date.now()}`,
params: [
{
resume: this.authToken,
},
],
};
logger.debug('[ROCKETCHAT_CALL_LISTENER] Sending login message');
this.ws.send(JSON.stringify(loginMessage));
}
/**
* Subscribe to webrtc events for incoming calls
*/
private subscribeToCalls(): void {
if (!this.ws || !this.userId) {
return;
}
this.subscriptionId = `call-sub-${Date.now()}`;
const subscribeMessage = {
msg: 'sub',
id: this.subscriptionId,
name: 'stream-notify-user',
params: [`${this.userId}/webrtc`, false],
};
logger.debug('[ROCKETCHAT_CALL_LISTENER] Subscribing to webrtc events', {
subscriptionId: this.subscriptionId,
userId: this.userId,
});
this.ws.send(JSON.stringify(subscribeMessage));
}
/**
* Handle incoming WebSocket messages
*/
private handleMessage(message: any): void {
// Handle login response
if (message.msg === 'result' && message.id?.startsWith('login-')) {
if (message.result?.token) {
logger.debug('[ROCKETCHAT_CALL_LISTENER] Login successful');
this.subscribeToCalls();
} else {
logger.error('[ROCKETCHAT_CALL_LISTENER] Login failed', { message });
}
return;
}
// Handle subscription ready
if (message.msg === 'ready' && message.subs?.includes(this.subscriptionId)) {
logger.debug('[ROCKETCHAT_CALL_LISTENER] Subscription ready');
return;
}
// Handle call events (webrtc)
if (message.msg === 'changed' && message.collection === 'stream-notify-user') {
const eventName = message.fields?.eventName;
const args = message.fields?.args || [];
if (eventName?.includes('/webrtc') && args.length > 0) {
this.handleCallEvent(args[0]);
}
}
}
/**
* Handle call event from RocketChat
*/
private handleCallEvent(eventData: any): void {
logger.debug('[ROCKETCHAT_CALL_LISTENER] Received call event', { eventData });
try {
// Parse the call event data
// RocketChat webrtc events typically contain:
// - type: 'call', 'ringing', 'answered', 'ended'
// - from: user info
// - roomId: room identifier
const callType = eventData.type || eventData.action;
const from = eventData.from || eventData.caller || {};
const roomId = eventData.roomId || eventData.rid;
if (!callType || !roomId) {
logger.warn('[ROCKETCHAT_CALL_LISTENER] Invalid call event data', { eventData });
return;
}
// Detect incoming call
if (callType === 'call' || callType === 'ringing' || eventData.action === 'ringing') {
const callEvent: CallEvent = {
type: 'call-incoming',
from: {
userId: from._id || from.userId || '',
username: from.username || '',
name: from.name || from.username,
},
roomId,
roomName: eventData.roomName || from.name,
timestamp: new Date(),
};
logger.info('[ROCKETCHAT_CALL_LISTENER] Incoming call detected', {
from: callEvent.from.username,
roomId,
});
// Notify all handlers
this.callHandlers.forEach((handler) => {
try {
handler(callEvent);
} catch (error) {
logger.error('[ROCKETCHAT_CALL_LISTENER] Error in call handler', { error });
}
});
}
} catch (error) {
logger.error('[ROCKETCHAT_CALL_LISTENER] Error handling call event', {
error: error instanceof Error ? error.message : String(error),
eventData,
});
}
}
/**
* Register a handler for call events
*/
public onCall(handler: CallEventHandler): () => void {
this.callHandlers.add(handler);
// Return unsubscribe function
return () => {
this.callHandlers.delete(handler);
};
}
/**
* Attempt to reconnect
*/
private attemptReconnect(): void {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
logger.error('[ROCKETCHAT_CALL_LISTENER] Max reconnect attempts reached');
return;
}
if (this.reconnectTimeout) {
return;
}
this.reconnectAttempts++;
const delay = this.reconnectDelay * this.reconnectAttempts;
logger.debug('[ROCKETCHAT_CALL_LISTENER] Scheduling reconnect', {
attempt: this.reconnectAttempts,
delay,
});
this.reconnectTimeout = setTimeout(() => {
this.reconnectTimeout = null;
this.connect();
}, delay);
}
/**
* Disconnect and cleanup
*/
public disconnect(): void {
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout);
this.reconnectTimeout = null;
}
if (this.ws) {
this.ws.close();
this.ws = null;
}
this.isConnected = false;
this.isConnecting = false;
this.subscriptionId = null;
this.callHandlers.clear();
logger.debug('[ROCKETCHAT_CALL_LISTENER] Disconnected');
}
/**
* Check if connected
*/
public get connected(): boolean {
return this.isConnected;
}
}