Skip to content

Chat_api

Building a Real-Time Chat API with Messages, Rooms, and Presence

Section titled “Building a Real-Time Chat API with Messages, Rooms, and Presence”

This project demonstrates a real-time chat API with messaging, chat rooms, user presence, and typing indicators.

Real-Time Chat API Architecture
================================================================================
+-------------------+ +-------------------+ +-------------------+
| Users | | Rooms | | Messages |
+-------------------+ +-------------------+ +-------------------+
| id | | id | | id |
| username |<--->| name |<--->| content |
| email | | type | | type |
| status | | isPrivate | | roomId |
| lastSeenAt | | createdBy | | senderId |
+-------------------+ +-------------------+ | replyToId |
| | | readBy |
| | +-------------------+
| |
v v
+-------------------+ +-------------------+
| room_members | | MessageReads |
+-------------------+ +-------------------+
| roomId | | messageId |
| userId | | userId |
| role | | readAt |
| lastReadAt | +-------------------+
| joinedAt |
+-------------------+
+-------------------+ +-------------------+
| TypingStatus | | Attachments |
+-------------------+ +-------------------+
| roomId | | id |
| userId | | url |
| isTyping | | type |
| updatedAt | | messageId |
+-------------------+ +-------------------+
================================================================================

src/users/user.entity.ts
import {
Entity,
PrimaryGeneratedColumn,
Column,
OneToMany,
ManyToMany,
CreateDateColumn,
UpdateDateColumn,
Index,
} from 'typeorm';
import { Message } from '../messages/message.entity';
import { RoomMember } from '../rooms/room-member.entity';
export enum UserStatus {
ONLINE = 'online',
AWAY = 'away',
BUSY = 'busy',
OFFLINE = 'offline',
}
@Entity('users')
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column({ unique: true, length: 50 })
@Index()
username: string;
@Column({ unique: true })
@Index()
email: string;
@Column({ select: false })
password: string;
@Column({ nullable: true })
avatar: string;
@Column({ type: 'text', nullable: true })
bio: string;
@Column({ type: 'enum', enum: UserStatus, default: UserStatus.OFFLINE })
status: UserStatus;
@Column({ type: 'timestamp', nullable: true })
lastSeenAt: Date;
@OneToMany(() => Message, (message) => message.sender)
messages: Message[];
@ManyToMany(() => 'Room', (room: any) => room.members)
rooms: any[];
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
src/rooms/room.entity.ts
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToMany,
OneToMany,
ManyToOne,
JoinTable,
CreateDateColumn,
UpdateDateColumn,
Index,
} from 'typeorm';
import { User } from '../users/user.entity';
import { RoomMember } from './room-member.entity';
import { Message } from '../messages/message.entity';
export enum RoomType {
DIRECT = 'direct',
GROUP = 'group',
CHANNEL = 'channel',
}
@Entity('rooms')
@Index(['type', 'isActive'])
export class Room {
@PrimaryGeneratedColumn()
id: number;
@Column({ length: 100, nullable: true })
name: string;
@Column({ type: 'text', nullable: true })
description: string;
@Column({ nullable: true })
avatar: string;
@Column({ type: 'enum', enum: RoomType })
type: RoomType;
@Column({ default: false })
isPrivate: boolean;
@Column({ default: true })
isActive: boolean;
@Column({ nullable: true })
createdById: number;
@ManyToOne(() => User)
createdBy: User;
@ManyToMany(() => User, (user) => user.rooms)
@JoinTable({ name: 'room_members' })
members: User[];
@OneToMany(() => RoomMember, (member) => member.room)
memberDetails: RoomMember[];
@OneToMany(() => Message, (message) => message.room)
messages: Message[];
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
src/rooms/room-member.entity.ts
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
Unique,
CreateDateColumn,
Index,
} from 'typeorm';
import { User } from '../users/user.entity';
import { Room } from './room.entity';
export enum RoomRole {
MEMBER = 'member',
MODERATOR = 'moderator',
ADMIN = 'admin',
OWNER = 'owner',
}
@Entity('room_member_details')
@Unique(['roomId', 'userId'])
@Index(['roomId'])
@Index(['userId'])
export class RoomMember {
@PrimaryGeneratedColumn()
id: number;
@Column()
roomId: number;
@Column()
userId: number;
@Column({ type: 'enum', enum: RoomRole, default: RoomRole.MEMBER })
role: RoomRole;
@Column({ default: true })
isMuted: boolean;
@Column({ type: 'timestamp', nullable: true })
lastReadAt: Date;
@Column({ type: 'timestamp', nullable: true })
lastSeenAt: Date;
@ManyToOne(() => Room, (room) => room.memberDetails, { onDelete: 'CASCADE' })
room: Room;
@ManyToOne(() => User)
user: User;
@CreateDateColumn()
joinedAt: Date;
}
src/messages/message.entity.ts
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
OneToMany,
OneToOne,
CreateDateColumn,
Index,
} from 'typeorm';
import { User } from '../users/user.entity';
import { Room } from '../rooms/room.entity';
import { MessageRead } from './message-read.entity';
import { Attachment } from './attachment.entity';
export enum MessageType {
TEXT = 'text',
IMAGE = 'image',
VIDEO = 'video',
FILE = 'file',
SYSTEM = 'system',
}
@Entity('messages')
@Index(['roomId', 'createdAt'])
@Index(['senderId'])
export class Message {
@PrimaryGeneratedColumn()
id: number;
@Column({ type: 'text' })
content: string;
@Column({ type: 'enum', enum: MessageType, default: MessageType.TEXT })
type: MessageType;
@Column({ default: false })
isEdited: boolean;
@Column({ default: false })
isDeleted: boolean;
@Column({ nullable: true })
roomId: number;
@Column()
senderId: number;
@Column({ nullable: true })
replyToId: number;
@ManyToOne(() => Room, (room) => room.messages, { onDelete: 'CASCADE' })
room: Room;
@ManyToOne(() => User, (user) => user.messages)
sender: User;
@ManyToOne(() => Message, (message) => message.replies)
replyTo: Message;
@OneToMany(() => Message, (message) => message.replyTo)
replies: Message[];
@OneToMany(() => MessageRead, (read) => read.message)
reads: MessageRead[];
@OneToMany(() => Attachment, (attachment) => attachment.message, {
cascade: true,
})
attachments: Attachment[];
@CreateDateColumn()
createdAt: Date;
}
src/messages/message-read.entity.ts
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
Unique,
CreateDateColumn,
Index,
} from 'typeorm';
import { Message } from './message.entity';
import { User } from '../users/user.entity';
@Entity('message_reads')
@Unique(['messageId', 'userId'])
@Index(['messageId'])
@Index(['userId'])
export class MessageRead {
@PrimaryGeneratedColumn()
id: number;
@Column()
messageId: number;
@Column()
userId: number;
@ManyToOne(() => Message, (message) => message.reads, { onDelete: 'CASCADE' })
message: Message;
@ManyToOne(() => User)
user: User;
@CreateDateColumn()
readAt: Date;
}

