Implemented mailbox in ConsoleUI

This commit is contained in:
2026-10-22 18:01:22 +02:00
parent 6ff016e7ec
commit cb89817d88
20 changed files with 3208 additions and 19 deletions
@@ -0,0 +1,330 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useState, useEffect, useMemo, useCallback } from 'react';
import { useAuth } from '../auth/AuthProvider';
import {
getUserNotifications,
getNotifications,
createNotification,
markNotificationAsRead,
updateNotification,
getCurrentUserTenantId,
} from '../services/api';
import {
Notification,
NotificationPlatform,
NotificationStatus,
CreateNotificationRequest,
} from '../types';
export interface DraftMessage {
id: string;
recipientId: string;
recipientEmail: string;
recipientName?: string;
subject: string;
message: string;
platform?: NotificationPlatform;
updatedAt: string;
}
export function useInAppNotifications() {
const { user, claims } = useAuth();
const queryClient = useQueryClient();
const userId = (user?.id || claims?.sub || '') as string;
const userEmail = (user?.email || claims?.email || '') as string;
// Local storage keys for client-side starred and drafts
const draftsStorageKey = `pfm_mailbox_drafts_${userId || 'default'}`;
const starredStorageKey = `pfm_mailbox_starred_${userId || 'default'}`;
const trashStorageKey = `pfm_mailbox_trash_${userId || 'default'}`;
const sentStorageKey = `pfm_mailbox_sent_${userId || 'default'}`;
// Starred messages set
const [starredIds, setStarredIds] = useState<string[]>(() => {
try {
const stored = localStorage.getItem(starredStorageKey);
return stored ? JSON.parse(stored) : [];
} catch {
return [];
}
});
// Trashed messages set
const [trashedIds, setTrashedIds] = useState<string[]>(() => {
try {
const stored = localStorage.getItem(trashStorageKey);
return stored ? JSON.parse(stored) : [];
} catch {
return [];
}
});
// Local drafts list
const [drafts, setDrafts] = useState<DraftMessage[]>(() => {
try {
const stored = localStorage.getItem(draftsStorageKey);
return stored ? JSON.parse(stored) : [];
} catch {
return [];
}
});
// Local sent messages history
const [localSent, setLocalSent] = useState<Notification[]>(() => {
try {
const stored = localStorage.getItem(sentStorageKey);
return stored ? JSON.parse(stored) : [];
} catch {
return [];
}
});
// Sync state to localStorage
useEffect(() => {
try {
localStorage.setItem(starredStorageKey, JSON.stringify(starredIds));
} catch {}
}, [starredIds, starredStorageKey]);
useEffect(() => {
try {
localStorage.setItem(trashStorageKey, JSON.stringify(trashedIds));
} catch {}
}, [trashedIds, trashStorageKey]);
useEffect(() => {
try {
localStorage.setItem(draftsStorageKey, JSON.stringify(drafts));
} catch {}
}, [drafts, draftsStorageKey]);
useEffect(() => {
try {
localStorage.setItem(sentStorageKey, JSON.stringify(localSent));
} catch {}
}, [localSent, sentStorageKey]);
// Fetch notifications from backend
const { data: rawNotifications = [], isLoading, refetch } = useQuery<Notification[]>({
queryKey: ['inapp-notifications', userId],
queryFn: async () => {
// 1. Try user-specific notifications
let list = await getUserNotifications(userId || undefined);
// 2. If list is empty or fallback needed, also check organisation notifications
if (!list || list.length === 0) {
const orgList = await getNotifications();
if (orgList && orgList.length > 0) {
list = orgList;
}
}
return list || [];
},
// Live polling every 30 seconds
refetchInterval: 30000,
staleTime: 10000,
});
// InApp notifications (strictly platform === InApp 5) for Bell & Counters
const inAppNotifications = useMemo(() => {
return rawNotifications.filter(
n => Number(n.platform) === NotificationPlatform.InApp || n.platform === (NotificationPlatform.InApp as any)
);
}, [rawNotifications]);
// Active inbox notifications (InApp + Email for mailbox, excluding trashed and drafts)
const allMailboxNotifications = useMemo(() => {
return rawNotifications.filter(
n =>
(Number(n.platform) === NotificationPlatform.InApp || Number(n.platform) === NotificationPlatform.Email) &&
!trashedIds.includes(String(n.id)) &&
Number(n.status) !== NotificationStatus.Draft
);
}, [rawNotifications, trashedIds]);
// Active in-app notifications in inbox
const inboxNotifications = useMemo(() => {
return inAppNotifications.filter(
n => !trashedIds.includes(String(n.id)) && Number(n.status) !== NotificationStatus.Draft
);
}, [inAppNotifications, trashedIds]);
// Unread InApp notifications: status !== Read && status !== Draft && status !== Cancelled && status !== Failed
const unreadNotifications = useMemo(() => {
return inboxNotifications.filter(
n =>
Number(n.status) !== NotificationStatus.Read &&
Number(n.status) !== NotificationStatus.Cancelled &&
Number(n.status) !== NotificationStatus.Failed
);
}, [inboxNotifications]);
const unreadCount = unreadNotifications.length;
const top5Unread = useMemo(() => unreadNotifications.slice(0, 5), [unreadNotifications]);
// Starred notifications
const starredNotifications = useMemo(() => {
return rawNotifications.filter(
n =>
starredIds.includes(String(n.id)) &&
!trashedIds.includes(String(n.id)) &&
(Number(n.platform) === NotificationPlatform.InApp || Number(n.platform) === NotificationPlatform.Email)
);
}, [rawNotifications, starredIds, trashedIds]);
// Trashed notifications
const trashedNotifications = useMemo(() => {
return rawNotifications.filter(n => trashedIds.includes(String(n.id)));
}, [rawNotifications, trashedIds]);
// Mutation to mark notification as read
const markAsReadMutation = useMutation({
mutationFn: async (id: number | string) => {
await markNotificationAsRead(id);
},
onMutate: async (id) => {
await queryClient.cancelQueries({ queryKey: ['inapp-notifications', userId] });
queryClient.setQueryData<Notification[]>(['inapp-notifications', userId], (old = []) =>
old.map(n => (String(n.id) === String(id) ? { ...n, status: NotificationStatus.Read } : n))
);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['inapp-notifications'] });
},
});
// Mark all unread as read
const markAllAsRead = useCallback(async () => {
const unread = unreadNotifications;
for (const item of unread) {
try {
await markNotificationAsRead(item.id);
} catch {}
}
queryClient.invalidateQueries({ queryKey: ['inapp-notifications'] });
}, [unreadNotifications, queryClient]);
// Toggle star
const toggleStar = useCallback((id: string | number) => {
const strId = String(id);
setStarredIds(prev => (prev.includes(strId) ? prev.filter(x => x !== strId) : [...prev, strId]));
}, []);
// Delete message (move to trash)
const deleteMessage = useCallback((id: string | number) => {
const strId = String(id);
setTrashedIds(prev => (prev.includes(strId) ? prev : [...prev, strId]));
}, []);
// Restore from trash
const restoreMessage = useCallback((id: string | number) => {
const strId = String(id);
setTrashedIds(prev => prev.filter(x => x !== strId));
}, []);
// Permanent delete
const permanentDelete = useCallback((id: string | number) => {
const strId = String(id);
setTrashedIds(prev => prev.filter(x => x !== strId));
setLocalSent(prev => prev.filter(n => String(n.id) !== strId));
}, []);
// Draft management
const saveDraft = useCallback((draft: Omit<DraftMessage, 'id' | 'updatedAt'> & { id?: string }) => {
const draftId = draft.id || `draft_${Date.now()}`;
const newDraft: DraftMessage = {
...draft,
id: draftId,
updatedAt: new Date().toISOString(),
};
setDrafts(prev => {
const filtered = prev.filter(d => d.id !== draftId);
return [newDraft, ...filtered];
});
return draftId;
}, []);
const deleteDraft = useCallback((id: string) => {
setDrafts(prev => prev.filter(d => d.id !== id));
}, []);
// Send new notification (InApp or Email)
const sendMessageMutation = useMutation({
mutationFn: async (payload: {
recipientId: string;
recipientEmail: string;
recipientName?: string;
subject: string;
message: string;
platform?: NotificationPlatform;
draftId?: string;
}) => {
const orgId = getCurrentUserTenantId() || 1;
const targetPlatform = payload.platform || NotificationPlatform.InApp;
const formattedDestination = `To: ${payload.recipientEmail} | From: ${userEmail || 'System User'}`;
const req: CreateNotificationRequest = {
organisationId: orgId,
recipient: payload.recipientId,
platform: targetPlatform,
subject: payload.subject,
destination: formattedDestination,
message: payload.message,
status: targetPlatform === NotificationPlatform.InApp ? NotificationStatus.Delivered : NotificationStatus.Queued,
};
await createNotification(req);
// Record in local sent messages
const sentItem: Notification = {
id: `sent_${Date.now()}`,
organisationId: orgId,
recipient: payload.recipientId,
platform: targetPlatform,
status: NotificationStatus.Sent,
subject: payload.subject,
message: payload.message,
destination: formattedDestination,
createdAt: new Date().toISOString(),
sentAt: new Date().toISOString(),
};
setLocalSent(prev => [sentItem, ...prev]);
// If sending a draft, remove the draft
if (payload.draftId) {
deleteDraft(payload.draftId);
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['inapp-notifications'] });
},
});
return {
rawNotifications,
inAppNotifications,
allMailboxNotifications,
inboxNotifications,
unreadNotifications,
unreadCount,
top5Unread,
starredNotifications,
trashedNotifications,
sentNotifications: localSent,
drafts,
starredIds,
trashedIds,
isLoading,
refetch,
markAsRead: (id: string | number) => markAsReadMutation.mutate(id),
markAllAsRead,
toggleStar,
deleteMessage,
restoreMessage,
permanentDelete,
saveDraft,
deleteDraft,
sendMessage: sendMessageMutation.mutateAsync,
isSending: sendMessageMutation.isPending,
};
}