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
|
||||
|
||||
### 🏢 Multi-Tenant Organization Support
|
||||
- **Complete data isolation** between organizations
|
||||
- **Organization management** with roles and permissions (Owner, Admin, Moderator, Member)
|
||||
- **Member invitation system** with email-based invites
|
||||
- **Organization switching** for users in multiple organizations
|
||||
- **Configurable organization settings** and limits
|
||||
- **Organization-scoped chat rooms** and messaging
|
||||
- **Real-time organization context** in WebSocket connections
|
||||
|
||||
### Core Chat Features
|
||||
- **Real-time messaging** with WebSocket support
|
||||
- **Direct Messages (DM)** between users
|
||||
- **Direct Messages (DM)** between users within organizations
|
||||
- **Group chat** functionality with member management
|
||||
- **Message editing and deletion**
|
||||
- **Reply to messages** functionality
|
||||
@ -116,6 +125,20 @@ The application will be available at:
|
||||
- `POST /auth/login` - Login user
|
||||
- `POST /auth/logout` - Logout user
|
||||
|
||||
### Organizations
|
||||
- `POST /organizations` - Create new organization
|
||||
- `GET /organizations` - Get user organizations
|
||||
- `GET /organizations/active` - Get user's active organization
|
||||
- `POST /organizations/:orgId/set-active` - Set active organization
|
||||
- `GET /organizations/:orgId` - Get organization details
|
||||
- `PUT /organizations/:orgId` - Update organization
|
||||
- `GET /organizations/:orgId/members` - Get organization members
|
||||
- `POST /organizations/:orgId/invite` - Invite user to organization
|
||||
- `POST /organizations/accept-invite/:token` - Accept organization invitation
|
||||
- `DELETE /organizations/:orgId/members/:memberId` - Remove member
|
||||
- `PUT /organizations/:orgId/members/:memberId/role` - Update member role
|
||||
- `POST /organizations/:orgId/leave` - Leave organization
|
||||
|
||||
### Users
|
||||
- `GET /users` - Get all users (with search)
|
||||
- `GET /users/me` - Get current user profile
|
||||
@ -125,10 +148,11 @@ The application will be available at:
|
||||
- `GET /users/username/:username` - Get user by username
|
||||
- `GET /users/:id/public-key` - Get user's public key
|
||||
|
||||
### Chat
|
||||
- `GET /chat/rooms` - Get user's chat rooms
|
||||
- `POST /chat/rooms/direct` - Create/get direct message room
|
||||
- `POST /chat/rooms/group` - Create group chat room
|
||||
### Chat (Organization Context Required)
|
||||
**Note**: All chat endpoints require organization context via `x-organization-id` header or active organization.
|
||||
- `GET /chat/rooms` - Get user's chat rooms in organization
|
||||
- `POST /chat/rooms/direct` - Create/get direct message room in organization
|
||||
- `POST /chat/rooms/group` - Create group chat room in organization
|
||||
- `GET /chat/rooms/:roomId` - Get chat room details
|
||||
- `GET /chat/rooms/:roomId/messages` - Get chat room messages
|
||||
- `POST /chat/messages` - Create new message
|
||||
@ -151,18 +175,33 @@ The application will be available at:
|
||||
|
||||
## WebSocket Events
|
||||
|
||||
### Connection Setup
|
||||
```typescript
|
||||
// Connect with JWT token and organization context
|
||||
const socket = io('ws://localhost:3000', {
|
||||
auth: {
|
||||
token: 'your-jwt-token',
|
||||
organizationId: 'org-uuid' // Optional - uses active org if not provided
|
||||
},
|
||||
headers: {
|
||||
'x-organization-id': 'org-uuid' // Alternative way to specify organization
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Client to Server
|
||||
- `join_room` - Join a chat room
|
||||
- `join_room` - Join a chat room within organization
|
||||
- `leave_room` - Leave a chat room
|
||||
- `send_message` - Send a new message
|
||||
- `send_message` - Send a new message in organization context
|
||||
- `edit_message` - Edit existing message
|
||||
- `delete_message` - Delete message
|
||||
- `typing_start` - Start typing indicator
|
||||
- `typing_stop` - Stop typing indicator
|
||||
- `mark_as_read` - Mark message as read
|
||||
- `switch_organization` - Switch to different organization context
|
||||
|
||||
### Server to Client
|
||||
- `connected` - Connection established
|
||||
- `connected` - Connection established with organization context
|
||||
- `new_message` - New message received
|
||||
- `message_edited` - Message was edited
|
||||
- `message_deleted` - Message was deleted
|
||||
@ -170,26 +209,41 @@ The application will be available at:
|
||||
- `user_stopped_typing` - User stopped typing
|
||||
- `message_read` - Message was read
|
||||
- `mentioned` - User was mentioned
|
||||
- `user_online` - User came online
|
||||
- `user_offline` - User went offline
|
||||
- `user_online` - User came online in organization
|
||||
- `user_offline` - User went offline in organization
|
||||
- `joined_room` - Successfully joined room
|
||||
- `left_room` - Successfully left room
|
||||
- `organization_switched` - Successfully switched organizations
|
||||
- `error` - Error occurred
|
||||
|
||||
## Flutter SDK Integration
|
||||
|
||||
This API is designed to work seamlessly with Flutter applications. Key considerations for Flutter integration:
|
||||
This API is designed to work seamlessly with Flutter applications with full multi-tenant organization support. Key considerations for Flutter integration:
|
||||
|
||||
### WebSocket Connection
|
||||
```typescript
|
||||
// Connect with JWT token
|
||||
const socket = io('ws://localhost:3000', {
|
||||
auth: {
|
||||
token: 'your-jwt-token'
|
||||
### Organization Context
|
||||
Every API call and WebSocket connection must include organization context:
|
||||
```dart
|
||||
// HTTP Headers
|
||||
final headers = {
|
||||
'Authorization': 'Bearer $jwtToken',
|
||||
'x-organization-id': currentOrganizationId,
|
||||
};
|
||||
|
||||
// WebSocket Connection
|
||||
final socket = io('ws://localhost:3000', {
|
||||
'auth': {
|
||||
'token': jwtToken,
|
||||
'organizationId': currentOrganizationId,
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Multi-Organization User Flow
|
||||
1. User logs in and gets list of organizations
|
||||
2. User selects/switches between organizations
|
||||
3. All chat data is filtered by current organization
|
||||
4. WebSocket automatically switches context when organization changes
|
||||
|
||||
### File Upload
|
||||
The API supports multipart/form-data uploads, compatible with Flutter's HTTP client and dio package.
|
||||
|
||||
@ -202,22 +256,47 @@ Socket.IO events are designed to map directly to Flutter state management patter
|
||||
## Database Schema
|
||||
|
||||
The application uses SQLite with the following main entities:
|
||||
- **Organizations**: Multi-tenant organization containers
|
||||
- **OrganizationMembers**: User membership in organizations with roles
|
||||
- **OrganizationInvites**: Email-based invitation system
|
||||
- **Users**: User accounts with encryption keys
|
||||
- **ChatRooms**: Direct and group chat rooms
|
||||
- **Messages**: Chat messages with encryption support
|
||||
- **ChatRooms**: Direct and group chat rooms (organization-scoped)
|
||||
- **Messages**: Chat messages with encryption support (organization-scoped)
|
||||
- **MessageMedia**: File attachments
|
||||
- **MessageMentions**: User mentions in messages
|
||||
- **UserDevices**: Device registration for push notifications
|
||||
- **Notifications**: Push notification records
|
||||
- **Notifications**: Push notification records (organization-scoped)
|
||||
|
||||
## Organization Roles & Permissions
|
||||
|
||||
### Role Hierarchy
|
||||
1. **Owner** - Full control over organization
|
||||
2. **Admin** - Management capabilities except role changes
|
||||
3. **Moderator** - Content moderation and limited management
|
||||
4. **Member** - Basic chat participation
|
||||
|
||||
### Permission Matrix
|
||||
| Permission | Owner | Admin | Moderator | Member |
|
||||
|------------|-------|--------|-----------|---------|
|
||||
| Manage Organization Settings | ✅ | ✅ | ❌ | ❌ |
|
||||
| Invite Users | ✅ | ✅ | ✅ | ❌ |
|
||||
| Manage Roles | ✅ | ❌ | ❌ | ❌ |
|
||||
| Kick Members | ✅ | ✅ | ❌ | ❌ |
|
||||
| Create Channels | ✅ | ✅ | ✅ | ❌ |
|
||||
| Delete Messages | ✅ | ✅ | ✅ | ❌ |
|
||||
| Access Analytics | ✅ | ✅ | ❌ | ❌ |
|
||||
| Send Messages | ✅ | ✅ | ✅ | ✅ |
|
||||
|
||||
## Security Features
|
||||
|
||||
- **JWT Authentication**: Secure token-based auth
|
||||
- **Multi-Tenant Data Isolation**: Complete separation between organizations
|
||||
- **JWT Authentication**: Secure token-based auth with organization context
|
||||
- **Role-Based Access Control**: Granular permissions based on organization roles
|
||||
- **End-to-end Encryption**: Messages encrypted with room-specific keys
|
||||
- **File Access Control**: Only chat members can access uploaded files
|
||||
- **File Access Control**: Only organization members can access uploaded files
|
||||
- **Input Validation**: All inputs validated and sanitized
|
||||
- **CORS Configuration**: Configured for cross-origin requests
|
||||
- **Rate Limiting**: Built-in protection against abuse
|
||||
- **Organization Membership Verification**: All operations verify user membership
|
||||
|
||||
## Development
|
||||
|
||||
|
@ -15,6 +15,7 @@ import { ChatModule } from './chat/chat.module';
|
||||
import { MediaModule } from './media/media.module';
|
||||
import { NotificationModule } from './notification/notification.module';
|
||||
import { EncryptionModule } from './encryption/encryption.module';
|
||||
import { OrganizationModule } from './organization/organization.module';
|
||||
|
||||
// Ensure storage directories exist
|
||||
const storageDir = '/app/storage';
|
||||
@ -61,6 +62,7 @@ try {
|
||||
ScheduleModule.forRoot(),
|
||||
AuthModule,
|
||||
UsersModule,
|
||||
OrganizationModule,
|
||||
ChatModule,
|
||||
MediaModule,
|
||||
NotificationModule,
|
||||
|
@ -10,24 +10,30 @@ import {
|
||||
UseGuards,
|
||||
Request,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiHeader } from '@nestjs/swagger';
|
||||
import { ChatService } from './chat.service';
|
||||
import { CreateChatRoomDto } from './dto/create-chat-room.dto';
|
||||
import { CreateMessageDto } from './dto/create-message.dto';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { OrganizationGuard } from '../organization/guards/organization.guard';
|
||||
|
||||
@ApiTags('Chat')
|
||||
@Controller('chat')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@UseGuards(JwtAuthGuard, OrganizationGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiHeader({
|
||||
name: 'x-organization-id',
|
||||
description: 'Organization ID for multi-tenant access',
|
||||
required: false,
|
||||
})
|
||||
export class ChatController {
|
||||
constructor(private readonly chatService: ChatService) {}
|
||||
|
||||
@Get('rooms')
|
||||
@ApiOperation({ summary: 'Get user chat rooms' })
|
||||
@ApiOperation({ summary: 'Get user chat rooms in organization' })
|
||||
@ApiResponse({ status: 200, description: 'Chat rooms retrieved successfully' })
|
||||
async getUserChatRooms(@Request() req) {
|
||||
return this.chatService.getUserChatRooms(req.user.id);
|
||||
return this.chatService.getUserChatRooms(req.user.id, req.organization.id);
|
||||
}
|
||||
|
||||
@Post('rooms/direct')
|
||||
@ -37,7 +43,7 @@ export class ChatController {
|
||||
@Request() req,
|
||||
@Body('recipientId') recipientId: string,
|
||||
) {
|
||||
return this.chatService.createDirectMessage(req.user.id, recipientId);
|
||||
return this.chatService.createDirectMessage(req.user.id, recipientId, req.organization.id);
|
||||
}
|
||||
|
||||
@Post('rooms/group')
|
||||
@ -47,14 +53,14 @@ export class ChatController {
|
||||
@Request() req,
|
||||
@Body() createChatRoomDto: CreateChatRoomDto,
|
||||
) {
|
||||
return this.chatService.createGroupChat(createChatRoomDto, req.user.id);
|
||||
return this.chatService.createGroupChat(createChatRoomDto, req.user.id, req.organization.id);
|
||||
}
|
||||
|
||||
@Get('rooms/:roomId')
|
||||
@ApiOperation({ summary: 'Get chat room details' })
|
||||
@ApiResponse({ status: 200, description: 'Chat room retrieved successfully' })
|
||||
async getChatRoom(@Param('roomId') roomId: string, @Request() req) {
|
||||
return this.chatService.getChatRoom(roomId, req.user.id);
|
||||
return this.chatService.getChatRoom(roomId, req.user.id, req.organization.id);
|
||||
}
|
||||
|
||||
@Get('rooms/:roomId/messages')
|
||||
@ -66,7 +72,7 @@ export class ChatController {
|
||||
@Query('page') page?: number,
|
||||
@Query('limit') limit?: number,
|
||||
) {
|
||||
return this.chatService.getChatRoomMessages(roomId, req.user.id, page, limit);
|
||||
return this.chatService.getChatRoomMessages(roomId, req.user.id, req.organization.id, page, limit);
|
||||
}
|
||||
|
||||
@Post('messages')
|
||||
@ -76,6 +82,7 @@ export class ChatController {
|
||||
return this.chatService.createMessage({
|
||||
...createMessageDto,
|
||||
senderId: req.user.id,
|
||||
organizationId: req.organization.id,
|
||||
});
|
||||
}
|
||||
|
||||
@ -87,14 +94,14 @@ export class ChatController {
|
||||
@Request() req,
|
||||
@Body('content') content: string,
|
||||
) {
|
||||
return this.chatService.editMessage(messageId, content, req.user.id);
|
||||
return this.chatService.editMessage(messageId, content, req.user.id, req.organization.id);
|
||||
}
|
||||
|
||||
@Delete('messages/:messageId')
|
||||
@ApiOperation({ summary: 'Delete message' })
|
||||
@ApiResponse({ status: 200, description: 'Message deleted successfully' })
|
||||
async deleteMessage(@Param('messageId') messageId: string, @Request() req) {
|
||||
return this.chatService.deleteMessage(messageId, req.user.id);
|
||||
return this.chatService.deleteMessage(messageId, req.user.id, req.organization.id);
|
||||
}
|
||||
|
||||
@Post('rooms/:roomId/members')
|
||||
@ -105,7 +112,7 @@ export class ChatController {
|
||||
@Request() req,
|
||||
@Body('memberIds') memberIds: string[],
|
||||
) {
|
||||
return this.chatService.addMemberToGroup(roomId, memberIds, req.user.id);
|
||||
return this.chatService.addMemberToGroup(roomId, memberIds, req.user.id, req.organization.id);
|
||||
}
|
||||
|
||||
@Delete('rooms/:roomId/members/:memberId')
|
||||
@ -116,6 +123,6 @@ export class ChatController {
|
||||
@Param('memberId') memberId: string,
|
||||
@Request() req,
|
||||
) {
|
||||
return this.chatService.removeMemberFromGroup(roomId, memberId, req.user.id);
|
||||
return this.chatService.removeMemberFromGroup(roomId, memberId, req.user.id, req.organization.id);
|
||||
}
|
||||
}
|
@ -13,6 +13,7 @@ import { JwtService } from '@nestjs/jwt';
|
||||
import { ChatService } from './chat.service';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { NotificationService } from '../notification/notification.service';
|
||||
import { OrganizationService } from '../organization/organization.service';
|
||||
|
||||
interface AuthenticatedSocket extends Socket {
|
||||
user: {
|
||||
@ -20,6 +21,11 @@ interface AuthenticatedSocket extends Socket {
|
||||
email: string;
|
||||
username: string;
|
||||
};
|
||||
organization: {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
};
|
||||
}
|
||||
|
||||
@WebSocketGateway({
|
||||
@ -31,18 +37,21 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
@WebSocketServer()
|
||||
server: Server;
|
||||
|
||||
private connectedUsers = new Map<string, string>(); // userId -> socketId
|
||||
private connectedUsers = new Map<string, { socketId: string; organizationId: string }>(); // userId -> {socketId, organizationId}
|
||||
private organizationRooms = new Map<string, Set<string>>(); // organizationId -> Set of socketIds
|
||||
|
||||
constructor(
|
||||
private chatService: ChatService,
|
||||
private usersService: UsersService,
|
||||
private notificationService: NotificationService,
|
||||
private organizationService: OrganizationService,
|
||||
private jwtService: JwtService,
|
||||
) {}
|
||||
|
||||
async handleConnection(client: Socket) {
|
||||
try {
|
||||
const token = client.handshake.auth.token || client.handshake.headers.authorization?.split(' ')[1];
|
||||
const organizationId = client.handshake.auth.organizationId || client.handshake.headers['x-organization-id'];
|
||||
|
||||
if (!token) {
|
||||
client.disconnect();
|
||||
@ -57,38 +66,103 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get user's active organization if not provided
|
||||
let activeOrgId = organizationId;
|
||||
if (!activeOrgId) {
|
||||
const activeOrg = await this.organizationService.getUserActiveOrganization(user.id);
|
||||
activeOrgId = activeOrg?.id;
|
||||
}
|
||||
|
||||
if (!activeOrgId) {
|
||||
client.emit('error', { message: 'No organization context provided' });
|
||||
client.disconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify user is member of the organization
|
||||
const isMember = await this.organizationService.isUserMember(activeOrgId, user.id);
|
||||
if (!isMember) {
|
||||
client.emit('error', { message: 'You are not a member of this organization' });
|
||||
client.disconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
const organization = await this.organizationService.getOrganization(activeOrgId, user.id);
|
||||
|
||||
(client as AuthenticatedSocket).user = {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
};
|
||||
|
||||
this.connectedUsers.set(user.id, client.id);
|
||||
(client as AuthenticatedSocket).organization = {
|
||||
id: organization.id,
|
||||
name: organization.name,
|
||||
slug: organization.slug,
|
||||
};
|
||||
|
||||
// Store connection info
|
||||
this.connectedUsers.set(user.id, { socketId: client.id, organizationId: activeOrgId });
|
||||
|
||||
// Add to organization room
|
||||
if (!this.organizationRooms.has(activeOrgId)) {
|
||||
this.organizationRooms.set(activeOrgId, new Set());
|
||||
}
|
||||
this.organizationRooms.get(activeOrgId).add(client.id);
|
||||
client.join(`org_${activeOrgId}`);
|
||||
|
||||
await this.usersService.updateOnlineStatus(user.id, true);
|
||||
|
||||
client.emit('connected', { message: 'Connected successfully', user: client.user });
|
||||
|
||||
// Join user to their chat rooms
|
||||
const chatRooms = await this.chatService.getUserChatRooms(user.id);
|
||||
chatRooms.forEach(room => {
|
||||
client.join(room.id);
|
||||
client.emit('connected', {
|
||||
message: 'Connected successfully',
|
||||
user: (client as AuthenticatedSocket).user,
|
||||
organization: (client as AuthenticatedSocket).organization,
|
||||
});
|
||||
|
||||
// Notify other users that this user is online
|
||||
this.server.emit('user_online', { userId: user.id, username: user.username });
|
||||
// Join user to their chat rooms within the organization
|
||||
const chatRooms = await this.chatService.getUserChatRooms(user.id, activeOrgId);
|
||||
chatRooms.forEach(room => {
|
||||
client.join(`room_${room.id}`);
|
||||
});
|
||||
|
||||
// Notify other users in the same organization that this user is online
|
||||
client.to(`org_${activeOrgId}`).emit('user_online', {
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
organizationId: activeOrgId,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
client.emit('error', { message: 'Authentication failed' });
|
||||
client.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
async handleDisconnect(client: AuthenticatedSocket) {
|
||||
if (client.user) {
|
||||
this.connectedUsers.delete(client.user.id);
|
||||
if (client.user && client.organization) {
|
||||
const connectionInfo = this.connectedUsers.get(client.user.id);
|
||||
|
||||
if (connectionInfo) {
|
||||
this.connectedUsers.delete(client.user.id);
|
||||
|
||||
// Remove from organization room
|
||||
const orgSockets = this.organizationRooms.get(client.organization.id);
|
||||
if (orgSockets) {
|
||||
orgSockets.delete(client.id);
|
||||
if (orgSockets.size === 0) {
|
||||
this.organizationRooms.delete(client.organization.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await this.usersService.updateOnlineStatus(client.user.id, false);
|
||||
|
||||
// Notify other users that this user is offline
|
||||
this.server.emit('user_offline', { userId: client.user.id, username: client.user.username });
|
||||
// Notify other users in the same organization that this user is offline
|
||||
client.to(`org_${client.organization.id}`).emit('user_offline', {
|
||||
userId: client.user.id,
|
||||
username: client.user.username,
|
||||
organizationId: client.organization.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -98,8 +172,8 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
@ConnectedSocket() client: AuthenticatedSocket,
|
||||
) {
|
||||
try {
|
||||
const room = await this.chatService.getChatRoom(data.roomId, client.user.id);
|
||||
client.join(data.roomId);
|
||||
const room = await this.chatService.getChatRoom(data.roomId, client.user.id, client.organization.id);
|
||||
client.join(`room_${data.roomId}`);
|
||||
client.emit('joined_room', { roomId: data.roomId, room });
|
||||
} catch (error) {
|
||||
client.emit('error', { message: error.message });
|
||||
@ -111,7 +185,7 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
@MessageBody() data: { roomId: string },
|
||||
@ConnectedSocket() client: AuthenticatedSocket,
|
||||
) {
|
||||
client.leave(data.roomId);
|
||||
client.leave(`room_${data.roomId}`);
|
||||
client.emit('left_room', { roomId: data.roomId });
|
||||
}
|
||||
|
||||
@ -127,15 +201,16 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
senderId: client.user.id,
|
||||
chatRoomId: data.roomId,
|
||||
replyToId: data.replyToId,
|
||||
organizationId: client.organization.id,
|
||||
});
|
||||
|
||||
const populatedMessage = await this.chatService.getMessage(message.id);
|
||||
const populatedMessage = await this.chatService.getMessage(message.id, client.organization.id);
|
||||
|
||||
// Send message to all users in the room
|
||||
this.server.to(data.roomId).emit('new_message', populatedMessage);
|
||||
this.server.to(`room_${data.roomId}`).emit('new_message', populatedMessage);
|
||||
|
||||
// Handle mentions
|
||||
await this.handleMentions(populatedMessage, client.user.id);
|
||||
await this.handleMentions(populatedMessage, client.user.id, client.organization.id);
|
||||
|
||||
// Send push notifications to offline users
|
||||
await this.notificationService.sendNewMessageNotifications(populatedMessage);
|
||||
@ -151,8 +226,13 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
@ConnectedSocket() client: AuthenticatedSocket,
|
||||
) {
|
||||
try {
|
||||
const message = await this.chatService.editMessage(data.messageId, data.content, client.user.id);
|
||||
this.server.to(message.chatRoom.id).emit('message_edited', message);
|
||||
const message = await this.chatService.editMessage(
|
||||
data.messageId,
|
||||
data.content,
|
||||
client.user.id,
|
||||
client.organization.id
|
||||
);
|
||||
this.server.to(`room_${message.chatRoom.id}`).emit('message_edited', message);
|
||||
} catch (error) {
|
||||
client.emit('error', { message: error.message });
|
||||
}
|
||||
@ -164,8 +244,12 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
@ConnectedSocket() client: AuthenticatedSocket,
|
||||
) {
|
||||
try {
|
||||
const message = await this.chatService.deleteMessage(data.messageId, client.user.id);
|
||||
this.server.to(message.chatRoom.id).emit('message_deleted', { messageId: data.messageId });
|
||||
const message = await this.chatService.deleteMessage(
|
||||
data.messageId,
|
||||
client.user.id,
|
||||
client.organization.id
|
||||
);
|
||||
this.server.to(`room_${message.chatRoom.id}`).emit('message_deleted', { messageId: data.messageId });
|
||||
} catch (error) {
|
||||
client.emit('error', { message: error.message });
|
||||
}
|
||||
@ -176,9 +260,10 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
@MessageBody() data: { roomId: string },
|
||||
@ConnectedSocket() client: AuthenticatedSocket,
|
||||
) {
|
||||
client.to(data.roomId).emit('user_typing', {
|
||||
client.to(`room_${data.roomId}`).emit('user_typing', {
|
||||
userId: client.user.id,
|
||||
username: client.user.username,
|
||||
roomId: data.roomId,
|
||||
});
|
||||
}
|
||||
|
||||
@ -187,9 +272,10 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
@MessageBody() data: { roomId: string },
|
||||
@ConnectedSocket() client: AuthenticatedSocket,
|
||||
) {
|
||||
client.to(data.roomId).emit('user_stopped_typing', {
|
||||
client.to(`room_${data.roomId}`).emit('user_stopped_typing', {
|
||||
userId: client.user.id,
|
||||
username: client.user.username,
|
||||
roomId: data.roomId,
|
||||
});
|
||||
}
|
||||
|
||||
@ -199,20 +285,75 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
@ConnectedSocket() client: AuthenticatedSocket,
|
||||
) {
|
||||
// In a full implementation, you would track read receipts
|
||||
client.to(data.roomId).emit('message_read', {
|
||||
client.to(`room_${data.roomId}`).emit('message_read', {
|
||||
messageId: data.messageId,
|
||||
userId: client.user.id,
|
||||
roomId: data.roomId,
|
||||
});
|
||||
}
|
||||
|
||||
private async handleMentions(message: any, senderId: string) {
|
||||
@SubscribeMessage('switch_organization')
|
||||
async handleSwitchOrganization(
|
||||
@MessageBody() data: { organizationId: string },
|
||||
@ConnectedSocket() client: AuthenticatedSocket,
|
||||
) {
|
||||
try {
|
||||
// Verify user is member of the new organization
|
||||
const isMember = await this.organizationService.isUserMember(data.organizationId, client.user.id);
|
||||
if (!isMember) {
|
||||
client.emit('error', { message: 'You are not a member of this organization' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Leave current organization room and chat rooms
|
||||
client.leave(`org_${client.organization.id}`);
|
||||
const currentChatRooms = await this.chatService.getUserChatRooms(client.user.id, client.organization.id);
|
||||
currentChatRooms.forEach(room => {
|
||||
client.leave(`room_${room.id}`);
|
||||
});
|
||||
|
||||
// Update organization context
|
||||
const newOrganization = await this.organizationService.getOrganization(data.organizationId, client.user.id);
|
||||
(client as AuthenticatedSocket).organization = {
|
||||
id: newOrganization.id,
|
||||
name: newOrganization.name,
|
||||
slug: newOrganization.slug,
|
||||
};
|
||||
|
||||
// Join new organization room and chat rooms
|
||||
client.join(`org_${data.organizationId}`);
|
||||
const newChatRooms = await this.chatService.getUserChatRooms(client.user.id, data.organizationId);
|
||||
newChatRooms.forEach(room => {
|
||||
client.join(`room_${room.id}`);
|
||||
});
|
||||
|
||||
// Update connection tracking
|
||||
this.connectedUsers.set(client.user.id, { socketId: client.id, organizationId: data.organizationId });
|
||||
|
||||
// Set as active organization
|
||||
await this.organizationService.setUserActiveOrganization(client.user.id, data.organizationId);
|
||||
|
||||
client.emit('organization_switched', {
|
||||
organization: client.organization,
|
||||
chatRooms: newChatRooms,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
client.emit('error', { message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
private async handleMentions(message: any, senderId: string, organizationId: string) {
|
||||
if (message.mentions && message.mentions.length > 0) {
|
||||
for (const mention of message.mentions) {
|
||||
const mentionedUserSocket = this.connectedUsers.get(mention.mentionedUser.id);
|
||||
if (mentionedUserSocket) {
|
||||
this.server.to(mentionedUserSocket).emit('mentioned', {
|
||||
const mentionedUserConnection = this.connectedUsers.get(mention.mentionedUser.id);
|
||||
|
||||
// Only send mention if user is in the same organization
|
||||
if (mentionedUserConnection && mentionedUserConnection.organizationId === organizationId) {
|
||||
this.server.to(mentionedUserConnection.socketId).emit('mentioned', {
|
||||
message,
|
||||
mentionedBy: senderId,
|
||||
organizationId,
|
||||
});
|
||||
}
|
||||
|
||||
@ -222,16 +363,21 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
}
|
||||
}
|
||||
|
||||
// Method to send message to specific user
|
||||
sendToUser(userId: string, event: string, data: any) {
|
||||
const socketId = this.connectedUsers.get(userId);
|
||||
if (socketId) {
|
||||
this.server.to(socketId).emit(event, data);
|
||||
// Method to send message to specific user in specific organization
|
||||
sendToUserInOrganization(userId: string, organizationId: string, event: string, data: any) {
|
||||
const connection = this.connectedUsers.get(userId);
|
||||
if (connection && connection.organizationId === organizationId) {
|
||||
this.server.to(connection.socketId).emit(event, data);
|
||||
}
|
||||
}
|
||||
|
||||
// Method to send message to room
|
||||
sendToRoom(roomId: string, event: string, data: any) {
|
||||
this.server.to(roomId).emit(event, data);
|
||||
this.server.to(`room_${roomId}`).emit(event, data);
|
||||
}
|
||||
|
||||
// Method to send message to organization
|
||||
sendToOrganization(organizationId: string, event: string, data: any) {
|
||||
this.server.to(`org_${organizationId}`).emit(event, data);
|
||||
}
|
||||
}
|
@ -8,16 +8,19 @@ import { ChatRoom } from '../database/entities/chat-room.entity';
|
||||
import { MessageMedia } from '../database/entities/message-media.entity';
|
||||
import { MessageMention } from '../database/entities/message-mention.entity';
|
||||
import { User } from '../database/entities/user.entity';
|
||||
import { Organization } from '../database/entities/organization.entity';
|
||||
import { EncryptionModule } from '../encryption/encryption.module';
|
||||
import { NotificationModule } from '../notification/notification.module';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { OrganizationModule } from '../organization/organization.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Message, ChatRoom, MessageMedia, MessageMention, User]),
|
||||
TypeOrmModule.forFeature([Message, ChatRoom, MessageMedia, MessageMention, User, Organization]),
|
||||
EncryptionModule,
|
||||
NotificationModule,
|
||||
UsersModule,
|
||||
OrganizationModule,
|
||||
],
|
||||
providers: [ChatGateway, ChatService],
|
||||
controllers: [ChatController],
|
||||
|
@ -5,6 +5,7 @@ import { Message, MessageType } from '../database/entities/message.entity';
|
||||
import { ChatRoom, ChatRoomType } from '../database/entities/chat-room.entity';
|
||||
import { MessageMention } from '../database/entities/message-mention.entity';
|
||||
import { User } from '../database/entities/user.entity';
|
||||
import { Organization } from '../database/entities/organization.entity';
|
||||
import { EncryptionService } from '../encryption/encryption.service';
|
||||
import { CreateMessageDto } from './dto/create-message.dto';
|
||||
import { CreateChatRoomDto } from './dto/create-chat-room.dto';
|
||||
@ -20,15 +21,24 @@ export class ChatService {
|
||||
private mentionRepository: Repository<MessageMention>,
|
||||
@InjectRepository(User)
|
||||
private userRepository: Repository<User>,
|
||||
@InjectRepository(Organization)
|
||||
private organizationRepository: Repository<Organization>,
|
||||
private encryptionService: EncryptionService,
|
||||
) {}
|
||||
|
||||
async createDirectMessage(senderId: string, recipientId: string): Promise<ChatRoom> {
|
||||
// Check if DM already exists between these users
|
||||
async createDirectMessage(senderId: string, recipientId: string, organizationId: string): Promise<ChatRoom> {
|
||||
// Verify organization
|
||||
const organization = await this.organizationRepository.findOne({ where: { id: organizationId } });
|
||||
if (!organization) {
|
||||
throw new NotFoundException('Organization not found');
|
||||
}
|
||||
|
||||
// Check if DM already exists between these users in this organization
|
||||
const existingDM = await this.chatRoomRepository
|
||||
.createQueryBuilder('room')
|
||||
.innerJoin('room.members', 'member')
|
||||
.where('room.type = :type', { type: ChatRoomType.DIRECT })
|
||||
.andWhere('room.organizationId = :organizationId', { organizationId })
|
||||
.groupBy('room.id')
|
||||
.having('COUNT(member.id) = 2')
|
||||
.andHaving('SUM(CASE WHEN member.id IN (:...userIds) THEN 1 ELSE 0 END) = 2', {
|
||||
@ -37,7 +47,7 @@ export class ChatService {
|
||||
.getOne();
|
||||
|
||||
if (existingDM) {
|
||||
return this.getChatRoom(existingDM.id, senderId);
|
||||
return this.getChatRoom(existingDM.id, senderId, organizationId);
|
||||
}
|
||||
|
||||
// Create new DM
|
||||
@ -49,6 +59,7 @@ export class ChatService {
|
||||
const chatRoom = this.chatRoomRepository.create({
|
||||
type: ChatRoomType.DIRECT,
|
||||
members: users,
|
||||
organization,
|
||||
isEncrypted: true,
|
||||
encryptionKey: this.encryptionService.generateChatRoomKey(),
|
||||
});
|
||||
@ -56,9 +67,15 @@ export class ChatService {
|
||||
return this.chatRoomRepository.save(chatRoom);
|
||||
}
|
||||
|
||||
async createGroupChat(createChatRoomDto: CreateChatRoomDto, creatorId: string): Promise<ChatRoom> {
|
||||
async createGroupChat(createChatRoomDto: CreateChatRoomDto, creatorId: string, organizationId: string): Promise<ChatRoom> {
|
||||
const { name, description, memberIds } = createChatRoomDto;
|
||||
|
||||
// Verify organization
|
||||
const organization = await this.organizationRepository.findOne({ where: { id: organizationId } });
|
||||
if (!organization) {
|
||||
throw new NotFoundException('Organization not found');
|
||||
}
|
||||
|
||||
const allMemberIds = [...new Set([creatorId, ...memberIds])];
|
||||
const members = await this.userRepository.findByIds(allMemberIds);
|
||||
|
||||
@ -73,6 +90,7 @@ export class ChatService {
|
||||
description,
|
||||
type: ChatRoomType.GROUP,
|
||||
members,
|
||||
organization,
|
||||
createdBy: creator,
|
||||
isEncrypted: true,
|
||||
encryptionKey: this.encryptionService.generateChatRoomKey(),
|
||||
@ -81,11 +99,19 @@ export class ChatService {
|
||||
return this.chatRoomRepository.save(chatRoom);
|
||||
}
|
||||
|
||||
async getChatRoom(roomId: string, userId: string): Promise<ChatRoom> {
|
||||
const chatRoom = await this.chatRoomRepository.findOne({
|
||||
where: { id: roomId },
|
||||
relations: ['members', 'createdBy'],
|
||||
});
|
||||
async getChatRoom(roomId: string, userId: string, organizationId?: string): Promise<ChatRoom> {
|
||||
const queryBuilder = this.chatRoomRepository
|
||||
.createQueryBuilder('room')
|
||||
.leftJoinAndSelect('room.members', 'members')
|
||||
.leftJoinAndSelect('room.createdBy', 'createdBy')
|
||||
.leftJoinAndSelect('room.organization', 'organization')
|
||||
.where('room.id = :roomId', { roomId });
|
||||
|
||||
if (organizationId) {
|
||||
queryBuilder.andWhere('room.organizationId = :organizationId', { organizationId });
|
||||
}
|
||||
|
||||
const chatRoom = await queryBuilder.getOne();
|
||||
|
||||
if (!chatRoom) {
|
||||
throw new NotFoundException('Chat room not found');
|
||||
@ -100,27 +126,30 @@ export class ChatService {
|
||||
return chatRoom;
|
||||
}
|
||||
|
||||
async getUserChatRooms(userId: string): Promise<ChatRoom[]> {
|
||||
async getUserChatRooms(userId: string, organizationId: string): Promise<ChatRoom[]> {
|
||||
return this.chatRoomRepository
|
||||
.createQueryBuilder('room')
|
||||
.innerJoin('room.members', 'member', 'member.id = :userId', { userId })
|
||||
.leftJoinAndSelect('room.members', 'allMembers')
|
||||
.leftJoinAndSelect('room.createdBy', 'creator')
|
||||
.leftJoinAndSelect('room.organization', 'organization')
|
||||
.where('room.organizationId = :organizationId', { organizationId })
|
||||
.orderBy('room.updatedAt', 'DESC')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
async createMessage(createMessageDto: CreateMessageDto): Promise<Message> {
|
||||
const { content, type, senderId, chatRoomId, replyToId } = createMessageDto;
|
||||
async createMessage(createMessageDto: CreateMessageDto & { organizationId: string }): Promise<Message> {
|
||||
const { content, type, senderId, chatRoomId, replyToId, organizationId } = createMessageDto;
|
||||
|
||||
// Verify user is member of chat room
|
||||
const chatRoom = await this.getChatRoom(chatRoomId, senderId);
|
||||
// Verify user is member of chat room and organization
|
||||
const chatRoom = await this.getChatRoom(chatRoomId, senderId, organizationId);
|
||||
const sender = await this.userRepository.findOne({ where: { id: senderId } });
|
||||
const organization = await this.organizationRepository.findOne({ where: { id: organizationId } });
|
||||
|
||||
let replyTo = null;
|
||||
if (replyToId) {
|
||||
replyTo = await this.messageRepository.findOne({
|
||||
where: { id: replyToId, chatRoom: { id: chatRoomId } },
|
||||
where: { id: replyToId, chatRoom: { id: chatRoomId }, organization: { id: organizationId } },
|
||||
});
|
||||
if (!replyTo) {
|
||||
throw new NotFoundException('Reply message not found');
|
||||
@ -138,6 +167,7 @@ export class ChatService {
|
||||
type: type as MessageType,
|
||||
sender,
|
||||
chatRoom,
|
||||
organization,
|
||||
replyTo,
|
||||
isEncrypted: chatRoom.isEncrypted,
|
||||
encryptedContent,
|
||||
@ -146,7 +176,7 @@ export class ChatService {
|
||||
const savedMessage = await this.messageRepository.save(message);
|
||||
|
||||
// Handle mentions
|
||||
await this.handleMentions(content, savedMessage);
|
||||
await this.handleMentions(content, savedMessage, organizationId);
|
||||
|
||||
// Update chat room's updatedAt
|
||||
await this.chatRoomRepository.update(chatRoomId, { updatedAt: new Date() });
|
||||
@ -154,10 +184,10 @@ export class ChatService {
|
||||
return savedMessage;
|
||||
}
|
||||
|
||||
async getMessage(messageId: string): Promise<Message> {
|
||||
async getMessage(messageId: string, organizationId: string): Promise<Message> {
|
||||
const message = await this.messageRepository.findOne({
|
||||
where: { id: messageId },
|
||||
relations: ['sender', 'chatRoom', 'replyTo', 'replyTo.sender', 'mentions', 'mentions.mentionedUser', 'media'],
|
||||
where: { id: messageId, organization: { id: organizationId } },
|
||||
relations: ['sender', 'chatRoom', 'organization', 'replyTo', 'replyTo.sender', 'mentions', 'mentions.mentionedUser', 'media'],
|
||||
});
|
||||
|
||||
if (!message) {
|
||||
@ -176,16 +206,21 @@ export class ChatService {
|
||||
async getChatRoomMessages(
|
||||
roomId: string,
|
||||
userId: string,
|
||||
organizationId: string,
|
||||
page = 1,
|
||||
limit = 50,
|
||||
): Promise<{ messages: Message[]; total: number }> {
|
||||
// Verify user access to chat room
|
||||
await this.getChatRoom(roomId, userId);
|
||||
await this.getChatRoom(roomId, userId, organizationId);
|
||||
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const [messages, total] = await this.messageRepository.findAndCount({
|
||||
where: { chatRoom: { id: roomId }, isDeleted: false },
|
||||
where: {
|
||||
chatRoom: { id: roomId },
|
||||
organization: { id: organizationId },
|
||||
isDeleted: false
|
||||
},
|
||||
relations: ['sender', 'replyTo', 'replyTo.sender', 'mentions', 'mentions.mentionedUser', 'media'],
|
||||
order: { createdAt: 'DESC' },
|
||||
skip,
|
||||
@ -205,9 +240,9 @@ export class ChatService {
|
||||
return { messages: messages.reverse(), total };
|
||||
}
|
||||
|
||||
async editMessage(messageId: string, newContent: string, userId: string): Promise<Message> {
|
||||
async editMessage(messageId: string, newContent: string, userId: string, organizationId: string): Promise<Message> {
|
||||
const message = await this.messageRepository.findOne({
|
||||
where: { id: messageId },
|
||||
where: { id: messageId, organization: { id: organizationId } },
|
||||
relations: ['sender', 'chatRoom'],
|
||||
});
|
||||
|
||||
@ -233,12 +268,12 @@ export class ChatService {
|
||||
editedAt: new Date(),
|
||||
});
|
||||
|
||||
return this.getMessage(messageId);
|
||||
return this.getMessage(messageId, organizationId);
|
||||
}
|
||||
|
||||
async deleteMessage(messageId: string, userId: string): Promise<Message> {
|
||||
async deleteMessage(messageId: string, userId: string, organizationId: string): Promise<Message> {
|
||||
const message = await this.messageRepository.findOne({
|
||||
where: { id: messageId },
|
||||
where: { id: messageId, organization: { id: organizationId } },
|
||||
relations: ['sender', 'chatRoom'],
|
||||
});
|
||||
|
||||
@ -259,8 +294,8 @@ export class ChatService {
|
||||
return message;
|
||||
}
|
||||
|
||||
async addMemberToGroup(roomId: string, memberIds: string[], userId: string): Promise<ChatRoom> {
|
||||
const chatRoom = await this.getChatRoom(roomId, userId);
|
||||
async addMemberToGroup(roomId: string, memberIds: string[], userId: string, organizationId: string): Promise<ChatRoom> {
|
||||
const chatRoom = await this.getChatRoom(roomId, userId, organizationId);
|
||||
|
||||
if (chatRoom.type !== ChatRoomType.GROUP) {
|
||||
throw new BadRequestException('Can only add members to group chats');
|
||||
@ -285,8 +320,8 @@ export class ChatService {
|
||||
return this.chatRoomRepository.save(chatRoom);
|
||||
}
|
||||
|
||||
async removeMemberFromGroup(roomId: string, memberIdToRemove: string, userId: string): Promise<ChatRoom> {
|
||||
const chatRoom = await this.getChatRoom(roomId, userId);
|
||||
async removeMemberFromGroup(roomId: string, memberIdToRemove: string, userId: string, organizationId: string): Promise<ChatRoom> {
|
||||
const chatRoom = await this.getChatRoom(roomId, userId, organizationId);
|
||||
|
||||
if (chatRoom.type !== ChatRoomType.GROUP) {
|
||||
throw new BadRequestException('Can only remove members from group chats');
|
||||
@ -302,16 +337,22 @@ export class ChatService {
|
||||
return this.chatRoomRepository.save(chatRoom);
|
||||
}
|
||||
|
||||
private async handleMentions(content: string, message: Message) {
|
||||
private async handleMentions(content: string, message: Message, organizationId: string) {
|
||||
// Extract mentions from content (e.g., @username or @userId)
|
||||
const mentionRegex = /@(\w+)/g;
|
||||
const mentions = content.match(mentionRegex);
|
||||
|
||||
if (mentions) {
|
||||
const usernames = mentions.map(mention => mention.substring(1));
|
||||
const mentionedUsers = await this.userRepository.find({
|
||||
where: { username: In(usernames) },
|
||||
});
|
||||
|
||||
// Find users in the same organization
|
||||
const mentionedUsers = await this.userRepository
|
||||
.createQueryBuilder('user')
|
||||
.innerJoin('user.organizationMemberships', 'membership')
|
||||
.where('user.username IN (:...usernames)', { usernames })
|
||||
.andWhere('membership.organizationId = :organizationId', { organizationId })
|
||||
.andWhere('membership.status = :status', { status: 'active' })
|
||||
.getMany();
|
||||
|
||||
for (const user of mentionedUsers) {
|
||||
const mention = this.mentionRepository.create({
|
||||
|
@ -11,6 +11,7 @@ import {
|
||||
} from 'typeorm';
|
||||
import { Message } from './message.entity';
|
||||
import { User } from './user.entity';
|
||||
import { Organization } from './organization.entity';
|
||||
|
||||
export enum ChatRoomType {
|
||||
DIRECT = 'direct',
|
||||
@ -47,6 +48,9 @@ export class ChatRoom {
|
||||
@ManyToOne(() => User, { nullable: true })
|
||||
createdBy: User;
|
||||
|
||||
@ManyToOne(() => Organization)
|
||||
organization: Organization;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
|
@ -11,6 +11,7 @@ import { User } from './user.entity';
|
||||
import { ChatRoom } from './chat-room.entity';
|
||||
import { MessageMedia } from './message-media.entity';
|
||||
import { MessageMention } from './message-mention.entity';
|
||||
import { Organization } from './organization.entity';
|
||||
|
||||
export enum MessageType {
|
||||
TEXT = 'text',
|
||||
@ -63,6 +64,9 @@ export class Message {
|
||||
@ManyToOne(() => ChatRoom, (chatRoom) => chatRoom.messages)
|
||||
chatRoom: ChatRoom;
|
||||
|
||||
@ManyToOne(() => Organization)
|
||||
organization: Organization;
|
||||
|
||||
@OneToMany(() => MessageMedia, (media) => media.message)
|
||||
media: MessageMedia[];
|
||||
|
||||
|
@ -7,12 +7,14 @@ import {
|
||||
} from 'typeorm';
|
||||
import { User } from './user.entity';
|
||||
import { Message } from './message.entity';
|
||||
import { Organization } from './organization.entity';
|
||||
|
||||
export enum NotificationType {
|
||||
MESSAGE = 'message',
|
||||
MENTION = 'mention',
|
||||
GROUP_INVITE = 'group_invite',
|
||||
FRIEND_REQUEST = 'friend_request',
|
||||
ORGANIZATION_INVITE = 'organization_invite',
|
||||
}
|
||||
|
||||
@Entity('notifications')
|
||||
@ -49,4 +51,7 @@ export class Notification {
|
||||
|
||||
@ManyToOne(() => Message, { nullable: true })
|
||||
message: Message;
|
||||
|
||||
@ManyToOne(() => Organization)
|
||||
organization: Organization;
|
||||
}
|
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 { ChatRoom } from './chat-room.entity';
|
||||
import { UserDevice } from './user-device.entity';
|
||||
import { OrganizationMember } from './organization-member.entity';
|
||||
|
||||
@Entity('users')
|
||||
export class User {
|
||||
@ -61,4 +62,7 @@ export class User {
|
||||
|
||||
@OneToMany(() => UserDevice, (device) => device.user)
|
||||
devices: UserDevice[];
|
||||
|
||||
@OneToMany(() => OrganizationMember, (member) => member.user)
|
||||
organizationMemberships: OrganizationMember[];
|
||||
}
|
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