src/messages/message.service.ts
import {
Injectable,
NotFoundException,
ForbiddenException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource, SelectQueryBuilder } from 'typeorm';
import { Message, MessageType } from './message.entity';
import { MessageRead } from './message-read.entity';
import { RoomMember, RoomRole } from '../rooms/room-member.entity';
import { CreateMessageDto } from './dto/create-message.dto';
import { GetMessagesDto } from './dto/get-messages.dto';
@Injectable()
export class MessageService {
constructor(
@InjectRepository(Message)
private messageRepository: Repository<Message>,
@InjectRepository(MessageRead)
private messageReadRepository: Repository<MessageRead>,
@InjectRepository(RoomMember)
private roomMemberRepository: Repository<RoomMember>,
private dataSource: DataSource,
) {}
async create(
userId: number,
createMessageDto: CreateMessageDto,
): Promise<Message> {
// Check if user is a member of the room
const membership = await this.roomMemberRepository.findOne({
where: {
roomId: createMessageDto.roomId,
userId,
},
});
if (!membership) {
throw new ForbiddenException('You are not a member of this room');
}
const message = this.messageRepository.create({
...createMessageDto,
senderId: userId,
});
return this.messageRepository.save(message);
}
async getRoomMessages(
userId: number,
roomId: number,
query: GetMessagesDto,
) {
// Check membership
const membership = await this.roomMemberRepository.findOne({
where: { roomId, userId },
});
if (!membership) {
throw new ForbiddenException('You are not a member of this room');
}
const qb = this.messageRepository
.createQueryBuilder('message')
.leftJoinAndSelect('message.sender', 'sender')
.leftJoinAndSelect('message.attachments', 'attachments')
.leftJoinAndSelect('message.replyTo', 'replyTo')
.where('message.roomId = :roomId', { roomId })
.andWhere('message.isDeleted = :isDeleted', { isDeleted: false });
// Cursor-based pagination
if (query.before) {
qb.andWhere('message.id < :before', { before: query.before });
} else if (query.after) {
qb.andWhere('message.id > :after', { after: query.after });
}
const limit = query.limit || 50;
qb.orderBy('message.createdAt', 'DESC').take(limit);
const messages = await qb.getMany();
// Mark messages as read
await this.markAsRead(userId, roomId);
return messages.reverse();
}
async markAsRead(userId: number, roomId: number): Promise<void> {
await this.dataSource.transaction(async (manager) => {
// Get unread messages
const unreadMessages = await manager
.createQueryBuilder(Message, 'message')
.leftJoin(
MessageRead,
'read',
'read.messageId = message.id AND read.userId = :userId',
{ userId },
)
.where('message.roomId = :roomId', { roomId })
.andWhere('message.senderId != :userId', { userId })
.andWhere('read.id IS NULL')
.getMany();
// Create read records
if (unreadMessages.length > 0) {
const reads = unreadMessages.map((msg) =>
manager.create(MessageRead, {
messageId: msg.id,
userId,
}),
);
await manager.save(reads);
}
// Update member's last read at
await manager.update(
RoomMember,
{ roomId, userId },
{ lastReadAt: new Date() },
);
});
}
async getUnreadCount(userId: number, roomId: number): Promise<number> {
const membership = await this.roomMemberRepository.findOne({
where: { roomId, userId },
});
if (!membership || !membership.lastReadAt) {
return 0;
}
return this.messageRepository
.createQueryBuilder('message')
.where('message.roomId = :roomId', { roomId })
.andWhere('message.senderId != :userId', { userId })
.andWhere('message.createdAt > :lastReadAt', {
lastReadAt: membership.lastReadAt,
})
.getCount();
}
async update(
userId: number,
id: number,
content: string,
): Promise<Message> {
const message = await this.messageRepository.findOne({
where: { id },
});
if (!message) {
throw new NotFoundException('Message not found');
}
if (message.senderId !== userId) {
throw new ForbiddenException('You can only edit your own messages');
}
message.content = content;
message.isEdited = true;
return this.messageRepository.save(message);
}
async softDelete(userId: number, id: number): Promise<void> {
const message = await this.messageRepository.findOne({
where: { id },
});
if (!message) {
throw new NotFoundException('Message not found');
}
if (message.senderId !== userId) {
throw new ForbiddenException('You can only delete your own messages');
}
message.isDeleted = true;
message.content = 'This message has been deleted';
await this.messageRepository.save(message);
}
}
src/rooms/room.service.ts
import {
Injectable,
NotFoundException,
ForbiddenException,
BadRequestException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource, In } from 'typeorm';
import { Room, RoomType } from './room.entity';
import { RoomMember, RoomRole } from './room-member.entity';
import { User } from '../users/user.entity';
import { CreateRoomDto } from './dto/create-room.dto';
@Injectable()
export class RoomService {
constructor(
@InjectRepository(Room)
private roomRepository: Repository<Room>,
@InjectRepository(RoomMember)
private roomMemberRepository: Repository<RoomMember>,
@InjectRepository(User)
private userRepository: Repository<User>,
private dataSource: DataSource,
) {}
async createDirectMessage(
userId: number,
otherUserId: number,
): Promise<Room> {
if (userId === otherUserId) {
throw new BadRequestException('Cannot create DM with yourself');
}
// Check if DM already exists
const existingRoom = await this.findDirectMessage(userId, otherUserId);
if (existingRoom) {
return existingRoom;
}
return this.dataSource.transaction(async (manager) => {
const room = manager.create(Room, {
type: RoomType.DIRECT,
isPrivate: true,
createdById: userId,
});
const savedRoom = await manager.save(room);
// Add both users as members
const members = [
manager.create(RoomMember, {
roomId: savedRoom.id,
userId,
role: RoomRole.MEMBER,
}),
manager.create(RoomMember, {
roomId: savedRoom.id,
userId: otherUserId,
role: RoomRole.MEMBER,
}),
];
await manager.save(members);
return this.findOne(savedRoom.id, userId);
});
}
async createGroupRoom(
userId: number,
createRoomDto: CreateRoomDto,
): Promise<Room> {
return this.dataSource.transaction(async (manager) => {
const room = manager.create(Room, {
...createRoomDto,
type: RoomType.GROUP,
createdById: userId,
});
const savedRoom = await manager.save(room);
// Add creator as owner
const ownerMember = manager.create(RoomMember, {
roomId: savedRoom.id,
userId,
role: RoomRole.OWNER,
});
await manager.save(ownerMember);
// Add other members
if (createRoomDto.memberIds && createRoomDto.memberIds.length > 0) {
const members = createRoomDto.memberIds.map((memberId) =>
manager.create(RoomMember, {
roomId: savedRoom.id,
userId: memberId,
role: RoomRole.MEMBER,
}),
);
await manager.save(members);
}
return this.findOne(savedRoom.id, userId);
});
}
async findOne(id: number, userId: number): Promise<Room> {
const room = await this.roomRepository
.createQueryBuilder('room')
.leftJoinAndSelect('room.members', 'members')
.leftJoinAndSelect('room.memberDetails', 'memberDetails')
.leftJoinAndSelect('memberDetails.user', 'user')
.where('room.id = :id', { id })
.getOne();
if (!room) {
throw new NotFoundException('Room not found');
}
// Check if user is a member
const isMember = room.members.some((member) => member.id === userId);
if (!isMember && room.isPrivate) {
throw new ForbiddenException('You do not have access to this room');
}
return room;
}
async getUserRooms(userId: number): Promise<Room[]> {
return this.roomRepository
.createQueryBuilder('room')
.innerJoin('room.members', 'member', 'member.id = :userId', { userId })
.leftJoinAndSelect('room.members', 'allMembers')
.leftJoinAndSelect('room.memberDetails', 'memberDetails')
.where('room.isActive = :isActive', { isActive: true })
.orderBy('room.updatedAt', 'DESC')
.getMany();
}
async addMember(
roomId: number,
userId: number,
newMemberId: number,
role: RoomRole = RoomRole.MEMBER,
): Promise<RoomMember> {
const room = await this.roomRepository.findOne({ where: { id: roomId } });
if (!room) {
throw new NotFoundException('Room not found');
}
// Check if user has permission
const userMembership = await this.roomMemberRepository.findOne({
where: { roomId, userId },
});
if (!userMembership || userMembership.role === RoomRole.MEMBER) {
throw new ForbiddenException('You do not have permission to add members');
}
// Check if already a member
const existingMember = await this.roomMemberRepository.findOne({
where: { roomId, userId: newMemberId },
});
if (existingMember) {
throw new BadRequestException('User is already a member');
}
const member = this.roomMemberRepository.create({
roomId,
userId: newMemberId,
role,
});
return this.roomMemberRepository.save(member);
}
async removeMember(
roomId: number,
userId: number,
memberId: number,
): Promise<void> {
const userMembership = await this.roomMemberRepository.findOne({
where: { roomId, userId },
});
if (!userMembership) {
throw new ForbiddenException('You are not a member of this room');
}
const targetMembership = await this.roomMemberRepository.findOne({
where: { roomId, userId: memberId },
});
if (!targetMembership) {
throw new NotFoundException('Member not found');
}
// Check permissions
if (userId === memberId) {
// User can remove themselves
await this.roomMemberRepository.remove(targetMembership);
return;
}
if (
userMembership.role === RoomRole.OWNER ||
(userMembership.role === RoomRole.ADMIN &&
targetMembership.role !== RoomRole.OWNER)
) {
await this.roomMemberRepository.remove(targetMembership);
return;
}
throw new ForbiddenException('You do not have permission to remove members');
}
private async findDirectMessage(
userId: number,
otherUserId: number,
): Promise<Room | null> {
const rooms = await this.roomRepository
.createQueryBuilder('room')
.innerJoin('room.memberDetails', 'member1', 'member1.userId = :userId', {
userId,
})
.innerJoin('room.memberDetails', 'member2', 'member2.userId = :otherUserId', {
otherUserId,
})
.where('room.type = :type', { type: RoomType.DIRECT })
.getOne();
return rooms || null;
}
}

