import { TRPCError } from "@trpc/server"; import { chatChannelNameSchema, chatClientMutationIdSchema, chatCommandSchema, chatCreateChannelInputSchema, chatCreateDirectInputSchema, } from "@tasks/shared"; import { addPrivateMember, archiveChannel, ChannelPersistenceError, ChatPersistenceError, createChannel, createChannelTransactionRunner, createChatTransactionRunner, createDirectChannel, listVisibleChannels, removePrivateMember, renameChannel, sendChatMessage, } from "@tasks/database/chat"; import { z } from "zod"; import { router, workspaceProcedure } from "@/server/trpc"; const sendCommandSchema = chatCommandSchema.refine( (command) => command.type === "send_message", { message: "Expected a send_message command" }, ); const inputSchema = z .object({ workspace: z.string().min(1), command: sendCommandSchema, mentionedUserIds: z.array(z.string().uuid()).max(100).optional(), }) .strict(); function mapPersistenceError(error: unknown): never { if (error instanceof ChatPersistenceError) { if (error.code === "CHAT_FORBIDDEN") { throw new TRPCError({ code: "NOT_FOUND", message: "Chat channel not found", }); } if (error.code === "CHAT_IDEMPOTENCY_CONFLICT") { throw new TRPCError({ code: "CONFLICT", message: "Message request conflicts with an existing request", }); } throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid chat message request", }); } throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Unable to send chat message", }); } const workspaceInput = z.object({ workspace: z.string().min(1) }).strict(); const channelInput = workspaceInput .extend({ channelId: z.string().uuid(), clientMutationId: chatClientMutationIdSchema, }) .strict(); const runChannels = ( db: Parameters[0], ) => createChannelTransactionRunner(db); async function channelCall(operation: () => Promise): Promise { try { return await operation(); } catch (error) { if (error instanceof ChannelPersistenceError) { if (error.code === "NOT_FOUND") throw new TRPCError({ code: "NOT_FOUND", message: "Chat channel not found", }); if (error.code === "CONFLICT") throw new TRPCError({ code: "CONFLICT", message: "Channel conflicts with existing state", }); throw new TRPCError({ code: "BAD_REQUEST", message: error.code === "READ_ONLY" ? "Channel is archived" : "Invalid channel request", }); } throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Unable to update chat channel", }); } } export const chatRouter = router({ listChannels: workspaceProcedure.input(workspaceInput).query(({ ctx }) => channelCall(() => listVisibleChannels(runChannels(ctx.db), { workspaceId: ctx.workspace.id, actorUserId: ctx.session.user.id, }), ), ), createChannel: workspaceProcedure .input(chatCreateChannelInputSchema) .mutation(({ ctx, input }) => channelCall(() => createChannel(runChannels(ctx.db), { workspaceId: ctx.workspace.id, actorUserId: ctx.session.user.id, kind: input.kind, name: input.name, clientMutationId: input.clientMutationId, }), ), ), renameChannel: workspaceProcedure .input(channelInput.extend({ name: chatChannelNameSchema }).strict()) .mutation(({ ctx, input }) => channelCall(() => renameChannel(runChannels(ctx.db), { workspaceId: ctx.workspace.id, actorUserId: ctx.session.user.id, channelId: input.channelId, name: input.name, clientMutationId: input.clientMutationId, }), ), ), archiveChannel: workspaceProcedure .input(channelInput) .mutation(({ ctx, input }) => channelCall(() => archiveChannel(runChannels(ctx.db), { workspaceId: ctx.workspace.id, actorUserId: ctx.session.user.id, channelId: input.channelId, clientMutationId: input.clientMutationId, }), ), ), addPrivateMember: workspaceProcedure .input(channelInput.extend({ userId: z.string().uuid() }).strict()) .mutation(({ ctx, input }) => channelCall(() => addPrivateMember(runChannels(ctx.db), { workspaceId: ctx.workspace.id, actorUserId: ctx.session.user.id, channelId: input.channelId, userId: input.userId, clientMutationId: input.clientMutationId, }), ), ), removePrivateMember: workspaceProcedure .input(channelInput.extend({ userId: z.string().uuid() }).strict()) .mutation(({ ctx, input }) => channelCall(() => removePrivateMember(runChannels(ctx.db), { workspaceId: ctx.workspace.id, actorUserId: ctx.session.user.id, channelId: input.channelId, userId: input.userId, clientMutationId: input.clientMutationId, }), ), ), createDirect: workspaceProcedure .input(chatCreateDirectInputSchema) .mutation(({ ctx, input }) => channelCall(() => createDirectChannel(runChannels(ctx.db), { workspaceId: ctx.workspace.id, actorUserId: ctx.session.user.id, participantUserIds: input.participantUserIds, clientMutationId: input.clientMutationId, }), ), ), send: workspaceProcedure .input(inputSchema) .mutation(async ({ ctx, input }) => { const command = { ...input.command, workspaceId: ctx.workspace.id, context: { type: "workspace" as const, workspaceId: ctx.workspace.id }, }; try { const result = await sendChatMessage( createChatTransactionRunner(ctx.db), { actorUserId: ctx.session.user.id, command, mentionedUserIds: input.mentionedUserIds ?? [], }, ); return { created: result.created, message: { ...result.message, sequence: result.message.sequence.toString(), }, }; } catch (error) { mapPersistenceError(error); } }), });