Add messaging

This commit is contained in:
Aslan 2026-01-11 14:17:13 -05:00
parent 23128f25e1
commit 5733975aa0
29 changed files with 986 additions and 8 deletions

View file

@ -13,12 +13,16 @@ import type {
IDeleteChannelParams,
IDeleteChannelResponseError,
IDeleteChannelResponseSuccess,
IGetMessagesParams,
IGetMessagesResponseError,
IGetMessagesResponseSuccess,
} from "./types.js";
import {
createChannelAuth,
deleteChannelByIdAuth,
getChannelByIdAuth,
updateChannelByIdAuth,
getChannelMessagesByIdAuth,
} from "../../services/channel/channel.js";
import { API_ERROR } from "../errors.js";
@ -135,4 +139,42 @@ const deleteChannel = async (request: FastifyRequest, reply: FastifyReply) => {
} as IDeleteChannelResponseSuccess;
};
export { getChannel, postCreateChannel, patchChannel, deleteChannel };
const getMessages = async (request: FastifyRequest, reply: FastifyReply) => {
const { id } = request.params as IGetMessagesParams;
const authHeader = request.headers["authorization"];
const messages = await getChannelMessagesByIdAuth(id, authHeader);
if (!messages) {
reply.status(404);
return {
id: id,
error: API_ERROR.NOT_FOUND,
} as IGetMessagesResponseError;
}
if (messages === API_ERROR.ACCESS_DENIED) {
reply.status(403);
return {
id: id,
error: API_ERROR.ACCESS_DENIED,
} as IGetMessagesResponseError;
}
return {
id: id,
messages: messages.map((message) => ({
id: message.id,
text: message.text,
edited: message.edited,
ownerId: message.ownerId,
creationDate: message.creationDate.getTime(),
})),
} as IGetMessagesResponseSuccess;
};
export {
getChannel,
postCreateChannel,
patchChannel,
deleteChannel,
getMessages,
};

View file

@ -6,6 +6,7 @@ const channelRoutes = async (fastify: FastifyInstance) => {
fastify.post(`/`, controller.postCreateChannel);
fastify.patch(`/:id`, controller.patchChannel);
fastify.delete(`/:id`, controller.deleteChannel);
fastify.get(`/:id/messages`, controller.getMessages);
};
export { channelRoutes };

View file