src/presence/presence.service.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User, UserStatus } from '../users/user.entity';
@Injectable()
export class PresenceService {
constructor(
@InjectRepository(User)
private userRepository: Repository<User>,
) {}
async updateStatus(userId: number, status: UserStatus): Promise<User> {
const user = await this.userRepository.findOne({ where: { id: userId } });
if (!user) return null;
user.status = status;
user.lastSeenAt = new Date();
return this.userRepository.save(user);
}
async setOnline(userId: number): Promise<void> {
await this.userRepository.update(userId, {
status: UserStatus.ONLINE,
lastSeenAt: new Date(),
});
}
async setOffline(userId: number): Promise<void> {
await this.userRepository.update(userId, {
status: UserStatus.OFFLINE,
lastSeenAt: new Date(),
});
}
async getOnlineUsers(roomId?: number): Promise<User[]> {
const qb = this.userRepository
.createQueryBuilder('user')
.where('user.status != :offline', { offline: UserStatus.OFFLINE });
if (roomId) {
qb.innerJoin(
'room_member_details',
'member',
'member.userId = user.id AND member.roomId = :roomId',
{ roomId },
);
}
return qb.getMany();
}
async getUserPresence(userIds: number[]): Promise<Map<number, UserStatus>> {
const users = await this.userRepository.findByIds(userIds);
const presenceMap = new Map<number, UserStatus>();
users.forEach((user) => {
presenceMap.set(user.id, user.status);
});
return presenceMap;
}
}

