From 4c8731122b9f8204d1d30129b7ba73377910e50c Mon Sep 17 00:00:00 2001 From: Mahdi Date: Wed, 30 Sep 2026 11:08:33 +0330 Subject: [PATCH] Add channel to conversation --- .../conversations-permissions.ts | 10 +- .../conversations/conversations.controller.ts | 15 ++ .../conversations/conversations.service.ts | 138 ++++++++++++++--- apps/api/src/conversations/dto.ts | 13 ++ apps/web/src/views/ConversationsView.vue | 144 ++++++++++++++++-- packages/shared/src/index.ts | 6 +- 6 files changed, 291 insertions(+), 35 deletions(-) diff --git a/apps/api/src/conversations/conversations-permissions.ts b/apps/api/src/conversations/conversations-permissions.ts index b2188a3..e9bd938 100644 --- a/apps/api/src/conversations/conversations-permissions.ts +++ b/apps/api/src/conversations/conversations-permissions.ts @@ -4,6 +4,7 @@ import { sanitizePermissions } from '../org-roles/role-defaults'; export type ConversationAccess = { canUse: boolean; canCreateGroup: boolean; + canCreateChannel: boolean; }; export function resolveConversationAccess( @@ -11,11 +12,14 @@ export function resolveConversationAccess( orgRolePermissions: unknown, ): ConversationAccess { if (membershipRole === 'owner' || membershipRole === 'admin') { - return { canUse: true, canCreateGroup: true }; + return { canUse: true, canCreateGroup: true, canCreateChannel: true }; } const permissions = sanitizePermissions(orgRolePermissions) as RolePermissionKey[]; + const canUse = permissions.includes('conversations'); + const canCreateGroup = permissions.includes('conversations_groups'); return { - canUse: permissions.includes('conversations'), - canCreateGroup: permissions.includes('conversations_groups'), + canUse, + canCreateGroup, + canCreateChannel: canCreateGroup, }; } diff --git a/apps/api/src/conversations/conversations.controller.ts b/apps/api/src/conversations/conversations.controller.ts index 5a9ed65..2a1d54a 100644 --- a/apps/api/src/conversations/conversations.controller.ts +++ b/apps/api/src/conversations/conversations.controller.ts @@ -6,6 +6,7 @@ import { OrgModuleGuard } from '../billing/org-module.guard'; import { CreateConversationDmDto, CreateConversationGroupDto, + CreateConversationChannelDto, HideConversationMessagesDto, PinConversationMessageDto, PinConversationRoomDto, @@ -66,6 +67,20 @@ export class ConversationsController { ); } + @Post('rooms/channel') + createChannel( + @CurrentUser() user: { userId: string; organizationId: string; role: string }, + @Body() dto: CreateConversationChannelDto, + ) { + return this.conversations.createChannel( + user.userId, + user.organizationId, + user.role, + dto.name, + dto.memberUserIds, + ); + } + @Post('rooms/pin') pinRoom( @CurrentUser() user: { userId: string; organizationId: string; role: string }, diff --git a/apps/api/src/conversations/conversations.service.ts b/apps/api/src/conversations/conversations.service.ts index a0454a9..6faa4e2 100644 --- a/apps/api/src/conversations/conversations.service.ts +++ b/apps/api/src/conversations/conversations.service.ts @@ -190,34 +190,45 @@ export class ConversationsService { }); const trackedByExternal = new Map(tracked.map((t) => [t.externalRoomId, t])); const pinnedIds = await this.userPinnedRoomIds(organizationId, userId); + const membership = await this.prisma.membership.findFirst({ + where: { userId, organizationId, isActive: true }, + select: { role: true }, + }); + const effectiveRole = String(membership?.role ?? role); try { const external = await this.chat.listUserRooms(creds.host, creds.apiKey, chatUserId); return external.map((row) => { - const dto = this.toRoomDto(row, trackedByExternal, organizationId, userId); + const dto = this.toRoomDto(row, trackedByExternal, organizationId, userId, effectiveRole); return { ...dto, pinned: pinnedIds.has(dto.externalRoomId) }; }); } catch (e) { // اگر سرویس چت در دسترس نبود، حداقل roomهای ثبت‌شده در CRM را نشان بده if (tracked.length) { - return tracked.map((t) => ({ - id: t.id, - externalRoomId: t.externalRoomId, - kind: (t.kind === 'group' ? 'group' : 'dm') as 'dm' | 'group', - name: t.name || (t.kind === 'group' ? 'گروه' : 'گفتگو'), - unreadCount: 0, - lastMessagePreview: null, - lastMessageAt: null, - peerUserId: - t.kind === 'dm' - ? t.dmUserMinId === userId - ? t.dmUserMaxId - : t.dmUserMaxId === userId - ? t.dmUserMinId - : null - : null, - pinned: pinnedIds.has(t.externalRoomId), - })); + return tracked.map((t) => { + const kind = this.normalizeKind(t.kind); + return { + id: t.id, + externalRoomId: t.externalRoomId, + kind, + name: + t.name || + (kind === 'channel' ? 'کانال' : kind === 'group' ? 'گروه' : 'گفتگو'), + unreadCount: 0, + lastMessagePreview: null, + lastMessageAt: null, + peerUserId: + kind === 'dm' + ? t.dmUserMinId === userId + ? t.dmUserMaxId + : t.dmUserMaxId === userId + ? t.dmUserMinId + : null + : null, + pinned: pinnedIds.has(t.externalRoomId), + canPost: this.canPostInRoom(kind, t.createdByUserId, userId, effectiveRole), + }; + }); } throw e; } @@ -353,6 +364,69 @@ export class ConversationsService { lastMessagePreview: null, lastMessageAt: null, peerUserId: null, + canPost: true, + }; + } + + async createChannel( + userId: string, + organizationId: string, + role: string, + name: string, + memberUserIds: string[], + ): Promise { + const access = await this.loadAccess(userId, organizationId, role); + if (!access.canCreateChannel) { + throw new ForbiddenException('اجازه ساخت کانال را ندارید'); + } + + const uniqueIds = [...new Set(memberUserIds.filter((id) => id && id !== userId))]; + const memberships = await this.prisma.membership.findMany({ + where: { + organizationId, + userId: { in: uniqueIds }, + isActive: true, + user: { isActive: true }, + }, + select: { userId: true }, + }); + if (memberships.length !== uniqueIds.length) { + throw new BadRequestException('برخی اعضای کانال در این سازمان فعال نیستند'); + } + + const creds = await this.getCredentials(organizationId); + const members = [ + buildChatUserId(organizationId, userId), + ...uniqueIds.map((id) => buildChatUserId(organizationId, id)), + ]; + const trimmedName = name.trim(); + // سرویس خارجی معمولاً CHANNEL ندارد؛ به‌صورت GROUP ساخته و در CRM به‌عنوان کانال یک‌طرفه نگه داشته می‌شود + const created = await this.chat.createRoom(creds.host, creds.apiKey, { + name: trimmedName, + type: 'GROUP', + members, + }); + + const row = await this.prisma.conversationRoom.create({ + data: { + organizationId, + externalRoomId: created.room_id, + kind: 'channel', + name: trimmedName, + createdByUserId: userId, + }, + }); + + return { + id: row.id, + externalRoomId: row.externalRoomId, + kind: 'channel', + name: trimmedName, + unreadCount: 0, + lastMessagePreview: null, + lastMessageAt: null, + peerUserId: null, + canPost: true, }; } @@ -422,6 +496,23 @@ export class ConversationsService { } } + private normalizeKind(raw: string | null | undefined): 'dm' | 'group' | 'channel' { + if (raw === 'channel') return 'channel'; + if (raw === 'dm') return 'dm'; + return 'group'; + } + + private canPostInRoom( + kind: 'dm' | 'group' | 'channel', + createdByUserId: string | null | undefined, + currentUserId: string, + role: string, + ): boolean { + if (kind !== 'channel') return true; + if (createdByUserId && createdByUserId === currentUserId) return true; + return role === 'owner' || role === 'admin'; + } + private toRoomDto( row: ExternalRoomRow, trackedByExternal: Map< @@ -431,14 +522,16 @@ export class ConversationsService { name: string | null; dmUserMinId: string | null; dmUserMaxId: string | null; + createdByUserId: string | null; } >, organizationId: string, currentUserId: string, + role = '', ): ConversationRoomDto { const externalRoomId = String(row.id ?? row.room_id ?? '').trim(); const tracked = trackedByExternal.get(externalRoomId); - const kind = tracked?.kind === 'group' ? 'group' : tracked?.kind === 'dm' ? 'dm' : 'group'; + const kind = this.normalizeKind(tracked?.kind); const lastMsg = (row.last_message ?? row.lastMessage) as Record | undefined; const lastType = String(lastMsg?.type_content ?? lastMsg?.type ?? 'text') @@ -478,7 +571,7 @@ export class ConversationsService { let name = String(tracked?.name ?? row.name ?? row.title ?? '').trim() || - (kind === 'group' ? 'گروه' : 'گفتگو'); + (kind === 'channel' ? 'کانال' : kind === 'group' ? 'گروه' : 'گفتگو'); let peerUserId: string | null = null; if (tracked?.kind === 'dm' && tracked.dmUserMinId && tracked.dmUserMaxId) { @@ -489,13 +582,14 @@ export class ConversationsService { return { id: externalRoomId, externalRoomId, - kind: kind as 'dm' | 'group', + kind, name, unreadCount: Number.isFinite(unread) ? Math.max(0, unread) : 0, lastMessagePreview: preview || null, lastMessageAt: lastAtRaw ? String(lastAtRaw) : null, peerUserId, pinned: false, + canPost: this.canPostInRoom(kind, tracked?.createdByUserId, currentUserId, role), }; } diff --git a/apps/api/src/conversations/dto.ts b/apps/api/src/conversations/dto.ts index 4349955..b9562c8 100644 --- a/apps/api/src/conversations/dto.ts +++ b/apps/api/src/conversations/dto.ts @@ -51,6 +51,19 @@ export class CreateConversationGroupDto { memberUserIds!: string[]; } +export class CreateConversationChannelDto { + @IsString() + @MinLength(2) + @MaxLength(120) + name!: string; + + @IsArray() + @ArrayMinSize(1) + @ArrayMaxSize(100) + @IsString({ each: true }) + memberUserIds!: string[]; +} + export class HideConversationMessagesDto { @IsString() @MinLength(1) diff --git a/apps/web/src/views/ConversationsView.vue b/apps/web/src/views/ConversationsView.vue index 7b04f81..1348894 100644 --- a/apps/web/src/views/ConversationsView.vue +++ b/apps/web/src/views/ConversationsView.vue @@ -40,8 +40,11 @@ const activeRoom = ref(null); const draft = ref(''); const showNewDm = ref(false); const showNewGroup = ref(false); +const showNewChannel = ref(false); const groupName = ref(''); const groupMemberIds = ref([]); +const channelName = ref(''); +const channelMemberIds = ref([]); const dmPeerId = ref(''); const chatUserId = ref(''); const pendingAttaches = ref([]); @@ -96,7 +99,7 @@ const selectedIds = ref([]); const showForward = ref(false); const forwardBusy = ref(false); const roomListQuery = ref(''); -const roomListTab = ref<'all' | 'dm' | 'group'>('all'); +const roomListTab = ref<'all' | 'dm' | 'group' | 'channel'>('all'); const pinnedMessage = ref(null); const chatSearchOpen = ref(false); const chatSearchQuery = ref(''); @@ -105,6 +108,15 @@ const mentionState = ref<{ start: number; end: number; query: string } | null>(n const mentionIndex = ref(0); const canCreateGroup = computed(() => access.value?.canCreateGroup ?? false); +const canCreateChannel = computed( + () => access.value?.canCreateChannel ?? access.value?.canCreateGroup ?? false, +); +const canPostInActive = computed(() => { + const room = activeRoom.value; + if (!room) return false; + if (room.kind !== 'channel') return true; + return room.canPost !== false; +}); async function scrollToBottom() { await nextTick(); @@ -129,6 +141,7 @@ const filteredRooms = computed(() => { return sortedRooms.value.filter((room) => { if (roomListTab.value === 'dm' && room.kind !== 'dm') return false; if (roomListTab.value === 'group' && room.kind !== 'group') return false; + if (roomListTab.value === 'channel' && room.kind !== 'channel') return false; if (!q) return true; const title = roomTitle(room).toLowerCase(); const preview = (room.lastMessagePreview || '').toLowerCase(); @@ -164,11 +177,17 @@ const activeSearchHitId = computed(() => { }); function roomTitle(room: ConversationRoomDto) { - if (room.kind === 'group') return room.name; + if (room.kind === 'group' || room.kind === 'channel') return room.name; const peer = peers.value.find((p) => p.id === room.peerUserId); return peer?.fullName ?? room.name; } +function roomKindLabel(kind: ConversationRoomDto['kind']) { + if (kind === 'channel') return 'کانال'; + if (kind === 'group') return 'گروه'; + return 'خصوصی'; +} + type TextSeg = { text: string; mention?: boolean; hit?: boolean }; function messageTextSegments(content: string, highlightQuery = ''): TextSeg[] { @@ -941,6 +960,10 @@ async function openRoom(room: ConversationRoomDto) { async function submitMessage() { if (!activeRoom.value) return; + if (!canPostInActive.value) { + error.value = 'فقط مدیران کانال می‌توانند پیام بفرستند.'; + return; + } if (sending.value || recording.value) return; const text = draft.value.trim(); const attaches = [...pendingAttaches.value]; @@ -1034,6 +1057,27 @@ async function startGroup() { } } +async function startChannel() { + if (!channelName.value.trim() || !channelMemberIds.value.length) return; + try { + const { data } = await api.post('/conversations/rooms/channel', { + name: channelName.value.trim(), + memberUserIds: channelMemberIds.value, + }); + rooms.value = [{ ...data, canPost: true }, ...rooms.value]; + showNewChannel.value = false; + channelName.value = ''; + channelMemberIds.value = []; + await openRoom({ ...data, canPost: true }); + } catch (e) { + if (axios.isAxiosError(e) && e.response?.status === 403) { + error.value = 'اجازه ساخت کانال را ندارید.'; + } else { + error.value = 'ساخت کانال ناموفق بود.'; + } + } +} + function toggleGroupMember(id: string) { if (groupMemberIds.value.includes(id)) { groupMemberIds.value = groupMemberIds.value.filter((x) => x !== id); @@ -1042,6 +1086,14 @@ function toggleGroupMember(id: string) { } } +function toggleChannelMember(id: string) { + if (channelMemberIds.value.includes(id)) { + channelMemberIds.value = channelMemberIds.value.filter((x) => x !== id); + } else { + channelMemberIds.value = [...channelMemberIds.value, id]; + } +} + watch(activeRoom, () => { draft.value = ''; clearPendingAttaches(); @@ -1134,6 +1186,14 @@ onUnmounted(() => { > گروه +
    @@ -1295,7 +1364,9 @@ onUnmounted(() => {

    {{ roomTitle(activeRoom) }}

    - {{ activeRoom.kind === 'group' ? 'گروه' : 'خصوصی' }} + {{ + roomKindLabel(activeRoom.kind) + }}
-
+
+ + فقط مدیران کانال می‌توانند پیام بفرستند +
+
+ + + +