@ -62,6 +62,28 @@ interface IDeleteChannelResponseSuccess {
communityId: string;
}
interface IGetMessagesParams {
id: string;
}
interface IGetMessagesResponseError {
id: string;
error: API_ERROR;
}
interface IGetMessagesResponseSuccess {
id: string;
messages: IGetMessagesResponseMessage[];
}
interface IGetMessagesResponseMessage {
id: string;
text: string;
edited: boolean;
ownerId: string;
creationDate: number;
}
export {
type IChannel,
type IGetChannelParams,
@ -77,4 +99,8 @@ export {
type IDeleteChannelParams,
type IDeleteChannelResponseError,
type IDeleteChannelResponseSuccess,
type IGetMessagesParams,
type IGetMessagesResponseError,
type IGetMessagesResponseSuccess,
type IGetMessagesResponseMessage,
};

View file

@ -0,0 +1,3 @@
export * from "./message.js";
export * from "./routes.js";
export * from "./types.js";

View file

@ -0,0 +1,145 @@
import { type FastifyReply, type FastifyRequest } from "fastify";
import type {
IGetMessageParams,
IGetMessageResponseError,
IGetMessageResponseSuccess,
IPostCreateMessageRequest,
IPostCreateMessageResponseError,
IPostCreateMessageResponseSuccess,
IPatchMessageParams,
IPatchMessageRequest,
IPatchMessageResponseError,
IPatchMessageResponseSuccess,
IDeleteMessageParams,
IDeleteMessageResponseError,
IDeleteMessageResponseSuccess,
} from "./types.js";
import {
createMessageAuth,
deleteMessageByIdAuth,
getMessageByIdAuth,
updateMessageByIdAuth,
} from "../../services/message/message.js";
import { API_ERROR } from "../errors.js";
const getMessage = async (request: FastifyRequest, reply: FastifyReply) => {
const { id } = request.params as IGetMessageParams;
const authHeader = request.headers["authorization"];
const message = await getMessageByIdAuth(id, authHeader);
if (!message) {
reply.status(404);
return {
id: id,
error: API_ERROR.NOT_FOUND,
} as IGetMessageResponseError;
}
if (message === API_ERROR.ACCESS_DENIED) {
reply.status(403);
return {
id: id,
error: API_ERROR.ACCESS_DENIED,
} as IGetMessageResponseError;
}
return {
id: message.id,
text: message.text,
editHistory: message.editHistory,
edited: message.edited,
ownerId: message.ownerId,
channelId: message.channelId,
creationDate: message.creationDate.getTime(),
} as IGetMessageResponseSuccess;
};
const postCreateMessage = async (
request: FastifyRequest,
reply: FastifyReply,
) => {
const createMessageRequest = request.body as IPostCreateMessageRequest;
const authHeader = request.headers["authorization"];
const message = await createMessageAuth(createMessageRequest, authHeader);
if (message === API_ERROR.ACCESS_DENIED) {
reply.status(403);
return {
error: API_ERROR.ACCESS_DENIED,
} as IPostCreateMessageResponseError;
}
return {
id: message.id,
text: message.text,
editHistory: message.editHistory,
edited: message.edited,
ownerId: message.ownerId,
channelId: message.channelId,
creationDate: message.creationDate.getTime(),
} as IPostCreateMessageResponseSuccess;
};
const patchMessage = async (request: FastifyRequest, reply: FastifyReply) => {
const { id } = request.params as IPatchMessageParams;
const patchMessageRequest = request.body as IPatchMessageRequest;
const authHeader = request.headers["authorization"];
const message = await updateMessageByIdAuth(
id,
patchMessageRequest,
authHeader,
);
if (!message) {
reply.status(404);
return {
id: id,
error: API_ERROR.NOT_FOUND,
} as IPatchMessageResponseError;
}
if (message === API_ERROR.ACCESS_DENIED) {
reply.status(403);
return {
id: id,
error: API_ERROR.ACCESS_DENIED,
} as IPatchMessageResponseError;
}
return {
id: message.id,
text: message.text,
editHistory: message.editHistory,
edited: message.edited,
ownerId: message.ownerId,
channelId: message.channelId,
creationDate: message.creationDate.getTime(),
} as IPatchMessageResponseSuccess;
};
const deleteMessage = async (request: FastifyRequest, reply: FastifyReply) => {
const { id } = request.params as IDeleteMessageParams;
const authHeader = request.headers["authorization"];
const message = await deleteMessageByIdAuth(id, authHeader);
if (!message) {
reply.status(404);
return {
id: id,
error: API_ERROR.NOT_FOUND,
} as IDeleteMessageResponseError;
}
if (message === API_ERROR.ACCESS_DENIED) {
reply.status(403);
return {
id: id,
error: API_ERROR.ACCESS_DENIED,
} as IDeleteMessageResponseError;
}
return {
id: message.id,
ownerId: message.ownerId,
channelId: message.channelId,
} as IDeleteMessageResponseSuccess;
};
export { getMessage, postCreateMessage, patchMessage, deleteMessage };

View file

@ -0,0 +1,11 @@
import { type FastifyInstance } from "fastify";
import * as controller from "./message.js";
const messageRoutes = async (fastify: FastifyInstance) => {
fastify.get(`/:id`, controller.getMessage);
fastify.post(`/`, controller.postCreateMessage);
fastify.patch(`/:id`, controller.patchMessage);
fastify.delete(`/:id`, controller.deleteMessage);
};
export { messageRoutes };

View file

@ -0,0 +1,81 @@
import type { API_ERROR } from "../errors.js";
interface IMessage {
id: string;
text: string;
editHistory: string[];
edited: boolean;
ownerId: string;
channelId: string;
creationDate: number;
}
interface IGetMessageParams {
id: string;
}
interface IGetMessageResponseError {
id: string;
error: API_ERROR;
}
interface IGetMessageResponseSuccess extends IMessage {}
interface IPostCreateMessageRequest {
text: string;
channelId: string;
}
interface IPostCreateMessageResponseError {
id: string;
error: API_ERROR;
}
interface IPostCreateMessageResponseSuccess extends IMessage {}
interface IPatchMessageParams {
id: string;
}
interface IPatchMessageRequest {
text: string;
}
interface IPatchMessageResponseError {
id: string;
error: API_ERROR;
}
interface IPatchMessageResponseSuccess extends IMessage {}
interface IDeleteMessageParams {
id: string;
}
interface IDeleteMessageResponseError {
id: string;
error: API_ERROR;
}
interface IDeleteMessageResponseSuccess {
id: string;
ownerId: string;
channelId: string;
}
export {
type IMessage,
type IGetMessageParams,
type IGetMessageResponseError,
type IGetMessageResponseSuccess,
type IPostCreateMessageRequest,
type IPostCreateMessageResponseError,
type IPostCreateMessageResponseSuccess,
type IPatchMessageParams,
type IPatchMessageRequest,
type IPatchMessageResponseError,
type IPatchMessageResponseSuccess,
type IDeleteMessageParams,
type IDeleteMessageResponseError,
type IDeleteMessageResponseSuccess,
};

View file

@ -0,0 +1,3 @@
export * from "./websocket.js";
export * from "./routes.js";
export * from "./types.js";

View file

@ -0,0 +1,12 @@
import { type FastifyInstance } from "fastify";
import * as controller from "./websocket.js";
const websocketRoutes = async (fastify: FastifyInstance) => {
fastify.get(
`/`,
{ websocket: true, preHandler: controller.handleWebSockets },
controller.getWebSockets,
);
};
export { websocketRoutes };

View file

@ -0,0 +1,7 @@
import type { API_ERROR } from "../errors.js";
interface IGetWebSocketResponseError {
error: API_ERROR;
}
export { type IGetWebSocketResponseError };

View file

@ -0,0 +1,33 @@
import { type FastifyReply, type FastifyRequest } from "fastify";
import type { IGetWebSocketResponseError } from "./types.js";
import { API_ERROR } from "../errors.js";
import { getUserFromCookie } from "../../services/auth/helpers.js";
import { handleNewWebSocket } from "../../services/websocket/websocket.js";
import type { WebSocket } from "@fastify/websocket";
const handleWebSockets = async (
request: FastifyRequest,
reply: FastifyReply,
) => {
const cookie = request.cookies["token"];
const user = await getUserFromCookie(cookie);
if (!user) {
reply.status(403);
return {
error: API_ERROR.ACCESS_DENIED,
} as IGetWebSocketResponseError;
}
};
const getWebSockets = async (socket: WebSocket, request: FastifyRequest) => {
const cookie = request.cookies["token"];
const user = await getUserFromCookie(cookie);
if (!user) {
socket.close();
return;
}
handleNewWebSocket(socket, user.id);
};
export { handleWebSockets, getWebSockets };

View file

@ -1,6 +1,7 @@
import Fastify from "fastify";
import cors from "@fastify/cors";
import cookie from "@fastify/cookie";
import websocket from "@fastify/websocket";
import { config } from "./config.js";
@ -14,6 +15,8 @@ import { communityRoutes } from "./controllers/community/routes.js";
import { channelRoutes } from "./controllers/channel/routes.js";
import { roleRoutes } from "./controllers/role/routes.js";
import { inviteRoutes } from "./controllers/invite/routes.js";
import { messageRoutes } from "./controllers/message/routes.js";
import { websocketRoutes } from "./controllers/websocket/routes.js";
const app = Fastify({
logger: true,
@ -26,6 +29,8 @@ app.register(cors, {
app.register(cookie, { secret: getCookieSecret() });
app.register(websocket);
app.register(testRoutes);
app.register(authRoutes, { prefix: "/api/v1/auth" });
app.register(userRoutes, { prefix: "/api/v1/user" });
@ -34,6 +39,8 @@ app.register(communityRoutes, { prefix: "/api/v1/community" });
app.register(channelRoutes, { prefix: "/api/v1/channel" });
app.register(roleRoutes, { prefix: "/api/v1/role" });
app.register(inviteRoutes, { prefix: "/api/v1/invite" });
app.register(messageRoutes, { prefix: "/api/v1/message" });
app.register(websocketRoutes, { prefix: "/ws" });
app.listen({ port: config.port }, (err, address) => {
if (err) throw err;

View file

@ -47,6 +47,31 @@ const verifyPassword = async (
return await argon2.verify(passwordHash, passwordToCheck);
};
const getUserFromCookie = async (
cookie: string | undefined,
): Promise<User | null> => {
if (!cookie) {
return null;
}
const session = await getDB().session.findFirst({
where: {
cookie: cookie,
},
});
if (!session) {
return null;
}
const user = await getUserBySessionId(session.id);
if (!user) {
return null;
}
return user;
};
const getUserBySessionId = async (sessionId: string): Promise<User | null> => {
return await getDB().user.findFirst({
where: {
@ -116,6 +141,12 @@ const isUserOwnerOrAdmin = async (
if (ownerCheck.role !== undefined) {
return false;
}
if (
ownerCheck.message !== undefined &&
ownerCheck.message?.ownerId !== user.id
) {
return false;
}
return true;
};
@ -214,6 +245,7 @@ export {
verifyToken,
hashPassword,
verifyPassword,
getUserFromCookie,
getUserBySessionId,
getUserFromAuth,
isUserOwnerOrAdmin,

View file

@ -7,8 +7,12 @@ enum PERMISSION {
MEMBERS_READ = "MEMBERS_READ",
MEMBERS_KICK = "MEMBERS_KICK",
MEMBERS_BAN = "MEMBERS_BAN",
INVITES_READ = "INVITES_READ",
INVITES_CREATE = "INVITES_CREATE",
INVITES_DELETE = "INVITES_DELETE",
MESSAGES_READ = "MESSAGES_READ",
MESSAGES_CREATE = "MESSAGES_CREATE",
MESSAGES_DELETE = "MESSAGES_DELETE",
}
export { PERMISSION };

View file

@ -6,6 +6,7 @@ import type {
Role,
Session,
User,
Message,
} from "../../generated/prisma/client.js";
interface AccessTokenPayload extends JwtPayload {
@ -30,6 +31,7 @@ interface IOwnerCheck {
invite?: Invite | null;
channel?: Channel | null;
role?: Role | null;
message?: Message | null;
}
export {

View file

@ -1,5 +1,5 @@
import { API_ERROR } from "../../controllers/errors.js";
import type { Channel } from "../../generated/prisma/client.js";
import type { Channel, Message } from "../../generated/prisma/client.js";
import { getDB } from "../../store/store.js";
import { getUserFromAuth, isUserAllowed } from "../auth/helpers.js";
import { PERMISSION } from "../auth/permission.js";
@ -136,6 +136,40 @@ const deleteChannelByIdAuth = async (
return await deleteChannelById(id);
};
const getChannelMessagesById = async (
id: string,
): Promise<Message[] | null> => {
return await getDB().message.findMany({
where: {
channelId: id,
},
});
};
const getChannelMessagesByIdAuth = async (
id: string,
authHeader: string | undefined,
): Promise<Message[] | null | API_ERROR.ACCESS_DENIED> => {
const authUser = await getUserFromAuth(authHeader);
const channel = await getChannelById(id);
const community = await getCommunityById(channel?.communityId ?? "");
if (
!(await isUserAllowed(
authUser,
{
community: community,
},
community,
[PERMISSION.MESSAGES_READ],
))
) {
return API_ERROR.ACCESS_DENIED;
}
return await getChannelMessagesById(id);
};
export {
getChannelById,
getChannelByIdAuth,
@ -145,4 +179,6 @@ export {
updateChannelByIdAuth,
deleteChannelById,
deleteChannelByIdAuth,
getChannelMessagesById,
getChannelMessagesByIdAuth,
};

View file

@ -255,7 +255,7 @@ const getCommunityInvitesByIdAuth = async (
community: community,
},
community,
[PERMISSION.INVITES_CREATE],
[PERMISSION.INVITES_READ],
))
) {
return API_ERROR.ACCESS_DENIED;

View file

@ -0,0 +1,2 @@
export * from "./message.js";
export * from "./types.js";

View file

@ -0,0 +1,202 @@
import { API_ERROR } from "../../controllers/errors.js";
import type { Message } from "../../generated/prisma/client.js";
import { getDB } from "../../store/store.js";
import {
getUserFromAuth,
isUserAllowed,
isUserInCommunity,
isUserOwnerOrAdmin,
} from "../auth/helpers.js";
import { PERMISSION } from "../auth/permission.js";
import { getChannelById } from "../channel/channel.js";
import { getCommunityById } from "../community/community.js";
import { SocketMessageTypes } from "../websocket/types.js";
import { sendMessageToUsers } from "../websocket/websocket.js";
import type { ICreateMessage, IUpdateMessage } from "./types.js";
const getMessageById = async (id: string): Promise<Message | null> => {
return await getDB().message.findUnique({
where: { id: id },
});
};
const getMessageByIdAuth = async (
id: string,
authHeader: string | undefined,
): Promise<Message | null | API_ERROR.ACCESS_DENIED> => {
const authUser = await getUserFromAuth(authHeader);
const message = await getMessageById(id);
const channel = await getChannelById(message?.channelId ?? "");
const community = await getCommunityById(channel?.communityId ?? "");
if (
!(await isUserAllowed(
authUser,
{
community: community,
},
community,
[PERMISSION.MESSAGES_READ],
))
) {
return API_ERROR.ACCESS_DENIED;
}
return message;
};
const createMessage = async (
ownerId: string,
communityId: string,
create: ICreateMessage,
): Promise<Message> => {
const message = await getDB().message.create({
data: {
ownerId: ownerId,
...create,
},
});
const usersInCommunity = await getDB().user.findMany({
select: {
id: true,
},
where: {
communities: {
some: {
id: communityId,
},
},
},
});
const userIds = usersInCommunity.map((user) => user.id);
sendMessageToUsers(userIds, {
type: SocketMessageTypes.NEW_MESSAGE,
payload: {
channelId: message.channelId,
message: {
id: message.id,
text: message.text,
edited: message.edited,
ownerId: message.ownerId,
creationDate: message.creationDate.getTime(),
},
},
});
return message;
};
const createMessageAuth = async (
create: ICreateMessage,
authHeader: string | undefined,
): Promise<Message | API_ERROR.ACCESS_DENIED> => {
const authUser = await getUserFromAuth(authHeader);
const channel = await getChannelById(create.channelId);
const community = await getCommunityById(channel?.communityId ?? "");
if (
!authUser ||
!community ||
!(await isUserAllowed(
authUser,
{
community: community,
},
community,
[PERMISSION.MESSAGES_CREATE],
))
) {
return API_ERROR.ACCESS_DENIED;
}
return await createMessage(authUser.id, community.id, create);
};
const updateMessageById = async (
id: string,
update: IUpdateMessage,
): Promise<Message | null> => {
const message = await getMessageById(id);
if (!message) {
return null;
}
const newEditHistory = [...message.editHistory, message.text];
return await getDB().message.update({
where: {
id: id,
},
data: {
...update,
editHistory: newEditHistory,
edited: true,
},
});
};
const updateMessageByIdAuth = async (
id: string,
update: IUpdateMessage,
authHeader: string | undefined,
): Promise<Message | null | API_ERROR.ACCESS_DENIED> => {
const authUser = await getUserFromAuth(authHeader);
const message = await getMessageById(id);
const channel = await getChannelById(message?.channelId ?? "");
const community = await getCommunityById(channel?.communityId ?? "");
if (
!(await isUserOwnerOrAdmin(authUser, {
message: message,
})) ||
!(await isUserInCommunity(authUser, community))
) {
return API_ERROR.ACCESS_DENIED;
}
return await updateMessageById(id, update);
};
const deleteMessageById = async (id: string): Promise<Message | null> => {
return await getDB().message.delete({
where: { id: id },
});
};
const deleteMessageByIdAuth = async (
id: string,
authHeader: string | undefined,
): Promise<Message | null | API_ERROR.ACCESS_DENIED> => {
const authUser = await getUserFromAuth(authHeader);
const message = await getMessageById(id);
const channel = await getChannelById(message?.channelId ?? "");
const community = await getCommunityById(channel?.communityId ?? "");
if (
!(await isUserAllowed(
authUser,
{
community: community,
},
community,
[PERMISSION.MESSAGES_DELETE],
))
) {
return API_ERROR.ACCESS_DENIED;
}
return await deleteMessageById(id);
};
export {
getMessageById,
getMessageByIdAuth,
createMessage,
createMessageAuth,
updateMessageById,
updateMessageByIdAuth,
deleteMessageById,
deleteMessageByIdAuth,
};

View file

@ -0,0 +1,10 @@
interface ICreateMessage {
text: string;
channelId: string;
}
interface IUpdateMessage {
text: string;
}
export { type ICreateMessage, type IUpdateMessage };

View file

@ -0,0 +1,2 @@
export * from "./websocket.js";
export * from "./types.js";

View file

@ -0,0 +1,53 @@
import type { WebSocket } from "@fastify/websocket";
import type { IGetMessagesResponseMessage } from "../../controllers/channel/types.js";
interface ISocketConnection {
socket: WebSocket;
userId: string;
}
enum SocketRequestTypes {
PING = "PING",
}
enum SocketMessageTypes {
NEW_ANNOUNCEMENT = "NEW_ANNOUNCEMENT",
NEW_MESSAGE = "NEW_MESSAGE",
NEW_CHANNEL = "NEW_CHANNEL",
}
type SocketRequest = {
type: SocketRequestTypes.PING;
};
type SocketMessage =
| {
type: SocketMessageTypes.NEW_ANNOUNCEMENT;
payload: {
title: string;
description: string;
};
}
| {
type: SocketMessageTypes.NEW_MESSAGE;
payload: {
channelId: string;
message: IGetMessagesResponseMessage;
};
}
| {
type: SocketMessageTypes.NEW_CHANNEL;
payload: {
id: string;
communityId: string;
name: string;
};
};
export {
type ISocketConnection,
SocketRequestTypes,
SocketMessageTypes,
type SocketRequest,
type SocketMessage,
};

View file

@ -0,0 +1,75 @@
import type { CloseEvent, MessageEvent } from "ws";
import type { WebSocket } from "@fastify/websocket";
import type {
ISocketConnection,
SocketRequest,
SocketMessage,
} from "./types.js";
const userConnections = new Map<string, Set<ISocketConnection>>();
const handleNewWebSocket = (socket: WebSocket, userId: string) => {
const connection = {
socket: socket,
userId: userId,
};
if (!userConnections.has(userId)) {
userConnections.set(userId, new Set());
}
userConnections.get(userId)?.add(connection);
onCloseWsHandler(connection);
onMessageWsHandler(connection);
};
const handleRequest = (data: string): SocketRequest | null => {
const request = JSON.parse(data) as SocketRequest;
if (!request.type) {
return null;
}
return request;
};
const onCloseWsHandler = (connection: ISocketConnection) => {
connection.socket.onclose = (event: CloseEvent) => {
const connections = userConnections.get(connection.userId);
connections?.delete(connection);
if (connections?.size === 0) {
userConnections.delete(connection.userId);
}
};
};
const onMessageWsHandler = (connection: ISocketConnection) => {
connection.socket.onmessage = (event: MessageEvent) => {
const request = handleRequest(event.data.toString());
if (!request) {
return;
}
};
};
const sendMessageToUser = (userId: string, message: SocketMessage) => {
const connections = userConnections.get(userId);
connections?.forEach((connection) => {
connection.socket.send(JSON.stringify(message));
});
};
const sendMessageToUsers = (userIds: string[], message: SocketMessage) => {
userIds?.forEach((userId) => {
sendMessageToUser(userId, message);
});
};
export {
userConnections,
handleNewWebSocket,
sendMessageToUser,
sendMessageToUsers,
};