Submatter/apps/web/server/routers/chat.ts

219 lines
6.3 KiB
TypeScript
Raw Normal View History

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<typeof createChannelTransactionRunner>[0],
) => createChannelTransactionRunner(db);
async function channelCall<T>(operation: () => Promise<T>): Promise<T> {
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);
}
}),
});