Real-time Chat Application
Learn how to build a real-time chat application with Query-2jz, including WebSocket connections, message persistence, and user presence.
Project Overview
Build a full-featured real-time chat application with rooms, direct messages, file sharing, and user presence indicators.
Features
- • Real-time messaging with WebSockets
- • Chat rooms and direct messages
- • User presence and typing indicators
- • Message history and search
- • File and image sharing
- • Message reactions and replies
- • User authentication and authorization
- • Message encryption and security
Project Structure
chat-app/
├── src/
│ ├── models/
│ │ ├── User.ts
│ │ ├── Room.ts
│ │ ├── Message.ts
│ │ ├── Participant.ts
│ │ └── Reaction.ts
│ ├── services/
│ │ ├── ChatService.ts
│ │ ├── PresenceService.ts
│ │ ├── NotificationService.ts
│ │ └── FileService.ts
│ ├── controllers/
│ │ ├── ChatController.ts
│ │ ├── RoomController.ts
│ │ └── UserController.ts
│ ├── websocket/
│ │ ├── ChatHandler.ts
│ │ ├── PresenceHandler.ts
│ │ └── TypingHandler.ts
│ ├── config/
│ │ └── query-2jz.config.ts
│ └── index.ts
├── package.json
├── tsconfig.json
└── README.mdData Models
Define comprehensive data models for the chat application.
Message Model
// src/models/Message.ts
export interface Message {
id: string;
roomId: string;
userId: string;
content: string;
type: 'text' | 'image' | 'file' | 'system';
replyToId?: string;
editedAt?: Date;
deletedAt?: Date;
metadata?: {
fileName?: string;
fileSize?: number;
mimeType?: string;
imageUrl?: string;
thumbnailUrl?: string;
};
reactions: Reaction[];
isEncrypted: boolean;
createdAt: Date;
updatedAt: Date;
room?: Room;
user?: User;
replyTo?: Message;
replies?: Message[];
}
export interface Reaction {
id: string;
messageId: string;
userId: string;
emoji: string;
createdAt: Date;
message?: Message;
user?: User;
}
export interface Room {
id: string;
name: string;
description?: string;
type: 'public' | 'private' | 'direct';
createdBy: string;
isActive: boolean;
settings: {
allowFileUploads: boolean;
maxFileSize: number;
allowedFileTypes: string[];
messageRetention: number; // days
encryption: boolean;
};
createdAt: Date;
updatedAt: Date;
createdByUser?: User;
participants?: Participant[];
messages?: Message[];
}
export interface Participant {
id: string;
roomId: string;
userId: string;
role: 'admin' | 'moderator' | 'member';
joinedAt: Date;
lastReadAt?: Date;
isActive: boolean;
room?: Room;
user?: User;
}User Model
// src/models/User.ts
export interface User {
id: string;
username: string;
email: string;
displayName: string;
avatar?: string;
status: 'online' | 'away' | 'busy' | 'offline';
lastSeenAt: Date;
preferences: {
theme: 'light' | 'dark';
notifications: {
sound: boolean;
desktop: boolean;
email: boolean;
};
privacy: {
showOnlineStatus: boolean;
allowDirectMessages: boolean;
};
};
isActive: boolean;
createdAt: Date;
updatedAt: Date;
rooms?: Participant[];
messages?: Message[];
reactions?: Reaction[];
}
export interface UserPresence {
userId: string;
status: 'online' | 'away' | 'busy' | 'offline';
lastSeenAt: Date;
currentRoom?: string;
isTyping?: boolean;
typingIn?: string;
}Query-2jz Configuration
Configure Query-2jz with real-time features and chat-specific models.
// src/config/query-2jz.config.ts
import { Query-2jzConfig } from 'query-2jz';
export const query-2jzConfig: Query-2jzConfig = {
database: {
type: 'postgresql',
connection: process.env.DATABASE_URL
},
cache: {
type: 'redis',
connection: process.env.REDIS_URL
},
realtime: {
enabled: true,
transport: 'websocket',
port: 3001,
cors: {
origin: process.env.ALLOWED_ORIGINS?.split(','),
credentials: true
}
},
models: [
{
name: 'User',
fields: {
id: { type: 'id' },
username: { type: 'string', required: true, unique: true },
email: { type: 'email', required: true, unique: true },
displayName: { type: 'string', required: true },
avatar: { type: 'string' },
status: { type: 'enum', values: ['online', 'away', 'busy', 'offline'], default: 'offline' },
lastSeenAt: { type: 'date', default: 'now' },
preferences: { type: 'json', default: {} },
isActive: { type: 'boolean', default: true },
createdAt: { type: 'date', default: 'now' },
updatedAt: { type: 'date', default: 'now' },
rooms: { type: 'relation', model: 'Participant', many: true, foreignKey: 'userId' },
messages: { type: 'relation', model: 'Message', many: true, foreignKey: 'userId' }
},
indexes: ['username', 'email', 'status']
},
{
name: 'Room',
fields: {
id: { type: 'id' },
name: { type: 'string', required: true },
description: { type: 'string' },
type: { type: 'enum', values: ['public', 'private', 'direct'], default: 'public' },
createdBy: { type: 'string', required: true },
isActive: { type: 'boolean', default: true },
settings: { type: 'json', default: {} },
createdAt: { type: 'date', default: 'now' },
updatedAt: { type: 'date', default: 'now' },
createdByUser: { type: 'relation', model: 'User', foreignKey: 'createdBy' },
participants: { type: 'relation', model: 'Participant', many: true, foreignKey: 'roomId' },
messages: { type: 'relation', model: 'Message', many: true, foreignKey: 'roomId' }
},
indexes: ['type', 'isActive', 'createdBy']
},
{
name: 'Message',
fields: {
id: { type: 'id' },
roomId: { type: 'string', required: true },
userId: { type: 'string', required: true },
content: { type: 'string', required: true },
type: { type: 'enum', values: ['text', 'image', 'file', 'system'], default: 'text' },
replyToId: { type: 'string' },
editedAt: { type: 'date' },
deletedAt: { type: 'date' },
metadata: { type: 'json', default: {} },
isEncrypted: { type: 'boolean', default: false },
createdAt: { type: 'date', default: 'now' },
updatedAt: { type: 'date', default: 'now' },
room: { type: 'relation', model: 'Room', foreignKey: 'roomId' },
user: { type: 'relation', model: 'User', foreignKey: 'userId' },
replyTo: { type: 'relation', model: 'Message', foreignKey: 'replyToId' },
replies: { type: 'relation', model: 'Message', many: true, foreignKey: 'replyToId' },
reactions: { type: 'relation', model: 'Reaction', many: true, foreignKey: 'messageId' }
},
indexes: ['roomId', 'userId', 'type', 'createdAt']
},
{
name: 'Participant',
fields: {
id: { type: 'id' },
roomId: { type: 'string', required: true },
userId: { type: 'string', required: true },
role: { type: 'enum', values: ['admin', 'moderator', 'member'], default: 'member' },
joinedAt: { type: 'date', default: 'now' },
lastReadAt: { type: 'date' },
isActive: { type: 'boolean', default: true },
room: { type: 'relation', model: 'Room', foreignKey: 'roomId' },
user: { type: 'relation', model: 'User', foreignKey: 'userId' }
},
indexes: ['roomId', 'userId', 'role', 'isActive']
},
{
name: 'Reaction',
fields: {
id: { type: 'id' },
messageId: { type: 'string', required: true },
userId: { type: 'string', required: true },
emoji: { type: 'string', required: true },
createdAt: { type: 'date', default: 'now' },
message: { type: 'relation', model: 'Message', foreignKey: 'messageId' },
user: { type: 'relation', model: 'User', foreignKey: 'userId' }
},
indexes: ['messageId', 'userId', 'emoji']
}
]
};WebSocket Handler
Implement WebSocket handlers for real-time chat functionality.
Chat Handler
// src/websocket/ChatHandler.ts
import { WebSocket } from 'ws';
import { Query-2jz } from 'query-2jz';
import { ChatService } from '../services/ChatService';
import { PresenceService } from '../services/PresenceService';
export class ChatHandler {
private connections = new Map<string, WebSocket>();
private userRooms = new Map<string, Set<string>>();
constructor(
private query-2jz: Query-2jz,
private chatService: ChatService,
private presenceService: PresenceService
) {}
handleConnection(ws: WebSocket, userId: string) {
this.connections.set(userId, ws);
// Update user presence
this.presenceService.setUserOnline(userId);
// Send user's active rooms
this.sendUserRooms(userId);
ws.on('message', (data) => {
try {
const message = JSON.parse(data.toString());
this.handleMessage(userId, message);
} catch (error) {
ws.send(JSON.stringify({ type: 'error', message: 'Invalid message format' }));
}
});
ws.on('close', () => {
this.connections.delete(userId);
this.presenceService.setUserOffline(userId);
this.leaveAllRooms(userId);
});
ws.on('error', (error) => {
console.error('WebSocket error:', error);
this.connections.delete(userId);
this.presenceService.setUserOffline(userId);
});
}
private async handleMessage(userId: string, message: any) {
switch (message.type) {
case 'join_room':
await this.handleJoinRoom(userId, message.roomId);
break;
case 'leave_room':
await this.handleLeaveRoom(userId, message.roomId);
break;
case 'send_message':
await this.handleSendMessage(userId, message);
break;
case 'typing_start':
await this.handleTypingStart(userId, message.roomId);
break;
case 'typing_stop':
await this.handleTypingStop(userId, message.roomId);
break;
case 'add_reaction':
await this.handleAddReaction(userId, message);
break;
case 'remove_reaction':
await this.handleRemoveReaction(userId, message);
break;
default:
this.sendToUser(userId, { type: 'error', message: 'Unknown message type' });
}
}
private async handleJoinRoom(userId: string, roomId: string) {
try {
// Check if user has access to room
const hasAccess = await this.chatService.userHasAccessToRoom(userId, roomId);
if (!hasAccess) {
this.sendToUser(userId, { type: 'error', message: 'Access denied' });
return;
}
// Add user to room
if (!this.userRooms.has(userId)) {
this.userRooms.set(userId, new Set());
}
this.userRooms.get(userId)!.add(roomId);
// Send room messages
const messages = await this.chatService.getRoomMessages(roomId, 50);
this.sendToUser(userId, {
type: 'room_messages',
roomId,
messages: messages.data
});
// Notify other users in room
this.broadcastToRoom(roomId, {
type: 'user_joined',
userId,
roomId
}, userId);
// Update presence
this.presenceService.setUserInRoom(userId, roomId);
} catch (error) {
this.sendToUser(userId, { type: 'error', message: error.message });
}
}
private async handleLeaveRoom(userId: string, roomId: string) {
if (this.userRooms.has(userId)) {
this.userRooms.get(userId)!.delete(roomId);
}
// Notify other users in room
this.broadcastToRoom(roomId, {
type: 'user_left',
userId,
roomId
}, userId);
// Update presence
this.presenceService.setUserInRoom(userId, null);
}
private async handleSendMessage(userId: string, message: any) {
try {
const { roomId, content, type = 'text', replyToId, metadata } = message;
// Check if user has access to room
const hasAccess = await this.chatService.userHasAccessToRoom(userId, roomId);
if (!hasAccess) {
this.sendToUser(userId, { type: 'error', message: 'Access denied' });
return;
}
// Create message
const newMessage = await this.chatService.createMessage({
roomId,
userId,
content,
type,
replyToId,
metadata
});
// Broadcast to all users in room
this.broadcastToRoom(roomId, {
type: 'new_message',
message: newMessage
});
// Update last read time for sender
await this.chatService.updateLastReadTime(userId, roomId);
} catch (error) {
this.sendToUser(userId, { type: 'error', message: error.message });
}
}
private async handleTypingStart(userId: string, roomId: string) {
this.broadcastToRoom(roomId, {
type: 'user_typing',
userId,
roomId,
isTyping: true
}, userId);
}
private async handleTypingStop(userId: string, roomId: string) {
this.broadcastToRoom(roomId, {
type: 'user_typing',
userId,
roomId,
isTyping: false
}, userId);
}
private async handleAddReaction(userId: string, message: any) {
try {
const { messageId, emoji } = message;
const reaction = await this.chatService.addReaction(messageId, userId, emoji);
// Broadcast reaction to room
const messageData = await this.query-2jz.query({
model: 'Message',
where: { id: messageId },
include: ['room']
});
if (messageData.data.length > 0) {
const roomId = messageData.data[0].roomId;
this.broadcastToRoom(roomId, {
type: 'reaction_added',
messageId,
reaction
});
}
} catch (error) {
this.sendToUser(userId, { type: 'error', message: error.message });
}
}
private async handleRemoveReaction(userId: string, message: any) {
try {
const { messageId, emoji } = message;
await this.chatService.removeReaction(messageId, userId, emoji);
// Broadcast reaction removal to room
const messageData = await this.query-2jz.query({
model: 'Message',
where: { id: messageId },
include: ['room']
});
if (messageData.data.length > 0) {
const roomId = messageData.data[0].roomId;
this.broadcastToRoom(roomId, {
type: 'reaction_removed',
messageId,
userId,
emoji
});
}
} catch (error) {
this.sendToUser(userId, { type: 'error', message: error.message });
}
}
private sendToUser(userId: string, data: any) {
const ws = this.connections.get(userId);
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(data));
}
}
private broadcastToRoom(roomId: string, data: any, excludeUserId?: string) {
for (const [userId, userRooms] of this.userRooms.entries()) {
if (userRooms.has(roomId) && userId !== excludeUserId) {
this.sendToUser(userId, data);
}
}
}
private async sendUserRooms(userId: string) {
const rooms = await this.chatService.getUserRooms(userId);
this.sendToUser(userId, {
type: 'user_rooms',
rooms: rooms.data
});
}
private leaveAllRooms(userId: string) {
if (this.userRooms.has(userId)) {
const rooms = this.userRooms.get(userId)!;
for (const roomId of rooms) {
this.broadcastToRoom(roomId, {
type: 'user_left',
userId,
roomId
}, userId);
}
this.userRooms.delete(userId);
}
}
}Chat Service
Implement business logic for chat operations.
Chat Service Implementation
// src/services/ChatService.ts
import { Query-2jz } from 'query-2jz';
export class ChatService {
constructor(private query-2jz: Query-2jz) {}
async createMessage(data: {
roomId: string;
userId: string;
content: string;
type?: string;
replyToId?: string;
metadata?: any;
}) {
const message = await this.query-2jz.create({
model: 'Message',
data: {
roomId: data.roomId,
userId: data.userId,
content: data.content,
type: data.type || 'text',
replyToId: data.replyToId,
metadata: data.metadata || {}
}
});
// Get message with relations
const messageWithRelations = await this.query-2jz.query({
model: 'Message',
where: { id: message.id },
include: ['user', 'room', 'replyTo', 'reactions']
});
return messageWithRelations.data[0];
}
async getRoomMessages(roomId: string, limit = 50, offset = 0) {
return this.query-2jz.query({
model: 'Message',
where: {
roomId,
deletedAt: null
},
include: ['user', 'replyTo', 'reactions', 'reactions.user'],
orderBy: 'createdAt:desc',
limit,
offset
});
}
async getUserRooms(userId: string) {
return this.query-2jz.query({
model: 'Participant',
where: {
userId,
isActive: true
},
include: ['room', 'room.participants', 'room.participants.user']
});
}
async createRoom(data: {
name: string;
description?: string;
type: 'public' | 'private' | 'direct';
createdBy: string;
settings?: any;
}) {
const room = await this.query-2jz.create({
model: 'Room',
data: {
name: data.name,
description: data.description,
type: data.type,
createdBy: data.createdBy,
settings: data.settings || {}
}
});
// Add creator as admin
await this.query-2jz.create({
model: 'Participant',
data: {
roomId: room.id,
userId: data.createdBy,
role: 'admin'
}
});
return room;
}
async addUserToRoom(roomId: string, userId: string, role = 'member') {
// Check if user is already in room
const existing = await this.query-2jz.query({
model: 'Participant',
where: { roomId, userId }
});
if (existing.data.length > 0) {
// Update existing participant
return this.query-2jz.update({
model: 'Participant',
id: existing.data[0].id,
data: { isActive: true, role }
});
}
// Create new participant
return this.query-2jz.create({
model: 'Participant',
data: {
roomId,
userId,
role
}
});
}
async removeUserFromRoom(roomId: string, userId: string) {
const participant = await this.query-2jz.query({
model: 'Participant',
where: { roomId, userId }
});
if (participant.data.length > 0) {
return this.query-2jz.update({
model: 'Participant',
id: participant.data[0].id,
data: { isActive: false }
});
}
}
async userHasAccessToRoom(userId: string, roomId: string): Promise<boolean> {
const participant = await this.query-2jz.query({
model: 'Participant',
where: {
roomId,
userId,
isActive: true
}
});
return participant.data.length > 0;
}
async updateLastReadTime(userId: string, roomId: string) {
const participant = await this.query-2jz.query({
model: 'Participant',
where: { roomId, userId }
});
if (participant.data.length > 0) {
return this.query-2jz.update({
model: 'Participant',
id: participant.data[0].id,
data: { lastReadAt: new Date() }
});
}
}
async addReaction(messageId: string, userId: string, emoji: string) {
// Check if reaction already exists
const existing = await this.query-2jz.query({
model: 'Reaction',
where: { messageId, userId, emoji }
});
if (existing.data.length > 0) {
return existing.data[0];
}
return this.query-2jz.create({
model: 'Reaction',
data: {
messageId,
userId,
emoji
}
});
}
async removeReaction(messageId: string, userId: string, emoji: string) {
const reaction = await this.query-2jz.query({
model: 'Reaction',
where: { messageId, userId, emoji }
});
if (reaction.data.length > 0) {
return this.query-2jz.delete({
model: 'Reaction',
id: reaction.data[0].id
});
}
}
async searchMessages(query: string, roomId?: string, userId?: string) {
const where: any = {
content: { $like: `%${query}%` },
deletedAt: null
};
if (roomId) {
where.roomId = roomId;
}
if (userId) {
where.userId = userId;
}
return this.query-2jz.query({
model: 'Message',
where,
include: ['user', 'room'],
orderBy: 'createdAt:desc',
limit: 100
});
}
}Client Implementation
Create a client-side implementation for the chat application.
WebSocket Client
// client/chat-client.ts
export class ChatClient {
private ws: WebSocket | null = null;
private userId: string;
private currentRoom: string | null = null;
private typingTimeout: NodeJS.Timeout | null = null;
constructor(userId: string) {
this.userId = userId;
}
connect() {
this.ws = new WebSocket(`ws://localhost:3001?userId=${this.userId}`);
this.ws.onopen = () => {
console.log('Connected to chat server');
};
this.ws.onmessage = (event) => {
const message = JSON.parse(event.data);
this.handleMessage(message);
};
this.ws.onclose = () => {
console.log('Disconnected from chat server');
// Attempt to reconnect
setTimeout(() => this.connect(), 5000);
};
this.ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
}
private handleMessage(message: any) {
switch (message.type) {
case 'new_message':
this.onNewMessage(message.message);
break;
case 'user_joined':
this.onUserJoined(message.userId, message.roomId);
break;
case 'user_left':
this.onUserLeft(message.userId, message.roomId);
break;
case 'user_typing':
this.onUserTyping(message.userId, message.roomId, message.isTyping);
break;
case 'reaction_added':
this.onReactionAdded(message.messageId, message.reaction);
break;
case 'reaction_removed':
this.onReactionRemoved(message.messageId, message.userId, message.emoji);
break;
case 'room_messages':
this.onRoomMessages(message.roomId, message.messages);
break;
case 'user_rooms':
this.onUserRooms(message.rooms);
break;
case 'error':
this.onError(message.message);
break;
}
}
joinRoom(roomId: string) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.currentRoom = roomId;
this.ws.send(JSON.stringify({
type: 'join_room',
roomId
}));
}
}
leaveRoom(roomId: string) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({
type: 'leave_room',
roomId
}));
if (this.currentRoom === roomId) {
this.currentRoom = null;
}
}
}
sendMessage(content: string, type = 'text', replyToId?: string, metadata?: any) {
if (this.ws && this.ws.readyState === WebSocket.OPEN && this.currentRoom) {
this.ws.send(JSON.stringify({
type: 'send_message',
roomId: this.currentRoom,
content,
type,
replyToId,
metadata
}));
}
}
startTyping() {
if (this.ws && this.ws.readyState === WebSocket.OPEN && this.currentRoom) {
this.ws.send(JSON.stringify({
type: 'typing_start',
roomId: this.currentRoom
}));
// Auto-stop typing after 3 seconds
if (this.typingTimeout) {
clearTimeout(this.typingTimeout);
}
this.typingTimeout = setTimeout(() => {
this.stopTyping();
}, 3000);
}
}
stopTyping() {
if (this.ws && this.ws.readyState === WebSocket.OPEN && this.currentRoom) {
this.ws.send(JSON.stringify({
type: 'typing_stop',
roomId: this.currentRoom
}));
}
if (this.typingTimeout) {
clearTimeout(this.typingTimeout);
this.typingTimeout = null;
}
}
addReaction(messageId: string, emoji: string) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({
type: 'add_reaction',
messageId,
emoji
}));
}
}
removeReaction(messageId: string, emoji: string) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({
type: 'remove_reaction',
messageId,
emoji
}));
}
}
// Event handlers (to be implemented by the application)
onNewMessage(message: any) {
console.log('New message:', message);
}
onUserJoined(userId: string, roomId: string) {
console.log('User joined:', userId, roomId);
}
onUserLeft(userId: string, roomId: string) {
console.log('User left:', userId, roomId);
}
onUserTyping(userId: string, roomId: string, isTyping: boolean) {
console.log('User typing:', userId, roomId, isTyping);
}
onReactionAdded(messageId: string, reaction: any) {
console.log('Reaction added:', messageId, reaction);
}
onReactionRemoved(messageId: string, userId: string, emoji: string) {
console.log('Reaction removed:', messageId, userId, emoji);
}
onRoomMessages(roomId: string, messages: any[]) {
console.log('Room messages:', roomId, messages);
}
onUserRooms(rooms: any[]) {
console.log('User rooms:', rooms);
}
onError(message: string) {
console.error('Chat error:', message);
}
disconnect() {
if (this.ws) {
this.ws.close();
this.ws = null;
}
}
}Next Steps
Enhance your chat application with additional features.
Advanced Features
- • Message encryption and security
- • File and image sharing
- • Voice and video calls
- • Message search and filtering
- • Custom emojis and reactions
- • Message threading and replies
- • Bot integration and commands