src/typing/typing.service.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { TypingStatus } from './typing-status.entity';
@Injectable()
export class TypingService {
private typingTimeout = 5000; // 5 seconds
constructor(
@InjectRepository(TypingStatus)
private typingRepository: Repository<TypingStatus>,
) {}
async setTyping(roomId: number, userId: number): Promise<TypingStatus> {
let typing = await this.typingRepository.findOne({
where: { roomId, userId },
});
if (!typing) {
typing = this.typingRepository.create({ roomId, userId });
}
typing.isTyping = true;
typing.updatedAt = new Date();
return this.typingRepository.save(typing);
}
async stopTyping(roomId: number, userId: number): Promise<void> {
await this.typingRepository.update(
{ roomId, userId },
{ isTyping: false, updatedAt: new Date() },
);
}
async getTypingUsers(roomId: number): Promise<number[]> {
const typingStatuses = await this.typingRepository
.createQueryBuilder('typing')
.where('typing.roomId = :roomId', { roomId })
.andWhere('typing.isTyping = :isTyping', { isTyping: true })
.andWhere('typing.updatedAt > :threshold', {
threshold: new Date(Date.now() - this.typingTimeout),
})
.getMany();
return typingStatuses.map((t) => t.userId);
}
// Clean up stale typing indicators
async cleanupStale(): Promise<void> {
await this.typingRepository
.createQueryBuilder()
.update(TypingStatus)
.set({ isTyping: false })
.where('isTyping = :isTyping', { isTyping: true })
.andWhere('updatedAt < :threshold', {
threshold: new Date(Date.now() - this.typingTimeout),
})
.execute();
}
}

