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:
parent
545563e776
commit
a67a002106
129
README.md
129
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
|
## 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
|
### Core Chat Features
|
||||||
- **Real-time messaging** with WebSocket support
|
- **Real-time messaging** with WebSocket support
|
||||||
- **Direct Messages (DM)** between users
|
- **Direct Messages (DM)** between users within organizations
|
||||||
- **Group chat** functionality with member management
|
- **Group chat** functionality with member management
|
||||||
- **Message editing and deletion**
|
- **Message editing and deletion**
|
||||||
- **Reply to messages** functionality
|
- **Reply to messages** functionality
|
||||||
@ -116,6 +125,20 @@ The application will be available at:
|
|||||||
- `POST /auth/login` - Login user
|
- `POST /auth/login` - Login user
|
||||||
- `POST /auth/logout` - Logout 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
|
### Users
|
||||||
- `GET /users` - Get all users (with search)
|
- `GET /users` - Get all users (with search)
|
||||||
- `GET /users/me` - Get current user profile
|
- `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/username/:username` - Get user by username
|
||||||
- `GET /users/:id/public-key` - Get user's public key
|
- `GET /users/:id/public-key` - Get user's public key
|
||||||
|
|
||||||
### Chat
|
### Chat (Organization Context Required)
|
||||||
- `GET /chat/rooms` - Get user's chat rooms
|
**Note**: All chat endpoints require organization context via `x-organization-id` header or active organization.
|
||||||
- `POST /chat/rooms/direct` - Create/get direct message room
|
- `GET /chat/rooms` - Get user's chat rooms in organization
|
||||||
- `POST /chat/rooms/group` - Create group chat room
|
- `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` - Get chat room details
|
||||||
- `GET /chat/rooms/:roomId/messages` - Get chat room messages
|
- `GET /chat/rooms/:roomId/messages` - Get chat room messages
|
||||||
- `POST /chat/messages` - Create new message
|
- `POST /chat/messages` - Create new message
|
||||||
@ -151,18 +175,33 @@ The application will be available at:
|
|||||||
|
|
||||||
## WebSocket Events
|
## 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
|
### Client to Server
|
||||||
- `join_room` - Join a chat room
|
- `join_room` - Join a chat room within organization
|
||||||
- `leave_room` - Leave a chat room
|
- `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
|
- `edit_message` - Edit existing message
|
||||||
- `delete_message` - Delete message
|
- `delete_message` - Delete message
|
||||||
- `typing_start` - Start typing indicator
|
- `typing_start` - Start typing indicator
|
||||||
- `typing_stop` - Stop typing indicator
|
- `typing_stop` - Stop typing indicator
|
||||||
- `mark_as_read` - Mark message as read
|
- `mark_as_read` - Mark message as read
|
||||||
|
- `switch_organization` - Switch to different organization context
|
||||||
|
|
||||||
### Server to Client
|
### Server to Client
|
||||||
- `connected` - Connection established
|
- `connected` - Connection established with organization context
|
||||||
- `new_message` - New message received
|
- `new_message` - New message received
|
||||||
- `message_edited` - Message was edited
|
- `message_edited` - Message was edited
|
||||||
- `message_deleted` - Message was deleted
|
- `message_deleted` - Message was deleted
|
||||||
@ -170,26 +209,41 @@ The application will be available at:
|
|||||||
- `user_stopped_typing` - User stopped typing
|
- `user_stopped_typing` - User stopped typing
|
||||||
- `message_read` - Message was read
|
- `message_read` - Message was read
|
||||||
- `mentioned` - User was mentioned
|
- `mentioned` - User was mentioned
|
||||||
- `user_online` - User came online
|
- `user_online` - User came online in organization
|
||||||
- `user_offline` - User went offline
|
- `user_offline` - User went offline in organization
|
||||||
- `joined_room` - Successfully joined room
|
- `joined_room` - Successfully joined room
|
||||||
- `left_room` - Successfully left room
|
- `left_room` - Successfully left room
|
||||||
|
- `organization_switched` - Successfully switched organizations
|
||||||
- `error` - Error occurred
|
- `error` - Error occurred
|
||||||
|
|
||||||
## Flutter SDK Integration
|
## 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
|
### Organization Context
|
||||||
```typescript
|
Every API call and WebSocket connection must include organization context:
|
||||||
// Connect with JWT token
|
```dart
|
||||||
const socket = io('ws://localhost:3000', {
|
// HTTP Headers
|
||||||
auth: {
|
final headers = {
|
||||||
token: 'your-jwt-token'
|
'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
|
### File Upload
|
||||||
The API supports multipart/form-data uploads, compatible with Flutter's HTTP client and dio package.
|
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
|
## Database Schema
|
||||||
|
|
||||||
The application uses SQLite with the following main entities:
|
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
|
- **Users**: User accounts with encryption keys
|
||||||
- **ChatRooms**: Direct and group chat rooms
|
- **ChatRooms**: Direct and group chat rooms (organization-scoped)
|
||||||
- **Messages**: Chat messages with encryption support
|
- **Messages**: Chat messages with encryption support (organization-scoped)
|
||||||
- **MessageMedia**: File attachments
|
- **MessageMedia**: File attachments
|
||||||
- **MessageMentions**: User mentions in messages
|
- **MessageMentions**: User mentions in messages
|
||||||
- **UserDevices**: Device registration for push notifications
|
- **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
|
## 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
|
- **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
|
- **Input Validation**: All inputs validated and sanitized
|
||||||
- **CORS Configuration**: Configured for cross-origin requests
|
- **CORS Configuration**: Configured for cross-origin requests
|
||||||
- **Rate Limiting**: Built-in protection against abuse
|
- **Organization Membership Verification**: All operations verify user membership
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
|
@ -15,6 +15,7 @@ import { ChatModule } from './chat/chat.module';
|
|||||||
import { MediaModule } from './media/media.module';
|
import { MediaModule } from './media/media.module';
|
||||||
import { NotificationModule } from './notification/notification.module';
|
import { NotificationModule } from './notification/notification.module';
|
||||||
import { EncryptionModule } from './encryption/encryption.module';
|
import { EncryptionModule } from './encryption/encryption.module';
|
||||||
|
import { OrganizationModule } from './organization/organization.module';
|
||||||
|
|
||||||
// Ensure storage directories exist
|
// Ensure storage directories exist
|
||||||
const storageDir = '/app/storage';
|
const storageDir = '/app/storage';
|
||||||
@ -61,6 +62,7 @@ try {
|
|||||||
ScheduleModule.forRoot(),
|
ScheduleModule.forRoot(),
|
||||||
AuthModule,
|
AuthModule,
|
||||||
UsersModule,
|
UsersModule,
|
||||||
|
OrganizationModule,
|
||||||
ChatModule,
|
ChatModule,
|
||||||
MediaModule,
|
MediaModule,
|
||||||
NotificationModule,
|
NotificationModule,
|
||||||
|
@ -10,24 +10,30 @@ import {
|
|||||||
UseGuards,
|
UseGuards,
|
||||||
Request,
|
Request,
|
||||||
} from '@nestjs/common';
|
} 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 { ChatService } from './chat.service';
|
||||||
import { CreateChatRoomDto } from './dto/create-chat-room.dto';
|
import { CreateChatRoomDto } from './dto/create-chat-room.dto';
|
||||||
import { CreateMessageDto } from './dto/create-message.dto';
|
import { CreateMessageDto } from './dto/create-message.dto';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { OrganizationGuard } from '../organization/guards/organization.guard';
|
||||||
|
|
||||||
@ApiTags('Chat')
|
@ApiTags('Chat')
|
||||||
@Controller('chat')
|
@Controller('chat')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard, OrganizationGuard)
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
|
@ApiHeader({
|
||||||
|
name: 'x-organization-id',
|
||||||
|
description: 'Organization ID for multi-tenant access',
|
||||||
|
required: false,
|
||||||
|
})
|
||||||
export class ChatController {
|
export class ChatController {
|
||||||
constructor(private readonly chatService: ChatService) {}
|
constructor(private readonly chatService: ChatService) {}
|
||||||
|
|
||||||
@Get('rooms')
|
@Get('rooms')
|
||||||
@ApiOperation({ summary: 'Get user chat rooms' })
|
@ApiOperation({ summary: 'Get user chat rooms in organization' })
|
||||||
@ApiResponse({ status: 200, description: 'Chat rooms retrieved successfully' })
|
@ApiResponse({ status: 200, description: 'Chat rooms retrieved successfully' })
|
||||||
async getUserChatRooms(@Request() req) {
|
async getUserChatRooms(@Request() req) {
|
||||||
return this.chatService.getUserChatRooms(req.user.id);
|
return this.chatService.getUserChatRooms(req.user.id, req.organization.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('rooms/direct')
|
@Post('rooms/direct')
|
||||||
@ -37,7 +43,7 @@ export class ChatController {
|
|||||||
@Request() req,
|
@Request() req,
|
||||||
@Body('recipientId') recipientId: string,
|
@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')
|
@Post('rooms/group')
|
||||||
@ -47,14 +53,14 @@ export class ChatController {
|
|||||||
@Request() req,
|
@Request() req,
|
||||||
@Body() createChatRoomDto: CreateChatRoomDto,
|
@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')
|
@Get('rooms/:roomId')
|
||||||
@ApiOperation({ summary: 'Get chat room details' })
|
@ApiOperation({ summary: 'Get chat room details' })
|
||||||
@ApiResponse({ status: 200, description: 'Chat room retrieved successfully' })
|
@ApiResponse({ status: 200, description: 'Chat room retrieved successfully' })
|
||||||
async getChatRoom(@Param('roomId') roomId: string, @Request() req) {
|
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')
|
@Get('rooms/:roomId/messages')
|
||||||
@ -66,7 +72,7 @@ export class ChatController {
|
|||||||
@Query('page') page?: number,
|
@Query('page') page?: number,
|
||||||
@Query('limit') limit?: 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')
|
@Post('messages')
|
||||||
@ -76,6 +82,7 @@ export class ChatController {
|
|||||||
return this.chatService.createMessage({
|
return this.chatService.createMessage({
|
||||||
...createMessageDto,
|
...createMessageDto,
|
||||||
senderId: req.user.id,
|
senderId: req.user.id,
|
||||||
|
organizationId: req.organization.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -87,14 +94,14 @@ export class ChatController {
|
|||||||
@Request() req,
|
@Request() req,
|
||||||
@Body('content') content: string,
|
@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')
|
@Delete('messages/:messageId')
|
||||||
@ApiOperation({ summary: 'Delete message' })
|
@ApiOperation({ summary: 'Delete message' })
|
||||||
@ApiResponse({ status: 200, description: 'Message deleted successfully' })
|
@ApiResponse({ status: 200, description: 'Message deleted successfully' })
|
||||||
async deleteMessage(@Param('messageId') messageId: string, @Request() req) {
|
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')
|
@Post('rooms/:roomId/members')
|
||||||
@ -105,7 +112,7 @@ export class ChatController {
|
|||||||
@Request() req,
|
@Request() req,
|
||||||
@Body('memberIds') memberIds: string[],
|
@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')
|
@Delete('rooms/:roomId/members/:memberId')
|
||||||
@ -116,6 +123,6 @@ export class ChatController {
|
|||||||
@Param('memberId') memberId: string,
|
@Param('memberId') memberId: string,
|
||||||
@Request() req,
|
@Request() req,
|
||||||
) {
|
) {
|
||||||
return this.chatService.removeMemberFromGroup(roomId, memberId, req.user.id);
|
return this.chatService.removeMemberFromGroup(roomId, memberId, req.user.id, req.organization.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -13,6 +13,7 @@ import { JwtService } from '@nestjs/jwt';
|
|||||||
import { ChatService } from './chat.service';
|
import { ChatService } from './chat.service';
|
||||||
import { UsersService } from '../users/users.service';
|
import { UsersService } from '../users/users.service';
|
||||||
import { NotificationService } from '../notification/notification.service';
|
import { NotificationService } from '../notification/notification.service';
|
||||||
|
import { OrganizationService } from '../organization/organization.service';
|
||||||
|
|
||||||
interface AuthenticatedSocket extends Socket {
|
interface AuthenticatedSocket extends Socket {
|
||||||
user: {
|
user: {
|
||||||
@ -20,6 +21,11 @@ interface AuthenticatedSocket extends Socket {
|
|||||||
email: string;
|
email: string;
|
||||||
username: string;
|
username: string;
|
||||||
};
|
};
|
||||||
|
organization: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@WebSocketGateway({
|
@WebSocketGateway({
|
||||||
@ -31,18 +37,21 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
|||||||
@WebSocketServer()
|
@WebSocketServer()
|
||||||
server: Server;
|
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(
|
constructor(
|
||||||
private chatService: ChatService,
|
private chatService: ChatService,
|
||||||
private usersService: UsersService,
|
private usersService: UsersService,
|
||||||
private notificationService: NotificationService,
|
private notificationService: NotificationService,
|
||||||
|
private organizationService: OrganizationService,
|
||||||
private jwtService: JwtService,
|
private jwtService: JwtService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async handleConnection(client: Socket) {
|
async handleConnection(client: Socket) {
|
||||||
try {
|
try {
|
||||||
const token = client.handshake.auth.token || client.handshake.headers.authorization?.split(' ')[1];
|
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) {
|
if (!token) {
|
||||||
client.disconnect();
|
client.disconnect();
|
||||||
@ -57,38 +66,103 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
|||||||
return;
|
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 = {
|
(client as AuthenticatedSocket).user = {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
username: user.username,
|
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);
|
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
|
// Join user to their chat rooms within the organization
|
||||||
const chatRooms = await this.chatService.getUserChatRooms(user.id);
|
const chatRooms = await this.chatService.getUserChatRooms(user.id, activeOrgId);
|
||||||
chatRooms.forEach(room => {
|
chatRooms.forEach(room => {
|
||||||
client.join(room.id);
|
client.join(`room_${room.id}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Notify other users that this user is online
|
// Notify other users in the same organization that this user is online
|
||||||
this.server.emit('user_online', { userId: user.id, username: user.username });
|
client.to(`org_${activeOrgId}`).emit('user_online', {
|
||||||
|
userId: user.id,
|
||||||
|
username: user.username,
|
||||||
|
organizationId: activeOrgId,
|
||||||
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
client.emit('error', { message: 'Authentication failed' });
|
||||||
client.disconnect();
|
client.disconnect();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleDisconnect(client: AuthenticatedSocket) {
|
async handleDisconnect(client: AuthenticatedSocket) {
|
||||||
if (client.user) {
|
if (client.user && client.organization) {
|
||||||
this.connectedUsers.delete(client.user.id);
|
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);
|
await this.usersService.updateOnlineStatus(client.user.id, false);
|
||||||
|
|
||||||
// Notify other users that this user is offline
|
// Notify other users in the same organization that this user is offline
|
||||||
this.server.emit('user_offline', { userId: client.user.id, username: client.user.username });
|
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,
|
@ConnectedSocket() client: AuthenticatedSocket,
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const room = await this.chatService.getChatRoom(data.roomId, client.user.id);
|
const room = await this.chatService.getChatRoom(data.roomId, client.user.id, client.organization.id);
|
||||||
client.join(data.roomId);
|
client.join(`room_${data.roomId}`);
|
||||||
client.emit('joined_room', { roomId: data.roomId, room });
|
client.emit('joined_room', { roomId: data.roomId, room });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
client.emit('error', { message: error.message });
|
client.emit('error', { message: error.message });
|
||||||
@ -111,7 +185,7 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
|||||||
@MessageBody() data: { roomId: string },
|
@MessageBody() data: { roomId: string },
|
||||||
@ConnectedSocket() client: AuthenticatedSocket,
|
@ConnectedSocket() client: AuthenticatedSocket,
|
||||||
) {
|
) {
|
||||||
client.leave(data.roomId);
|
client.leave(`room_${data.roomId}`);
|
||||||
client.emit('left_room', { roomId: data.roomId });
|
client.emit('left_room', { roomId: data.roomId });
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -127,15 +201,16 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
|||||||
senderId: client.user.id,
|
senderId: client.user.id,
|
||||||
chatRoomId: data.roomId,
|
chatRoomId: data.roomId,
|
||||||
replyToId: data.replyToId,
|
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
|
// 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
|
// 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
|
// Send push notifications to offline users
|
||||||
await this.notificationService.sendNewMessageNotifications(populatedMessage);
|
await this.notificationService.sendNewMessageNotifications(populatedMessage);
|
||||||
@ -151,8 +226,13 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
|||||||
@ConnectedSocket() client: AuthenticatedSocket,
|
@ConnectedSocket() client: AuthenticatedSocket,
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const message = await this.chatService.editMessage(data.messageId, data.content, client.user.id);
|
const message = await this.chatService.editMessage(
|
||||||
this.server.to(message.chatRoom.id).emit('message_edited', message);
|
data.messageId,
|
||||||
|
data.content,
|
||||||
|
client.user.id,
|
||||||
|
client.organization.id
|
||||||
|
);
|
||||||
|
this.server.to(`room_${message.chatRoom.id}`).emit('message_edited', message);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
client.emit('error', { message: error.message });
|
client.emit('error', { message: error.message });
|
||||||
}
|
}
|
||||||
@ -164,8 +244,12 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
|||||||
@ConnectedSocket() client: AuthenticatedSocket,
|
@ConnectedSocket() client: AuthenticatedSocket,
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const message = await this.chatService.deleteMessage(data.messageId, client.user.id);
|
const message = await this.chatService.deleteMessage(
|
||||||
this.server.to(message.chatRoom.id).emit('message_deleted', { messageId: data.messageId });
|
data.messageId,
|
||||||
|
client.user.id,
|
||||||
|
client.organization.id
|
||||||
|
);
|
||||||
|
this.server.to(`room_${message.chatRoom.id}`).emit('message_deleted', { messageId: data.messageId });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
client.emit('error', { message: error.message });
|
client.emit('error', { message: error.message });
|
||||||
}
|
}
|
||||||
@ -176,9 +260,10 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
|||||||
@MessageBody() data: { roomId: string },
|
@MessageBody() data: { roomId: string },
|
||||||
@ConnectedSocket() client: AuthenticatedSocket,
|
@ConnectedSocket() client: AuthenticatedSocket,
|
||||||
) {
|
) {
|
||||||
client.to(data.roomId).emit('user_typing', {
|
client.to(`room_${data.roomId}`).emit('user_typing', {
|
||||||
userId: client.user.id,
|
userId: client.user.id,
|
||||||
username: client.user.username,
|
username: client.user.username,
|
||||||
|
roomId: data.roomId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -187,9 +272,10 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
|||||||
@MessageBody() data: { roomId: string },
|
@MessageBody() data: { roomId: string },
|
||||||
@ConnectedSocket() client: AuthenticatedSocket,
|
@ConnectedSocket() client: AuthenticatedSocket,
|
||||||
) {
|
) {
|
||||||
client.to(data.roomId).emit('user_stopped_typing', {
|
client.to(`room_${data.roomId}`).emit('user_stopped_typing', {
|
||||||
userId: client.user.id,
|
userId: client.user.id,
|
||||||
username: client.user.username,
|
username: client.user.username,
|
||||||
|
roomId: data.roomId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -199,20 +285,75 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
|||||||
@ConnectedSocket() client: AuthenticatedSocket,
|
@ConnectedSocket() client: AuthenticatedSocket,
|
||||||
) {
|
) {
|
||||||
// In a full implementation, you would track read receipts
|
// 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,
|
messageId: data.messageId,
|
||||||
userId: client.user.id,
|
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) {
|
if (message.mentions && message.mentions.length > 0) {
|
||||||
for (const mention of message.mentions) {
|
for (const mention of message.mentions) {
|
||||||
const mentionedUserSocket = this.connectedUsers.get(mention.mentionedUser.id);
|
const mentionedUserConnection = this.connectedUsers.get(mention.mentionedUser.id);
|
||||||
if (mentionedUserSocket) {
|
|
||||||
this.server.to(mentionedUserSocket).emit('mentioned', {
|
// Only send mention if user is in the same organization
|
||||||
|
if (mentionedUserConnection && mentionedUserConnection.organizationId === organizationId) {
|
||||||
|
this.server.to(mentionedUserConnection.socketId).emit('mentioned', {
|
||||||
message,
|
message,
|
||||||
mentionedBy: senderId,
|
mentionedBy: senderId,
|
||||||
|
organizationId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -222,16 +363,21 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Method to send message to specific user
|
// Method to send message to specific user in specific organization
|
||||||
sendToUser(userId: string, event: string, data: any) {
|
sendToUserInOrganization(userId: string, organizationId: string, event: string, data: any) {
|
||||||
const socketId = this.connectedUsers.get(userId);
|
const connection = this.connectedUsers.get(userId);
|
||||||
if (socketId) {
|
if (connection && connection.organizationId === organizationId) {
|
||||||
this.server.to(socketId).emit(event, data);
|
this.server.to(connection.socketId).emit(event, data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Method to send message to room
|
// Method to send message to room
|
||||||
sendToRoom(roomId: string, event: string, data: any) {
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -8,16 +8,19 @@ import { ChatRoom } from '../database/entities/chat-room.entity';
|
|||||||
import { MessageMedia } from '../database/entities/message-media.entity';
|
import { MessageMedia } from '../database/entities/message-media.entity';
|
||||||
import { MessageMention } from '../database/entities/message-mention.entity';
|
import { MessageMention } from '../database/entities/message-mention.entity';
|
||||||
import { User } from '../database/entities/user.entity';
|
import { User } from '../database/entities/user.entity';
|
||||||
|
import { Organization } from '../database/entities/organization.entity';
|
||||||
import { EncryptionModule } from '../encryption/encryption.module';
|
import { EncryptionModule } from '../encryption/encryption.module';
|
||||||
import { NotificationModule } from '../notification/notification.module';
|
import { NotificationModule } from '../notification/notification.module';
|
||||||
import { UsersModule } from '../users/users.module';
|
import { UsersModule } from '../users/users.module';
|
||||||
|
import { OrganizationModule } from '../organization/organization.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([Message, ChatRoom, MessageMedia, MessageMention, User]),
|
TypeOrmModule.forFeature([Message, ChatRoom, MessageMedia, MessageMention, User, Organization]),
|
||||||
EncryptionModule,
|
EncryptionModule,
|
||||||
NotificationModule,
|
NotificationModule,
|
||||||
UsersModule,
|
UsersModule,
|
||||||
|
OrganizationModule,
|
||||||
],
|
],
|
||||||
providers: [ChatGateway, ChatService],
|
providers: [ChatGateway, ChatService],
|
||||||
controllers: [ChatController],
|
controllers: [ChatController],
|
||||||
|
@ -5,6 +5,7 @@ import { Message, MessageType } from '../database/entities/message.entity';
|
|||||||
import { ChatRoom, ChatRoomType } from '../database/entities/chat-room.entity';
|
import { ChatRoom, ChatRoomType } from '../database/entities/chat-room.entity';
|
||||||
import { MessageMention } from '../database/entities/message-mention.entity';
|
import { MessageMention } from '../database/entities/message-mention.entity';
|
||||||
import { User } from '../database/entities/user.entity';
|
import { User } from '../database/entities/user.entity';
|
||||||
|
import { Organization } from '../database/entities/organization.entity';
|
||||||
import { EncryptionService } from '../encryption/encryption.service';
|
import { EncryptionService } from '../encryption/encryption.service';
|
||||||
import { CreateMessageDto } from './dto/create-message.dto';
|
import { CreateMessageDto } from './dto/create-message.dto';
|
||||||
import { CreateChatRoomDto } from './dto/create-chat-room.dto';
|
import { CreateChatRoomDto } from './dto/create-chat-room.dto';
|
||||||
@ -20,15 +21,24 @@ export class ChatService {
|
|||||||
private mentionRepository: Repository<MessageMention>,
|
private mentionRepository: Repository<MessageMention>,
|
||||||
@InjectRepository(User)
|
@InjectRepository(User)
|
||||||
private userRepository: Repository<User>,
|
private userRepository: Repository<User>,
|
||||||
|
@InjectRepository(Organization)
|
||||||
|
private organizationRepository: Repository<Organization>,
|
||||||
private encryptionService: EncryptionService,
|
private encryptionService: EncryptionService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async createDirectMessage(senderId: string, recipientId: string): Promise<ChatRoom> {
|
async createDirectMessage(senderId: string, recipientId: string, organizationId: string): Promise<ChatRoom> {
|
||||||
// Check if DM already exists between these users
|
// 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
|
const existingDM = await this.chatRoomRepository
|
||||||
.createQueryBuilder('room')
|
.createQueryBuilder('room')
|
||||||
.innerJoin('room.members', 'member')
|
.innerJoin('room.members', 'member')
|
||||||
.where('room.type = :type', { type: ChatRoomType.DIRECT })
|
.where('room.type = :type', { type: ChatRoomType.DIRECT })
|
||||||
|
.andWhere('room.organizationId = :organizationId', { organizationId })
|
||||||
.groupBy('room.id')
|
.groupBy('room.id')
|
||||||
.having('COUNT(member.id) = 2')
|
.having('COUNT(member.id) = 2')
|
||||||
.andHaving('SUM(CASE WHEN member.id IN (:...userIds) THEN 1 ELSE 0 END) = 2', {
|
.andHaving('SUM(CASE WHEN member.id IN (:...userIds) THEN 1 ELSE 0 END) = 2', {
|
||||||
@ -37,7 +47,7 @@ export class ChatService {
|
|||||||
.getOne();
|
.getOne();
|
||||||
|
|
||||||
if (existingDM) {
|
if (existingDM) {
|
||||||
return this.getChatRoom(existingDM.id, senderId);
|
return this.getChatRoom(existingDM.id, senderId, organizationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new DM
|
// Create new DM
|
||||||
@ -49,6 +59,7 @@ export class ChatService {
|
|||||||
const chatRoom = this.chatRoomRepository.create({
|
const chatRoom = this.chatRoomRepository.create({
|
||||||
type: ChatRoomType.DIRECT,
|
type: ChatRoomType.DIRECT,
|
||||||
members: users,
|
members: users,
|
||||||
|
organization,
|
||||||
isEncrypted: true,
|
isEncrypted: true,
|
||||||
encryptionKey: this.encryptionService.generateChatRoomKey(),
|
encryptionKey: this.encryptionService.generateChatRoomKey(),
|
||||||
});
|
});
|
||||||
@ -56,9 +67,15 @@ export class ChatService {
|
|||||||
return this.chatRoomRepository.save(chatRoom);
|
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;
|
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 allMemberIds = [...new Set([creatorId, ...memberIds])];
|
||||||
const members = await this.userRepository.findByIds(allMemberIds);
|
const members = await this.userRepository.findByIds(allMemberIds);
|
||||||
|
|
||||||
@ -73,6 +90,7 @@ export class ChatService {
|
|||||||
description,
|
description,
|
||||||
type: ChatRoomType.GROUP,
|
type: ChatRoomType.GROUP,
|
||||||
members,
|
members,
|
||||||
|
organization,
|
||||||
createdBy: creator,
|
createdBy: creator,
|
||||||
isEncrypted: true,
|
isEncrypted: true,
|
||||||
encryptionKey: this.encryptionService.generateChatRoomKey(),
|
encryptionKey: this.encryptionService.generateChatRoomKey(),
|
||||||
@ -81,11 +99,19 @@ export class ChatService {
|
|||||||
return this.chatRoomRepository.save(chatRoom);
|
return this.chatRoomRepository.save(chatRoom);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getChatRoom(roomId: string, userId: string): Promise<ChatRoom> {
|
async getChatRoom(roomId: string, userId: string, organizationId?: string): Promise<ChatRoom> {
|
||||||
const chatRoom = await this.chatRoomRepository.findOne({
|
const queryBuilder = this.chatRoomRepository
|
||||||
where: { id: roomId },
|
.createQueryBuilder('room')
|
||||||
relations: ['members', 'createdBy'],
|
.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) {
|
if (!chatRoom) {
|
||||||
throw new NotFoundException('Chat room not found');
|
throw new NotFoundException('Chat room not found');
|
||||||
@ -100,27 +126,30 @@ export class ChatService {
|
|||||||
return chatRoom;
|
return chatRoom;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getUserChatRooms(userId: string): Promise<ChatRoom[]> {
|
async getUserChatRooms(userId: string, organizationId: string): Promise<ChatRoom[]> {
|
||||||
return this.chatRoomRepository
|
return this.chatRoomRepository
|
||||||
.createQueryBuilder('room')
|
.createQueryBuilder('room')
|
||||||
.innerJoin('room.members', 'member', 'member.id = :userId', { userId })
|
.innerJoin('room.members', 'member', 'member.id = :userId', { userId })
|
||||||
.leftJoinAndSelect('room.members', 'allMembers')
|
.leftJoinAndSelect('room.members', 'allMembers')
|
||||||
.leftJoinAndSelect('room.createdBy', 'creator')
|
.leftJoinAndSelect('room.createdBy', 'creator')
|
||||||
|
.leftJoinAndSelect('room.organization', 'organization')
|
||||||
|
.where('room.organizationId = :organizationId', { organizationId })
|
||||||
.orderBy('room.updatedAt', 'DESC')
|
.orderBy('room.updatedAt', 'DESC')
|
||||||
.getMany();
|
.getMany();
|
||||||
}
|
}
|
||||||
|
|
||||||
async createMessage(createMessageDto: CreateMessageDto): Promise<Message> {
|
async createMessage(createMessageDto: CreateMessageDto & { organizationId: string }): Promise<Message> {
|
||||||
const { content, type, senderId, chatRoomId, replyToId } = createMessageDto;
|
const { content, type, senderId, chatRoomId, replyToId, organizationId } = createMessageDto;
|
||||||
|
|
||||||
// Verify user is member of chat room
|
// Verify user is member of chat room and organization
|
||||||
const chatRoom = await this.getChatRoom(chatRoomId, senderId);
|
const chatRoom = await this.getChatRoom(chatRoomId, senderId, organizationId);
|
||||||
const sender = await this.userRepository.findOne({ where: { id: senderId } });
|
const sender = await this.userRepository.findOne({ where: { id: senderId } });
|
||||||
|
const organization = await this.organizationRepository.findOne({ where: { id: organizationId } });
|
||||||
|
|
||||||
let replyTo = null;
|
let replyTo = null;
|
||||||
if (replyToId) {
|
if (replyToId) {
|
||||||
replyTo = await this.messageRepository.findOne({
|
replyTo = await this.messageRepository.findOne({
|
||||||
where: { id: replyToId, chatRoom: { id: chatRoomId } },
|
where: { id: replyToId, chatRoom: { id: chatRoomId }, organization: { id: organizationId } },
|
||||||
});
|
});
|
||||||
if (!replyTo) {
|
if (!replyTo) {
|
||||||
throw new NotFoundException('Reply message not found');
|
throw new NotFoundException('Reply message not found');
|
||||||
@ -138,6 +167,7 @@ export class ChatService {
|
|||||||
type: type as MessageType,
|
type: type as MessageType,
|
||||||
sender,
|
sender,
|
||||||
chatRoom,
|
chatRoom,
|
||||||
|
organization,
|
||||||
replyTo,
|
replyTo,
|
||||||
isEncrypted: chatRoom.isEncrypted,
|
isEncrypted: chatRoom.isEncrypted,
|
||||||
encryptedContent,
|
encryptedContent,
|
||||||
@ -146,7 +176,7 @@ export class ChatService {
|
|||||||
const savedMessage = await this.messageRepository.save(message);
|
const savedMessage = await this.messageRepository.save(message);
|
||||||
|
|
||||||
// Handle mentions
|
// Handle mentions
|
||||||
await this.handleMentions(content, savedMessage);
|
await this.handleMentions(content, savedMessage, organizationId);
|
||||||
|
|
||||||
// Update chat room's updatedAt
|
// Update chat room's updatedAt
|
||||||
await this.chatRoomRepository.update(chatRoomId, { updatedAt: new Date() });
|
await this.chatRoomRepository.update(chatRoomId, { updatedAt: new Date() });
|
||||||
@ -154,10 +184,10 @@ export class ChatService {
|
|||||||
return savedMessage;
|
return savedMessage;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getMessage(messageId: string): Promise<Message> {
|
async getMessage(messageId: string, organizationId: string): Promise<Message> {
|
||||||
const message = await this.messageRepository.findOne({
|
const message = await this.messageRepository.findOne({
|
||||||
where: { id: messageId },
|
where: { id: messageId, organization: { id: organizationId } },
|
||||||
relations: ['sender', 'chatRoom', 'replyTo', 'replyTo.sender', 'mentions', 'mentions.mentionedUser', 'media'],
|
relations: ['sender', 'chatRoom', 'organization', 'replyTo', 'replyTo.sender', 'mentions', 'mentions.mentionedUser', 'media'],
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!message) {
|
if (!message) {
|
||||||
@ -176,16 +206,21 @@ export class ChatService {
|
|||||||
async getChatRoomMessages(
|
async getChatRoomMessages(
|
||||||
roomId: string,
|
roomId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
|
organizationId: string,
|
||||||
page = 1,
|
page = 1,
|
||||||
limit = 50,
|
limit = 50,
|
||||||
): Promise<{ messages: Message[]; total: number }> {
|
): Promise<{ messages: Message[]; total: number }> {
|
||||||
// Verify user access to chat room
|
// Verify user access to chat room
|
||||||
await this.getChatRoom(roomId, userId);
|
await this.getChatRoom(roomId, userId, organizationId);
|
||||||
|
|
||||||
const skip = (page - 1) * limit;
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
const [messages, total] = await this.messageRepository.findAndCount({
|
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'],
|
relations: ['sender', 'replyTo', 'replyTo.sender', 'mentions', 'mentions.mentionedUser', 'media'],
|
||||||
order: { createdAt: 'DESC' },
|
order: { createdAt: 'DESC' },
|
||||||
skip,
|
skip,
|
||||||
@ -205,9 +240,9 @@ export class ChatService {
|
|||||||
return { messages: messages.reverse(), total };
|
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({
|
const message = await this.messageRepository.findOne({
|
||||||
where: { id: messageId },
|
where: { id: messageId, organization: { id: organizationId } },
|
||||||
relations: ['sender', 'chatRoom'],
|
relations: ['sender', 'chatRoom'],
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -233,12 +268,12 @@ export class ChatService {
|
|||||||
editedAt: new Date(),
|
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({
|
const message = await this.messageRepository.findOne({
|
||||||
where: { id: messageId },
|
where: { id: messageId, organization: { id: organizationId } },
|
||||||
relations: ['sender', 'chatRoom'],
|
relations: ['sender', 'chatRoom'],
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -259,8 +294,8 @@ export class ChatService {
|
|||||||
return message;
|
return message;
|
||||||
}
|
}
|
||||||
|
|
||||||
async addMemberToGroup(roomId: string, memberIds: string[], userId: string): Promise<ChatRoom> {
|
async addMemberToGroup(roomId: string, memberIds: string[], userId: string, organizationId: string): Promise<ChatRoom> {
|
||||||
const chatRoom = await this.getChatRoom(roomId, userId);
|
const chatRoom = await this.getChatRoom(roomId, userId, organizationId);
|
||||||
|
|
||||||
if (chatRoom.type !== ChatRoomType.GROUP) {
|
if (chatRoom.type !== ChatRoomType.GROUP) {
|
||||||
throw new BadRequestException('Can only add members to group chats');
|
throw new BadRequestException('Can only add members to group chats');
|
||||||
@ -285,8 +320,8 @@ export class ChatService {
|
|||||||
return this.chatRoomRepository.save(chatRoom);
|
return this.chatRoomRepository.save(chatRoom);
|
||||||
}
|
}
|
||||||
|
|
||||||
async removeMemberFromGroup(roomId: string, memberIdToRemove: string, userId: string): Promise<ChatRoom> {
|
async removeMemberFromGroup(roomId: string, memberIdToRemove: string, userId: string, organizationId: string): Promise<ChatRoom> {
|
||||||
const chatRoom = await this.getChatRoom(roomId, userId);
|
const chatRoom = await this.getChatRoom(roomId, userId, organizationId);
|
||||||
|
|
||||||
if (chatRoom.type !== ChatRoomType.GROUP) {
|
if (chatRoom.type !== ChatRoomType.GROUP) {
|
||||||
throw new BadRequestException('Can only remove members from group chats');
|
throw new BadRequestException('Can only remove members from group chats');
|
||||||
@ -302,16 +337,22 @@ export class ChatService {
|
|||||||
return this.chatRoomRepository.save(chatRoom);
|
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)
|
// Extract mentions from content (e.g., @username or @userId)
|
||||||
const mentionRegex = /@(\w+)/g;
|
const mentionRegex = /@(\w+)/g;
|
||||||
const mentions = content.match(mentionRegex);
|
const mentions = content.match(mentionRegex);
|
||||||
|
|
||||||
if (mentions) {
|
if (mentions) {
|
||||||
const usernames = mentions.map(mention => mention.substring(1));
|
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) {
|
for (const user of mentionedUsers) {
|
||||||
const mention = this.mentionRepository.create({
|
const mention = this.mentionRepository.create({
|
||||||
|
@ -11,6 +11,7 @@ import {
|
|||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { Message } from './message.entity';
|
import { Message } from './message.entity';
|
||||||
import { User } from './user.entity';
|
import { User } from './user.entity';
|
||||||
|
import { Organization } from './organization.entity';
|
||||||
|
|
||||||
export enum ChatRoomType {
|
export enum ChatRoomType {
|
||||||
DIRECT = 'direct',
|
DIRECT = 'direct',
|
||||||
@ -47,6 +48,9 @@ export class ChatRoom {
|
|||||||
@ManyToOne(() => User, { nullable: true })
|
@ManyToOne(() => User, { nullable: true })
|
||||||
createdBy: User;
|
createdBy: User;
|
||||||
|
|
||||||
|
@ManyToOne(() => Organization)
|
||||||
|
organization: Organization;
|
||||||
|
|
||||||
@CreateDateColumn()
|
@CreateDateColumn()
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|
||||||
|
@ -11,6 +11,7 @@ import { User } from './user.entity';
|
|||||||
import { ChatRoom } from './chat-room.entity';
|
import { ChatRoom } from './chat-room.entity';
|
||||||
import { MessageMedia } from './message-media.entity';
|
import { MessageMedia } from './message-media.entity';
|
||||||
import { MessageMention } from './message-mention.entity';
|
import { MessageMention } from './message-mention.entity';
|
||||||
|
import { Organization } from './organization.entity';
|
||||||
|
|
||||||
export enum MessageType {
|
export enum MessageType {
|
||||||
TEXT = 'text',
|
TEXT = 'text',
|
||||||
@ -63,6 +64,9 @@ export class Message {
|
|||||||
@ManyToOne(() => ChatRoom, (chatRoom) => chatRoom.messages)
|
@ManyToOne(() => ChatRoom, (chatRoom) => chatRoom.messages)
|
||||||
chatRoom: ChatRoom;
|
chatRoom: ChatRoom;
|
||||||
|
|
||||||
|
@ManyToOne(() => Organization)
|
||||||
|
organization: Organization;
|
||||||
|
|
||||||
@OneToMany(() => MessageMedia, (media) => media.message)
|
@OneToMany(() => MessageMedia, (media) => media.message)
|
||||||
media: MessageMedia[];
|
media: MessageMedia[];
|
||||||
|
|
||||||
|
@ -7,12 +7,14 @@ import {
|
|||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { User } from './user.entity';
|
import { User } from './user.entity';
|
||||||
import { Message } from './message.entity';
|
import { Message } from './message.entity';
|
||||||
|
import { Organization } from './organization.entity';
|
||||||
|
|
||||||
export enum NotificationType {
|
export enum NotificationType {
|
||||||
MESSAGE = 'message',
|
MESSAGE = 'message',
|
||||||
MENTION = 'mention',
|
MENTION = 'mention',
|
||||||
GROUP_INVITE = 'group_invite',
|
GROUP_INVITE = 'group_invite',
|
||||||
FRIEND_REQUEST = 'friend_request',
|
FRIEND_REQUEST = 'friend_request',
|
||||||
|
ORGANIZATION_INVITE = 'organization_invite',
|
||||||
}
|
}
|
||||||
|
|
||||||
@Entity('notifications')
|
@Entity('notifications')
|
||||||
@ -49,4 +51,7 @@ export class Notification {
|
|||||||
|
|
||||||
@ManyToOne(() => Message, { nullable: true })
|
@ManyToOne(() => Message, { nullable: true })
|
||||||
message: Message;
|
message: Message;
|
||||||
|
|
||||||
|
@ManyToOne(() => Organization)
|
||||||
|
organization: Organization;
|
||||||
}
|
}
|
66
src/database/entities/organization-invite.entity.ts
Normal file
66
src/database/entities/organization-invite.entity.ts
Normal 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;
|
||||||
|
}
|
76
src/database/entities/organization-member.entity.ts
Normal file
76
src/database/entities/organization-member.entity.ts
Normal 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;
|
||||||
|
}
|
100
src/database/entities/organization.entity.ts
Normal file
100
src/database/entities/organization.entity.ts
Normal 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[];
|
||||||
|
}
|
@ -12,6 +12,7 @@ import { Exclude } from 'class-transformer';
|
|||||||
import { Message } from './message.entity';
|
import { Message } from './message.entity';
|
||||||
import { ChatRoom } from './chat-room.entity';
|
import { ChatRoom } from './chat-room.entity';
|
||||||
import { UserDevice } from './user-device.entity';
|
import { UserDevice } from './user-device.entity';
|
||||||
|
import { OrganizationMember } from './organization-member.entity';
|
||||||
|
|
||||||
@Entity('users')
|
@Entity('users')
|
||||||
export class User {
|
export class User {
|
||||||
@ -61,4 +62,7 @@ export class User {
|
|||||||
|
|
||||||
@OneToMany(() => UserDevice, (device) => device.user)
|
@OneToMany(() => UserDevice, (device) => device.user)
|
||||||
devices: UserDevice[];
|
devices: UserDevice[];
|
||||||
|
|
||||||
|
@OneToMany(() => OrganizationMember, (member) => member.user)
|
||||||
|
organizationMemberships: OrganizationMember[];
|
||||||
}
|
}
|
62
src/organization/dto/create-organization.dto.ts
Normal file
62
src/organization/dto/create-organization.dto.ts
Normal 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;
|
||||||
|
};
|
||||||
|
}
|
19
src/organization/dto/invite-user.dto.ts
Normal file
19
src/organization/dto/invite-user.dto.ts
Normal 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;
|
||||||
|
}
|
62
src/organization/dto/update-organization.dto.ts
Normal file
62
src/organization/dto/update-organization.dto.ts
Normal 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;
|
||||||
|
};
|
||||||
|
}
|
48
src/organization/guards/organization.guard.ts
Normal file
48
src/organization/guards/organization.guard.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
131
src/organization/organization.controller.ts
Normal file
131
src/organization/organization.controller.ts
Normal 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' };
|
||||||
|
}
|
||||||
|
}
|
20
src/organization/organization.module.ts
Normal file
20
src/organization/organization.module.ts
Normal 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 {}
|
443
src/organization/organization.service.ts
Normal file
443
src/organization/organization.service.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user