Chat_api
Chapter 48: Real-Time Chat API Project
Section titled “Chapter 48: Real-Time Chat API Project”Building a Real-Time Chat API with Messages, Rooms, and Presence
Section titled “Building a Real-Time Chat API with Messages, Rooms, and Presence”48.1 Project Overview
Section titled “48.1 Project Overview”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 | +-------------------+ +-------------------+
================================================================================48.2 Entity Definitions
Section titled “48.2 Entity Definitions”User Entity
Section titled “User Entity”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;}Room Entity
Section titled “Room Entity”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;}Room Member Entity
Section titled “Room Member Entity”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;}Message Entity
Section titled “Message Entity”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;}Message Read Entity
Section titled “Message Read Entity”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;}48.3 Service Implementation
Section titled “48.3 Service Implementation”Messages Service
Section titled “Messages Service”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); }}Rooms Service
Section titled “Rooms Service”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; }}48.4 Presence Service
Section titled “48.4 Presence Service”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; }}48.5 Typing Indicator Service
Section titled “48.5 Typing Indicator Service”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(); }}48.6 WebSocket Gateway
Section titled “48.6 WebSocket Gateway”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 }; }}48.7 Message Flow Diagram
Section titled “48.7 Message Flow Diagram” 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]------->| | | |
================================================================================48.8 Summary
Section titled “48.8 Summary”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
Next Chapter
Section titled “Next Chapter”Chapter 49: Multi-Tenant SaaS Project
Last Updated: February 2026