src/chat/chat.gateway.ts
import {
WebSocketGateway,
WebSocketServer,
SubscribeMessage,
OnGatewayConnection,
OnGatewayDisconnect,
ConnectedSocket,
MessageBody,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import { UseGuards } from '@nestjs/common';
import { WsJwtGuard } from '../auth/guards/ws-jwt.guard';
import { MessageService } from '../messages/message.service';
import { RoomService } from '../rooms/room.service';
import { PresenceService } from '../presence/presence.service';
import { TypingService } from '../typing/typing.service';
@WebSocketGateway({
cors: { origin: '*' },
namespace: '/chat',
})
@UseGuards(WsJwtGuard)
export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
@WebSocketServer()
server: Server;
constructor(
private messageService: MessageService,
private roomService: RoomService,
private presenceService: PresenceService,
private typingService: TypingService,
) {}
async handleConnection(client: Socket) {
const userId = client.handshake.auth.userId;
if (!userId) {
client.disconnect();
return;
}
// Store userId in socket
client.data.userId = userId;
// Set user online
await this.presenceService.setOnline(userId);
// Join all user's rooms
const rooms = await this.roomService.getUserRooms(userId);
rooms.forEach((room) => {
client.join(`room:${room.id}`);
});
// Broadcast online status
this.server.emit('user:online', { userId });
}
async handleDisconnect(client: Socket) {
const userId = client.data.userId;
if (userId) {
await this.presenceService.setOffline(userId);
this.server.emit('user:offline', { userId });
}
}
@SubscribeMessage('message:send')
async handleMessage(
@ConnectedSocket() client: Socket,
@MessageBody() data: { roomId: number; content: string; replyToId?: number },
) {
const userId = client.data.userId;
const message = await this.messageService.create(userId, {
roomId: data.roomId,
content: data.content,
replyToId: data.replyToId,
});
// Stop typing indicator
await this.typingService.stopTyping(data.roomId, userId);
// Broadcast to room
this.server.to(`room:${data.roomId}`).emit('message:new', message);
return message;
}
@SubscribeMessage('typing:start')
async handleTypingStart(
@ConnectedSocket() client: Socket,
@MessageBody() data: { roomId: number },
) {
const userId = client.data.userId;
await this.typingService.setTyping(data.roomId, userId);
// Broadcast to room (except sender)
client.to(`room:${data.roomId}`).emit('typing:start', {
roomId: data.roomId,
userId,
});
}
@SubscribeMessage('typing:stop')
async handleTypingStop(
@ConnectedSocket() client: Socket,
@MessageBody() data: { roomId: number },
) {
const userId = client.data.userId;
await this.typingService.stopTyping(data.roomId, userId);
client.to(`room:${data.roomId}`).emit('typing:stop', {
roomId: data.roomId,
userId,
});
}
@SubscribeMessage('room:join')
async handleRoomJoin(
@ConnectedSocket() client: Socket,
@MessageBody() data: { roomId: number },
) {
client.join(`room:${data.roomId}`);
return { success: true };
}
@SubscribeMessage('room:leave')
async handleRoomLeave(
@ConnectedSocket() client: Socket,
@MessageBody() data: { roomId: number },
) {
client.leave(`room:${data.roomId}`);
return { success: true };
}
}

Message Flow
================================================================================
User A Server User B
| | |
|---[connect]------------>| |
| |---[user:online]-------->|
| | |
|---[message:send]------->| |
| |---[message:new]-------->|
| | |
|---[typing:start]------->| |
| |---[typing:start]------->|
| | |
|---[typing:stop]-------->| |
| |---[typing:stop]-------->|
| | |
| |<---[message:send]-------|
|<--[message:new]---------| |
| | |
|---[disconnect]--------->| |
| |---[user:offline]------->|
| | |
================================================================================

This Real-Time Chat API project demonstrates:

  • Entity Relationships: Users, Rooms, Messages, Members
  • Real-Time Communication: WebSocket integration with Socket.io
  • Presence System: Online/offline status tracking
  • Typing Indicators: Real-time typing status
  • Message Features: Replies, edits, soft deletes, read receipts
  • Room Management: Direct messages, group rooms, member roles
  • Cursor-Based Pagination: Efficient message loading

Chapter 49: Multi-Tenant SaaS Project


Last Updated: February 2026