From b256293b77b43ae0206207aacc32b0c5abf8497f Mon Sep 17 00:00:00 2001 From: Marco Pedone Date: Wed, 22 Apr 2026 12:20:32 +0200 Subject: [PATCH] feat: update PostgreSQL image to version 18-bookworm and adjust volume paths; add chat_sse2 router and related schemas; implement AdminChat and GuestChat components --- apps/db/dev-docker-compose.yml | 6 +- apps/infoalloggi/.vscode/settings.json | 1 + apps/infoalloggi/TODO | 2 + .../infoalloggi/src/pages/test/chat-admin.tsx | 277 ++++++++++++++++++ apps/infoalloggi/src/pages/test/chat.tsx | 196 +++++++++++++ .../src/schemas/public/PublicSchema.ts | 6 +- apps/infoalloggi/src/server/api/root.ts | 2 + .../src/server/api/routers/chat_sse2.ts | 246 ++++++++++++++++ .../src/server/utils/zAsyncIterable.ts | 81 +++++ .../infoalloggi/src/server/utils/zod_types.ts | 29 ++ apps/infoalloggi/src/utils/keydb.ts | 13 +- 11 files changed, 849 insertions(+), 10 deletions(-) create mode 100644 apps/infoalloggi/src/pages/test/chat-admin.tsx create mode 100644 apps/infoalloggi/src/pages/test/chat.tsx create mode 100644 apps/infoalloggi/src/server/api/routers/chat_sse2.ts create mode 100644 apps/infoalloggi/src/server/utils/zAsyncIterable.ts diff --git a/apps/db/dev-docker-compose.yml b/apps/db/dev-docker-compose.yml index e66ef86..fec6af4 100644 --- a/apps/db/dev-docker-compose.yml +++ b/apps/db/dev-docker-compose.yml @@ -1,7 +1,7 @@ services: db: - image: postgres:16 + image: postgres:18-bookworm environment: POSTGRES_USER: ${POSTGRES_USER} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} @@ -12,7 +12,7 @@ services: ports: - "5433:5432" volumes: - - dbdata:/var/lib/postgresql/data + - dbdata_v18:/var/lib/postgresql healthcheck: test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"] interval: 1s @@ -42,4 +42,4 @@ networks: external: true volumes: - dbdata: \ No newline at end of file + dbdata_v18: \ No newline at end of file diff --git a/apps/infoalloggi/.vscode/settings.json b/apps/infoalloggi/.vscode/settings.json index 56a92f8..5324afb 100644 --- a/apps/infoalloggi/.vscode/settings.json +++ b/apps/infoalloggi/.vscode/settings.json @@ -3,6 +3,7 @@ "source.organizeImports.biome": "explicit", "source.fixAll.biome": "explicit" }, + "files.autoSave": "off", "editor.formatOnPaste": false, "editor.formatOnType": false, "editor.formatOnSave": true, diff --git a/apps/infoalloggi/TODO b/apps/infoalloggi/TODO index c052631..62986c6 100644 --- a/apps/infoalloggi/TODO +++ b/apps/infoalloggi/TODO @@ -1,5 +1,7 @@ TODOS: - Log email fallite e retry + - refresh token rotation + - Annunci compatibili filtri AFTER MVP: - TODO migrazione app router https://nextjs.org/docs/pages/building-your-application/upgrading/app-router-migration#migrating-from-pages-to-app diff --git a/apps/infoalloggi/src/pages/test/chat-admin.tsx b/apps/infoalloggi/src/pages/test/chat-admin.tsx new file mode 100644 index 0000000..f835cde --- /dev/null +++ b/apps/infoalloggi/src/pages/test/chat-admin.tsx @@ -0,0 +1,277 @@ +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) => ( + + ))} + +
+

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... +
+ )} +
+ +
+ + +
+ + )} +
+
+

Raw Chat Events (KeyDB)

+ +
+					{JSON.stringify(chatEvents, null, 2)}
+				
+
+
+ ); +}; diff --git a/apps/infoalloggi/src/pages/test/chat.tsx b/apps/infoalloggi/src/pages/test/chat.tsx new file mode 100644 index 0000000..3fa5171 --- /dev/null +++ b/apps/infoalloggi/src/pages/test/chat.tsx @@ -0,0 +1,196 @@ +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... +
+ )} +
+ +
+ + +
+
+ +
+

📡 KeyDB Debug Panel

+ +
+

Last 10 Events:

