diff --git a/README.md b/README.md index df9f151c..471ccec7 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/app.module.ts b/src/app.module.ts index 841cd56e..373018e2 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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, diff --git a/src/chat/chat.controller.ts b/src/chat/chat.controller.ts index 12383a63..569afa46 100644 --- a/src/chat/chat.controller.ts +++ b/src/chat/chat.controller.ts @@ -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); } } \ No newline at end of file diff --git a/src/chat/chat.gateway.ts b/src/chat/chat.gateway.ts index d1552188..53a08bdb 100644 --- a/src/chat/chat.gateway.ts +++ b/src/chat/chat.gateway.ts @@ -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(); // userId -> socketId + private connectedUsers = new Map(); // userId -> {socketId, organizationId} + private organizationRooms = new Map>(); // 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 }); + client.emit('connected', { + message: 'Connected successfully', + user: (client as AuthenticatedSocket).user, + organization: (client as AuthenticatedSocket).organization, + }); - // Join user to their chat rooms - const chatRooms = await this.chatService.getUserChatRooms(user.id); + // Join user to their chat rooms within the organization + const chatRooms = await this.chatService.getUserChatRooms(user.id, activeOrgId); chatRooms.forEach(room => { - client.join(room.id); + client.join(`room_${room.id}`); }); - // Notify other users that this user is online - this.server.emit('user_online', { userId: user.id, username: user.username }); + // 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); } } \ No newline at end of file diff --git a/src/chat/chat.module.ts b/src/chat/chat.module.ts index fb58a712..2dc01d38 100644 --- a/src/chat/chat.module.ts +++ b/src/chat/chat.module.ts @@ -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], diff --git a/src/chat/chat.service.ts b/src/chat/chat.service.ts index 431dedb9..4686a053 100644 --- a/src/chat/chat.service.ts +++ b/src/chat/chat.service.ts @@ -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, @InjectRepository(User) private userRepository: Repository, + @InjectRepository(Organization) + private organizationRepository: Repository, private encryptionService: EncryptionService, ) {} - async createDirectMessage(senderId: string, recipientId: string): Promise { - // Check if DM already exists between these users + async createDirectMessage(senderId: string, recipientId: string, organizationId: string): Promise { + // 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 { + async createGroupChat(createChatRoomDto: CreateChatRoomDto, creatorId: string, organizationId: string): Promise { 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 { - const chatRoom = await this.chatRoomRepository.findOne({ - where: { id: roomId }, - relations: ['members', 'createdBy'], - }); + async getChatRoom(roomId: string, userId: string, organizationId?: string): Promise { + 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 { + async getUserChatRooms(userId: string, organizationId: string): Promise { 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 { - const { content, type, senderId, chatRoomId, replyToId } = createMessageDto; + async createMessage(createMessageDto: CreateMessageDto & { organizationId: string }): Promise { + 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 { + async getMessage(messageId: string, organizationId: string): Promise { 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 { + async editMessage(messageId: string, newContent: string, userId: string, organizationId: string): Promise { 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 { + async deleteMessage(messageId: string, userId: string, organizationId: string): Promise { 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 { - const chatRoom = await this.getChatRoom(roomId, userId); + async addMemberToGroup(roomId: string, memberIds: string[], userId: string, organizationId: string): Promise { + 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 { - const chatRoom = await this.getChatRoom(roomId, userId); + async removeMemberFromGroup(roomId: string, memberIdToRemove: string, userId: string, organizationId: string): Promise { + 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({ diff --git a/src/database/entities/chat-room.entity.ts b/src/database/entities/chat-room.entity.ts index d53287f2..127bb079 100644 --- a/src/database/entities/chat-room.entity.ts +++ b/src/database/entities/chat-room.entity.ts @@ -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; diff --git a/src/database/entities/message.entity.ts b/src/database/entities/message.entity.ts index 3785af42..c519f93a 100644 --- a/src/database/entities/message.entity.ts +++ b/src/database/entities/message.entity.ts @@ -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[]; diff --git a/src/database/entities/notification.entity.ts b/src/database/entities/notification.entity.ts index 1755c7d4..ffbfedc4 100644 --- a/src/database/entities/notification.entity.ts +++ b/src/database/entities/notification.entity.ts @@ -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; } \ No newline at end of file diff --git a/src/database/entities/organization-invite.entity.ts b/src/database/entities/organization-invite.entity.ts new file mode 100644 index 00000000..8ba1cefe --- /dev/null +++ b/src/database/entities/organization-invite.entity.ts @@ -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; +} \ No newline at end of file diff --git a/src/database/entities/organization-member.entity.ts b/src/database/entities/organization-member.entity.ts new file mode 100644 index 00000000..f39ab916 --- /dev/null +++ b/src/database/entities/organization-member.entity.ts @@ -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; +} \ No newline at end of file diff --git a/src/database/entities/organization.entity.ts b/src/database/entities/organization.entity.ts new file mode 100644 index 00000000..ea8ecb87 --- /dev/null +++ b/src/database/entities/organization.entity.ts @@ -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[]; +} \ No newline at end of file diff --git a/src/database/entities/user.entity.ts b/src/database/entities/user.entity.ts index cb943563..1abb3532 100644 --- a/src/database/entities/user.entity.ts +++ b/src/database/entities/user.entity.ts @@ -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[]; } \ No newline at end of file diff --git a/src/organization/dto/create-organization.dto.ts b/src/organization/dto/create-organization.dto.ts new file mode 100644 index 00000000..edc58304 --- /dev/null +++ b/src/organization/dto/create-organization.dto.ts @@ -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; + }; +} \ No newline at end of file diff --git a/src/organization/dto/invite-user.dto.ts b/src/organization/dto/invite-user.dto.ts new file mode 100644 index 00000000..0db75b67 --- /dev/null +++ b/src/organization/dto/invite-user.dto.ts @@ -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; +} \ No newline at end of file diff --git a/src/organization/dto/update-organization.dto.ts b/src/organization/dto/update-organization.dto.ts new file mode 100644 index 00000000..d74af4a1 --- /dev/null +++ b/src/organization/dto/update-organization.dto.ts @@ -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; + }; +} \ No newline at end of file diff --git a/src/organization/guards/organization.guard.ts b/src/organization/guards/organization.guard.ts new file mode 100644 index 00000000..f9bf9c59 --- /dev/null +++ b/src/organization/guards/organization.guard.ts @@ -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 { + 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; + } +} \ No newline at end of file diff --git a/src/organization/organization.controller.ts b/src/organization/organization.controller.ts new file mode 100644 index 00000000..cf736fba --- /dev/null +++ b/src/organization/organization.controller.ts @@ -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' }; + } +} \ No newline at end of file diff --git a/src/organization/organization.module.ts b/src/organization/organization.module.ts new file mode 100644 index 00000000..7e9d0b04 --- /dev/null +++ b/src/organization/organization.module.ts @@ -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 {} \ No newline at end of file diff --git a/src/organization/organization.service.ts b/src/organization/organization.service.ts new file mode 100644 index 00000000..bdc6c5a0 --- /dev/null +++ b/src/organization/organization.service.ts @@ -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, + @InjectRepository(OrganizationMember) + private memberRepository: Repository, + @InjectRepository(OrganizationInvite) + private inviteRepository: Repository, + @InjectRepository(User) + private userRepository: Repository, + private notificationService: NotificationService, + ) {} + + async createOrganization(createOrgDto: CreateOrganizationDto, ownerId: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, + }; + } + } +} \ No newline at end of file