diff --git a/apps/db/migrations/46_uuidv7.up.sql b/apps/db/migrations/46_uuidv7.up.sql new file mode 100644 index 0000000..ff79578 --- /dev/null +++ b/apps/db/migrations/46_uuidv7.up.sql @@ -0,0 +1,15 @@ +BEGIN; + +ALTER TABLE chats ALTER COLUMN chatid SET DEFAULT uuidv7(); +ALTER TABLE ordini ALTER COLUMN ordine_id SET DEFAULT uuidv7(); +ALTER TABLE potenziali_groups ALTER COLUMN id SET DEFAULT uuidv7(); +ALTER TABLE potenziali ALTER COLUMN id SET DEFAULT uuidv7(); +ALTER TABLE messages ALTER COLUMN messageid SET DEFAULT uuidv7(); +ALTER TABLE rinnovi ALTER COLUMN id SET DEFAULT uuidv7(); +ALTER TABLE user_invites ALTER COLUMN id SET DEFAULT uuidv7(); +ALTER TABLE users ALTER COLUMN id SET DEFAULT uuidv7(); +ALTER TABLE servizio ALTER COLUMN servizio_id SET DEFAULT uuidv7(); +ALTER TABLE users_storage ALTER COLUMN user_storage_id SET DEFAULT uuidv7(); +ALTER TABLE users_anagrafica ALTER COLUMN idanagrafica SET DEFAULT uuidv7(); + +COMMIT; \ No newline at end of file diff --git a/apps/infoalloggi/src/components/chat/chat-attachments.tsx b/apps/infoalloggi/src/components/chat/chat-attachments.tsx new file mode 100644 index 0000000..9ba46a7 --- /dev/null +++ b/apps/infoalloggi/src/components/chat/chat-attachments.tsx @@ -0,0 +1,195 @@ +import { differenceInDays, differenceInMinutes, format } from "date-fns"; +import { Paperclip } from "lucide-react"; +import Link from "next/link"; +import { ExtIcon } from "~/components/area-riservata/allegati"; +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle, + CredenzaTrigger, +} from "~/components/custom_ui/credenza"; +import { LoadingPage } from "~/components/loading"; +import { Button, buttonVariants } from "~/components/ui/button"; +import { UploadComponent } from "~/components/upload_modal"; +import { cn } from "~/lib/utils"; +import { useTranslation } from "~/providers/I18nProvider"; +import type { Session } from "~/server/api/trpc"; +import type { ChatUserInfo } from "~/server/services/chat.service"; +import { api } from "~/utils/api"; + +type ChatAttachmentsProps = { + userData: Session; + chatUserData: ChatUserInfo; +}; +export const ChatAttachments = ({ + userData, + chatUserData, +}: ChatAttachmentsProps) => { + const { t } = useTranslation(); + const { + data: attachments, + isLoading: attachmentsLoading, + refetch, + } = api.storage.retrieveUserFileData.useQuery( + { userId: chatUserData.id }, + { refetchOnMount: true }, + ); + + return ( + o && refetch()}> + + + {attachments && attachments.length > 0 && ( + + {attachments.length} + + )} + + + + + + Allegati + + Chat Attachments + + + + + + + {attachmentsLoading ? ( + + ) : ( + <> + {!attachments || attachments.length === 0 ? ( + + + {t.allegati.nessun_allegato} + + + ) : ( + + + {t.allegati.allegati_recenti} + + {attachments?.map((file, idx) => { + if (idx >= 3) return null; + + return ( + + idx + }`} + > + console.log("touching")} + target="_blank" + > + + + + {file.originalName} + + + + + {TimeSince(new Date(file.uploadedAt))} + + + ); + })} + + )} + > + )} + + + {t.allegati.i_tuoi_allegati} + {attachments && attachments.length > 0 && ( + + {attachments.length} + + )} + + + + + {t.chiudi} + + + + + ); +}; + +const TimeSince = (date: Date) => { + const minsSince = Math.abs(differenceInMinutes(date, new Date())); + const daysSince = Math.abs(differenceInDays(date, new Date())); + + const relevantMinsIncrements = [1, 3, 5, 10, 15, 30, 60, 120]; + + if (daysSince === 0) { + if (minsSince < 1) { + return "ora"; + } + + const closestToNow = relevantMinsIncrements.reduce((prev, curr) => { + return Math.abs(curr - minsSince) < Math.abs(prev - minsSince) + ? curr + : prev; + }); + + if (closestToNow === 60) { + return "~1 ora fa"; + } + + if (closestToNow === 120 && minsSince < 121) { + return "~2 ore fa"; + } + + return `~${closestToNow} min fa`; + } + + if (daysSince === 1) { + return "ieri"; + } + + if (daysSince < 30) { + return `${daysSince} giorni fa`; + } + + return format(date, "dd/MM/yyyy"); +}; diff --git a/apps/infoalloggi/src/components/chat/chat-bottombar.tsx b/apps/infoalloggi/src/components/chat/chat-bottombar.tsx new file mode 100644 index 0000000..1a891cf --- /dev/null +++ b/apps/infoalloggi/src/components/chat/chat-bottombar.tsx @@ -0,0 +1,172 @@ +"use client"; + +import { SendHorizontal, SmilePlusIcon, ThumbsUp } from "lucide-react"; +import { + type ChangeEvent, + useCallback, + useEffect, + useRef, + useState, +} from "react"; +import { ChatAttachments } from "~/components/chat/chat-attachments"; +import { + type AutosizeTextAreaRef, + AutosizeTextarea, +} from "~/components/custom_ui/autoResizeTextArea"; +import { + EmojiPicker, + EmojiPickerContent, + EmojiPickerSearch, +} from "~/components/custom_ui/emoji-picker"; +import { Button } from "~/components/ui/button"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "~/components/ui/popover"; +import { useThrottledIsTypingMutation } from "~/hooks/chatHooks"; +import { useMediaQuery } from "~/hooks/use-media-query"; +import { useTranslation } from "~/providers/I18nProvider"; +import type { ChatsChatid } from "~/schemas/public/Chats"; +import type { Session } from "~/server/api/trpc"; +import type { ChatUserInfo } from "~/server/services/chat.service"; +import { api } from "~/utils/api"; + +type ChatBottombarProps = { + chatId: ChatsChatid; + userData: Session; + chatUserData: ChatUserInfo; +}; + +export default function ChatBottombar({ + chatId, + userData, + chatUserData, +}: ChatBottombarProps) { + const { locale } = useTranslation(); + const [message, setMessage] = useState(""); + const [isFocused, setIsFocused] = useState(false); + const inputRef = useRef(null); + + const { mutate: addMutation } = api.chatSSE.sendMessage.useMutation({}); + + const handleThumbsUp = useCallback(() => { + addMutation({ + chatId, + message: "👍", + sender: userData.id, + }); + setMessage(""); + }, [chatId, addMutation, userData]); + + const handleSend = () => { + if (message.length !== 0) { + addMutation({ + chatId, + message: message.trim(), + sender: userData.id, + }); + + setMessage(""); + + if (inputRef.current) { + inputRef.current.textArea.focus(); + inputRef.current.textArea.style.height = "auto"; + } + } + }; + const isTypingMutation = useThrottledIsTypingMutation({ + chatId, + username: userData.username, + userId: userData.id, + }); + + useEffect(() => { + // update isTyping state + isTypingMutation(isFocused && message.trim().length > 0); + }, [isFocused, message, isTypingMutation]); + + useEffect(() => { + if (inputRef.current) { + inputRef.current.textArea.focus(); + } + }, []); + const [openEmoji, setOpenEmoji] = useState(false); + + const isMobile = useMediaQuery("(min-width: 426px)"); + return ( + + + + + + setIsFocused(false)} + onChange={(e: ChangeEvent) => { + setMessage(e.target.value); + }} + onFocus={() => setIsFocused(true)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + if (e.shiftKey) { + setMessage((prev) => `${prev}\n`); + } else { + handleSend(); + } + } + }} + placeholder="Aa" + ref={inputRef} + value={message} + /> + {isMobile && ( + + + + + + + + + { + setMessage(message + emoji); + // if (inputRef.current) { + // inputRef.current.textArea.focus(); + // } + }} + > + + + + + + + )} + + handleSend() : () => handleThumbsUp() + } + variant="ghost" + > + {message.length !== 0 ? ( + + ) : ( + + )} + + + ); +} diff --git a/apps/infoalloggi/src/components/chat/chat-bubble.tsx b/apps/infoalloggi/src/components/chat/chat-bubble.tsx new file mode 100644 index 0000000..e61dad5 --- /dev/null +++ b/apps/infoalloggi/src/components/chat/chat-bubble.tsx @@ -0,0 +1,135 @@ +import { cva, type VariantProps } from "class-variance-authority"; +import { Check, CheckCheck } from "lucide-react"; +import * as React from "react"; +import MessageLoading from "~/components/chat/message-loading"; +import { cn } from "~/lib/utils"; + +// ChatBubble +const chatBubbleVariant = cva( + "flex gap-2 max-w-[85%] items-end relative group ", + { + defaultVariants: { + layout: "default", + variant: "received", + }, + variants: { + layout: { + ai: "max-w-full w-full items-center", + default: "", + }, + variant: { + received: "self-start", + sent: "self-end flex-row-reverse", + }, + }, + }, +); + +interface ChatBubbleProps + extends React.HTMLAttributes, + VariantProps {} + +const ChatBubble = React.forwardRef( + ({ className, variant, layout, children, ...props }, ref) => ( + + {React.Children.map(children, (child) => + React.isValidElement(child) && typeof child.type !== "string" + ? React.cloneElement(child, { + layout, + variant, + } as Partial>) + : child, + )} + + ), +); +ChatBubble.displayName = "ChatBubble"; + +type ChatBubbleReadStatusProps = { + isread: boolean; +}; + +export const ChatBubbleReadStatus = ({ isread }: ChatBubbleReadStatusProps) => { + if (isread) { + return ; + } + return ; +}; + +// ChatBubbleMessage +const chatBubbleMessageVariants = cva("px-1.5 py-0.75 ", { + defaultVariants: { + layout: "default", + variant: "received", + }, + variants: { + layout: { + ai: "border-t w-full rounded-none bg-transparent", + default: "", + }, + variant: { + received: + "bg-secondary text-secondary-foreground rounded-r-lg rounded-tl-lg", + sent: "bg-primary text-primary-foreground rounded-l-lg rounded-tr-lg", + }, + }, +}); + +interface ChatBubbleMessageProps + extends React.HTMLAttributes, + VariantProps { + isLoading?: boolean; +} + +const ChatBubbleMessage = React.forwardRef< + HTMLDivElement, + ChatBubbleMessageProps +>( + ( + { className, variant, layout, isLoading = false, children, ...props }, + ref, + ) => ( + + {isLoading ? ( + + + + ) : ( + children + )} + + ), +); +ChatBubbleMessage.displayName = "ChatBubbleMessage"; + +// ChatBubbleTimestamp +interface ChatBubbleTimestampProps + extends React.HTMLAttributes { + timestamp: string; +} + +const ChatBubbleTimestamp: React.FC = ({ + timestamp, + className, + ...props +}) => ( + + {timestamp} + +); + +export { ChatBubble, ChatBubbleMessage, ChatBubbleTimestamp }; diff --git a/apps/infoalloggi/src/components/chat/chat-list.tsx b/apps/infoalloggi/src/components/chat/chat-list.tsx new file mode 100644 index 0000000..2fa2e8c --- /dev/null +++ b/apps/infoalloggi/src/components/chat/chat-list.tsx @@ -0,0 +1,470 @@ +import { AnimatePresence, motion } from "framer-motion"; +import { Pencil, Trash2 } from "lucide-react"; +import { + type FormEvent, + useCallback, + useEffect, + useRef, + useState, +} from "react"; +import toast from "react-hot-toast"; +import { + ChatBubble, + ChatBubbleMessage, + ChatBubbleReadStatus, + ChatBubbleTimestamp, +} from "~/components/chat/chat-bubble"; +import LoadingButton from "~/components/custom_ui/loading-button"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "~/components/ui/alert-dialog"; +import { Button } from "~/components/ui/button"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from "~/components/ui/context-menu"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "~/components/ui/dialog"; +import Input from "~/components/ui/input"; +import { Textarea } from "~/components/ui/textarea"; +import { getUserColorV2, UserAvatar } from "~/components/user_avatar"; +import type { LiveChat } from "~/hooks/chatHooks"; +import { cn, getInitials } from "~/lib/utils"; +import { useTranslation } from "~/providers/I18nProvider"; +import type { ChatsChatid } from "~/schemas/public/Chats"; +import type { MessagesMessageid } from "~/schemas/public/Messages"; +import type { UsersId } from "~/schemas/public/Users"; +import type { Message, TypingData } from "~/server/api/routers/chat_sse"; +import type { Session } from "~/server/api/trpc"; +import { api } from "~/utils/api"; + +type ChatListProps = { + chatId: ChatsChatid; + messages: Message[]; + userData: Session; + query: LiveChat["query"]; + typingStatus: TypingData; +}; + +export const ChatList = ({ + chatId, + messages, + userData, + query, + typingStatus, +}: ChatListProps) => { + const messagesContainerRef = useRef(null); + const [editModalOpen, setEditModalOpen] = useState(false); + const [msgSelected, setMsgSelected] = useState(null); + const [deleteModalOpen, setDeleteModalOpen] = useState(false); + + const handleEditOpen = (status: boolean) => { + setEditModalOpen(status); + }; + const handleDeleteOpen = (status: boolean) => { + setDeleteModalOpen(status); + }; + + const { mutate: deleteMutation } = api.chatSSE.deleteMessage.useMutation({ + onError: () => { + toast.error("Error deleting message", { id: "deleteMessage" }); + }, + onMutate: () => { + toast("Deleting message...", { icon: "🗑️", id: "deleteMessage" }); + }, + onSuccess: () => { + toast("Message deleted", { icon: "🗑️", id: "deleteMessage" }); + }, + }); + const { mutate: editMutation } = api.chatSSE.editMessage.useMutation({ + onError: () => { + toast.error("Error editing message", { id: "editMessage" }); + }, + onMutate: () => { + toast("Editing message...", { icon: "📝", id: "editMessage" }); + }, + onSuccess: () => { + toast("Message edited", { icon: "📝", id: "editMessage" }); + }, + }); + + const handleEditSubmit = useCallback( + (e: FormEvent) => { + e.preventDefault(); + const form = e.currentTarget; + const formData = new FormData(form); + + const raw_messageId = formData.get("message-id"); + const raw_message = formData.get("message-edit"); + + if (!raw_message || !raw_messageId) { + return; + } + const messageId = raw_messageId.toString() as MessagesMessageid; + const message = raw_message.toString(); + editMutation({ + chatId, + message, + messageId, + }); + setEditModalOpen(false); + }, + [editMutation], + ); + const [locked, setLocked] = useState(false); + + useEffect(() => { + if ( + messagesContainerRef.current && + !locked && + !query.isLoading && + !query.isFetching + ) { + messagesContainerRef.current.scrollTop = + messagesContainerRef.current.scrollHeight; + } + setLocked(false); + }, [messages, typingStatus]); + + return ( + + + + {query.hasNextPage && ( + { + setLocked(true); + await query.fetchNextPage(); + }} + variant="secondary" + > + {query.isFetchingNextPage + ? "Caricamento..." + : "Carica messaggi precedenti"} + + )} + + {messages.map((message, index) => { + const variant = getMessageVariant(message.sender, userData.id); + + const isNextSameSender = + index + 1 < messages.length && + messages[index + 1]?.sender === message.sender; + + const isPreviousSameSender = + index - 1 >= 0 && messages[index - 1]?.sender === message.sender; + + const isPrevSameDay = + index - 1 >= 0 && + // biome-ignore lint/style/noNonNullAssertion: + new Date(messages[index - 1]!.time).toDateString() === + new Date(message.time).toDateString(); + + const userColor = getUserColorV2( + `${getInitials(message.sender_nome)}${message.sender}`, + ); + + return ( + + {!isPrevSameDay && ( + + {new Date(message.time).toLocaleDateString("it", { + day: "2-digit", + month: "2-digit", + year: "2-digit", + })} + + )} + + + {isNextSameSender ? ( + + ) : ( + + )} + + + + {!isPreviousSameSender && ( + + {message.sender_nome} + + )} + + + {message.message} + + + {userData.id === message.sender && ( + + )} + {message.time && ( + + )} + + + + + + { + setMsgSelected(message); + handleDeleteOpen(true); + }} + > + + Cancella + + { + setMsgSelected(message); + handleEditOpen(true); + }} + > + + Modifica + + + + + + ); + })} + + + + + + { + deleteMutation({ chatId, messageId }); + }} + msgDelete={msgSelected} + onOpenChange={handleDeleteOpen} + open={deleteModalOpen} + /> + + ); +}; + +const Typers = ({ + currentTypers, + messageLenght, + userId, +}: { + currentTypers: TypingData; + messageLenght: number; + userId: UsersId; +}) => { + return ( + <> + {currentTypers.map((typer) => { + if (typer.userId === userId) return null; + const variant = "received"; + + return ( + + + + + + + + ); + })} + > + ); +}; + +const EditMessageDialog = ({ + open, + onOpenChange, + msgEdit, + handleEditSubmit, +}: { + open: boolean; + onOpenChange: (status: boolean) => void; + msgEdit: Message | null; + handleEditSubmit: (e: FormEvent) => void; +}) => { + const { t } = useTranslation(); + return ( + + + + {t.modifica} + Edit + + {msgEdit && ( + + + + ID + + + + Modifica messaggio + + + + {t.salva} + + + )} + + + ); +}; + +const DeleteMessageDialog = ({ + open, + onOpenChange, + msgDelete, + handleDeleteSubmit, +}: { + open: boolean; + onOpenChange: (status: boolean) => void; + msgDelete: Message | null; + handleDeleteSubmit: (msgId: MessagesMessageid) => void; +}) => { + const { t } = useTranslation(); + return ( + <> + {msgDelete && ( + + + + Vuoi eliminare il messaggio? + + Questa azione non può essere annullata. + + + + {t.annulla} + handleDeleteSubmit(msgDelete.messageid)} + > + {t.procedi} + + + + + )} + > + ); +}; + +const getMessageVariant = (messageName: string, selectedUserName: string) => + messageName !== selectedUserName ? "received" : "sent"; diff --git a/apps/infoalloggi/src/components/chat/chat-sidebar.tsx b/apps/infoalloggi/src/components/chat/chat-sidebar.tsx new file mode 100644 index 0000000..8877299 --- /dev/null +++ b/apps/infoalloggi/src/components/chat/chat-sidebar.tsx @@ -0,0 +1,458 @@ +import { Check, CheckCheck, Search, SquarePen } from "lucide-react"; +import Link from "next/link"; +import { useRouter } from "next/router"; +import { useCallback, useState } from "react"; +import { Etichetta, EtichetteModal } from "~/components/etichette"; +import { LoadingPage } from "~/components/loading"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "~/components/ui/alert-dialog"; +import { Button, buttonVariants } from "~/components/ui/button"; +import { + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "~/components/ui/command"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuTrigger, +} from "~/components/ui/context-menu"; +import Input from "~/components/ui/input"; +import { UserAvatar } from "~/components/user_avatar"; +import { useMediaQuery } from "~/hooks/use-media-query"; +import { cn } from "~/lib/utils"; +import type { ChatsChatid } from "~/schemas/public/Chats"; +import type { UsersId } from "~/schemas/public/Users"; +import type { ActiveChatsType } from "~/server/controllers/chat.controller"; +import type { NonNullableUser } from "~/server/controllers/user.controller"; +import { api } from "~/utils/api"; + +export const ChatSidebar = ({ + chatId, + userId, +}: { + chatId: ChatsChatid | null; + userId: UsersId; +}) => { + const utils = api.useUtils(); + + api.chatSSE.onSidebarUpdate.useSubscription(undefined, { + onData() { + void utils.chat.getActiveChats.invalidate(); + }, + }); + const { data: activeChats, isLoading: loadingActiveChats } = + api.chat.getActiveChats.useQuery(undefined, { + refetchOnWindowFocus: true, + }); + const { data: inactiveUsers, isLoading: loadingInactiveUsers } = + api.users.getActiveUsersWithoutChat.useQuery(); + + const router = useRouter(); + const isDesktop = useMediaQuery("(min-width: 768px)"); + const [selectedChat, setSelectedChat] = useState(null); + const [addDialogOpen, setAddDialogOpen] = useState(false); + const [searchDialogOpen, setSearchDialogOpen] = useState(false); + const [deleteModalOpen, setDeleteModalOpen] = useState(false); + const [editEtichettaModalOpen, setEditEtichettaModalOpen] = useState(false); + + const { mutate: deletechat } = api.chat.deleteChat.useMutation({ + onSuccess: async () => { + await utils.chat.getActiveChats.invalidate(); + await utils.users.getActiveUsersWithoutChat.invalidate(); + }, + }); + + const handleChatSelection = useCallback( + async (new_chatid: ChatsChatid) => { + if (chatId === new_chatid) return; + await router.push(`/area-riservata/admin/chats/${new_chatid}`); + setSearchDialogOpen(false); + }, + [chatId, router], + ); + + if (loadingActiveChats || loadingInactiveUsers) return ; + if (!activeChats || !inactiveUsers) return Errore caricamento; + + return ( + + + {isDesktop ? ( + + + + Search Chats + + + + ) : ( + setSearchDialogOpen(true)} + size="icon" + variant="ghost" + > + + + )} + + + setAddDialogOpen(true)} + size="icon" + variant="ghost" + > + + + + + + + + {activeChats.map((chat) => ( + + + handleChatSelection(chat.chatid)} + > + + + {isDesktop && ( + + {chat.username} + + {chat.etichette.map((etichetta) => ( + + ))} + + {chat.lastmessage && ( + <> + + + {chat.lastmessage.sender === userId ? "Tu: " : ""} + {chat.lastmessage.message} + + + {chat.lastmessage.sender === userId && + (chat.lastmessage.isread ? ( + + ) : ( + + ))} + + + {new Date(chat.lastmessage.time).toLocaleString( + "it", + { + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + month: "2-digit", + year: "numeric", + }, + )} + + > + )} + + )} + + + + + ))} + {selectedChat && ( + <> + + + + Sei assolutamente sicuro? + + Questa azione non può essere annullata. Questo eliminerà + definitivamente la chat e rimuoverà i dati dai nostri + server. + + + + Annulla + { + if (selectedChat) { + deletechat({ + chatId: selectedChat, + }); + } + }} + > + Continua + + + + + + > + )} + + + ); +}; + +const SearchUserComponent = ({ + isOpen, + OpenChange, + activeChats, + handleChatSelection, +}: { + isOpen: boolean; + OpenChange: (status: boolean) => void; + activeChats: ActiveChatsType[]; + handleChatSelection: (chatId: ChatsChatid) => void; +}) => { + return ( + + + + Nessun risultato + 0 ? "Suggerimenti" : undefined} + > + {activeChats?.map((chat) => ( + { + handleChatSelection(chat.chatid); + }} + > + + + + + {chat.username} + + + + ))} + + + + ); +}; + +const NewUserComponent = ({ + isOpen, + OpenChange, + inactiveUsers, +}: { + isOpen: boolean; + OpenChange: (status: boolean) => void; + inactiveUsers: NonNullableUser[]; +}) => { + const utils = api.useUtils(); + const { mutate: createNewChat } = api.chatSSE.createChat.useMutation({ + onSuccess: async () => { + await utils.chat.getActiveChats.invalidate(); + OpenChange(false); + }, + }); + return ( + + + + Nessun risultato + 0 ? "Suggerimenti" : undefined} + > + {inactiveUsers?.map((user) => ( + { + createNewChat({ + userId: user.id, + }); + }} + > + + + + + {user.username} + + + + ))} + + + + ); +}; + +const ChatSidebarContextMenu = ({ + chatid, + setSelectedChat, + setDeleteModalOpen, + setEditEtichettaModalOpen, +}: { + chatid: ChatsChatid; + setSelectedChat: (chat: ChatsChatid | null) => void; + setDeleteModalOpen: (status: boolean) => void; + setEditEtichettaModalOpen: (status: boolean) => void; +}) => { + const { data: chatData } = api.chat.getChatInfobyId.useQuery({ + chatId: chatid, + }); + if (!chatData) return null; + + return ( + <> + + + Etichette + + + { + setSelectedChat(chatid); + setEditEtichettaModalOpen(true); + }} + type="button" + > + Modifica + + + + + + + + Profilo + + + + + + Ordini + + + + + Allegati + + + + Altro + + { + setSelectedChat(chatid); + setDeleteModalOpen(true); + }} + > + Elimina Chat + + + + + > + ); +}; diff --git a/apps/infoalloggi/src/components/chat/chat-topbar.tsx b/apps/infoalloggi/src/components/chat/chat-topbar.tsx new file mode 100644 index 0000000..19bcd3e --- /dev/null +++ b/apps/infoalloggi/src/components/chat/chat-topbar.tsx @@ -0,0 +1,202 @@ +import { Info, Paperclip, ShoppingBag, User, UserCog } from "lucide-react"; +import Image from "next/image"; +import Link from "next/link"; +import { useRouter } from "next/router"; +import { useCallback } from "react"; +import toast from "react-hot-toast"; +import { EtichetteDisplayer } from "~/components/etichette"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "~/components/ui/alert-dialog"; +import { Button, buttonVariants } from "~/components/ui/button"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "~/components/ui/popover"; +import { Separator } from "~/components/ui/separator"; +import { UserAvatar } from "~/components/user_avatar"; +import { cn } from "~/lib/utils"; +import type { ChatsChatid } from "~/schemas/public/Chats"; +import type { Session } from "~/server/api/trpc"; +import type { ChatUserInfo } from "~/server/services/chat.service"; +import { api } from "~/utils/api"; + +type ChatTopbarProps = { + chatId: ChatsChatid; + userData: Session; + chatUserData: ChatUserInfo; +}; +export default function ChatTopbar({ + chatId, + userData, + chatUserData, +}: ChatTopbarProps) { + const utils = api.useUtils(); + const { mutate: deletechat } = api.chat.deleteChat.useMutation({ + onSuccess: async () => { + await utils.chat.getActiveChats.invalidate(); + await utils.users.getActiveUsersWithoutChat.invalidate(); + await router.push("/area-riservata/admin/chats/"); + }, + }); + + const router = useRouter(); + const { mutate: swap } = api.auth.swapUser.useMutation({ + onSuccess: async () => { + toast.success("Utente cambiato con successo"); + await router.push("/area-riservata/load-page"); + }, + }); + + const handleDeleteChat = useCallback(() => { + deletechat({ chatId }); + }, [chatId, deletechat]); + + const handleSwapUser = useCallback(() => { + swap({ id: chatUserData.id }); + }, [chatUserData, swap]); + + return ( + + + {!userData.isAdmin ? ( + + ) : ( + + )} + + + + {!userData.isAdmin ? "Chat Infoalloggi" : chatUserData.username} + + {userData.isAdmin && } + + + + + + {userData.isAdmin && ( + + )} + + + ); +} + +const AdminPopover = ({ + chatUserData, + handleSwapUser, + handleDeleteChat, +}: { + chatUserData: ChatUserInfo; + handleSwapUser: () => void; + handleDeleteChat: () => void; +}) => ( + + + + + + + + Profilo + + + + Ordini + + + Allegati + + + Login + + + + + + Cancella Chat + + + + + Sei assolutamente sicuro? + + Questa azione non può essere annullata. Questo eliminerà + definitivamente la chat e rimuoverà i dati dai nostri server. + + + + Annulla + + Continua + + + + + + + +); diff --git a/apps/infoalloggi/src/components/chat/chat.tsx b/apps/infoalloggi/src/components/chat/chat.tsx new file mode 100644 index 0000000..f5e4209 --- /dev/null +++ b/apps/infoalloggi/src/components/chat/chat.tsx @@ -0,0 +1,50 @@ +import { memo } from "react"; +import ChatBottombar from "~/components/chat/chat-bottombar"; +import { ChatList } from "~/components/chat/chat-list"; +import ChatTopbar from "~/components/chat/chat-topbar"; +import { useLiveChat } from "~/hooks/chatHooks"; +import type { ChatsChatid } from "~/schemas/public/Chats"; +import type { Session } from "~/server/api/trpc"; +import { api } from "~/utils/api"; + +const Chat = memo( + ({ chatId, userData }: { chatId: ChatsChatid; userData: Session }) => { + const liveChat = useLiveChat(chatId, userData); + const [userinfo] = api.chat.getUserInfosByChat.useSuspenseQuery({ + chatId, + }); + + return ( + + + + + + + + + + + ); + }, + (prevProps, nextProps) => + prevProps.chatId === nextProps.chatId && + prevProps.userData.id === nextProps.userData.id, +); +Chat.displayName = "Chat"; + +export default Chat; diff --git a/apps/infoalloggi/src/components/chat/message-loading.tsx b/apps/infoalloggi/src/components/chat/message-loading.tsx new file mode 100644 index 0000000..ed52f00 --- /dev/null +++ b/apps/infoalloggi/src/components/chat/message-loading.tsx @@ -0,0 +1,46 @@ +// @hidden +export default function MessageLoading() { + return ( + + Loading + + + + + + + + + + + ); +} diff --git a/apps/infoalloggi/src/components/user_avatar.tsx b/apps/infoalloggi/src/components/user_avatar.tsx index 54366b3..90c7eb9 100644 --- a/apps/infoalloggi/src/components/user_avatar.tsx +++ b/apps/infoalloggi/src/components/user_avatar.tsx @@ -1,76 +1,8 @@ -import React from "react"; +import type React from "react"; import { isColorDark } from "~/lib/color"; -import { cn, getHexColorFromString, getInitials } from "~/lib/utils"; +import { cn, getInitials } from "~/lib/utils"; import type { UsersId } from "~/schemas/public/Users"; -const colorOptions = new Map(); - -export const getUserColor = (str: string) => { - if (colorOptions.has(str)) { - // biome-ignore lint/style/noNonNullAssertion: - return colorOptions.get(str)!; - } - const usercolor = getHexColorFromString(str); - colorOptions.set(str, usercolor); - return usercolor; -}; -/* -export const UserAvatarOld = ({ - userId, - username, - className, -}: { - userId: UsersId; - username?: string; - className?: string; -}) => { - const userColor = getUserColor(userId); - - return ( - - - {username && getInitials(username)} - - - ); -}; -*/ -/* -export const UserAvatar2 = ( - props: { - userId: UsersId; - username?: string; - } & Omit< - FacehashProps, - | "name" - | "colors" - | "initialsCount" - | "intensity3d" - | "variant" - | "showInitial" - >, -) => { - const { userId, username, ...rest } = props; - const value = `${username && getInitials(username)}${userId}`; - return ( - - ); -}; -*/ const user_colors = [ "#2D3436", "#535C68", @@ -108,23 +40,49 @@ function stringHash(str: string): number { return Math.abs(hash); } -export const UserAvatar = ( - props: { - userId: UsersId; - username?: string; - } & React.HTMLAttributes, -) => { - const { userId, username, className, ...rest } = props; - const value = `${username && getInitials(username)}${userId}`; +export const getUserColorV2 = (value: string) => { + const hash = stringHash(value); + const colorsLength = user_colors.length ?? 1; + const colorIndex = hash % colorsLength; - const colorIndex = React.useMemo(() => { - const hash = stringHash(value); - const colorsLength = user_colors.length ?? 1; - const _colorIndex = hash % colorsLength; + return user_colors[colorIndex]; +}; - return _colorIndex; - }, [value]); - const userColor = user_colors[colorIndex]; +type UserAvatarProps = { + userId: UsersId; + username: string; + className?: string; + showInitials?: boolean; +} & React.HTMLAttributes; +export const UserAvatar = ({ + userId, + username, + showInitials = true, + className, + ...rest +}: UserAvatarProps) => { + if (!userId || !username) { + return ( + + + {showInitials && "U"} + + + ); + } + const value = `${getInitials(username)}${userId}`; + + const userColor = getUserColorV2(value); return ( - {username && getInitials(username)} + {showInitials && getInitials(username)} ); diff --git a/apps/infoalloggi/src/hooks/chatHooks.ts b/apps/infoalloggi/src/hooks/chatHooks.ts index 758a3b8..4f54966 100644 --- a/apps/infoalloggi/src/hooks/chatHooks.ts +++ b/apps/infoalloggi/src/hooks/chatHooks.ts @@ -2,23 +2,11 @@ import { skipToken } from "@tanstack/react-query"; import { useCallback, useEffect, useMemo, useState } from "react"; import type { ChatsChatid } from "~/schemas/public/Chats"; import type { MessagesMessageid } from "~/schemas/public/Messages"; +import type { UsersId } from "~/schemas/public/Users"; +import type { Message, TypingData } from "~/server/api/routers/chat_sse"; import type { Session } from "~/server/api/trpc"; -import type { ChatMessage } from "~/server/services/messages.service"; -import type { OnlineStatus, WhoIsTyping } from "~/server/sse"; import { api } from "~/utils/api"; -type EditEvent = { - eventType: "edit"; - data: { messageId: MessagesMessageid; message: string }; -}; - -type DeleteEvent = { - eventType: "delete"; - data: MessagesMessageid; -}; - -type MessageUpdate = EditEvent | DeleteEvent; - /** * Set isTyping with a throttle of 1s * Triggers immediately if state changes @@ -26,11 +14,13 @@ type MessageUpdate = EditEvent | DeleteEvent; export function useThrottledIsTypingMutation({ chatId, username, + userId, }: { chatId: ChatsChatid; username: string; + userId: UsersId; }) { - const { mutate } = api.chatSSE.type.useMutation(); + const { mutate } = api.chatSSE.toggleTyping.useMutation(); return useMemo(() => { let state = false; @@ -41,7 +31,7 @@ export function useThrottledIsTypingMutation({ timeout = null; } - mutate({ chatId, typing: state, username }); + mutate({ chatId, isTyping: state, username, userId }); } return (nextState: boolean) => { @@ -70,51 +60,42 @@ export type LiveChat = ReturnType; export const useLiveChat = (chatId: ChatsChatid, userData: Session) => { //console.log("useLiveChat", chatId); - const [, query] = api.messagesSSE.infinite.useSuspenseInfiniteQuery( + const [, query] = api.chatSSE.getChat.useSuspenseInfiniteQuery( { chatId }, { getNextPageParam: (d) => d.nextCursor, refetchOnMount: false, - // No need to refetch as we have a subscription refetchOnReconnect: false, refetchOnWindowFocus: false, }, ); - const [messages, setMessages] = useState(() => { - return query.data ? query.data.pages.flatMap((page) => page.items) : []; - }); + const [messages, setMessages] = useState( + () => query.data?.pages.flatMap((page) => page.items) ?? [], + ); /** * fn to add and dedupe new messages onto state */ - const addMessages = useCallback((incoming?: ChatMessage[]) => { + const addMessages = useCallback((incoming?: Message[]) => { setMessages((current) => { - const map: Record = {}; - for (const msg of current ?? []) { - map[msg.messageid] = msg; - } - for (const msg of incoming ?? []) { - map[msg.messageid] = msg; - } - return Object.values(map).sort( - (a, b) => a.time.getTime() - b.time.getTime(), + const map: Record = {}; + for (const msg of current ?? []) map[msg.messageid] = msg; + for (const msg of incoming ?? []) map[msg.messageid] = msg; + return Object.values(map).sort((a, b) => + a.messageid.localeCompare(b.messageid), ); }); }, []); // Edit a message from the chat const updateMessage = useCallback( - (msgId: MessagesMessageid, data: Partial) => { - setMessages((current) => { - const newMessages = current.map((msg) => { - if (msg.messageid === msgId) { - return { ...msg, ...data }; - } - return msg; - }); - return newMessages; - }); + (msgId: MessagesMessageid, data: Partial) => { + setMessages((current) => + current.map((msg) => + msg.messageid === msgId ? { ...msg, ...data } : msg, + ), + ); }, [], ); @@ -133,141 +114,72 @@ export const useLiveChat = (chatId: ChatsChatid, userData: Session) => { }, [query.data?.pages, addMessages]); const [lastEventId, setLastEventId] = useState< - // Query has not been run yet - | false - // Empty list - | null - // Event id - | MessagesMessageid + false | null | MessagesMessageid >(false); if (messages && lastEventId === false) { - // We should only set the lastEventId once, if the SSE-connection is lost, it will automatically reconnect and continue from the last event id - // Changing this value will trigger a new subscription setLastEventId(messages.at(-1)?.messageid ?? null); } - const { mutate: read } = api.messagesSSE.read.useMutation(); - api.messagesSSE.onAdd.useSubscription( + const { mutate: setRead } = api.chatSSE.setRead.useMutation(); + useEffect(() => { + setRead({ chatId }); + }, [chatId]); + + api.chatSSE.onMessage.useSubscription( lastEventId === false ? skipToken : { chatId, lastEventId }, { - onData(event) { - const someoneElseInChat = - onlineUsers.length > 1 && - onlineUsers.some( - (user) => - user.userId !== userData.id && user.isAdmin !== userData.isAdmin, - ); - addMessages([ - { ...event.data, isread: event.data.isread || someoneElseInChat }, - ]); - read({ chatId }); + onData({ data }) { + addMessages([data]); + setRead({ chatId }); }, onError(err) { console.error("onAdd Subscription client error:", err); - const lastMessageEventId = messages?.at(-1)?.messageid; - if (lastMessageEventId) { - // We've lost the connection, let's resubscribe from the last message - setLastEventId(lastMessageEventId); - } + const lastId = messages.at(-1)?.messageid; + if (lastId) setLastEventId(lastId); }, }, ); - - api.messagesSSE.onMessageEvent.useSubscription( + const utils = api.useUtils(); + api.chatSSE.onMessageUpdates.useSubscription( { chatId }, { - onData(data: { data: MessageUpdate }) { - switch (data.data.eventType) { - case "edit": - handleEdit(data.data.data); - break; - case "delete": - handleDelete(data.data.data); - break; + onData(event) { + if (event.eventType === "edit") { + updateMessage(event.data.messageId, { message: event.data.message }); + } else if (event.eventType === "delete") { + deleteMessage(event.data); + } else if (event.eventType === "read_receipt") { + // Mark all messages sent by this user as read + setMessages((current) => + current.map((msg) => + msg.sender === userData.id ? { ...msg, isread: true } : msg, + ), + ); + // Invalidate sidebar query to update last message read status + utils.chat.getActiveChats.invalidate(); } }, onError(err) { - console.error("Subscription error:", err); + console.error("onMessageUpdates subscription error:", err); }, }, ); - const handleEdit = (data: { - messageId: MessagesMessageid; - message: string; - }) => { - updateMessage(data.messageId, { message: data.message }); - }; - const handleDelete = (data: MessagesMessageid) => { - deleteMessage(data); - }; - - const [onlineUsers, setOnlineUsers] = useState([]); - const [typingStatus, setTypingStatus] = useState([]); - - api.chatSSE.onChatEvent.useSubscription( - { chatId }, + const [typingStatus, setTypingStatus] = useState([]); + api.chatSSE.onTyping.useSubscription( + { chatId, username: userData.username, userId: userData.id }, { - onComplete: () => { - leave({ chatId }); - }, - onData: (data) => { - switch (data.data.eventType) { - case "online": - console.log("Online event", data.data.data); - handleOnlineEvent(data.data.data); - break; - case "typing": - console.log("Typing event", data.data.data); - handleTypingEvent(data.data.data); - break; - } - }, - onStarted: () => { - enter({ chatId }); + onData(typers: TypingData) { + setTypingStatus(typers.filter((n) => n.userId !== userData.id)); }, }, ); - const handleOnlineEvent = (data: OnlineStatus[]) => { - setOnlineUsers(data); - - // Filter users who are not the current user and have a different `isAdmin` property - const someoneElse = data.filter( - (u) => u.userId !== userData.id && u.isAdmin !== userData.isAdmin, - ); - if (someoneElse.length > 0) { - // If there is someone else in chat and if thet are not the same usertype as me, update the read status of all messages - setMessages((current) => { - const newMessages = current.map((msg) => { - return { ...msg, isread: true }; - }); - return newMessages; - }); - } - }; - - const { mutate: enter } = api.chatSSE.enterChat.useMutation(); - const { mutate: leave } = api.chatSSE.leaveChat.useMutation(); - - useEffect(() => { - return () => { - // Cleanup: leave the chat when the component unmounts - console.log("Leaving chat", chatId); - leave({ chatId }); - }; - }, []); - - const handleTypingEvent = (data: WhoIsTyping[]) => { - setTypingStatus(data); - }; - return { messages, - onlineUsers, query, typingStatus, }; diff --git a/apps/infoalloggi/src/hooks/usePassword.ts b/apps/infoalloggi/src/hooks/usePassword.ts index 56d9d5f..136218f 100644 --- a/apps/infoalloggi/src/hooks/usePassword.ts +++ b/apps/infoalloggi/src/hooks/usePassword.ts @@ -2,7 +2,7 @@ import { useState } from "react"; import { type RefinementCtx, z } from "zod/v4"; import { useTranslation } from "~/providers/I18nProvider"; -export const zStrongPassword = z.string().superRefine((password, ctx) => { +const zStrongPassword = z.string().superRefine((password, ctx) => { const test = [/.{8,}/, /[0-9]/, /[a-z]/, /[A-Z]/].every((regex) => regex.test(password), ); @@ -33,7 +33,7 @@ type PasswordManager = { maxStrength: number; }; -export const useStrongPassword = (): PasswordManager => { +const useStrongPassword = (): PasswordManager => { const { t } = useTranslation(); const [isVisible, setIsVisible] = useState(false); @@ -106,7 +106,7 @@ export const useStrongPassword = (): PasswordManager => { }; }; -export const zMildPassword = z.string().superRefine((password, ctx) => { +const zMildPassword = z.string().superRefine((password, ctx) => { const test = [/.{6,}/, /[a-z]/, /[A-Z]/].every((regex) => regex.test(password), ); @@ -119,7 +119,7 @@ export const zMildPassword = z.string().superRefine((password, ctx) => { } }); -export const useMildPassword = (): PasswordManager => { +const useMildPassword = (): PasswordManager => { const { t } = useTranslation(); const [isVisible, setIsVisible] = useState(false); @@ -189,6 +189,9 @@ export const useMildPassword = (): PasswordManager => { }; }; -export const zPassword = zMildPassword; +const LEVEL = "strong"; -export const usePassword = useMildPassword; +export const zPassword = LEVEL === "strong" ? zStrongPassword : zMildPassword; + +export const usePassword = + LEVEL === "strong" ? useStrongPassword : useMildPassword; diff --git a/apps/infoalloggi/src/lib/utils.ts b/apps/infoalloggi/src/lib/utils.ts index 064a754..5d677e3 100644 --- a/apps/infoalloggi/src/lib/utils.ts +++ b/apps/infoalloggi/src/lib/utils.ts @@ -28,23 +28,23 @@ export function getInitials(name: string) { return name.slice(0, 2).toUpperCase(); } -export function getHexColorFromString(str: string): string { - let hash = 0; - if (str.length === 0) return "#000000"; // Return black for empty string +// export function getHexColorFromString(str: string): string { +// let hash = 0; +// if (str.length === 0) return "#000000"; // Return black for empty string - for (let i = 0; i < str.length; i++) { - hash = str.charCodeAt(i) + ((hash << 5) - hash); - hash = hash & 0xffffffff; // Ensure hash is a 32-bit integer - } +// for (let i = 0; i < str.length; i++) { +// hash = str.charCodeAt(i) + ((hash << 5) - hash); +// hash = hash & 0xffffffff; // Ensure hash is a 32-bit integer +// } - let color = "#"; - for (let i = 0; i < 3; i++) { - const value = (hash >> (i * 8)) & 255; - color += value.toString(16).padStart(2, "0"); - } +// let color = "#"; +// for (let i = 0; i < 3; i++) { +// const value = (hash >> (i * 8)) & 255; +// color += value.toString(16).padStart(2, "0"); +// } - return color; -} +// return color; +// } export function formatCurrency(amount: number): string { return new Intl.NumberFormat("it-IT", { diff --git a/apps/infoalloggi/src/pages/area-riservata/admin/chats.tsx b/apps/infoalloggi/src/pages/area-riservata/admin/chats.tsx index 7d3422c..92a4f04 100644 --- a/apps/infoalloggi/src/pages/area-riservata/admin/chats.tsx +++ b/apps/infoalloggi/src/pages/area-riservata/admin/chats.tsx @@ -15,7 +15,7 @@ import { api } from "~/utils/api"; * Pagina di gestione chat per admin: /area-riservata/admin/chats */ const AdminChats: NextPageWithLayout = () => { - const { status } = useSession(); + const { status, user } = useSession(); const { data: activeChats, isLoading: isLoadingActiveChats } = api.chat.getActiveChats.useQuery(); @@ -46,7 +46,7 @@ const AdminChats: NextPageWithLayout = () => { - + diff --git a/apps/infoalloggi/src/pages/area-riservata/admin/chats/[chatId].tsx b/apps/infoalloggi/src/pages/area-riservata/admin/chats/[chatId].tsx index 489f99e..51239ff 100644 --- a/apps/infoalloggi/src/pages/area-riservata/admin/chats/[chatId].tsx +++ b/apps/infoalloggi/src/pages/area-riservata/admin/chats/[chatId].tsx @@ -26,6 +26,7 @@ const ChatPage: NextPageWithLayout = ({ session, }: ChatPageProps) => { const { t } = useTranslation(); + return ( <> @@ -45,10 +46,10 @@ const ChatPage: NextPageWithLayout = ({ - + }> - + @@ -82,7 +83,7 @@ export const getServerSideProps = (async (context) => { const helper = await TrpcAuthedFetchingIstance({ access_token }); if (helper) { - await helper.trpc.messagesSSE.infinite.prefetchInfinite({ + await helper.trpc.chatSSE.getChat.prefetchInfinite({ chatId, }); await helper.trpc.chat.getUserInfosByChat.prefetch({ diff --git a/apps/infoalloggi/src/pages/area-riservata/chat.tsx b/apps/infoalloggi/src/pages/area-riservata/chat.tsx index b14c9ff..58ae346 100644 --- a/apps/infoalloggi/src/pages/area-riservata/chat.tsx +++ b/apps/infoalloggi/src/pages/area-riservata/chat.tsx @@ -64,7 +64,7 @@ export const getServerSideProps = (async (context) => { const helper = await TrpcAuthedFetchingIstance({ access_token }); if (helper) { const chat = await helper.trpc.chat.getSessionChats.fetch(); - await helper.trpc.messagesSSE.infinite.prefetchInfinite({ + await helper.trpc.chatSSE.getChat.prefetchInfinite({ chatId: chat.chatid, }); await helper.trpc.chat.getUserInfosByChat.prefetch({ diff --git a/apps/infoalloggi/src/pages/test/chat-admin.tsx b/apps/infoalloggi/src/pages/test/chat-admin.tsx deleted file mode 100644 index f835cde..0000000 --- a/apps/infoalloggi/src/pages/test/chat-admin.tsx +++ /dev/null @@ -1,277 +0,0 @@ -import { useEffect, useRef, useState } from "react"; -import { useSession } from "~/providers/SessionProvider"; -import type { ChatsChatid } from "~/schemas/public/Chats"; -import type { - ChatEvent, - Message, - SidebarUpdate, -} from "~/server/api/routers/chat_sse2"; -import type { Session } from "~/server/api/trpc"; -import { MessageDataSchema } from "~/server/utils/zod_types"; -import { api } from "~/utils/api"; - -const TEST_CHAT_ID = "123e4567-e89b-12d3-a456-426614174000"; - -export default function AdminChat() { - const { status, user } = useSession(); - if (status === "LOADING") return Loading...; - if (status !== "AUTHENTICATED") return null; - return ; -} - -const Content = ({ session }: { session: Session }) => { - const [selectedChat, setSelectedChat] = useState(null); - const [messages, setMessages] = useState([]); - const [inputText, setInputText] = useState(""); - - const [sidebarEvents, setSidebarEvents] = useState([]); - const [chatEvents, setChatEvents] = useState([]); - const [activeTypers, setActiveTypers] = useState([]); - - const typingTimeoutRef = useRef(null); - const { data: availableChats } = api.chatSSE2.listChats.useQuery(undefined, { - refetchOnWindowFocus: false, - }); - // 1. Sidebar Subscription (Global) - api.chatSSE2.onSidebarUpdate.useSubscription(undefined, { - onData(update: SidebarUpdate) { - setSidebarEvents((prev) => [update, ...prev].slice(0, 5)); - }, - }); - - // 2. Fetch History (Only runs when a chat is selected) - const { data: chatData, isSuccess } = api.chatSSE2.getChat.useQuery( - { chatId: selectedChat }, - { - enabled: !!selectedChat, - refetchOnWindowFocus: false, - }, - ); - - useEffect(() => { - if (selectedChat && isSuccess && chatData) { - setMessages(chatData); - } - }, [selectedChat, isSuccess, chatData]); - - // 3. Chat Specific Subscription - api.chatSSE2.onChatUpdate.useSubscription( - { chatId: selectedChat, username: session.username }, - { - enabled: !!selectedChat, - onData(update: ChatEvent) { - setChatEvents((prev) => [update, ...prev].slice(0, 5)); - - if (update.eventType === "message") { - const parsedMessage = MessageDataSchema.safeParse(update.data); - if (parsedMessage.success) { - setMessages((prev) => [...prev, parsedMessage.data]); - } else { - console.warn("Received invalid message data:", parsedMessage.error); - } - } - - if (update.eventType === "typing_update") - setActiveTypers( - update.data.filter((n: string) => n !== session.username), - ); - }, - }, - ); - - const sendMessage = api.chatSSE2.sendMessage.useMutation(); - const toggleTyping = api.chatSSE2.toggleTyping.useMutation(); - - const handleSend = (e: React.FormEvent) => { - e.preventDefault(); - if (!inputText || !selectedChat) return; - - sendMessage.mutate({ - chatId: selectedChat, - message: inputText, - sender: session.id, // Prod: Admin ID - }); - - setInputText(""); - if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current); - toggleTyping.mutate({ - chatId: selectedChat, - username: session.username, - isTyping: false, - }); - }; - - const handleInputChange = (e: React.ChangeEvent) => { - setInputText(e.target.value); - if (!selectedChat) return; - - toggleTyping.mutate({ - chatId: selectedChat, - username: session.username, - isTyping: true, - }); - if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current); - typingTimeoutRef.current = setTimeout(() => { - toggleTyping.mutate({ - chatId: selectedChat, - username: session.username, - isTyping: false, - }); - }, 2000); - }; - - return ( - - {/* Sidebar View */} - - Admin Inbox - - {availableChats?.length === 0 && No active chats} - {availableChats?.map((chat) => ( - setSelectedChat(chat.chatid)} - style={{ - width: "100%", - padding: "10px", - backgroundColor: - selectedChat === chat.chatid ? "#e0f7fa" : "#fff", - }} - type="button" - > - {chat.chatid} - - ))} - - - Global Sidebar Events: - {sidebarEvents.map((ev, i) => ( - - key={i} - style={{ borderBottom: "1px solid #ddd", marginBottom: "5px" }} - > - {ev.senderName}: {ev.lastMessage} - - ))} - - - - {/* Main Chat View */} - - {!selectedChat ? ( - - Select a chat to begin - - ) : ( - <> - - Chatting with: {TEST_CHAT_ID.split("-")[0]}... - - - - {messages.map((m) => ( - - - {m.message} - - - ))} - {activeTypers.length > 0 && ( - - {activeTypers.join(", ")} is typing... - - )} - - - - - Reply - - > - )} - - - Raw Chat Events (KeyDB) - setChatEvents([])} type="button"> - Clear - - - {JSON.stringify(chatEvents, null, 2)} - - - - ); -}; diff --git a/apps/infoalloggi/src/pages/test/chat.tsx b/apps/infoalloggi/src/pages/test/chat.tsx deleted file mode 100644 index 3fa5171..0000000 --- a/apps/infoalloggi/src/pages/test/chat.tsx +++ /dev/null @@ -1,196 +0,0 @@ -import { useEffect, useRef, useState } from "react"; -import { useSession } from "~/providers/SessionProvider"; -import type { ChatsChatid } from "~/schemas/public/Chats"; -import type { ChatEvent, Message } from "~/server/api/routers/chat_sse2"; -import type { Session } from "~/server/api/trpc"; -import { MessageDataSchema } from "~/server/utils/zod_types"; -import { api } from "~/utils/api"; - -// Hardcoded for testing the connection -const TEST_CHAT_ID = "85b6c0fb-2997-4b9a-9cac-ebfc1886927b" as ChatsChatid; - -export default function GuestChat() { - const { status, user } = useSession(); - if (status === "LOADING") return Loading...; - if (status !== "AUTHENTICATED") return null; - return ; -} - -const Content = ({ session }: { session: Session }) => { - const [messages, setMessages] = useState([]); - const [inputText, setInputText] = useState(""); - const [activeTypers, setActiveTypers] = useState([]); - const [debugLog, setDebugLog] = useState([]); - - const typingTimeoutRef = useRef(null); - const { data: historyData, isSuccess } = api.chatSSE2.getChat.useQuery( - { chatId: TEST_CHAT_ID }, - { - refetchOnWindowFocus: false, - }, - ); - - useEffect(() => { - if (isSuccess && historyData) { - setMessages(historyData); - } - }, [isSuccess, historyData]); - - // 2. Mutations - const sendMessage = api.chatSSE2.sendMessage.useMutation(); - const toggleTyping = api.chatSSE2.toggleTyping.useMutation(); - - // 3. The Live Subscription - api.chatSSE2.onChatUpdate.useSubscription( - { chatId: TEST_CHAT_ID, username: session.username }, - { - onData(update: ChatEvent) { - // Log raw event for debugging - setDebugLog((prev) => [update, ...prev].slice(0, 10)); - - if (update.eventType === "message") { - const parsedMessage = MessageDataSchema.safeParse(update.data); - if (parsedMessage.success) { - setMessages((prev) => [...prev, parsedMessage.data]); - } else { - console.warn("Received invalid message data:", parsedMessage.error); - } - } - if (update.eventType === "typing_update") { - setActiveTypers( - update.data.filter((name: string) => name !== session.username), - ); - } - }, - onError(err) { - console.error("Subscription Error:", err); - }, - }, - ); - - // Typing logic (Heartbeat) - const handleInputChange = (e: React.ChangeEvent) => { - setInputText(e.target.value); - - // Ping typing status - toggleTyping.mutate({ - chatId: TEST_CHAT_ID, - username: session.username, - isTyping: true, - }); - - // Clear previous timeout - if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current); - - // If user stops typing for 2 seconds, send isTyping: false - typingTimeoutRef.current = setTimeout(() => { - toggleTyping.mutate({ - chatId: TEST_CHAT_ID, - username: session.username, - isTyping: false, - }); - }, 2000); - }; - - const handleSend = (e: React.FormEvent) => { - e.preventDefault(); - if (!inputText) return; - - sendMessage.mutate({ - chatId: TEST_CHAT_ID, - message: inputText, - sender: session.id, - }); - - setInputText(""); - if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current); - toggleTyping.mutate({ - chatId: TEST_CHAT_ID, - username: session.username, - isTyping: false, - }); - }; - - return ( - - {/* UI Panel */} - - Guest View (Logged in as: {session.username}) - - {messages.map((m) => ( - - {m.sender_nome || "You"}: {m.message} - - ))} - {activeTypers.length > 0 && ( - - {activeTypers.join(", ")}{" "} - {activeTypers.length === 1 ? "is" : "are"} typing... - - )} - - - - - Send - - - - - 📡 KeyDB Debug Panel - setDebugLog([])} type="button"> - Clear - - - Last 10 Events: - - {JSON.stringify(debugLog, null, 2)} - - - - ); -}; diff --git a/apps/infoalloggi/src/server/api/root.ts b/apps/infoalloggi/src/server/api/root.ts index dc205bc..0ed7fc0 100644 --- a/apps/infoalloggi/src/server/api/root.ts +++ b/apps/infoalloggi/src/server/api/root.ts @@ -3,12 +3,10 @@ import { authRouter } from "~/server/api/routers/auth"; import { banlistRouter } from "~/server/api/routers/banlist"; import { chatRouter } from "~/server/api/routers/chat"; import { chatSSERouter } from "~/server/api/routers/chat_sse"; -import { chatSSE2Router } from "~/server/api/routers/chat_sse2"; import { comunicazioniRouter } from "~/server/api/routers/comunicazioni"; import { eventQueueRouter } from "~/server/api/routers/event_queue"; import { fattureRouter } from "~/server/api/routers/fatture"; import { intrestsRouter } from "~/server/api/routers/interests"; -import { messagesSSERouter } from "~/server/api/routers/messages_sse"; import { pagamentiRouter } from "~/server/api/routers/pagamenti"; import { prezziarioRouter } from "~/server/api/routers/prezziario"; import { servizioRouter } from "~/server/api/routers/servizio"; @@ -36,12 +34,10 @@ export const appRouter = createTRPCRouter({ banlist: banlistRouter, chat: chatRouter, chatSSE: chatSSERouter, - chatSSE2: chatSSE2Router, comunicazioni: comunicazioniRouter, eventQueue: eventQueueRouter, fatture: fattureRouter, intrests: intrestsRouter, - messagesSSE: messagesSSERouter, pagamenti: pagamentiRouter, prezziario: prezziarioRouter, servizio: servizioRouter, diff --git a/apps/infoalloggi/src/server/api/routers/catasto.ts b/apps/infoalloggi/src/server/api/routers/catasto.ts index e7b0c1e..60a0e68 100644 --- a/apps/infoalloggi/src/server/api/routers/catasto.ts +++ b/apps/infoalloggi/src/server/api/routers/catasto.ts @@ -1,25 +1,14 @@ -import z from "zod"; import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; import { getComuni, getNazioni, getProvincie, - searchComuniByCatasto, } from "~/server/controllers/catasto.controller"; export const catastoRouter = createTRPCRouter({ getComuni: publicProcedure.query(async () => { return await getComuni(); }), - searchComuniByCatasto: publicProcedure - .input( - z.object({ - catasto: z.string().array(), - }), - ) - .query(async ({ input }) => { - return await searchComuniByCatasto(input.catasto); - }), getProvincie: publicProcedure.query(async () => { return await getProvincie(); diff --git a/apps/infoalloggi/src/server/api/routers/chat.ts b/apps/infoalloggi/src/server/api/routers/chat.ts index 3565c0f..8488d9b 100644 --- a/apps/infoalloggi/src/server/api/routers/chat.ts +++ b/apps/infoalloggi/src/server/api/routers/chat.ts @@ -40,8 +40,8 @@ export const chatRouter = createTRPCRouter({ return await deleteChatHandler({ chatId: input.chatId }); }), - getActiveChats: adminProcedure.query(async ({ ctx }) => { - return await getActiveInfos({ userId: ctx.session.id }); + getActiveChats: adminProcedure.query(async () => { + return await getActiveInfos(); }), getChatEtichette: protectedProcedure .input(z.object({ chatId: zChatId })) diff --git a/apps/infoalloggi/src/server/api/routers/chat_sse.ts b/apps/infoalloggi/src/server/api/routers/chat_sse.ts index 715e715..465612e 100644 --- a/apps/infoalloggi/src/server/api/routers/chat_sse.ts +++ b/apps/infoalloggi/src/server/api/routers/chat_sse.ts @@ -1,20 +1,44 @@ +import { on } from "node:events"; import { TRPCError, tracked } from "@trpc/server"; import { z } from "zod/v4"; -import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; +import type { MessagesMessageid } from "~/schemas/public/Messages"; import { - addTyping, - getCurrentlyTyping, - removeStaleTyping, - removeTyping, -} from "~/server/controllers/chat.controller"; + adminProcedure, + createTRPCRouter, + protectedProcedure, + publicProcedure, +} from "~/server/api/trpc"; import { db } from "~/server/db"; import { add } from "~/server/services/chat.service"; -import { ee, type OnlineStatus } from "~/server/sse"; -import { zChatId, zUserId } from "~/server/utils/zod_types"; -import { getKeydbClient } from "~/utils/keydb"; -//IDEA forse cambiare in semplice interval polling invece di sse +import { + editMessage, + setReadMessage, +} from "~/server/services/messages.service"; +import { zAsyncIterable } from "~/server/utils/zAsyncIterable"; +import { + MessageDataSchema, + MessageUpdateEventSchema, + SidebarUpdateSchema, + TypingDataSchema, + zChatId, + zMessageId, + zUserId, +} from "~/server/utils/zod_types"; +import { getKeydbClient, getSubClient } from "~/utils/keydb"; + +const getChatChannel = (chatId: string) => `chat:${chatId}:messages`; +const getTypingChannel = (chatId: string) => `chat:${chatId}:typing`; +const getEventsChannel = (chatId: string) => `chat:${chatId}:events`; +const getTypingKey = (chatId: string) => `typing:${chatId}`; + +const SIDEBAR_CHANNEL = "sidebar_updates"; + +type SidebarUpdate = z.infer; +export type Message = z.infer; +export type TypingData = z.infer; + export const chatSSERouter = createTRPCRouter({ - create: protectedProcedure + createChat: protectedProcedure .input(z.object({ userId: zUserId })) .mutation(async ({ input }) => { const newChat = await add({ @@ -25,143 +49,493 @@ export const chatSSERouter = createTRPCRouter({ return newChat.chatid; }), - enterChat: protectedProcedure - .input(z.object({ chatId: zChatId })) - .mutation(async ({ input, ctx }) => { - const chatKey = `chat:${input.chatId}:onlineUsers`; - const keydb = getKeydbClient(); - - if (!keydb) { - console.warn( - "KeyDB client is not initialized. Skipping online status.", - ); - return []; - } - // Add the user to the Redis set - await keydb.sadd( - chatKey, - JSON.stringify({ - isAdmin: ctx.session.isAdmin, - nome: ctx.session.nome, - userId: ctx.session.id, - }), - ); - - const onlineUsers = await keydb.smembers(chatKey); - const parsedUsers = onlineUsers.map( - (user) => JSON.parse(user) as OnlineStatus, - ); - ee.emit("chatUpdates", input.chatId, { - data: parsedUsers, - eventType: "online", - }); - return parsedUsers; - }), - - leaveChat: protectedProcedure - .input(z.object({ chatId: zChatId })) - .mutation(async ({ input, ctx }) => { - const chatKey = `chat:${input.chatId}:onlineUsers`; - const keydb = getKeydbClient(); - if (!keydb) { - console.warn( - "KeyDB client is not initialized. Skipping online status.", - ); - return []; - } - // Remove the user from the Redis set - await keydb.srem( - chatKey, - JSON.stringify({ - isAdmin: ctx.session.isAdmin, - nome: ctx.session.nome, - userId: ctx.session.id, - }), - ); - - const onlineUsers = await keydb.smembers(chatKey); - const parsedUsers = onlineUsers.map( - (user) => JSON.parse(user) as OnlineStatus, - ); - ee.emit("chatUpdates", input.chatId, { - data: parsedUsers, - eventType: "online", - }); - return parsedUsers; - }), - // list: publicProcedure.query(async () => { - // return await db - // .selectFrom("chats") - // .select([ - // "chats.chatid", - // "chats.created_at", - // "chats.user_ref", - // getMessagesArray(), - // ]) - // .innerJoin("users", "users.id", "chats.user_ref") - // .select(["users.username", "users.email", "users.nome"]) - // .orderBy("chats.created_at", "desc") - // .execute(); - // }), - - onChatEvent: protectedProcedure + getChat: protectedProcedure .input( z.object({ chatId: zChatId, + cursor: zMessageId.nullish(), + take: z.number().min(1).max(50).nullish(), }), ) - .subscription(async function* ({ input, signal }) { + .query(async ({ input, ctx }) => { try { - const iterable = ee.toIterable("chatUpdates", { - signal, + const didUpdate = await setReadMessage({ + chatId: input.chatId, + db, + reader: ctx.session.id, }); - for await (const [chatId, data] of iterable) { - if (chatId === input.chatId) { - yield tracked(chatId, data); + if (didUpdate) { + const kdb = getKeydbClient(); + if (kdb) { + await kdb.publish( + getEventsChannel(input.chatId), + JSON.stringify({ eventType: "read_receipt", data: null }), + ); } } - } catch (e) { - if (e instanceof Error && e.name === "AbortError") return; - console.error("Error in onChatEvent subscription:", e); - } - }), - type: protectedProcedure - .input( - z.object({ chatId: zChatId, typing: z.boolean(), username: z.string() }), - ) - .mutation(async ({ ctx, input }) => { - try { - await removeStaleTyping(input.chatId); + const take = input.take ?? 20; + const cursor = input.cursor; - if (input.typing) { - // Add the user to the set - await addTyping({ - chatId: input.chatId, - userId: ctx.session.id, - username: input.username, - }); - } else { - await removeTyping({ - chatId: input.chatId, - userId: ctx.session.id, - username: input.username, - }); + let qry = db + .selectFrom("messages") + .innerJoin("users", "users.id", "messages.sender") + .select([ + "messages.message", + "messages.messageid", + "messages.chatid", + (eb) => + eb + .cast( + eb.fn("to_char", [ + "messages.time", + eb.val("YYYY-MM-DD HH24:MI:SS"), + ]), + "text", + ) + .as("time"), + "messages.sender", + "messages.isread", + "users.nome as sender_nome", + "users.isAdmin as sender_isAdmin", + ]) + .where("users.nome", "is not", null) + .where("messages.chatid", "=", input.chatId) + .orderBy("messages.messageid", "desc") + .limit(take + 1); + + if (cursor) { + qry = qry.where("messages.messageid", "<=", cursor); } - ee.emit("chatUpdates", input.chatId, { - data: await getCurrentlyTyping(input.chatId), - eventType: "typing", - }); - return true; + const page = await qry.execute(); + + const items = page.reverse(); + let nextCursor: typeof cursor | null = null; + if (items.length > take) { + const prev = items.shift(); + nextCursor = prev?.messageid ?? null; + } + return { + items, + nextCursor, + }; } catch (e) { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", - message: - "An error occurred while processing the typing event: " + - (e as Error).message, + message: `Error while fetching messages: ${(e as Error).message}`, }); } }), + sendMessage: protectedProcedure + .input( + z.object({ + chatId: zChatId, + message: z.string(), + sender: zUserId, + }), + ) + .mutation(async ({ input }) => { + const newMessage = await db + .insertInto("messages") + .values({ + chatid: input.chatId, + message: input.message, + sender: input.sender, + time: new Date(), + }) + .returningAll() + .executeTakeFirstOrThrow( + () => new Error("Failed to insert message into database"), + ); + + const userInfos = await db + .selectFrom("users") + .select(["nome as sender_nome", "isAdmin as sender_isAdmin"]) + .where("id", "=", input.sender) + .executeTakeFirst(); + + if (!userInfos) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "User not found or no name", + }); + } + const payload: Message = { + ...newMessage, + ...userInfos, + time: newMessage.time.toISOString(), + }; + + const kdb = getKeydbClient(); + const msgChannel = getChatChannel(input.chatId); + const typingChannel = getTypingChannel(input.chatId); + const typingKey = getTypingKey(input.chatId); + if (kdb) { + await kdb.publish(msgChannel, JSON.stringify(payload)); + const member = JSON.stringify({ + userId: input.sender, + username: userInfos.sender_nome, + }); + await kdb.zrem(typingKey, member); + const typingList = await kdb.zrange(typingKey, 0, -1); + + await kdb.publish( + typingChannel, + JSON.stringify(typingList.map((m) => JSON.parse(m))), + ); + const sidebarPayload: SidebarUpdate = { + chatId: input.chatId, + lastMessage: input.message, + senderName: userInfos.sender_nome, + }; + await kdb.publish(SIDEBAR_CHANNEL, JSON.stringify(sidebarPayload)); + } + + return payload; + }), + deleteMessage: adminProcedure + .input(z.object({ chatId: zChatId, messageId: zMessageId })) + .mutation(async ({ input }) => { + try { + await db + .deleteFrom("messages") + .where("messageid", "=", input.messageId) + .execute(); + + const kdb = getKeydbClient(); + if (kdb) { + await kdb.publish( + getEventsChannel(input.chatId), + JSON.stringify({ eventType: "delete", data: input.messageId }), + ); + } + } catch (err) { + console.error("Failed to delete message:", err); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to delete message", + }); + } + }), + + editMessage: adminProcedure + .input( + z.object({ chatId: zChatId, message: z.string(), messageId: zMessageId }), + ) + .mutation(async ({ input }) => { + try { + await editMessage({ + db, + message: input.message, + messageId: input.messageId, + }); + + const kdb = getKeydbClient(); + if (kdb) { + await kdb.publish( + getEventsChannel(input.chatId), + JSON.stringify({ + eventType: "edit", + data: { messageId: input.messageId, message: input.message }, + }), + ); + } + } catch (err) { + console.error("Failed to edit message:", err); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to edit message", + }); + } + }), + onMessageUpdates: publicProcedure + .input(z.object({ chatId: zChatId.nullable() })) + .output( + zAsyncIterable({ + yield: MessageUpdateEventSchema, + tracked: false, + }), + ) + .subscription(async function* ({ input, signal }) { + if (!input.chatId) return; + const sub = getSubClient(); + if (!sub) return; // Build-time safety + + const channel = getEventsChannel(input.chatId); + + try { + await sub.subscribe(channel); + const iterable = on(sub, "message"); + + for await (const [chan, message] of iterable) { + if (chan === channel) { + if (signal?.aborted) break; // Extra safety check + const parsed = MessageUpdateEventSchema.safeParse( + JSON.parse(message), + ); + if (parsed.success) { + yield parsed.data; + } else { + console.warn( + "Received invalid message update event:", + parsed.error, + ); + } + } + } + } finally { + await sub.unsubscribe(channel); + await sub.quit(); + } + }), + onSidebarUpdate: publicProcedure + .output( + zAsyncIterable({ + yield: SidebarUpdateSchema, + tracked: false, + }), + ) + .subscription(async function* () { + const sub = getSubClient(); + if (!sub) return; + try { + await sub.subscribe(SIDEBAR_CHANNEL); + const iterable = on(sub, "message"); + for await (const [_chan, message] of iterable) { + const parsed = SidebarUpdateSchema.safeParse(JSON.parse(message)); + if (parsed.success) { + yield parsed.data; + } else { + console.warn("Received invalid message event:", parsed.error); + } + } + } finally { + await sub.unsubscribe(SIDEBAR_CHANNEL); + await sub.quit(); + } + }), + + toggleTyping: publicProcedure + .input( + z.object({ + chatId: zChatId, + isTyping: z.boolean(), + userId: zUserId, + username: z.string(), + }), + ) + .mutation(async ({ input }) => { + const kdb = getKeydbClient(); + if (!kdb) return; + + const key = getTypingKey(input.chatId); + const now = Date.now(); + const member = JSON.stringify({ + userId: input.userId, + username: input.username, + }); + if (input.isTyping) { + await kdb.zadd(key, now, member); + } else { + await kdb.zrem(key, member); + } + + // Cleanup: Remove users who haven't pinged in 6 seconds + await kdb.zremrangebyscore(key, "-inf", now - 6000); + + const typingList = await kdb.zrange(key, 0, -1); + + await kdb.publish( + getTypingChannel(input.chatId), + JSON.stringify(typingList.map((m) => JSON.parse(m))), + ); + + return { success: true }; + }), + onMessage: publicProcedure + .input( + z.object({ + chatId: zChatId.nullable(), + lastEventId: z.string().nullish(), + }), + ) + .output( + zAsyncIterable({ + yield: MessageDataSchema, + tracked: true, + }), + ) + .subscription(async function* ({ input, signal }) { + if (!input.chatId) return; + const sub = getSubClient(); + if (!sub) return; // Build-time safety + + const channel = getChatChannel(input.chatId); + + try { + // ON CONNECT + await sub.subscribe(channel); + const iterable = on(sub, "message"); + + let lastMessageId: MessagesMessageid | null = + (() => { + if (!input.lastEventId) return null; + + const parsed = zMessageId.safeParse(input.lastEventId); + if (!parsed.success) { + return null; + } + return parsed.data; + })() ?? null; + + const newMessagesSinceLast = await db + .selectFrom("messages") + .innerJoin("users", "users.id", "messages.sender") + .select([ + "messages.message", + "messages.messageid", + "messages.chatid", + (eb) => + eb + .cast( + eb.fn("to_char", [ + "messages.time", + eb.val("YYYY-MM-DD HH24:MI:SS"), + ]), + "text", + ) + .as("time"), + "messages.sender", + "messages.isread", + "users.nome as sender_nome", + "users.isAdmin as sender_isAdmin", + ]) + .where("messages.chatid", "=", input.chatId) + .where("messages.messageid", ">", lastMessageId) + .orderBy("messages.messageid", "asc") + .execute(); + + function* maybeYield(msg: Message) { + if (msg.chatid !== input.chatId) { + // ignore posts from other channels - the event emitter can emit from other channels + return; + } + if (lastMessageId && msg.messageid <= lastMessageId) { + // ignore posts that we've already sent - happens if there is a race condition between the query and the event emitter + return; + } + + yield tracked(msg.messageid, msg); + + // update the cursor so that we don't send this post again + lastMessageId = msg.messageid; + } + for (const msg of newMessagesSinceLast) { + yield* maybeYield(msg); + } + + for await (const [chan, message] of iterable) { + if (chan === channel) { + if (signal?.aborted) break; // Extra safety check + const parsed = MessageDataSchema.safeParse(JSON.parse(message)); + if (parsed.success) { + yield* maybeYield(parsed.data); + } else { + console.warn("Received invalid chat event:", parsed.error); + } + } + } + } finally { + await sub.unsubscribe(channel); + await sub.quit(); + } + }), + onTyping: publicProcedure + .input( + z.object({ + chatId: zChatId.nullable(), + username: z.string(), + userId: zUserId, + }), + ) + .output( + zAsyncIterable({ + yield: TypingDataSchema, + tracked: false, + }), + ) + .subscription(async function* ({ input, signal }) { + if (!input.chatId) return; + const sub = getSubClient(); + const kdb = getKeydbClient(); + if (!sub || !kdb) return; // Build-time safety + + const channel = getTypingChannel(input.chatId); + const typingKey = getTypingKey(input.chatId); + + try { + // ON CONNECT + await sub.subscribe(channel); + const iterable = on(sub, "message"); + + const current = await kdb.zrange(typingKey, 0, -1); + + const parsed = TypingDataSchema.safeParse( + current.map((m) => JSON.parse(m)), + ); + if (parsed.success) { + yield parsed.data; + } + + for await (const [chan, message] of iterable) { + if (chan === channel) { + if (signal?.aborted) break; // Extra safety check + const parsed = TypingDataSchema.safeParse(JSON.parse(message)); + if (parsed.success) { + yield parsed.data; + } else { + console.warn("Received invalid chat event:", parsed.error); + } + } + } + } finally { + // ON DISCONNECT (Tab closed, refreshed, or network dropped) + const member = JSON.stringify({ + userId: input.userId, + username: input.username, + }); + await kdb.zrem(typingKey, member); + const remainingT = await kdb.zrange(typingKey, 0, -1); + await kdb.publish( + channel, + JSON.stringify(remainingT.map((m) => JSON.parse(m))), + ); + + await sub.unsubscribe(channel); + await sub.quit(); + } + }), + setRead: protectedProcedure + .input(z.object({ chatId: zChatId })) + .mutation(async ({ input, ctx }) => { + const didUpdate = await setReadMessage({ + chatId: input.chatId, + db, + reader: ctx.session.id, + }); + + if (didUpdate) { + const kdb = getKeydbClient(); + if (kdb) { + await kdb.publish( + getEventsChannel(input.chatId), + JSON.stringify({ eventType: "read_receipt", data: null }), + ); + } + } + + return true; + }), }); diff --git a/apps/infoalloggi/src/server/api/routers/chat_sse2.ts b/apps/infoalloggi/src/server/api/routers/chat_sse2.ts deleted file mode 100644 index 2455157..0000000 --- a/apps/infoalloggi/src/server/api/routers/chat_sse2.ts +++ /dev/null @@ -1,246 +0,0 @@ -import { on } from "node:events"; -import { TRPCError } from "@trpc/server"; -import { z } from "zod/v4"; -import { - createTRPCRouter, - protectedProcedure, - publicProcedure, -} from "~/server/api/trpc"; -import { db } from "~/server/db"; -import { add } from "~/server/services/chat.service"; -import { zAsyncIterable } from "~/server/utils/zAsyncIterable"; -import { - ChatEventSchema, - type MessageDataSchema, - type MessageEventSchema, - SidebarUpdateSchema, - zChatId, - zUserId, -} from "~/server/utils/zod_types"; -import { getKeydbClient, getSubClient } from "~/utils/keydb"; - -const getChatChannel = (chatId: string) => `chat:${chatId}`; -const getTypingKey = (chatId: string) => `typing:${chatId}`; -const SIDEBAR_CHANNEL = "sidebar_updates"; - -export type SidebarUpdate = z.infer; -export type Message = z.infer; -type MessageEvent = z.infer; -export type ChatEvent = z.infer; - -export const chatSSE2Router = createTRPCRouter({ - listChats: protectedProcedure.query(async () => { - return await db.selectFrom("chats").selectAll().execute(); - }), - createChat: protectedProcedure - .input(z.object({ userId: zUserId })) - .mutation(async ({ input }) => { - const newChat = await add({ - db, - userId: input.userId, - }); - - return newChat.chatid; - }), - getChat: publicProcedure - .input(z.object({ chatId: zChatId.nullable() })) - .query(async ({ input }) => { - if (!input.chatId) return; - const data = await db - .selectFrom("messages") - .innerJoin("users", "users.id", "messages.sender") - .select([ - "messages.message", - "messages.messageid", - "messages.time", - "users.nome as sender_nome", - "users.isAdmin as sender_isAdmin", - ]) - .where("messages.chatid", "=", input.chatId) - .orderBy("messages.time", "asc") - .execute(); - return data.map((item) => ({ - ...item, - time: item.time.toISOString(), - })); - }), - sendMessage: protectedProcedure - .input( - z.object({ - chatId: zChatId, - message: z.string(), - sender: zUserId, - }), - ) - .mutation(async ({ input }) => { - const newMessage = await db - .insertInto("messages") - .values({ - chatid: input.chatId, - message: input.message, - sender: input.sender, - time: new Date(), - }) - .returningAll() - .executeTakeFirstOrThrow( - () => new Error("Failed to insert message into database"), - ); - - const userInfos = await db - .selectFrom("users") - .select(["nome as sender_nome", "isAdmin as sender_isAdmin"]) - .where("id", "=", input.sender) - .executeTakeFirst(); - - if (!userInfos) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "User not found or no name", - }); - } - const payload: MessageEvent = { - data: { - ...newMessage, - ...userInfos, - time: newMessage.time.toISOString(), - }, - eventType: "message", - }; - - const kdb = getKeydbClient(); - if (kdb) { - await kdb.publish( - getChatChannel(input.chatId), - JSON.stringify(payload), - ); - - await kdb.zrem(getTypingKey(input.chatId), userInfos.sender_nome); - const typingList = await kdb.zrange(getTypingKey(input.chatId), 0, -1); - - await kdb.publish( - getChatChannel(input.chatId), - JSON.stringify({ - eventType: "typing_update", - data: typingList, - }), - ); - const sidebarPayload: SidebarUpdate = { - chatId: input.chatId, - lastMessage: input.message, - senderName: userInfos.sender_nome, - }; - await kdb.publish(SIDEBAR_CHANNEL, JSON.stringify(sidebarPayload)); - } - - return payload; - }), - onSidebarUpdate: publicProcedure - .output( - zAsyncIterable({ - yield: SidebarUpdateSchema, - tracked: false, - }), - ) - .subscription(async function* () { - const sub = getSubClient(); - if (!sub) return; - try { - await sub.subscribe(SIDEBAR_CHANNEL); - const iterable = on(sub, "message"); - for await (const [_chan, message] of iterable) { - const parsed = SidebarUpdateSchema.safeParse(JSON.parse(message)); - if (parsed.success) { - yield parsed.data; - } else { - console.warn("Received invalid message event:", parsed.error); - } - } - } finally { - await sub.unsubscribe(SIDEBAR_CHANNEL); - await sub.quit(); - } - }), - - toggleTyping: publicProcedure - .input( - z.object({ - chatId: zChatId, - isTyping: z.boolean(), - username: z.string(), - }), - ) - .mutation(async ({ input }) => { - const kdb = getKeydbClient(); - if (!kdb) return; - - const key = getTypingKey(input.chatId); - const now = Date.now(); - - if (input.isTyping) { - await kdb.zadd(key, now, input.username); - } else { - await kdb.zrem(key, input.username); - } - - // Cleanup: Remove users who haven't pinged in 6 seconds - await kdb.zremrangebyscore(key, "-inf", now - 6000); - - const typingList = await kdb.zrange(key, 0, -1); - - await kdb.publish( - getChatChannel(input.chatId), - JSON.stringify({ - eventType: "typing_update", - data: typingList, - }), - ); - - return { success: true }; - }), - onChatUpdate: publicProcedure - .input(z.object({ chatId: zChatId.nullable(), username: z.string() })) - .output( - zAsyncIterable({ - yield: ChatEventSchema, - tracked: false, - }), - ) - .subscription(async function* ({ input, signal }) { - if (!input.chatId) return; - const sub = getSubClient(); - const kdb = getKeydbClient(); - if (!sub || !kdb) return; // Build-time safety - - const channel = getChatChannel(input.chatId); - const typingKey = getTypingKey(input.chatId); - - try { - // ON CONNECT - await sub.subscribe(channel); - const iterable = on(sub, "message"); - - for await (const [chan, message] of iterable) { - if (chan === channel) { - if (signal?.aborted) break; // Extra safety check - const parsed = ChatEventSchema.safeParse(JSON.parse(message)); - if (parsed.success) { - yield parsed.data; - } else { - console.warn("Received invalid chat event:", parsed.error); - } - } - } - } finally { - // ON DISCONNECT (Tab closed, refreshed, or network dropped) - await kdb.zrem(typingKey, input.username); - const remainingT = await kdb.zrange(typingKey, 0, -1); - await kdb.publish( - channel, - JSON.stringify({ eventType: "typing_update", data: remainingT }), - ); - - await sub.unsubscribe(channel); - await sub.quit(); - } - }), -}); diff --git a/apps/infoalloggi/src/server/api/routers/messages_sse.ts b/apps/infoalloggi/src/server/api/routers/messages_sse.ts deleted file mode 100644 index b7a265a..0000000 --- a/apps/infoalloggi/src/server/api/routers/messages_sse.ts +++ /dev/null @@ -1,294 +0,0 @@ -import { TRPCError, tracked } from "@trpc/server"; -import { z } from "zod/v4"; -import { - adminProcedure, - createTRPCRouter, - protectedProcedure, -} from "~/server/api/trpc"; -import { - getCurrentlyTyping, - removeStaleTyping, - removeTyping, -} from "~/server/controllers/chat.controller"; -import { db } from "~/server/db"; -import { - type ChatMessage, - editMessage, - setReadMessage, -} from "~/server/services/messages.service"; -import { ee, type MessageUpdate } from "~/server/sse"; -import { zAsyncIterable } from "~/server/utils/sse_zod"; -import { zChatId, zMessageId, zUserId } from "~/server/utils/zod_types"; - -export const messagesSSERouter = createTRPCRouter({ - add: protectedProcedure - .input( - z.object({ - chatId: zChatId, - message: z.string(), - sender: zUserId, - }), - ) - .mutation(async ({ input }) => { - const newMessage = await db - .insertInto("messages") - .values({ - chatid: input.chatId, - message: input.message, - sender: input.sender, - time: new Date(), - }) - .returningAll() - .executeTakeFirstOrThrow( - () => new Error("Failed to insert message into database"), - ); - - const userInfos = await db - .selectFrom("users") - .select([ - "users.nome as sender_nome", - "users.isAdmin as sender_isAdmin", - ]) - .where("id", "=", input.sender) - .executeTakeFirst(); - - if (!userInfos) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "User not found or no name", - }); - } - - await removeTyping({ - chatId: input.chatId, - userId: input.sender, - username: userInfos.sender_nome, - }); - await removeStaleTyping(input.chatId); - - ee.emit("chatUpdates", input.chatId, { - data: await getCurrentlyTyping(input.chatId), - eventType: "typing", - }); - - ee.emit("add", input.chatId, { - ...newMessage, - ...userInfos, - }); - - return { ...newMessage, ...userInfos }; - }), - delete: adminProcedure - .input(z.object({ chatId: zChatId, messageId: zMessageId })) - .mutation(async ({ input }) => { - await db - .deleteFrom("messages") - .where("messageid", "=", input.messageId) - .execute(); - - ee.emit("messageUpdates", input.chatId, { - data: input.messageId, - eventType: "delete", - }); - }), - - edit: adminProcedure - .input( - z.object({ chatId: zChatId, message: z.string(), messageId: zMessageId }), - ) - .mutation(async ({ input }) => { - await editMessage({ - db, - message: input.message, - messageId: input.messageId, - }); - - ee.emit("messageUpdates", input.chatId, { - data: { - message: input.message, - messageId: input.messageId, - }, - eventType: "edit", - }); - }), - infinite: protectedProcedure - .input( - z.object({ - chatId: zChatId, - cursor: z.date().nullish(), - take: z.number().min(1).max(50).nullish(), - }), - ) - .query(async ({ input, ctx }) => { - try { - await setReadMessage({ - chatId: input.chatId, - db, - reader: ctx.session.id, - }); - - const take = input.take ?? 20; - const cursor = input.cursor; - - let qry = db - .selectFrom("messages") - .selectAll() - .innerJoin("users", "users.id", "messages.sender") - .select([ - "users.nome as sender_nome", - "users.isAdmin as sender_isAdmin", - ]) - .where("users.nome", "is not", null) - .where("messages.chatid", "=", input.chatId) - .orderBy("messages.time", "desc") - .limit(take + 1); - - if (cursor) { - qry = qry.where("messages.time", "<=", cursor); - } - - const page = await qry.execute(); - - const items = page.reverse(); - let nextCursor: typeof cursor | null = null; - if (items.length > take) { - const prev = items.shift(); - // biome-ignore lint/style/noNonNullAssertion: - nextCursor = prev!.time; - } - return { - items, - nextCursor, - }; - } catch (e) { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: `Error while fetching messages: ${(e as Error).message}`, - }); - } - }), - - onAdd: protectedProcedure - .input( - z.object({ - chatId: zChatId, - // lastEventId is the last event id that the client has received - // On the first call, it will be whatever was passed in the initial setup - // If the client reconnects, it will be the last event id that the client received - lastEventId: zMessageId.nullish(), - }), - ) - .subscription(async function* ({ input, signal, ctx }) { - try { - // We start by subscribing to the event emitter so that we don't miss any new events while fetching - const iterable = ee.toIterable("add", { - signal, - }); - - // Fetch the last message createdAt based on the last event id - let lastMessageCreatedAt = await (async () => { - const lastEventId = input.lastEventId; - console.log("lastEventId", lastEventId); - if (!lastEventId) return null; - - const messageById = await db - .selectFrom("messages") - .selectAll() - .where("messageid", "=", lastEventId) - .executeTakeFirstOrThrow(() => { - throw new Error(`Message not found - messageId: ${lastEventId}`); - }); - - return messageById?.time ?? null; - })(); - - let qry = db - .selectFrom("messages") - .selectAll() - .innerJoin("users", "users.id", "messages.sender") - .select([ - "users.nome as sender_nome", - "users.isAdmin as sender_isAdmin", - ]) - .where("messages.chatid", "=", input.chatId) - .orderBy("messages.time", "asc"); - - if (lastMessageCreatedAt) { - qry = qry.where("messages.time", ">", lastMessageCreatedAt); - } - - const newMessagesSinceLastMessage = await qry.execute(); - - await setReadMessage({ - chatId: input.chatId, - db, - reader: ctx.session.id, - }); - - function* maybeYield(msg: ChatMessage) { - if (msg.chatid !== input.chatId) { - // ignore messages from other channels - the event emitter can emit from other channels - return; - } - if (lastMessageCreatedAt && msg.time <= lastMessageCreatedAt) { - // ignore messages that we've already sent - happens if there is a race condition between the query and the event emitter - return; - } - - yield tracked(msg.messageid, msg); - - // update the cursor so that we don't send this post again - lastMessageCreatedAt = msg.time; - } - - // yield the messages we fetched from the db - for (const post of newMessagesSinceLastMessage) { - yield* maybeYield(post); - } - - // yield any new messages from the event emitter - for await (const [, post] of iterable) { - yield* maybeYield(post); - } - } catch (e) { - if (e instanceof Error && e.name === "AbortError") return; - console.error("Error in onAdd subscription:", e); - } - }), - - onMessageEvent: protectedProcedure - .input(z.object({ chatId: zChatId })) - .output( - zAsyncIterable({ - tracked: true, - yield: z.custom(), - }), - ) - .subscription(async function* ({ input, signal }) { - try { - const iterable = ee.toIterable("messageUpdates", { - signal, - }); - - for await (const [chatId, data] of iterable) { - if (chatId === input.chatId) { - yield tracked(chatId, data); - } - } - } catch (e) { - if (e instanceof Error && e.name === "AbortError") return; - console.error("Error in onMessageEvent subscription:", e); - } - }), - read: protectedProcedure - .input(z.object({ chatId: zChatId })) - .mutation(async ({ input, ctx }) => { - await setReadMessage({ - chatId: input.chatId, - db, - reader: ctx.session.id, - }); - - return true; - }), -}); diff --git a/apps/infoalloggi/src/server/controllers/catasto.controller.ts b/apps/infoalloggi/src/server/controllers/catasto.controller.ts index 0529645..a031562 100644 --- a/apps/infoalloggi/src/server/controllers/catasto.controller.ts +++ b/apps/infoalloggi/src/server/controllers/catasto.controller.ts @@ -17,22 +17,6 @@ export const getComuni = async () => { } }; -export const searchComuniByCatasto = async (catasto: string[]) => { - try { - return await db - .selectFrom("comuni") - .selectAll() - .orderBy("nome", "asc") - .where("catasto", "in", catasto) - .execute(); - } catch (e) { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: `Error while getting Comuni: ${(e as Error).message}`, - }); - } -}; - export const getProvincie = async () => { try { return await db.selectFrom("provincie").selectAll().execute(); diff --git a/apps/infoalloggi/src/server/controllers/chat.controller.ts b/apps/infoalloggi/src/server/controllers/chat.controller.ts index 6b61017..70e36a5 100644 --- a/apps/infoalloggi/src/server/controllers/chat.controller.ts +++ b/apps/infoalloggi/src/server/controllers/chat.controller.ts @@ -1,52 +1,58 @@ import { TRPCError } from "@trpc/server"; -import { subSeconds } from "date-fns"; import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/postgres"; import type { Chats, ChatsChatid } from "~/schemas/public/Chats"; import type { Etichette } from "~/schemas/public/Etichette"; -import type { UsersId } from "~/schemas/public/Users"; +import type { Users, UsersId } from "~/schemas/public/Users"; +import type { Message } from "~/server/api/routers/chat_sse"; import { db } from "~/server/db"; import { add, type ChatUserInfo, getRaw } from "~/server/services/chat.service"; -import type { WhoIsTyping } from "~/server/sse"; -import { getKeydbClient } from "~/utils/keydb"; -import { getUserInfo, type UserInfoQueryType } from "~/utils/kysely-helper"; +import { getUserInfo } from "~/utils/kysely-helper"; -export type ActiveChatsType = { - id: ChatsChatid; - created_at: Date; - userinfo: UserInfoQueryType; - lastmessage: { - message: string | null; - time: Date; - isLastSender: boolean; - } | null; - etichette: Etichette[]; -}; +export type ActiveChatsType = Pick & + Pick & { + lastmessage: Message | null; + etichette: Etichette[]; + }; -export const getActiveInfos = async ({ - userId, -}: { - userId: UsersId; -}): Promise => { +export const getActiveInfos = async (): Promise => { const chats = await db .selectFrom("chats") + .innerJoin("users", "users.id", "chats.user_ref") .select((eb) => [ "chatid", - "created_at", - getUserInfo(), + "chats.created_at", + "id", + "username", + "email", + "nome", jsonObjectFrom( eb .selectFrom("messages") - .selectAll("messages") .whereRef("messages.chatid", "=", "chats.chatid") - .leftJoin("users", "users.id", "messages.sender") + .innerJoin("users", "users.id", "messages.sender") .select([ + "messages.message", + "messages.messageid", + "messages.chatid", + (eb) => + eb + .cast( + eb.fn("to_char", [ + "messages.time", + eb.val("YYYY-MM-DD HH24:MI:SS"), + ]), + "text", + ) + .as("time"), + "messages.sender", + "messages.isread", "users.nome as sender_nome", "users.isAdmin as sender_isAdmin", ]) - .orderBy("messages.time", "desc") + .orderBy("messages.messageid", "desc") .limit(1), - ).as("lastMessage"), + ).as("lastmessage"), jsonArrayFrom( eb .selectFrom("etichette") @@ -67,33 +73,7 @@ export const getActiveInfos = async ({ .orderBy("created_at", "desc") .execute(); - const clean = chats - .map((chat) => { - if (!chat.userinfo) { - return null; - } - return { - created_at: chat.created_at, - etichette: chat.etichette as Etichette[], - id: chat.chatid, - lastmessage: chat.lastMessage - ? { - isLastSender: chat.lastMessage.sender === userId, - message: chat.lastMessage.message, - time: new Date(chat.lastMessage.time), - } - : null, - userinfo: chat.userinfo, - }; - }) - .filter((chat): chat is NonNullable => chat !== null) - .sort((a, b) => { - const timeA = a.lastmessage ? new Date(a.lastmessage.time).getTime() : 0; - const timeB = b.lastmessage ? new Date(b.lastmessage.time).getTime() : 0; - return timeB - timeA; - }); - - return clean; + return chats; }; export const getChatInfo = async ({ @@ -189,122 +169,3 @@ export const getChatByUserIdHandler = async ({ .executeTakeFirst(); return chat?.chatid || null; }; - -// Redis key prefix for typing status -const TYPING_KEY_PREFIX = "chat:typing:"; -const TYPING_TTL_SECONDS = 3; - -// const ONLINEUSER_KEY_PREFIX = "chat:online:"; - -export const addTyping = async ({ - chatId, - userId, - username, -}: { - chatId: ChatsChatid; - userId: UsersId; - username: string; -}) => { - try { - const keydb = getKeydbClient(); - const typingKey = `${TYPING_KEY_PREFIX}${chatId}`; - const typingData = JSON.stringify({ - userId, - username, - }); - if (!keydb) { - console.warn("KeyDB client is not initialized. Skipping typing status."); - return; - } - // Add the user to the Redis set with a TTL - await keydb.zadd(typingKey, Date.now(), typingData); - } catch (e) { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: `Error adding typing status to Redis: ${(e as Error).message}`, - }); - } -}; - -export const removeTyping = async ({ - chatId, - userId, - username, -}: { - chatId: ChatsChatid; - userId: UsersId; - username: string; -}) => { - try { - const keydb = getKeydbClient(); - const typingKey = `${TYPING_KEY_PREFIX}${chatId}`; - if (!keydb) { - console.warn("KeyDB client is not initialized. Skipping typing removal."); - return; - } - - await keydb.zrem(typingKey, JSON.stringify({ userId, username })); - } catch (e) { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: `Error removing typing status from Redis: ${(e as Error).message}`, - }); - } -}; - -export const removeStaleTyping = async (chatId: ChatsChatid) => { - try { - const keydb = getKeydbClient(); - const typingKey = `${TYPING_KEY_PREFIX}${chatId}`; - const now = new Date(); - const cutoff = subSeconds(now, TYPING_TTL_SECONDS); - if (!keydb) { - console.warn( - "KeyDB client is not initialized. Skipping stale typing removal.", - ); - return; - } - - await keydb.zremrangebyscore(typingKey, 0, cutoff.getTime()); - } catch (e) { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: - "Error removing stale typing status from Redis: " + - (e as Error).message, - }); - } -}; - -export const getCurrentlyTyping = async ( - chatId: ChatsChatid, -): Promise => { - try { - const keydb = getKeydbClient(); - const typingKey = `${TYPING_KEY_PREFIX}${chatId}`; - if (!keydb) { - console.warn("KeyDB client is not initialized. Skipping typing status."); - return []; - } - - // Fetch all members of the Redis set - const allTypers = await keydb.zrange(typingKey, 0, -1); - - if (!allTypers || allTypers.length === 0) { - return []; - } - - // Parse the entries into the WhoIsTyping structure - const typingStatus: WhoIsTyping[] = allTypers.map((entry) => { - const { userId, username } = JSON.parse(entry) as WhoIsTyping; - return { userId, username }; - }); - - return typingStatus; - } catch (e) { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: `Error getting typing status from Redis: ${(e as Error).message}`, - }); - } -}; diff --git a/apps/infoalloggi/src/server/controllers/invites.controller.ts b/apps/infoalloggi/src/server/controllers/invites.controller.ts index 47916de..03f4052 100644 --- a/apps/infoalloggi/src/server/controllers/invites.controller.ts +++ b/apps/infoalloggi/src/server/controllers/invites.controller.ts @@ -137,7 +137,7 @@ export const InviteAcceptance = async ( export type TokenVerErrors = { _tag: "TokenNotFound" } | { _tag: "Expired" }; -export async function verifyInviteToken( +async function verifyInviteToken( token: string, ): Promise> { try { @@ -164,7 +164,7 @@ export async function verifyInviteToken( } } -export async function consumeInviteToken(token: string) { +async function consumeInviteToken(token: string) { try { await db.deleteFrom("user_invites").where("token", "=", token).execute(); } catch (e) { diff --git a/apps/infoalloggi/src/server/services/messages.service.ts b/apps/infoalloggi/src/server/services/messages.service.ts index 0a6cc0e..a833bc0 100644 --- a/apps/infoalloggi/src/server/services/messages.service.ts +++ b/apps/infoalloggi/src/server/services/messages.service.ts @@ -1,14 +1,9 @@ import { TRPCError } from "@trpc/server"; import type { ChatsChatid } from "~/schemas/public/Chats"; -import type { Messages, MessagesMessageid } from "~/schemas/public/Messages"; +import type { MessagesMessageid } from "~/schemas/public/Messages"; import type { UsersId } from "~/schemas/public/Users"; import type { Querier } from "~/server/db"; -export type ChatMessage = Messages & { - sender_isAdmin: boolean; - sender_nome: string; -}; - export const editMessage = async ({ db, messageId, @@ -45,16 +40,16 @@ export const setReadMessage = async ({ reader: UsersId; }) => { try { - await db + const result = await db .updateTable("messages") - .set({ - isread: true, - }) + .set({ isread: true }) .where("messages.isread", "=", false) .where("messages.sender", "!=", reader) .where("messages.chatid", "=", chatId) - .returning("messages.chatid") + .returning("messages.messageid") .execute(); + + return result.length > 0; } catch (e) { throw new TRPCError({ code: "BAD_REQUEST", diff --git a/apps/infoalloggi/src/server/services/ordini.service.ts b/apps/infoalloggi/src/server/services/ordini.service.ts index 98a9aec..1df540f 100644 --- a/apps/infoalloggi/src/server/services/ordini.service.ts +++ b/apps/infoalloggi/src/server/services/ordini.service.ts @@ -12,9 +12,9 @@ import type { Users, UsersId } from "~/schemas/public/Users"; import { getComuni, getNazioni } from "~/server/controllers/catasto.controller"; import { db } from "~/server/db"; -export type ServizioOrdiniTable = Omit; +type ServizioOrdiniTable = Omit; //export type ServizioOrdini = Selectable; -export type NewServizioOrdini = Insertable; +type NewServizioOrdini = Insertable; //export type ServizioOrdiniUpdate = Updateable; export type RinnovoOrdiniTable = Omit; //export type RinnovoOrdini = Selectable; diff --git a/apps/infoalloggi/src/server/sse.ts b/apps/infoalloggi/src/server/sse.ts deleted file mode 100644 index b6e7618..0000000 --- a/apps/infoalloggi/src/server/sse.ts +++ /dev/null @@ -1,150 +0,0 @@ -import EventEmitter, { on } from "node:events"; -import type { ChatsChatid } from "~/schemas/public/Chats"; -import type { MessagesMessageid } from "~/schemas/public/Messages"; -import type { UsersId } from "~/schemas/public/Users"; -import type { ChatMessage } from "~/server/services/messages.service"; - -export type WhoIsTyping = { - userId: UsersId; - username: string; -}; - -export type OnlineStatus = { - userId: UsersId; - nome: string; - isAdmin: boolean; -}; - -type ChatUpdate = - | { - eventType: "online"; - data: OnlineStatus[]; - } - | { - eventType: "typing"; - data: WhoIsTyping[]; - }; - -// export type AddEvent = { -// eventType: "add"; -// data: ChatMessage; -// }; - -type EditEvent = { - eventType: "edit"; - data: { messageId: MessagesMessageid; message: string }; -}; - -type DeleteEvent = { - eventType: "delete"; - data: MessagesMessageid; -}; - -export type MessageUpdate = EditEvent | DeleteEvent; - -interface Events { - add: [chatId: ChatsChatid, data: ChatMessage]; - - test: [ch: string, data: string]; - notificaUpdate: [userId: UsersId]; - messageUpdates: [chatId: ChatsChatid, data: MessageUpdate]; - chatUpdates: [chatId: ChatsChatid, data: ChatUpdate]; -} - -enum LogLevel { - NONE = 0, // No logs - ERROR = 1, // Only errors - WARN = 2, // Warnings and errors - INFO = 3, // Info, warnings, and errors - DEBUG = 4, // Dbg, info, warnings, and errors -} - -class IterableEventEmitter extends EventEmitter { - private logLevel: LogLevel = LogLevel.INFO; // Default log level - - // Method to set the log level - setLogLevel(level: LogLevel): void { - this.logLevel = level; - } - - // Method to log messages based on the current log level - private log(level: LogLevel, message: string, ...args: unknown[]): void { - if (this.logLevel >= level) { - console.log(message, ...args); - } - } - - // Method to log emitted events - private logEvent(eventName: string | symbol, args: unknown[]): void { - this.log( - LogLevel.DEBUG, - `[EventEmitter] Event emitted: ${String(eventName)}`, - args, - ); - } - - // Method to log added listeners - private logListener(eventName: string | symbol): void { - this.log( - LogLevel.INFO, - `[EventEmitter] Listener added for event: ${String(eventName)}`, - ); - } - - // Method to log removed listeners - private logRemoveListener(eventName: string | symbol): void { - this.log( - LogLevel.INFO, - `[EventEmitter] Listener removed for event: ${String(eventName)}`, - ); - } - - // Override the `emit` method to include logging - emit( - eventName: TEventName, - ...args: Events[TEventName] - ): boolean { - this.logEvent(eventName, args); // Log the event - //@ts-expect-error - return super.emit(eventName, ...args); // Call the original `emit` method - } - - // Override the `on` method to include logging - on( - eventName: TEventName, - listener: (...args: Events[TEventName]) => void, - ): this { - this.logListener(eventName); // Log the listener addition - //@ts-expect-error - return super.on(eventName, listener); // Call the original `on` method - } - - // Override the `off` method to include logging - off( - eventName: TEventName, - listener: (...args: Events[TEventName]) => void, - ): this { - this.logRemoveListener(eventName); // Log the listener removal - //@ts-expect-error - return super.off(eventName, listener); // Call the original `off` method - } - - toIterable( - eventName: TEventName, - opts?: NonNullable[2]>, - ): AsyncIterable { - this.log( - LogLevel.DEBUG, - `[EventEmitter] Creating async iterable for event: ${String(eventName)}`, - ); - return on(this, eventName, opts) as AsyncIterable; - } -} - -// In a real app, you'd probably use Redis or something -export const ee = new IterableEventEmitter(); - -// Example: Set log level to DEBUG -ee.setLogLevel(LogLevel.ERROR); - -ee.setMaxListeners(200); diff --git a/apps/infoalloggi/src/server/utils/sse_zod.ts b/apps/infoalloggi/src/server/utils/sse_zod.ts deleted file mode 100644 index 756f2ee..0000000 --- a/apps/infoalloggi/src/server/utils/sse_zod.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** biome-ignore-all lint/suspicious/noExplicitAny: */ -import type { TrackedEnvelope } from "@trpc/server"; -import { isTrackedEnvelope, tracked } from "@trpc/server"; -import { z } from "zod"; - -function isAsyncIterable( - value: unknown, -): value is AsyncIterable { - return !!value && typeof value === "object" && Symbol.asyncIterator in value; -} -const trackedEnvelopeSchema = - z.custom>(isTrackedEnvelope); -/** - * A Zod schema helper designed specifically for validating async iterables. This schema ensures that: - * 1. The value being validated is an async iterable. - * 2. Each item yielded by the async iterable conforms to a specified type. - * 3. The return value of the async iterable, if any, also conforms to a specified type. - */ -export function zAsyncIterable< - TYield, - TReturn = void, - Tracked extends boolean = false, ->(opts: { - yield: z.ZodType; - return?: z.ZodType; - tracked?: Tracked; -}) { - const baseSchema = z.custom< - AsyncIterable< - Tracked extends true - ? TrackedEnvelope> - : z.input, - z.input - > - >((val: unknown): val is AsyncIterable => isAsyncIterable(val), { - message: "Expected AsyncIterable", - }); - - return baseSchema.transform(async function* (iter) { - const iterator = iter[Symbol.asyncIterator](); - try { - // biome-ignore lint/suspicious/noImplicitAnyLet: - let next; - // biome-ignore lint/suspicious/noAssignInExpressions: - while ((next = await iterator.next()) && !next.done) { - if (opts.tracked) { - const [id, data] = await trackedEnvelopeSchema.parseAsync(next.value); - yield tracked(id, await opts.yield.parseAsync(data)); - continue; - } - yield await opts.yield.parseAsync(next.value); - } - if (opts.return) { - return (await opts.return.parseAsync(next.value)) as TReturn; - } - return; - } finally { - await iterator.return?.(); - } - }); -} diff --git a/apps/infoalloggi/src/server/utils/zod_types.ts b/apps/infoalloggi/src/server/utils/zod_types.ts index 98a04c8..78677ad 100644 --- a/apps/infoalloggi/src/server/utils/zod_types.ts +++ b/apps/infoalloggi/src/server/utils/zod_types.ts @@ -99,30 +99,37 @@ export const zEventId = z.custom( ); export const MessageDataSchema = z.object({ - messageid: z.custom((val) => typeof val === "string"), + messageid: zMessageId, message: z.string().nullable(), + chatid: zChatId, time: z.string(), sender_nome: z.string(), sender_isAdmin: z.boolean(), -}); -export const MessageEventSchema = z.object({ - eventType: z.literal("message"), - data: MessageDataSchema, + sender: zUserId, + isread: z.boolean(), }); -const TypingUpdateEventSchema = z.object({ - eventType: z.literal("typing_update"), - data: z.array(z.string()), // List of users currently typing -}); - -export const ChatEventSchema = z.discriminatedUnion("eventType", [ - MessageEventSchema, - TypingUpdateEventSchema, - -]); +export const TypingDataSchema = z.array( + z.object({ + userId: zUserId, + username: z.string(), + }), +); export const SidebarUpdateSchema = z.object({ chatId: zChatId, lastMessage: z.string(), senderName: z.string(), }); + +export const MessageUpdateEventSchema = z.discriminatedUnion("eventType", [ + z.object({ + eventType: z.literal("edit"), + data: z.object({ messageId: zMessageId, message: z.string() }), + }), + z.object({ + eventType: z.literal("delete"), + data: zMessageId, + }), + z.object({ eventType: z.literal("read_receipt"), data: z.null() }), // all unread messages in this chat are now read +]); diff --git a/apps/infoalloggi/src/utils/kysely-helper.ts b/apps/infoalloggi/src/utils/kysely-helper.ts index 00bb780..5010437 100644 --- a/apps/infoalloggi/src/utils/kysely-helper.ts +++ b/apps/infoalloggi/src/utils/kysely-helper.ts @@ -1,26 +1,6 @@ -import { isValid } from "date-fns"; import { expressionBuilder } from "kysely"; import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/postgres"; import type Database from "~/schemas/Database"; -import type { UsersId } from "~/schemas/public/Users"; -// export function getMessagesArray() { -// const eb = expressionBuilder(); -// return jsonArrayFrom( -// eb -// .selectFrom("messages") -// .selectAll("messages") -// .whereRef("messages.chatid", "=", "chats.chatid") -// .leftJoin("users", "users.id", "messages.sender") -// .select(["users.nome as sender_nome", "users.isAdmin as sender_isAdmin"]) -// .orderBy("messages.time", "asc"), -// ).as("messagesArray"); -// } -export type UserInfoQueryType = { - id: UsersId; - username: string; - email: string; - nome: string | null; -}; export function getUserInfo() { const eb = expressionBuilder(); @@ -54,10 +34,10 @@ export function withVideos() { ).as("videos"); } -export function parseNullableDateString(str: string | null): Date | null { - if (str === null) { - return null; - } - const date = new Date(str); - return isValid(date) ? date : null; -} +// export function parseNullableDateString(str: string | null): Date | null { +// if (str === null) { +// return null; +// } +// const date = new Date(str); +// return isValid(date) ? date : null; +// }
+ {t.allegati.nessun_allegato} +
+ {message.sender_nome} +
- {JSON.stringify(chatEvents, null, 2)} -
- {JSON.stringify(debugLog, null, 2)} -