infoalloggi-monorepo/apps/infoalloggi/src/server/api/routers/messages_sse.ts

291 lines
7.2 KiB
TypeScript
Raw Normal View History

2025-08-28 18:27:07 +02:00
import { TRPCError, tracked } from "@trpc/server";
import { z } from "zod/v4";
2025-08-04 17:45:44 +02:00
import {
2025-08-28 18:27:07 +02:00
adminProcedure,
createTRPCRouter,
protectedProcedure,
2025-08-04 17:45:44 +02:00
} from "~/server/api/trpc";
import {
2025-08-28 18:27:07 +02:00
getCurrentlyTyping,
removeStaleTyping,
removeTyping,
} from "~/server/controllers/chat.controller";
import { db } from "~/server/db";
import {
type ChatMessage,
editMessage,
setReadMessage,
2025-08-04 17:45:44 +02:00
} from "~/server/services/messages.service";
2025-08-28 18:27:07 +02:00
import { ee, type MessageUpdate } from "~/server/sse";
2025-08-04 17:45:44 +02:00
import { zAsyncIterable } from "~/server/utils/sse_zod";
2025-08-28 18:27:07 +02:00
import { zChatId, zMessageId, zUserId } from "~/server/utils/zod_types";
2025-08-04 17:45:44 +02:00
export const messagesSSERouter = createTRPCRouter({
2025-08-28 18:27:07 +02:00
add: protectedProcedure
.input(
z.object({
chatId: zChatId,
2025-08-29 16:18:32 +02:00
message: z.string(),
2025-08-28 18:27:07 +02:00
sender: zUserId,
}),
)
.mutation(async ({ input }) => {
const newMessage = await db
.insertInto("messages")
.values({
2025-08-29 16:18:32 +02:00
chatid: input.chatId,
2025-08-28 18:27:07 +02:00
message: input.message,
sender: input.sender,
time: new Date(),
})
.returningAll()
.executeTakeFirstOrThrow();
2025-08-04 17:45:44 +02:00
2025-08-28 18:27:07 +02:00
const userInfos = await db
.selectFrom("users")
.select([
"users.nome as sender_nome",
"users.isAdmin as sender_isAdmin",
])
.where("id", "=", input.sender)
.executeTakeFirst();
2025-08-04 17:45:44 +02:00
2025-08-28 18:27:07 +02:00
if (!userInfos) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found or no name",
});
}
2025-08-04 17:45:44 +02:00
2025-08-28 18:27:07 +02:00
await removeTyping({
chatId: input.chatId,
userId: input.sender,
username: userInfos.sender_nome,
});
await removeStaleTyping(input.chatId);
2025-08-04 17:45:44 +02:00
2025-08-28 18:27:07 +02:00
ee.emit("chatUpdates", input.chatId, {
data: await getCurrentlyTyping(input.chatId),
2025-08-29 16:18:32 +02:00
eventType: "typing",
2025-08-28 18:27:07 +02:00
});
2025-08-04 17:45:44 +02:00
2025-08-28 18:27:07 +02:00
ee.emit("add", input.chatId, {
...newMessage,
...userInfos,
});
2025-08-04 17:45:44 +02:00
2025-08-28 18:27:07 +02:00
return { ...newMessage, ...userInfos };
}),
delete: adminProcedure
.input(z.object({ chatId: zChatId, messageId: zMessageId }))
.mutation(async ({ input }) => {
await db
.deleteFrom("messages")
.where("messageid", "=", input.messageId)
.execute();
2025-08-04 17:45:44 +02:00
2025-08-28 18:27:07 +02:00
ee.emit("messageUpdates", input.chatId, {
data: input.messageId,
2025-08-29 16:18:32 +02:00
eventType: "delete",
2025-08-28 18:27:07 +02:00
});
}),
2025-08-29 16:18:32 +02:00
edit: adminProcedure
.input(
z.object({ chatId: zChatId, message: z.string(), messageId: zMessageId }),
)
.mutation(async ({ input }) => {
await editMessage({
2025-08-28 18:27:07 +02:00
db,
2025-08-29 16:18:32 +02:00
message: input.message,
messageId: input.messageId,
2025-08-28 18:27:07 +02:00
});
2025-08-04 17:45:44 +02:00
2025-08-29 16:18:32 +02:00
ee.emit("messageUpdates", input.chatId, {
data: {
message: input.message,
messageId: input.messageId,
},
eventType: "edit",
});
2025-08-28 18:27:07 +02:00
}),
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,
2025-08-29 16:18:32 +02:00
db,
2025-08-28 18:27:07 +02:00
reader: ctx.session.id,
});
2025-08-04 17:45:44 +02:00
2025-08-28 18:27:07 +02:00
const take = input.take ?? 20;
const cursor = input.cursor;
2025-08-04 17:45:44 +02:00
2025-08-28 18:27:07 +02:00
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);
2025-08-04 17:45:44 +02:00
2025-08-28 18:27:07 +02:00
if (cursor) {
qry = qry.where("messages.time", "<=", cursor);
}
2025-08-04 17:45:44 +02:00
2025-08-28 18:27:07 +02:00
const page = await qry.execute();
2025-08-04 17:45:44 +02:00
2025-08-28 18:27:07 +02:00
const items = page.reverse();
let nextCursor: typeof cursor | null = null;
if (items.length > take) {
const prev = items.shift();
// biome-ignore lint/style/noNonNullAssertion: <ok>
nextCursor = prev!.time;
}
return {
items,
nextCursor,
};
} catch (e) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Error while fetching messages: ${(e as Error).message}`,
});
}
}),
2025-08-04 17:45:44 +02:00
2025-08-28 18:27:07 +02:00
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,
});
2025-08-04 17:45:44 +02:00
2025-08-28 18:27:07 +02:00
// 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;
2025-08-04 17:45:44 +02:00
2025-08-28 18:27:07 +02:00
const messageById = await db
.selectFrom("messages")
.selectAll()
.where("messageid", "=", lastEventId)
.executeTakeFirstOrThrow();
2025-08-04 17:45:44 +02:00
2025-08-28 18:27:07 +02:00
return messageById?.time ?? null;
})();
2025-08-04 17:45:44 +02:00
2025-08-28 18:27:07 +02:00
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");
2025-08-04 17:45:44 +02:00
2025-08-28 18:27:07 +02:00
if (lastMessageCreatedAt) {
qry = qry.where("messages.time", ">", lastMessageCreatedAt);
}
2025-08-04 17:45:44 +02:00
2025-08-28 18:27:07 +02:00
const newMessagesSinceLastMessage = await qry.execute();
2025-08-04 17:45:44 +02:00
2025-08-28 18:27:07 +02:00
await setReadMessage({
chatId: input.chatId,
2025-08-29 16:18:32 +02:00
db,
2025-08-28 18:27:07 +02:00
reader: ctx.session.id,
});
2025-08-04 17:45:44 +02:00
2025-08-28 18:27:07 +02:00
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;
}
2025-08-04 17:45:44 +02:00
2025-08-28 18:27:07 +02:00
yield tracked(msg.messageid, msg);
2025-08-04 17:45:44 +02:00
2025-08-28 18:27:07 +02:00
// update the cursor so that we don't send this post again
lastMessageCreatedAt = msg.time;
}
2025-08-04 17:45:44 +02:00
2025-08-28 18:27:07 +02:00
// yield the messages we fetched from the db
for (const post of newMessagesSinceLastMessage) {
yield* maybeYield(post);
}
2025-08-04 17:45:44 +02:00
2025-08-28 18:27:07 +02:00
// 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);
}
}),
2025-08-04 17:45:44 +02:00
2025-08-28 18:27:07 +02:00
onMessageEvent: protectedProcedure
.input(z.object({ chatId: zChatId }))
.output(
zAsyncIterable({
tracked: true,
2025-08-29 16:18:32 +02:00
yield: z.custom<MessageUpdate>(),
2025-08-28 18:27:07 +02:00
}),
)
.subscription(async function* ({ input, signal }) {
try {
const iterable = ee.toIterable("messageUpdates", {
signal,
});
2025-08-04 17:45:44 +02:00
2025-08-28 18:27:07 +02:00
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);
}
}),
2025-08-29 16:18:32 +02:00
read: protectedProcedure
.input(z.object({ chatId: zChatId }))
.mutation(async ({ input, ctx }) => {
await setReadMessage({
chatId: input.chatId,
db,
reader: ctx.session.id,
});
return true;
}),
2025-08-04 17:45:44 +02:00
});