+
+					{JSON.stringify(debugLog, null, 2)}
+				
+
+
+ ); +}; diff --git a/apps/infoalloggi/src/schemas/public/PublicSchema.ts b/apps/infoalloggi/src/schemas/public/PublicSchema.ts index 87a9b60..af9e3b8 100644 --- a/apps/infoalloggi/src/schemas/public/PublicSchema.ts +++ b/apps/infoalloggi/src/schemas/public/PublicSchema.ts @@ -22,8 +22,8 @@ import type { default as RatelimiterTable } from './Ratelimiter'; import type { default as ChatsTable } from './Chats'; import type { default as EtichetteTable } from './Etichette'; import type { default as MessagesTable } from './Messages'; -import type { default as ProvincieTable } from './Provincie'; import type { default as UsersAnagraficaTable } from './UsersAnagrafica'; +import type { default as ProvincieTable } from './Provincie'; import type { default as PotenzialiTable } from './Potenziali'; import type { default as VideosRefsTable } from './VideosRefs'; import type { default as BanlistTable } from './Banlist'; @@ -75,10 +75,10 @@ export default interface PublicSchema { messages: MessagesTable; - provincie: ProvincieTable; - users_anagrafica: UsersAnagraficaTable; + provincie: ProvincieTable; + potenziali: PotenzialiTable; videos_refs: VideosRefsTable; diff --git a/apps/infoalloggi/src/server/api/root.ts b/apps/infoalloggi/src/server/api/root.ts index 2850e18..dc205bc 100644 --- a/apps/infoalloggi/src/server/api/root.ts +++ b/apps/infoalloggi/src/server/api/root.ts @@ -3,6 +3,7 @@ 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"; @@ -35,6 +36,7 @@ export const appRouter = createTRPCRouter({ banlist: banlistRouter, chat: chatRouter, chatSSE: chatSSERouter, + chatSSE2: chatSSE2Router, comunicazioni: comunicazioniRouter, eventQueue: eventQueueRouter, fatture: fattureRouter, diff --git a/apps/infoalloggi/src/server/api/routers/chat_sse2.ts b/apps/infoalloggi/src/server/api/routers/chat_sse2.ts new file mode 100644 index 0000000..2455157 --- /dev/null +++ b/apps/infoalloggi/src/server/api/routers/chat_sse2.ts @@ -0,0 +1,246 @@ +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/utils/zAsyncIterable.ts b/apps/infoalloggi/src/server/utils/zAsyncIterable.ts new file mode 100644 index 0000000..476d0c7 --- /dev/null +++ b/apps/infoalloggi/src/server/utils/zAsyncIterable.ts @@ -0,0 +1,81 @@ +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< + TYieldIn, + TYieldOut, + TReturnIn = void, + TReturnOut = void, + Tracked extends boolean = false, +>(opts: { + /** + * Validate the value yielded by the async generator + */ + yield: z.ZodType; + /** + * Validate the return value of the async generator + * @remarks not applicable for subscriptions + */ + return?: z.ZodType; + /** + * Whether the yielded values are tracked + * @remarks only applicable for subscriptions + */ + tracked?: Tracked; +}) { + return z + .custom< + AsyncIterable< + Tracked extends true ? TrackedEnvelope : TYieldIn, + TReturnIn + > + >((val) => isAsyncIterable(val)) + .transform(async function* (iter) { + const iterator = iter[Symbol.asyncIterator](); + type TYield = Tracked extends true ? TrackedEnvelope : TYieldIn; + try { + let next: IteratorResult; + // biome-ignore lint/suspicious/noAssignInExpressions: + while ((next = await iterator.next()) && !next.done) { + if (opts.tracked) { + const [id, data] = trackedEnvelopeSchema.parse(next.value); + yield tracked(id, await opts.yield.parseAsync(data)); + continue; + } + yield opts.yield.parseAsync(next.value); + } + if (opts.return) { + return await opts.return.parseAsync(next.value); + } + return; + } finally { + await iterator.return?.(); + } + }) as z.ZodType< + AsyncIterable< + Tracked extends true ? TrackedEnvelope : TYieldIn, + TReturnIn, + unknown + >, + AsyncIterable< + Tracked extends true ? TrackedEnvelope : TYieldOut, + TReturnOut, + unknown + > + >; +} diff --git a/apps/infoalloggi/src/server/utils/zod_types.ts b/apps/infoalloggi/src/server/utils/zod_types.ts index ca7032d..98a04c8 100644 --- a/apps/infoalloggi/src/server/utils/zod_types.ts +++ b/apps/infoalloggi/src/server/utils/zod_types.ts @@ -97,3 +97,32 @@ export const zEventId = z.custom( (val) => typeof val === "number", "falied to validate EventId", ); + +export const MessageDataSchema = z.object({ + messageid: z.custom((val) => typeof val === "string"), + message: z.string().nullable(), + time: z.string(), + sender_nome: z.string(), + sender_isAdmin: z.boolean(), +}); +export const MessageEventSchema = z.object({ + eventType: z.literal("message"), + data: MessageDataSchema, +}); + +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 SidebarUpdateSchema = z.object({ + chatId: zChatId, + lastMessage: z.string(), + senderName: z.string(), +}); diff --git a/apps/infoalloggi/src/utils/keydb.ts b/apps/infoalloggi/src/utils/keydb.ts index 9de594a..7598642 100644 --- a/apps/infoalloggi/src/utils/keydb.ts +++ b/apps/infoalloggi/src/utils/keydb.ts @@ -1,14 +1,19 @@ import Redis from "ioredis"; import { env } from "~/env"; +const keydbConfig = `keydb://${env.KEYDB_URL}`; + let keydb: Redis | null = null; export function getKeydbClient() { - if (env.SKIP_REDIS === "true") { - return null; - } + if (env.SKIP_REDIS === "true") return null; if (!keydb) { - keydb = new Redis(`keydb://${env.KEYDB_URL}`); + keydb = new Redis(keydbConfig); } return keydb; } + +export function getSubClient() { + if (env.SKIP_REDIS === "true") return null; + return new Redis(keydbConfig, { enableReadyCheck: false }); +}