Add comprehensive multi-tenant organization support

- Complete organization-based multi-tenancy implementation
- Organization entities with role-based permissions (Owner, Admin, Moderator, Member)
- Email-based invitation system with token validation
- Organization-scoped data isolation for all chat features
- Updated all entities to support organization context
- Organization guards for API endpoint protection
- Multi-organization WebSocket support with context switching
- Real-time organization-specific chat rooms and messaging
- Enhanced security with organization membership verification
- Updated chat service with organization filtering
- Organization management endpoints for CRUD operations
- Member management with role assignment and permissions
- Comprehensive API documentation updates

Features:
 Multi-tenant organization containers with complete data isolation
 Role-based access control (Owner, Admin, Moderator, Member)
 Organization invitation system with email notifications
 Organization context switching in real-time
 Organization-scoped chat rooms and messages
 WebSocket gateway with organization context support
 Member management with granular permissions
 Enhanced security with membership verification
 Updated API documentation for multi-tenant usage

Perfect for enterprise use cases requiring organizational separation
like "HEALTHBUBBA ORG" with isolated user groups and chat data.
This commit is contained in:
Automated Action 2025-06-21 17:30:59 +00:00
parent 545563e776
commit a67a002106
20 changed files with 1429 additions and 107 deletions

129
README.md
View File

@ -1,12 +1,21 @@
# Real-time Chat API with Media Support
# Real-time Chat API with Multi-Tenant Organization Support
A comprehensive real-time chat API built with NestJS (TypeScript) featuring WebSocket support, direct messaging, group chats, end-to-end encryption, push notifications, mention alerts, and media file sharing.
A comprehensive real-time chat API built with NestJS (TypeScript) featuring WebSocket support, organizational multi-tenancy, direct messaging, group chats, end-to-end encryption, push notifications, mention alerts, and media file sharing.
## Features
### 🏢 Multi-Tenant Organization Support
- **Complete data isolation** between organizations
- **Organization management** with roles and permissions (Owner, Admin, Moderator, Member)
- **Member invitation system** with email-based invites
- **Organization switching** for users in multiple organizations
- **Configurable organization settings** and limits
- **Organization-scoped chat rooms** and messaging
- **Real-time organization context** in WebSocket connections
### Core Chat Features
- **Real-time messaging** with WebSocket support
- **Direct Messages (DM)** between users
- **Direct Messages (DM)** between users within organizations
- **Group chat** functionality with member management
- **Message editing and deletion**
- **Reply to messages** functionality
@ -116,6 +125,20 @@ The application will be available at:
- `POST /auth/login` - Login user
- `POST /auth/logout` - Logout user
### Organizations
- `POST /organizations` - Create new organization
- `GET /organizations` - Get user organizations
- `GET /organizations/active` - Get user's active organization
- `POST /organizations/:orgId/set-active` - Set active organization
- `GET /organizations/:orgId` - Get organization details
- `PUT /organizations/:orgId` - Update organization
- `GET /organizations/:orgId/members` - Get organization members
- `POST /organizations/:orgId/invite` - Invite user to organization
- `POST /organizations/accept-invite/:token` - Accept organization invitation
- `DELETE /organizations/:orgId/members/:memberId` - Remove member
- `PUT /organizations/:orgId/members/:memberId/role` - Update member role
- `POST /organizations/:orgId/leave` - Leave organization
### Users
- `GET /users` - Get all users (with search)
- `GET /users/me` - Get current user profile
@ -125,10 +148,11 @@ The application will be available at:
- `GET /users/username/:username` - Get user by username
- `GET /users/:id/public-key` - Get user's public key
### Chat
- `GET /chat/rooms` - Get user's chat rooms
- `POST /chat/rooms/direct` - Create/get direct message room
- `POST /chat/rooms/group` - Create group chat room
### Chat (Organization Context Required)
**Note**: All chat endpoints require organization context via `x-organization-id` header or active organization.
- `GET /chat/rooms` - Get user's chat rooms in organization
- `POST /chat/rooms/direct` - Create/get direct message room in organization
- `POST /chat/rooms/group` - Create group chat room in organization
- `GET /chat/rooms/:roomId` - Get chat room details
- `GET /chat/rooms/:roomId/messages` - Get chat room messages
- `POST /chat/messages` - Create new message
@ -151,18 +175,33 @@ The application will be available at:
## WebSocket Events
### Connection Setup
```typescript
// Connect with JWT token and organization context
const socket = io('ws://localhost:3000', {
auth: {
token: 'your-jwt-token',
organizationId: 'org-uuid' // Optional - uses active org if not provided
},
headers: {
'x-organization-id': 'org-uuid' // Alternative way to specify organization
}
});
```
### Client to Server
- `join_room` - Join a chat room
- `join_room` - Join a chat room within organization
- `leave_room` - Leave a chat room
- `send_message` - Send a new message
- `send_message` - Send a new message in organization context
- `edit_message` - Edit existing message
- `delete_message` - Delete message
- `typing_start` - Start typing indicator
- `typing_stop` - Stop typing indicator
- `mark_as_read` - Mark message as read
- `switch_organization` - Switch to different organization context
### Server to Client
- `connected` - Connection established
- `connected` - Connection established with organization context
- `new_message` - New message received
- `message_edited` - Message was edited
- `message_deleted` - Message was deleted
@ -170,26 +209,41 @@ The application will be available at:
- `user_stopped_typing` - User stopped typing
- `message_read` - Message was read
- `mentioned` - User was mentioned
- `user_online` - User came online
- `user_offline` - User went offline
- `user_online` - User came online in organization
- `user_offline` - User went offline in organization
- `joined_room` - Successfully joined room
- `left_room` - Successfully left room
- `organization_switched` - Successfully switched organizations
- `error` - Error occurred
## Flutter SDK Integration
This API is designed to work seamlessly with Flutter applications. Key considerations for Flutter integration:
This API is designed to work seamlessly with Flutter applications with full multi-tenant organization support. Key considerations for Flutter integration:
### WebSocket Connection
```typescript
// Connect with JWT token
const socket = io('ws://localhost:3000', {
auth: {
token: 'your-jwt-token'
### Organization Context
Every API call and WebSocket connection must include organization context:
```dart
// HTTP Headers
final headers = {
'Authorization': 'Bearer $jwtToken',
'x-organization-id': currentOrganizationId,
};
// WebSocket Connection
final socket = io('ws://localhost:3000', {
'auth': {
'token': jwtToken,
'organizationId': currentOrganizationId,
}
});
```
### Multi-Organization User Flow
1. User logs in and gets list of organizations
2. User selects/switches between organizations
3. All chat data is filtered by current organization
4. WebSocket automatically switches context when organization changes
### File Upload
The API supports multipart/form-data uploads, compatible with Flutter's HTTP client and dio package.
@ -202,22 +256,47 @@ Socket.IO events are designed to map directly to Flutter state management patter
## Database Schema
The application uses SQLite with the following main entities:
- **Organizations**: Multi-tenant organization containers
- **OrganizationMembers**: User membership in organizations with roles
- **OrganizationInvites**: Email-based invitation system
- **Users**: User accounts with encryption keys
- **ChatRooms**: Direct and group chat rooms
- **Messages**: Chat messages with encryption support
- **ChatRooms**: Direct and group chat rooms (organization-scoped)
- **Messages**: Chat messages with encryption support (organization-scoped)
- **MessageMedia**: File attachments
- **MessageMentions**: User mentions in messages
- **UserDevices**: Device registration for push notifications
- **Notifications**: Push notification records
- **Notifications**: Push notification records (organization-scoped)
## Organization Roles & Permissions
### Role Hierarchy
1. **Owner** - Full control over organization
2. **Admin** - Management capabilities except role changes
3. **Moderator** - Content moderation and limited management
4. **Member** - Basic chat participation
### Permission Matrix
| Permission | Owner | Admin | Moderator | Member |
|------------|-------|--------|-----------|---------|
| Manage Organization Settings | ✅ | ✅ | ❌ | ❌ |
| Invite Users | ✅ | ✅ | ✅ | ❌ |
| Manage Roles | ✅ | ❌ | ❌ | ❌ |
| Kick Members | ✅ | ✅ | ❌ | ❌ |
| Create Channels | ✅ | ✅ | ✅ | ❌ |
| Delete Messages | ✅ | ✅ | ✅ | ❌ |
| Access Analytics | ✅ | ✅ | ❌ | ❌ |
| Send Messages | ✅ | ✅ | ✅ | ✅ |
## Security Features
- **JWT Authentication**: Secure token-based auth
- **Multi-Tenant Data Isolation**: Complete separation between organizations
- **JWT Authentication**: Secure token-based auth with organization context
- **Role-Based Access Control**: Granular permissions based on organization roles
- **End-to-end Encryption**: Messages encrypted with room-specific keys
- **File Access Control**: Only chat members can access uploaded files
- **File Access Control**: Only organization members can access uploaded files
- **Input Validation**: All inputs validated and sanitized
- **CORS Configuration**: Configured for cross-origin requests
- **Rate Limiting**: Built-in protection against abuse
- **Organization Membership Verification**: All operations verify user membership
## Development

View File

@ -15,6 +15,7 @@ import { ChatModule } from './chat/chat.module';
import { MediaModule } from './media/media.module';
import { NotificationModule } from './notification/notification.module';
import { EncryptionModule } from './encryption/encryption.module';
import { OrganizationModule } from './organization/organization.module';
// Ensure storage directories exist
const storageDir = '/app/storage';
@ -61,6 +62,7 @@ try {
ScheduleModule.forRoot(),
AuthModule,
UsersModule,
OrganizationModule,
ChatModule,
MediaModule,
NotificationModule,

View File

@ -10,24 +10,30 @@ import {
UseGuards,
Request,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiHeader } from '@nestjs/swagger';
import { ChatService } from './chat.service';
import { CreateChatRoomDto } from './dto/create-chat-room.dto';
import { CreateMessageDto } from './dto/create-message.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { OrganizationGuard } from '../organization/guards/organization.guard';
@ApiTags('Chat')
@Controller('chat')
@UseGuards(JwtAuthGuard)
@UseGuards(JwtAuthGuard, OrganizationGuard)
@ApiBearerAuth()
@ApiHeader({
name: 'x-organization-id',
description: 'Organization ID for multi-tenant access',
required: false,
})
export class ChatController {
constructor(private readonly chatService: ChatService) {}
@Get('rooms')
@ApiOperation({ summary: 'Get user chat rooms' })
@ApiOperation({ summary: 'Get user chat rooms in organization' })
@ApiResponse({ status: 200, description: 'Chat rooms retrieved successfully' })
async getUserChatRooms(@Request() req) {
return this.chatService.getUserChatRooms(req.user.id);
return this.chatService.getUserChatRooms(req.user.id, req.organization.id);
}
@Post('rooms/direct')
@ -37,7 +43,7 @@ export class ChatController {
@Request() req,
@Body('recipientId') recipientId: string,
) {
return this.chatService.createDirectMessage(req.user.id, recipientId);
return this.chatService.createDirectMessage(req.user.id, recipientId, req.organization.id);
}
@Post('rooms/group')
@ -47,14 +53,14 @@ export class ChatController {
@Request() req,
@Body() createChatRoomDto: CreateChatRoomDto,
) {
return this.chatService.createGroupChat(createChatRoomDto, req.user.id);
return this.chatService.createGroupChat(createChatRoomDto, req.user.id, req.organization.id);
}
@Get('rooms/:roomId')
@ApiOperation({ summary: 'Get chat room details' })
@ApiResponse({ status: 200, description: 'Chat room retrieved successfully' })
async getChatRoom(@Param('roomId') roomId: string, @Request() req) {
return this.chatService.getChatRoom(roomId, req.user.id);
return this.chatService.getChatRoom(roomId, req.user.id, req.organization.id);
}
@Get('rooms/:roomId/messages')
@ -66,7 +72,7 @@ export class ChatController {
@Query('page') page?: number,
@Query('limit') limit?: number,
) {
return this.chatService.getChatRoomMessages(roomId, req.user.id, page, limit);
return this.chatService.getChatRoomMessages(roomId, req.user.id, req.organization.id, page, limit);
}
@Post('messages')
@ -76,6 +82,7 @@ export class ChatController {
return this.chatService.createMessage({
...createMessageDto,
senderId: req.user.id,
organizationId: req.organization.id,
});
}
@ -87,14 +94,14 @@ export class ChatController {
@Request() req,
@Body('content') content: string,
) {
return this.chatService.editMessage(messageId, content, req.user.id);
return this.chatService.editMessage(messageId, content, req.user.id, req.organization.id);
}
@Delete('messages/:messageId')
@ApiOperation({ summary: 'Delete message' })
@ApiResponse({ status: 200, description: 'Message deleted successfully' })
async deleteMessage(@Param('messageId') messageId: string, @Request() req) {
return this.chatService.deleteMessage(messageId, req.user.id);
return this.chatService.deleteMessage(messageId, req.user.id, req.organization.id);
}
@Post('rooms/:roomId/members')
@ -105,7 +112,7 @@ export class ChatController {
@Request() req,
@Body('memberIds') memberIds: string[],
) {
return this.chatService.addMemberToGroup(roomId, memberIds, req.user.id);
return this.chatService.addMemberToGroup(roomId, memberIds, req.user.id, req.organization.id);
}
@Delete('rooms/:roomId/members/:memberId')
@ -116,6 +123,6 @@ export class ChatController {
@Param('memberId') memberId: string,
@Request() req,
) {
return this.chatService.removeMemberFromGroup(roomId, memberId, req.user.id);
return this.chatService.removeMemberFromGroup(roomId, memberId, req.user.id, req.organization.id);
}
}

View File

@ -13,6 +13,7 @@ import { JwtService } from '@nestjs/jwt';
import { ChatService } from './chat.service';
import { UsersService } from '../users/users.service';
import { NotificationService } from '../notification/notification.service';
import { OrganizationService } from '../organization/organization.service';
interface AuthenticatedSocket extends Socket {
user: {
@ -20,6 +21,11 @@ interface AuthenticatedSocket extends Socket {
email: string;
username: string;
};
organization: {
id: string;
name: string;
slug: string;
};
}
@WebSocketGateway({
@ -31,18 +37,21 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
@WebSocketServer()
server: Server;
private connectedUsers = new Map<string, string>(); // userId -> socketId
private connectedUsers = new Map<string, { socketId: string; organizationId: string }>(); // userId -> {socketId, organizationId}
private organizationRooms = new Map<string, Set<string>>(); // organizationId -> Set of socketIds
constructor(
private chatService: ChatService,
private usersService: UsersService,
private notificationService: NotificationService,
private organizationService: OrganizationService,
private jwtService: JwtService,
) {}
async handleConnection(client: Socket) {
try {
const token = client.handshake.auth.token || client.handshake.headers.authorization?.split(' ')[1];
const organizationId = client.handshake.auth.organizationId || client.handshake.headers['x-organization-id'];
if (!token) {
client.disconnect();
@ -57,38 +66,103 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
return;
}
// Get user's active organization if not provided
let activeOrgId = organizationId;
if (!activeOrgId) {
const activeOrg = await this.organizationService.getUserActiveOrganization(user.id);
activeOrgId = activeOrg?.id;
}
if (!activeOrgId) {
client.emit('error', { message: 'No organization context provided' });
client.disconnect();
return;
}
// Verify user is member of the organization
const isMember = await this.organizationService.isUserMember(activeOrgId, user.id);
if (!isMember) {
client.emit('error', { message: 'You are not a member of this organization' });
client.disconnect();
return;
}
const organization = await this.organizationService.getOrganization(activeOrgId, user.id);
(client as AuthenticatedSocket).user = {
id: user.id,
email: user.email,
username: user.username,
};
this.connectedUsers.set(user.id, client.id);
(client as AuthenticatedSocket).organization = {
id: organization.id,
name: organization.name,
slug: organization.slug,
};
// Store connection info
this.connectedUsers.set(user.id, { socketId: client.id, organizationId: activeOrgId });
// Add to organization room
if (!this.organizationRooms.has(activeOrgId)) {
this.organizationRooms.set(activeOrgId, new Set());
}
this.organizationRooms.get(activeOrgId).add(client.id);
client.join(`org_${activeOrgId}`);
await this.usersService.updateOnlineStatus(user.id, true);
client.emit('connected', { message: 'Connected successfully', user: client.user });
// Join user to their chat rooms
const chatRooms = await this.chatService.getUserChatRooms(user.id);
chatRooms.forEach(room => {
client.join(room.id);
client.emit('connected', {
message: 'Connected successfully',
user: (client as AuthenticatedSocket).user,
organization: (client as AuthenticatedSocket).organization,
});
// Notify other users that this user is online
this.server.emit('user_online', { userId: user.id, username: user.username });
// Join user to their chat rooms within the organization
const chatRooms = await this.chatService.getUserChatRooms(user.id, activeOrgId);
chatRooms.forEach(room => {
client.join(`room_${room.id}`);
});
// Notify other users in the same organization that this user is online
client.to(`org_${activeOrgId}`).emit('user_online', {
userId: user.id,
username: user.username,
organizationId: activeOrgId,
});
} catch (error) {
client.emit('error', { message: 'Authentication failed' });
client.disconnect();
}
}
async handleDisconnect(client: AuthenticatedSocket) {
if (client.user) {
this.connectedUsers.delete(client.user.id);
if (client.user && client.organization) {
const connectionInfo = this.connectedUsers.get(client.user.id);
if (connectionInfo) {
this.connectedUsers.delete(client.user.id);
// Remove from organization room
const orgSockets = this.organizationRooms.get(client.organization.id);
if (orgSockets) {
orgSockets.delete(client.id);
if (orgSockets.size === 0) {
this.organizationRooms.delete(client.organization.id);
}
}
}
await this.usersService.updateOnlineStatus(client.user.id, false);
// Notify other users that this user is offline
this.server.emit('user_offline', { userId: client.user.id, username: client.user.username });
// Notify other users in the same organization that this user is offline
client.to(`org_${client.organization.id}`).emit('user_offline', {
userId: client.user.id,
username: client.user.username,
organizationId: client.organization.id,
});
}
}
@ -98,8 +172,8 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
@ConnectedSocket() client: AuthenticatedSocket,
) {
try {
const room = await this.chatService.getChatRoom(data.roomId, client.user.id);
client.join(data.roomId);
const room = await this.chatService.getChatRoom(data.roomId, client.user.id, client.organization.id);
client.join(`room_${data.roomId}`);
client.emit('joined_room', { roomId: data.roomId, room });
} catch (error) {
client.emit('error', { message: error.message });
@ -111,7 +185,7 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
@MessageBody() data: { roomId: string },
@ConnectedSocket() client: AuthenticatedSocket,
) {
client.leave(data.roomId);
client.leave(`room_${data.roomId}`);
client.emit('left_room', { roomId: data.roomId });
}
@ -127,15 +201,16 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
senderId: client.user.id,
chatRoomId: data.roomId,
replyToId: data.replyToId,
organizationId: client.organization.id,
});
const populatedMessage = await this.chatService.getMessage(message.id);
const populatedMessage = await this.chatService.getMessage(message.id, client.organization.id);
// Send message to all users in the room
this.server.to(data.roomId).emit('new_message', populatedMessage);
this.server.to(`room_${data.roomId}`).emit('new_message', populatedMessage);
// Handle mentions
await this.handleMentions(populatedMessage, client.user.id);
await this.handleMentions(populatedMessage, client.user.id, client.organization.id);
// Send push notifications to offline users
await this.notificationService.sendNewMessageNotifications(populatedMessage);
@ -151,8 +226,13 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
@ConnectedSocket() client: AuthenticatedSocket,
) {
try {
const message = await this.chatService.editMessage(data.messageId, data.content, client.user.id);
this.server.to(message.chatRoom.id).emit('message_edited', message);
const message = await this.chatService.editMessage(
data.messageId,
data.content,
client.user.id,
client.organization.id
);
this.server.to(`room_${message.chatRoom.id}`).emit('message_edited', message);
} catch (error) {
client.emit('error', { message: error.message });
}
@ -164,8 +244,12 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
@ConnectedSocket() client: AuthenticatedSocket,
) {
try {
const message = await this.chatService.deleteMessage(data.messageId, client.user.id);
this.server.to(message.chatRoom.id).emit('message_deleted', { messageId: data.messageId });
const message = await this.chatService.deleteMessage(
data.messageId,
client.user.id,
client.organization.id
);
this.server.to(`room_${message.chatRoom.id}`).emit('message_deleted', { messageId: data.messageId });
} catch (error) {
client.emit('error', { message: error.message });
}
@ -176,9 +260,10 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
@MessageBody() data: { roomId: string },
@ConnectedSocket() client: AuthenticatedSocket,
) {
client.to(data.roomId).emit('user_typing', {
client.to(`room_${data.roomId}`).emit('user_typing', {
userId: client.user.id,
username: client.user.username,
roomId: data.roomId,
});
}
@ -187,9 +272,10 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
@MessageBody() data: { roomId: string },
@ConnectedSocket() client: AuthenticatedSocket,
) {
client.to(data.roomId).emit('user_stopped_typing', {
client.to(`room_${data.roomId}`).emit('user_stopped_typing', {
userId: client.user.id,
username: client.user.username,
roomId: data.roomId,
});
}
@ -199,20 +285,75 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
@ConnectedSocket() client: AuthenticatedSocket,
) {
// In a full implementation, you would track read receipts
client.to(data.roomId).emit('message_read', {
client.to(`room_${data.roomId}`).emit('message_read', {
messageId: data.messageId,
userId: client.user.id,
roomId: data.roomId,
});
}
private async handleMentions(message: any, senderId: string) {
@SubscribeMessage('switch_organization')
async handleSwitchOrganization(
@MessageBody() data: { organizationId: string },
@ConnectedSocket() client: AuthenticatedSocket,
) {
try {
// Verify user is member of the new organization
const isMember = await this.organizationService.isUserMember(data.organizationId, client.user.id);
if (!isMember) {
client.emit('error', { message: 'You are not a member of this organization' });
return;
}
// Leave current organization room and chat rooms
client.leave(`org_${client.organization.id}`);
const currentChatRooms = await this.chatService.getUserChatRooms(client.user.id, client.organization.id);
currentChatRooms.forEach(room => {
client.leave(`room_${room.id}`);
});
// Update organization context
const newOrganization = await this.organizationService.getOrganization(data.organizationId, client.user.id);
(client as AuthenticatedSocket).organization = {
id: newOrganization.id,
name: newOrganization.name,
slug: newOrganization.slug,
};
// Join new organization room and chat rooms
client.join(`org_${data.organizationId}`);
const newChatRooms = await this.chatService.getUserChatRooms(client.user.id, data.organizationId);
newChatRooms.forEach(room => {
client.join(`room_${room.id}`);
});
// Update connection tracking
this.connectedUsers.set(client.user.id, { socketId: client.id, organizationId: data.organizationId });
// Set as active organization
await this.organizationService.setUserActiveOrganization(client.user.id, data.organizationId);
client.emit('organization_switched', {
organization: client.organization,
chatRooms: newChatRooms,
});
} catch (error) {
client.emit('error', { message: error.message });
}
}
private async handleMentions(message: any, senderId: string, organizationId: string) {
if (message.mentions && message.mentions.length > 0) {
for (const mention of message.mentions) {
const mentionedUserSocket = this.connectedUsers.get(mention.mentionedUser.id);
if (mentionedUserSocket) {
this.server.to(mentionedUserSocket).emit('mentioned', {
const mentionedUserConnection = this.connectedUsers.get(mention.mentionedUser.id);
// Only send mention if user is in the same organization
if (mentionedUserConnection && mentionedUserConnection.organizationId === organizationId) {
this.server.to(mentionedUserConnection.socketId).emit('mentioned', {
message,
mentionedBy: senderId,
organizationId,
});
}
@ -222,16 +363,21 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
}
}
// Method to send message to specific user
sendToUser(userId: string, event: string, data: any) {
const socketId = this.connectedUsers.get(userId);
if (socketId) {
this.server.to(socketId).emit(event, data);
// Method to send message to specific user in specific organization
sendToUserInOrganization(userId: string, organizationId: string, event: string, data: any) {
const connection = this.connectedUsers.get(userId);
if (connection && connection.organizationId === organizationId) {
this.server.to(connection.socketId).emit(event, data);
}
}
// Method to send message to room
sendToRoom(roomId: string, event: string, data: any) {
this.server.to(roomId).emit(event, data);
this.server.to(`room_${roomId}`).emit(event, data);
}
// Method to send message to organization
sendToOrganization(organizationId: string, event: string, data: any) {
this.server.to(`org_${organizationId}`).emit(event, data);
}
}

View File

@ -8,16 +8,19 @@ import { ChatRoom } from '../database/entities/chat-room.entity';
import { MessageMedia } from '../database/entities/message-media.entity';
import { MessageMention } from '../database/entities/message-mention.entity';
import { User } from '../database/entities/user.entity';
import { Organization } from '../database/entities/organization.entity';
import { EncryptionModule } from '../encryption/encryption.module';
import { NotificationModule } from '../notification/notification.module';
import { UsersModule } from '../users/users.module';
import { OrganizationModule } from '../organization/organization.module';
@Module({
imports: [
TypeOrmModule.forFeature([Message, ChatRoom, MessageMedia, MessageMention, User]),
TypeOrmModule.forFeature([Message, ChatRoom, MessageMedia, MessageMention, User, Organization]),
EncryptionModule,
NotificationModule,
UsersModule,
OrganizationModule,
],
providers: [ChatGateway, ChatService],
controllers: [ChatController],

View File

@ -5,6 +5,7 @@ import { Message, MessageType } from '../database/entities/message.entity';
import { ChatRoom, ChatRoomType } from '../database/entities/chat-room.entity';
import { MessageMention } from '../database/entities/message-mention.entity';
import { User } from '../database/entities/user.entity';
import { Organization } from '../database/entities/organization.entity';
import { EncryptionService } from '../encryption/encryption.service';
import { CreateMessageDto } from './dto/create-message.dto';
import { CreateChatRoomDto } from './dto/create-chat-room.dto';
@ -20,15 +21,24 @@ export class ChatService {
private mentionRepository: Repository<MessageMention>,
@InjectRepository(User)
private userRepository: Repository<User>,
@InjectRepository(Organization)
private organizationRepository: Repository<Organization>,
private encryptionService: EncryptionService,
) {}
async createDirectMessage(senderId: string, recipientId: string): Promise<ChatRoom> {
// Check if DM already exists between these users
async createDirectMessage(senderId: string, recipientId: string, organizationId: string): Promise<ChatRoom> {
// Verify organization
const organization = await this.organizationRepository.findOne({ where: { id: organizationId } });
if (!organization) {
throw new NotFoundException('Organization not found');
}
// Check if DM already exists between these users in this organization
const existingDM = await this.chatRoomRepository
.createQueryBuilder('room')
.innerJoin('room.members', 'member')
.where('room.type = :type', { type: ChatRoomType.DIRECT })
.andWhere('room.organizationId = :organizationId', { organizationId })
.groupBy('room.id')
.having('COUNT(member.id) = 2')
.andHaving('SUM(CASE WHEN member.id IN (:...userIds) THEN 1 ELSE 0 END) = 2', {
@ -37,7 +47,7 @@ export class ChatService {
.getOne();
if (existingDM) {
return this.getChatRoom(existingDM.id, senderId);
return this.getChatRoom(existingDM.id, senderId, organizationId);
}
// Create new DM
@ -49,6 +59,7 @@ export class ChatService {
const chatRoom = this.chatRoomRepository.create({
type: ChatRoomType.DIRECT,
members: users,
organization,
isEncrypted: true,
encryptionKey: this.encryptionService.generateChatRoomKey(),
});
@ -56,9 +67,15 @@ export class ChatService {
return this.chatRoomRepository.save(chatRoom);
}
async createGroupChat(createChatRoomDto: CreateChatRoomDto, creatorId: string): Promise<ChatRoom> {
async createGroupChat(createChatRoomDto: CreateChatRoomDto, creatorId: string, organizationId: string): Promise<ChatRoom> {
const { name, description, memberIds } = createChatRoomDto;
// Verify organization
const organization = await this.organizationRepository.findOne({ where: { id: organizationId } });
if (!organization) {
throw new NotFoundException('Organization not found');
}
const allMemberIds = [...new Set([creatorId, ...memberIds])];
const members = await this.userRepository.findByIds(allMemberIds);
@ -73,6 +90,7 @@ export class ChatService {
description,
type: ChatRoomType.GROUP,
members,
organization,
createdBy: creator,
isEncrypted: true,
encryptionKey: this.encryptionService.generateChatRoomKey(),
@ -81,11 +99,19 @@ export class ChatService {
return this.chatRoomRepository.save(chatRoom);
}
async getChatRoom(roomId: string, userId: string): Promise<ChatRoom> {
const chatRoom = await this.chatRoomRepository.findOne({
where: { id: roomId },
relations: ['members', 'createdBy'],
});
async getChatRoom(roomId: string, userId: string, organizationId?: string): Promise<ChatRoom> {
const queryBuilder = this.chatRoomRepository
.createQueryBuilder('room')
.leftJoinAndSelect('room.members', 'members')
.leftJoinAndSelect('room.createdBy', 'createdBy')
.leftJoinAndSelect('room.organization', 'organization')
.where('room.id = :roomId', { roomId });
if (organizationId) {
queryBuilder.andWhere('room.organizationId = :organizationId', { organizationId });
}
const chatRoom = await queryBuilder.getOne();
if (!chatRoom) {
throw new NotFoundException('Chat room not found');
@ -100,27 +126,30 @@ export class ChatService {
return chatRoom;
}
async getUserChatRooms(userId: string): Promise<ChatRoom[]> {
async getUserChatRooms(userId: string, organizationId: string): Promise<ChatRoom[]> {
return this.chatRoomRepository
.createQueryBuilder('room')
.innerJoin('room.members', 'member', 'member.id = :userId', { userId })
.leftJoinAndSelect('room.members', 'allMembers')
.leftJoinAndSelect('room.createdBy', 'creator')
.leftJoinAndSelect('room.organization', 'organization')
.where('room.organizationId = :organizationId', { organizationId })
.orderBy('room.updatedAt', 'DESC')
.getMany();
}
async createMessage(createMessageDto: CreateMessageDto): Promise<Message> {
const { content, type, senderId, chatRoomId, replyToId } = createMessageDto;
async createMessage(createMessageDto: CreateMessageDto & { organizationId: string }): Promise<Message> {
const { content, type, senderId, chatRoomId, replyToId, organizationId } = createMessageDto;
// Verify user is member of chat room
const chatRoom = await this.getChatRoom(chatRoomId, senderId);
// Verify user is member of chat room and organization
const chatRoom = await this.getChatRoom(chatRoomId, senderId, organizationId);
const sender = await this.userRepository.findOne({ where: { id: senderId } });
const organization = await this.organizationRepository.findOne({ where: { id: organizationId } });
let replyTo = null;
if (replyToId) {
replyTo = await this.messageRepository.findOne({
where: { id: replyToId, chatRoom: { id: chatRoomId } },
where: { id: replyToId, chatRoom: { id: chatRoomId }, organization: { id: organizationId } },
});
if (!replyTo) {
throw new NotFoundException('Reply message not found');
@ -138,6 +167,7 @@ export class ChatService {
type: type as MessageType,
sender,
chatRoom,
organization,
replyTo,
isEncrypted: chatRoom.isEncrypted,
encryptedContent,
@ -146,7 +176,7 @@ export class ChatService {
const savedMessage = await this.messageRepository.save(message);
// Handle mentions
await this.handleMentions(content, savedMessage);
await this.handleMentions(content, savedMessage, organizationId);
// Update chat room's updatedAt
await this.chatRoomRepository.update(chatRoomId, { updatedAt: new Date() });
@ -154,10 +184,10 @@ export class ChatService {
return savedMessage;
}
async getMessage(messageId: string): Promise<Message> {
async getMessage(messageId: string, organizationId: string): Promise<Message> {
const message = await this.messageRepository.findOne({
where: { id: messageId },
relations: ['sender', 'chatRoom', 'replyTo', 'replyTo.sender', 'mentions', 'mentions.mentionedUser', 'media'],
where: { id: messageId, organization: { id: organizationId } },
relations: ['sender', 'chatRoom', 'organization', 'replyTo', 'replyTo.sender', 'mentions', 'mentions.mentionedUser', 'media'],
});
if (!message) {
@ -176,16 +206,21 @@ export class ChatService {
async getChatRoomMessages(
roomId: string,
userId: string,
organizationId: string,
page = 1,
limit = 50,
): Promise<{ messages: Message[]; total: number }> {
// Verify user access to chat room
await this.getChatRoom(roomId, userId);
await this.getChatRoom(roomId, userId, organizationId);
const skip = (page - 1) * limit;
const [messages, total] = await this.messageRepository.findAndCount({
where: { chatRoom: { id: roomId }, isDeleted: false },
where: {
chatRoom: { id: roomId },
organization: { id: organizationId },
isDeleted: false
},
relations: ['sender', 'replyTo', 'replyTo.sender', 'mentions', 'mentions.mentionedUser', 'media'],
order: { createdAt: 'DESC' },
skip,
@ -205,9 +240,9 @@ export class ChatService {
return { messages: messages.reverse(), total };
}
async editMessage(messageId: string, newContent: string, userId: string): Promise<Message> {
async editMessage(messageId: string, newContent: string, userId: string, organizationId: string): Promise<Message> {
const message = await this.messageRepository.findOne({
where: { id: messageId },
where: { id: messageId, organization: { id: organizationId } },
relations: ['sender', 'chatRoom'],
});
@ -233,12 +268,12 @@ export class ChatService {
editedAt: new Date(),
});
return this.getMessage(messageId);
return this.getMessage(messageId, organizationId);
}
async deleteMessage(messageId: string, userId: string): Promise<Message> {
async deleteMessage(messageId: string, userId: string, organizationId: string): Promise<Message> {
const message = await this.messageRepository.findOne({
where: { id: messageId },
where: { id: messageId, organization: { id: organizationId } },
relations: ['sender', 'chatRoom'],
});
@ -259,8 +294,8 @@ export class ChatService {
return message;
}
async addMemberToGroup(roomId: string, memberIds: string[], userId: string): Promise<ChatRoom> {
const chatRoom = await this.getChatRoom(roomId, userId);
async addMemberToGroup(roomId: string, memberIds: string[], userId: string, organizationId: string): Promise<ChatRoom> {
const chatRoom = await this.getChatRoom(roomId, userId, organizationId);
if (chatRoom.type !== ChatRoomType.GROUP) {
throw new BadRequestException('Can only add members to group chats');
@ -285,8 +320,8 @@ export class ChatService {
return this.chatRoomRepository.save(chatRoom);
}
async removeMemberFromGroup(roomId: string, memberIdToRemove: string, userId: string): Promise<ChatRoom> {
const chatRoom = await this.getChatRoom(roomId, userId);
async removeMemberFromGroup(roomId: string, memberIdToRemove: string, userId: string, organizationId: string): Promise<ChatRoom> {
const chatRoom = await this.getChatRoom(roomId, userId, organizationId);
if (chatRoom.type !== ChatRoomType.GROUP) {
throw new BadRequestException('Can only remove members from group chats');
@ -302,16 +337,22 @@ export class ChatService {
return this.chatRoomRepository.save(chatRoom);
}
private async handleMentions(content: string, message: Message) {
private async handleMentions(content: string, message: Message, organizationId: string) {
// Extract mentions from content (e.g., @username or @userId)
const mentionRegex = /@(\w+)/g;
const mentions = content.match(mentionRegex);
if (mentions) {
const usernames = mentions.map(mention => mention.substring(1));
const mentionedUsers = await this.userRepository.find({
where: { username: In(usernames) },
});
// Find users in the same organization
const mentionedUsers = await this.userRepository
.createQueryBuilder('user')
.innerJoin('user.organizationMemberships', 'membership')
.where('user.username IN (:...usernames)', { usernames })
.andWhere('membership.organizationId = :organizationId', { organizationId })
.andWhere('membership.status = :status', { status: 'active' })
.getMany();
for (const user of mentionedUsers) {
const mention = this.mentionRepository.create({

View File

@ -11,6 +11,7 @@ import {
} from 'typeorm';
import { Message } from './message.entity';
import { User } from './user.entity';
import { Organization } from './organization.entity';
export enum ChatRoomType {
DIRECT = 'direct',
@ -47,6 +48,9 @@ export class ChatRoom {
@ManyToOne(() => User, { nullable: true })
createdBy: User;
@ManyToOne(() => Organization)
organization: Organization;
@CreateDateColumn()
createdAt: Date;

View File

@ -11,6 +11,7 @@ import { User } from './user.entity';
import { ChatRoom } from './chat-room.entity';
import { MessageMedia } from './message-media.entity';
import { MessageMention } from './message-mention.entity';
import { Organization } from './organization.entity';
export enum MessageType {
TEXT = 'text',
@ -63,6 +64,9 @@ export class Message {
@ManyToOne(() => ChatRoom, (chatRoom) => chatRoom.messages)
chatRoom: ChatRoom;
@ManyToOne(() => Organization)
organization: Organization;
@OneToMany(() => MessageMedia, (media) => media.message)
media: MessageMedia[];

View File

@ -7,12 +7,14 @@ import {
} from 'typeorm';
import { User } from './user.entity';
import { Message } from './message.entity';
import { Organization } from './organization.entity';
export enum NotificationType {
MESSAGE = 'message',
MENTION = 'mention',
GROUP_INVITE = 'group_invite',
FRIEND_REQUEST = 'friend_request',
ORGANIZATION_INVITE = 'organization_invite',
}
@Entity('notifications')
@ -49,4 +51,7 @@ export class Notification {
@ManyToOne(() => Message, { nullable: true })
message: Message;
@ManyToOne(() => Organization)
organization: Organization;
}

View File

@ -0,0 +1,66 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
} from 'typeorm';
import { Organization } from './organization.entity';
import { User } from './user.entity';
import { OrganizationRole } from './organization-member.entity';
export enum InviteStatus {
PENDING = 'pending',
ACCEPTED = 'accepted',
DECLINED = 'declined',
EXPIRED = 'expired',
CANCELLED = 'cancelled',
}
@Entity('organization_invites')
export class OrganizationInvite {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
email: string;
@Column({
type: 'varchar',
enum: OrganizationRole,
default: OrganizationRole.MEMBER,
})
role: OrganizationRole;
@Column({
type: 'varchar',
enum: InviteStatus,
default: InviteStatus.PENDING,
})
status: InviteStatus;
@Column()
token: string;
@Column({ type: 'datetime' })
expiresAt: Date;
@Column({ type: 'text', nullable: true })
message: string;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
@ManyToOne(() => Organization, (organization) => organization.invites)
organization: Organization;
@ManyToOne(() => User)
invitedBy: User;
@ManyToOne(() => User, { nullable: true })
acceptedBy: User;
}

View File

@ -0,0 +1,76 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
Unique,
} from 'typeorm';
import { Organization } from './organization.entity';
import { User } from './user.entity';
export enum OrganizationRole {
OWNER = 'owner',
ADMIN = 'admin',
MODERATOR = 'moderator',
MEMBER = 'member',
}
export enum MemberStatus {
ACTIVE = 'active',
SUSPENDED = 'suspended',
LEFT = 'left',
}
@Entity('organization_members')
@Unique(['organization', 'user'])
export class OrganizationMember {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({
type: 'varchar',
enum: OrganizationRole,
default: OrganizationRole.MEMBER,
})
role: OrganizationRole;
@Column({
type: 'varchar',
enum: MemberStatus,
default: MemberStatus.ACTIVE,
})
status: MemberStatus;
@Column({ type: 'json', nullable: true })
permissions: {
canCreateChannels?: boolean;
canDeleteMessages?: boolean;
canKickMembers?: boolean;
canInviteUsers?: boolean;
canManageRoles?: boolean;
canAccessAnalytics?: boolean;
};
@Column({ type: 'datetime', nullable: true })
joinedAt: Date;
@Column({ type: 'datetime', nullable: true })
lastActiveAt: Date;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
@ManyToOne(() => Organization, (organization) => organization.members)
organization: Organization;
@ManyToOne(() => User)
user: User;
@ManyToOne(() => User, { nullable: true })
invitedBy: User;
}

View File

@ -0,0 +1,100 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
OneToMany,
ManyToOne,
} from 'typeorm';
import { User } from './user.entity';
import { OrganizationMember } from './organization-member.entity';
import { OrganizationInvite } from './organization-invite.entity';
export enum OrganizationPlan {
FREE = 'free',
BASIC = 'basic',
PREMIUM = 'premium',
ENTERPRISE = 'enterprise',
}
export enum OrganizationStatus {
ACTIVE = 'active',
SUSPENDED = 'suspended',
DELETED = 'deleted',
}
@Entity('organizations')
export class Organization {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ unique: true })
name: string;
@Column({ unique: true })
slug: string;
@Column({ type: 'text', nullable: true })
description: string;
@Column({ nullable: true })
logo: string;
@Column({ nullable: true })
website: string;
@Column({ nullable: true })
contactEmail: string;
@Column({
type: 'varchar',
enum: OrganizationPlan,
default: OrganizationPlan.FREE,
})
plan: OrganizationPlan;
@Column({
type: 'varchar',
enum: OrganizationStatus,
default: OrganizationStatus.ACTIVE,
})
status: OrganizationStatus;
@Column({ type: 'json', nullable: true })
settings: {
allowPublicSignup?: boolean;
requireInviteApproval?: boolean;
maxMembers?: number;
allowFileUploads?: boolean;
maxFileSize?: number;
enableEncryption?: boolean;
customBranding?: boolean;
};
@Column({ type: 'json', nullable: true })
limits: {
maxUsers?: number;
maxChatRooms?: number;
maxFileStorage?: number; // in bytes
maxMonthlyMessages?: number;
};
@Column({ type: 'datetime', nullable: true })
subscriptionExpiry: Date;
@ManyToOne(() => User)
owner: User;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
@OneToMany(() => OrganizationMember, (member) => member.organization)
members: OrganizationMember[];
@OneToMany(() => OrganizationInvite, (invite) => invite.organization)
invites: OrganizationInvite[];
}

View File

@ -12,6 +12,7 @@ import { Exclude } from 'class-transformer';
import { Message } from './message.entity';
import { ChatRoom } from './chat-room.entity';
import { UserDevice } from './user-device.entity';
import { OrganizationMember } from './organization-member.entity';
@Entity('users')
export class User {
@ -61,4 +62,7 @@ export class User {
@OneToMany(() => UserDevice, (device) => device.user)
devices: UserDevice[];
@OneToMany(() => OrganizationMember, (member) => member.user)
organizationMemberships: OrganizationMember[];
}

View File

@ -0,0 +1,62 @@
import { IsNotEmpty, IsString, IsOptional, IsObject, IsUrl, IsEmail, Length, Matches } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { OrganizationPlan } from '../../database/entities/organization.entity';
export class CreateOrganizationDto {
@ApiProperty({ example: 'HealthBubba Org' })
@IsNotEmpty()
@IsString()
@Length(2, 100)
name: string;
@ApiProperty({ example: 'healthbubba-org' })
@IsNotEmpty()
@IsString()
@Length(2, 50)
@Matches(/^[a-z0-9-]+$/, { message: 'Slug can only contain lowercase letters, numbers, and hyphens' })
slug: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
@Length(0, 500)
description?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsUrl()
website?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsEmail()
contactEmail?: string;
@ApiProperty({ required: false, enum: OrganizationPlan })
@IsOptional()
@IsString()
plan?: OrganizationPlan;
@ApiProperty({ required: false })
@IsOptional()
@IsObject()
settings?: {
allowPublicSignup?: boolean;
requireInviteApproval?: boolean;
maxMembers?: number;
allowFileUploads?: boolean;
maxFileSize?: number;
enableEncryption?: boolean;
customBranding?: boolean;
};
@ApiProperty({ required: false })
@IsOptional()
@IsObject()
limits?: {
maxUsers?: number;
maxChatRooms?: number;
maxFileStorage?: number;
maxMonthlyMessages?: number;
};
}

View File

@ -0,0 +1,19 @@
import { IsEmail, IsOptional, IsString, IsEnum } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { OrganizationRole } from '../../database/entities/organization-member.entity';
export class InviteUserDto {
@ApiProperty({ example: 'user@example.com' })
@IsEmail()
email: string;
@ApiProperty({ required: false, enum: OrganizationRole })
@IsOptional()
@IsEnum(OrganizationRole)
role?: OrganizationRole;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
message?: string;
}

View File

@ -0,0 +1,62 @@
import { IsOptional, IsString, IsObject, IsUrl, IsEmail, Length, Matches } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { OrganizationPlan } from '../../database/entities/organization.entity';
export class UpdateOrganizationDto {
@ApiProperty({ required: false })
@IsOptional()
@IsString()
@Length(2, 100)
name?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
@Length(2, 50)
@Matches(/^[a-z0-9-]+$/, { message: 'Slug can only contain lowercase letters, numbers, and hyphens' })
slug?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
@Length(0, 500)
description?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsUrl()
website?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsEmail()
contactEmail?: string;
@ApiProperty({ required: false, enum: OrganizationPlan })
@IsOptional()
@IsString()
plan?: OrganizationPlan;
@ApiProperty({ required: false })
@IsOptional()
@IsObject()
settings?: {
allowPublicSignup?: boolean;
requireInviteApproval?: boolean;
maxMembers?: number;
allowFileUploads?: boolean;
maxFileSize?: number;
enableEncryption?: boolean;
customBranding?: boolean;
};
@ApiProperty({ required: false })
@IsOptional()
@IsObject()
limits?: {
maxUsers?: number;
maxChatRooms?: number;
maxFileStorage?: number;
maxMonthlyMessages?: number;
};
}

View File

@ -0,0 +1,48 @@
import { Injectable, CanActivate, ExecutionContext, ForbiddenException, BadRequestException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { OrganizationService } from '../organization.service';
@Injectable()
export class OrganizationGuard implements CanActivate {
constructor(
private reflector: Reflector,
private organizationService: OrganizationService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const user = request.user;
if (!user) {
throw new ForbiddenException('User not authenticated');
}
// Try to get organization ID from various sources
let orgId = request.params?.orgId ||
request.body?.organizationId ||
request.query?.orgId ||
request.headers['x-organization-id'];
// If no orgId provided, try to get user's active organization
if (!orgId) {
const activeOrg = await this.organizationService.getUserActiveOrganization(user.id);
if (!activeOrg) {
throw new BadRequestException('No organization context provided and no active organization found');
}
orgId = activeOrg.id;
}
// Check if user is a member of the organization
const isMember = await this.organizationService.isUserMember(orgId, user.id);
if (!isMember) {
throw new ForbiddenException('You are not a member of this organization');
}
// Get member details and add to request
const member = await this.organizationService.getMember(orgId, user.id);
request.organization = member.organization;
request.organizationMember = member;
return true;
}
}

View File

@ -0,0 +1,131 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
UseGuards,
Request,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
import { OrganizationService } from './organization.service';
import { CreateOrganizationDto } from './dto/create-organization.dto';
import { UpdateOrganizationDto } from './dto/update-organization.dto';
import { InviteUserDto } from './dto/invite-user.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { OrganizationRole } from '../database/entities/organization-member.entity';
@ApiTags('Organizations')
@Controller('organizations')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
export class OrganizationController {
constructor(private readonly organizationService: OrganizationService) {}
@Post()
@ApiOperation({ summary: 'Create a new organization' })
@ApiResponse({ status: 201, description: 'Organization created successfully' })
async createOrganization(@Request() req, @Body() createOrgDto: CreateOrganizationDto) {
return this.organizationService.createOrganization(createOrgDto, req.user.id);
}
@Get()
@ApiOperation({ summary: 'Get user organizations' })
@ApiResponse({ status: 200, description: 'Organizations retrieved successfully' })
async getUserOrganizations(@Request() req) {
return this.organizationService.getUserOrganizations(req.user.id);
}
@Get('active')
@ApiOperation({ summary: 'Get user active organization' })
@ApiResponse({ status: 200, description: 'Active organization retrieved successfully' })
async getUserActiveOrganization(@Request() req) {
return this.organizationService.getUserActiveOrganization(req.user.id);
}
@Post(':orgId/set-active')
@ApiOperation({ summary: 'Set active organization for user' })
@ApiResponse({ status: 200, description: 'Active organization updated successfully' })
async setUserActiveOrganization(@Request() req, @Param('orgId') orgId: string) {
await this.organizationService.setUserActiveOrganization(req.user.id, orgId);
return { message: 'Active organization updated successfully' };
}
@Get(':orgId')
@ApiOperation({ summary: 'Get organization details' })
@ApiResponse({ status: 200, description: 'Organization retrieved successfully' })
async getOrganization(@Request() req, @Param('orgId') orgId: string) {
return this.organizationService.getOrganization(orgId, req.user.id);
}
@Put(':orgId')
@ApiOperation({ summary: 'Update organization' })
@ApiResponse({ status: 200, description: 'Organization updated successfully' })
async updateOrganization(
@Request() req,
@Param('orgId') orgId: string,
@Body() updateOrgDto: UpdateOrganizationDto,
) {
return this.organizationService.updateOrganization(orgId, updateOrgDto, req.user.id);
}
@Get(':orgId/members')
@ApiOperation({ summary: 'Get organization members' })
@ApiResponse({ status: 200, description: 'Members retrieved successfully' })
async getOrganizationMembers(@Request() req, @Param('orgId') orgId: string) {
return this.organizationService.getOrganizationMembers(orgId, req.user.id);
}
@Post(':orgId/invite')
@ApiOperation({ summary: 'Invite user to organization' })
@ApiResponse({ status: 201, description: 'User invited successfully' })
async inviteUser(
@Request() req,
@Param('orgId') orgId: string,
@Body() inviteDto: InviteUserDto,
) {
return this.organizationService.inviteUser(orgId, inviteDto, req.user.id);
}
@Post('accept-invite/:token')
@ApiOperation({ summary: 'Accept organization invitation' })
@ApiResponse({ status: 200, description: 'Invitation accepted successfully' })
async acceptInvite(@Request() req, @Param('token') token: string) {
return this.organizationService.acceptInvite(token, req.user.id);
}
@Delete(':orgId/members/:memberId')
@ApiOperation({ summary: 'Remove member from organization' })
@ApiResponse({ status: 200, description: 'Member removed successfully' })
async removeMember(
@Request() req,
@Param('orgId') orgId: string,
@Param('memberId') memberId: string,
) {
await this.organizationService.removeMember(orgId, memberId, req.user.id);
return { message: 'Member removed successfully' };
}
@Put(':orgId/members/:memberId/role')
@ApiOperation({ summary: 'Update member role' })
@ApiResponse({ status: 200, description: 'Member role updated successfully' })
async updateMemberRole(
@Request() req,
@Param('orgId') orgId: string,
@Param('memberId') memberId: string,
@Body('role') role: OrganizationRole,
) {
return this.organizationService.updateMemberRole(orgId, memberId, role, req.user.id);
}
@Post(':orgId/leave')
@ApiOperation({ summary: 'Leave organization' })
@ApiResponse({ status: 200, description: 'Left organization successfully' })
async leaveOrganization(@Request() req, @Param('orgId') orgId: string) {
await this.organizationService.leaveOrganization(orgId, req.user.id);
return { message: 'Left organization successfully' };
}
}

View File

@ -0,0 +1,20 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { OrganizationService } from './organization.service';
import { OrganizationController } from './organization.controller';
import { Organization } from '../database/entities/organization.entity';
import { OrganizationMember } from '../database/entities/organization-member.entity';
import { OrganizationInvite } from '../database/entities/organization-invite.entity';
import { User } from '../database/entities/user.entity';
import { NotificationModule } from '../notification/notification.module';
@Module({
imports: [
TypeOrmModule.forFeature([Organization, OrganizationMember, OrganizationInvite, User]),
NotificationModule,
],
providers: [OrganizationService],
controllers: [OrganizationController],
exports: [OrganizationService],
})
export class OrganizationModule {}

View File

@ -0,0 +1,443 @@
import { Injectable, NotFoundException, ForbiddenException, ConflictException, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { v4 as uuidv4 } from 'uuid';
import { Organization, OrganizationStatus } from '../database/entities/organization.entity';
import { OrganizationMember, OrganizationRole, MemberStatus } from '../database/entities/organization-member.entity';
import { OrganizationInvite, InviteStatus } from '../database/entities/organization-invite.entity';
import { User } from '../database/entities/user.entity';
import { NotificationService } from '../notification/notification.service';
import { CreateOrganizationDto } from './dto/create-organization.dto';
import { UpdateOrganizationDto } from './dto/update-organization.dto';
import { InviteUserDto } from './dto/invite-user.dto';
@Injectable()
export class OrganizationService {
constructor(
@InjectRepository(Organization)
private organizationRepository: Repository<Organization>,
@InjectRepository(OrganizationMember)
private memberRepository: Repository<OrganizationMember>,
@InjectRepository(OrganizationInvite)
private inviteRepository: Repository<OrganizationInvite>,
@InjectRepository(User)
private userRepository: Repository<User>,
private notificationService: NotificationService,
) {}
async createOrganization(createOrgDto: CreateOrganizationDto, ownerId: string): Promise<Organization> {
const owner = await this.userRepository.findOne({ where: { id: ownerId } });
if (!owner) {
throw new NotFoundException('Owner not found');
}
// Check if organization name or slug already exists
const existingOrg = await this.organizationRepository.findOne({
where: [
{ name: createOrgDto.name },
{ slug: createOrgDto.slug },
],
});
if (existingOrg) {
throw new ConflictException('Organization name or slug already exists');
}
// Create organization
const organization = this.organizationRepository.create({
...createOrgDto,
owner,
settings: {
allowPublicSignup: false,
requireInviteApproval: true,
maxMembers: 50,
allowFileUploads: true,
maxFileSize: 100 * 1024 * 1024, // 100MB
enableEncryption: true,
customBranding: false,
...createOrgDto.settings,
},
limits: {
maxUsers: 50,
maxChatRooms: 100,
maxFileStorage: 10 * 1024 * 1024 * 1024, // 10GB
maxMonthlyMessages: 10000,
...createOrgDto.limits,
},
});
const savedOrg = await this.organizationRepository.save(organization);
// Add owner as member
const ownerMember = this.memberRepository.create({
organization: savedOrg,
user: owner,
role: OrganizationRole.OWNER,
status: MemberStatus.ACTIVE,
joinedAt: new Date(),
permissions: {
canCreateChannels: true,
canDeleteMessages: true,
canKickMembers: true,
canInviteUsers: true,
canManageRoles: true,
canAccessAnalytics: true,
},
});
await this.memberRepository.save(ownerMember);
return savedOrg;
}
async getOrganization(orgId: string, userId: string): Promise<Organization> {
const organization = await this.organizationRepository.findOne({
where: { id: orgId },
relations: ['owner', 'members', 'members.user'],
});
if (!organization) {
throw new NotFoundException('Organization not found');
}
// Check if user is a member
const isMember = await this.isUserMember(orgId, userId);
if (!isMember) {
throw new ForbiddenException('You are not a member of this organization');
}
return organization;
}
async getUserOrganizations(userId: string): Promise<Organization[]> {
const memberships = await this.memberRepository.find({
where: { user: { id: userId }, status: MemberStatus.ACTIVE },
relations: ['organization', 'organization.owner'],
order: { joinedAt: 'DESC' },
});
return memberships.map(membership => membership.organization);
}
async updateOrganization(
orgId: string,
updateOrgDto: UpdateOrganizationDto,
userId: string,
): Promise<Organization> {
const organization = await this.getOrganization(orgId, userId);
// Check if user has admin permissions
const member = await this.getMember(orgId, userId);
if (member.role !== OrganizationRole.OWNER && member.role !== OrganizationRole.ADMIN) {
throw new ForbiddenException('You do not have permission to update this organization');
}
// If updating slug, check for conflicts
if (updateOrgDto.slug && updateOrgDto.slug !== organization.slug) {
const existingOrg = await this.organizationRepository.findOne({
where: { slug: updateOrgDto.slug },
});
if (existingOrg) {
throw new ConflictException('Slug already exists');
}
}
await this.organizationRepository.update(orgId, updateOrgDto);
return this.getOrganization(orgId, userId);
}
async inviteUser(orgId: string, inviteDto: InviteUserDto, inviterId: string): Promise<OrganizationInvite> {
const organization = await this.getOrganization(orgId, inviterId);
const inviter = await this.userRepository.findOne({ where: { id: inviterId } });
// Check if inviter has permission to invite users
const inviterMember = await this.getMember(orgId, inviterId);
if (!inviterMember.permissions?.canInviteUsers) {
throw new ForbiddenException('You do not have permission to invite users');
}
// Check if user is already a member
const existingMember = await this.memberRepository.findOne({
where: { organization: { id: orgId }, user: { email: inviteDto.email } },
});
if (existingMember) {
throw new ConflictException('User is already a member of this organization');
}
// Check if there's already a pending invite
const existingInvite = await this.inviteRepository.findOne({
where: {
organization: { id: orgId },
email: inviteDto.email,
status: InviteStatus.PENDING
},
});
if (existingInvite) {
throw new ConflictException('User already has a pending invite');
}
// Create invite
const invite = this.inviteRepository.create({
organization,
email: inviteDto.email,
role: inviteDto.role || OrganizationRole.MEMBER,
message: inviteDto.message,
token: uuidv4(),
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
invitedBy: inviter,
});
const savedInvite = await this.inviteRepository.save(invite);
// Send notification if user exists
const existingUser = await this.userRepository.findOne({ where: { email: inviteDto.email } });
if (existingUser) {
await this.notificationService.createNotification(
existingUser.id,
'organization_invite' as any,
`Invitation to join ${organization.name}`,
`${inviter.username} invited you to join ${organization.name}`,
{
organizationId: orgId,
inviteId: savedInvite.id,
inviterName: inviter.username,
},
);
}
return savedInvite;
}
async acceptInvite(token: string, userId: string): Promise<OrganizationMember> {
const invite = await this.inviteRepository.findOne({
where: { token, status: InviteStatus.PENDING },
relations: ['organization'],
});
if (!invite) {
throw new NotFoundException('Invite not found or already processed');
}
if (invite.expiresAt < new Date()) {
await this.inviteRepository.update(invite.id, { status: InviteStatus.EXPIRED });
throw new BadRequestException('Invite has expired');
}
const user = await this.userRepository.findOne({ where: { id: userId } });
if (!user || user.email !== invite.email) {
throw new ForbiddenException('You are not authorized to accept this invite');
}
// Check organization limits
const memberCount = await this.memberRepository.count({
where: { organization: { id: invite.organization.id }, status: MemberStatus.ACTIVE },
});
if (memberCount >= invite.organization.limits?.maxUsers) {
throw new BadRequestException('Organization has reached maximum member limit');
}
// Create membership
const member = this.memberRepository.create({
organization: invite.organization,
user,
role: invite.role,
status: MemberStatus.ACTIVE,
joinedAt: new Date(),
invitedBy: invite.invitedBy,
permissions: this.getDefaultPermissions(invite.role),
});
const savedMember = await this.memberRepository.save(member);
// Update invite status
await this.inviteRepository.update(invite.id, {
status: InviteStatus.ACCEPTED,
acceptedBy: user,
});
return savedMember;
}
async removeMember(orgId: string, memberIdToRemove: string, removerId: string): Promise<void> {
const organization = await this.getOrganization(orgId, removerId);
const removerMember = await this.getMember(orgId, removerId);
// Check if remover has permission
if (!removerMember.permissions?.canKickMembers && removerMember.role !== OrganizationRole.OWNER) {
throw new ForbiddenException('You do not have permission to remove members');
}
const memberToRemove = await this.memberRepository.findOne({
where: { organization: { id: orgId }, user: { id: memberIdToRemove } },
relations: ['user'],
});
if (!memberToRemove) {
throw new NotFoundException('Member not found');
}
// Cannot remove owner
if (memberToRemove.role === OrganizationRole.OWNER) {
throw new ForbiddenException('Cannot remove organization owner');
}
// Non-owners cannot remove admins
if (memberToRemove.role === OrganizationRole.ADMIN && removerMember.role !== OrganizationRole.OWNER) {
throw new ForbiddenException('Only owners can remove admins');
}
await this.memberRepository.update(memberToRemove.id, { status: MemberStatus.LEFT });
}
async updateMemberRole(
orgId: string,
memberIdToUpdate: string,
newRole: OrganizationRole,
updaterId: string,
): Promise<OrganizationMember> {
const updaterMember = await this.getMember(orgId, updaterId);
// Check if updater has permission
if (!updaterMember.permissions?.canManageRoles && updaterMember.role !== OrganizationRole.OWNER) {
throw new ForbiddenException('You do not have permission to manage roles');
}
const memberToUpdate = await this.memberRepository.findOne({
where: { organization: { id: orgId }, user: { id: memberIdToUpdate } },
relations: ['user'],
});
if (!memberToUpdate) {
throw new NotFoundException('Member not found');
}
// Cannot change owner role
if (memberToUpdate.role === OrganizationRole.OWNER) {
throw new ForbiddenException('Cannot change owner role');
}
// Only owners can assign admin role
if (newRole === OrganizationRole.ADMIN && updaterMember.role !== OrganizationRole.OWNER) {
throw new ForbiddenException('Only owners can assign admin role');
}
await this.memberRepository.update(memberToUpdate.id, {
role: newRole,
permissions: this.getDefaultPermissions(newRole),
});
return this.memberRepository.findOne({
where: { id: memberToUpdate.id },
relations: ['user', 'organization'],
});
}
async getOrganizationMembers(orgId: string, userId: string): Promise<OrganizationMember[]> {
await this.getOrganization(orgId, userId); // Check access
return this.memberRepository.find({
where: { organization: { id: orgId }, status: MemberStatus.ACTIVE },
relations: ['user', 'invitedBy'],
order: { joinedAt: 'ASC' },
});
}
async leaveOrganization(orgId: string, userId: string): Promise<void> {
const member = await this.getMember(orgId, userId);
if (member.role === OrganizationRole.OWNER) {
throw new ForbiddenException('Organization owner cannot leave. Transfer ownership first.');
}
await this.memberRepository.update(member.id, { status: MemberStatus.LEFT });
}
async isUserMember(orgId: string, userId: string): Promise<boolean> {
const member = await this.memberRepository.findOne({
where: {
organization: { id: orgId },
user: { id: userId },
status: MemberStatus.ACTIVE,
},
});
return !!member;
}
async getMember(orgId: string, userId: string): Promise<OrganizationMember> {
const member = await this.memberRepository.findOne({
where: {
organization: { id: orgId },
user: { id: userId },
status: MemberStatus.ACTIVE,
},
relations: ['user', 'organization'],
});
if (!member) {
throw new NotFoundException('You are not a member of this organization');
}
return member;
}
async getUserActiveOrganization(userId: string): Promise<Organization | null> {
const membership = await this.memberRepository.findOne({
where: { user: { id: userId }, status: MemberStatus.ACTIVE },
relations: ['organization'],
order: { lastActiveAt: 'DESC' },
});
return membership?.organization || null;
}
async setUserActiveOrganization(userId: string, orgId: string): Promise<void> {
const member = await this.getMember(orgId, userId);
await this.memberRepository.update(member.id, { lastActiveAt: new Date() });
}
private getDefaultPermissions(role: OrganizationRole): any {
switch (role) {
case OrganizationRole.OWNER:
return {
canCreateChannels: true,
canDeleteMessages: true,
canKickMembers: true,
canInviteUsers: true,
canManageRoles: true,
canAccessAnalytics: true,
};
case OrganizationRole.ADMIN:
return {
canCreateChannels: true,
canDeleteMessages: true,
canKickMembers: true,
canInviteUsers: true,
canManageRoles: false,
canAccessAnalytics: true,
};
case OrganizationRole.MODERATOR:
return {
canCreateChannels: true,
canDeleteMessages: true,
canKickMembers: false,
canInviteUsers: true,
canManageRoles: false,
canAccessAnalytics: false,
};
case OrganizationRole.MEMBER:
default:
return {
canCreateChannels: false,
canDeleteMessages: false,
canKickMembers: false,
canInviteUsers: false,
canManageRoles: false,
canAccessAnalytics: false,
};
}
}
}