Adding the typing... indicator, loading older messages, and managing members/admins after creation.

This commit is contained in:
2026-09-30 11:53:04 +03:30
parent b978708487
commit e1d456ad11
9 changed files with 992 additions and 28 deletions
@@ -9,6 +9,7 @@ import type {
ConversationPeerDto,
ConversationPinnedMessageDto,
ConversationRoomDto,
ConversationRoomMemberDto,
ConversationSessionDto,
ConversationSettingsDto,
PlatformConversationStatsDto,
@@ -195,18 +196,32 @@ export class ConversationsService {
select: { role: true },
});
const effectiveRole = String(membership?.role ?? role);
const orgAdmin = effectiveRole === 'owner' || effectiveRole === 'admin';
const roomAdminIds = await this.userAdminRoomIds(organizationId, userId);
try {
const external = await this.chat.listUserRooms(creds.host, creds.apiKey, chatUserId);
return external.map((row) => {
const dto = this.toRoomDto(row, trackedByExternal, organizationId, userId, effectiveRole);
return { ...dto, pinned: pinnedIds.has(dto.externalRoomId) };
const isRoomAdmin =
roomAdminIds.has(dto.externalRoomId) ||
trackedByExternal.get(dto.externalRoomId)?.createdByUserId === userId;
return {
...dto,
pinned: pinnedIds.has(dto.externalRoomId),
canPost:
dto.kind !== 'channel' ? true : Boolean(isRoomAdmin || orgAdmin),
canManageMembers:
dto.kind === 'dm' ? false : Boolean(isRoomAdmin || orgAdmin),
};
});
} catch (e) {
// اگر سرویس چت در دسترس نبود، حداقل roomهای ثبت‌شده در CRM را نشان بده
if (tracked.length) {
return tracked.map((t) => {
const kind = this.normalizeKind(t.kind);
const isRoomAdmin =
roomAdminIds.has(t.externalRoomId) || t.createdByUserId === userId;
return {
id: t.id,
externalRoomId: t.externalRoomId,
@@ -226,7 +241,8 @@ export class ConversationsService {
: null
: null,
pinned: pinnedIds.has(t.externalRoomId),
canPost: this.canPostInRoom(kind, t.createdByUserId, userId, effectiveRole),
canPost: kind !== 'channel' ? true : Boolean(isRoomAdmin || orgAdmin),
canManageMembers: kind === 'dm' ? false : Boolean(isRoomAdmin || orgAdmin),
};
});
}
@@ -355,6 +371,8 @@ export class ConversationsService {
},
});
await this.seedRoomMembers(organizationId, row.externalRoomId, userId, uniqueIds);
return {
id: row.id,
externalRoomId: row.externalRoomId,
@@ -365,6 +383,7 @@ export class ConversationsService {
lastMessageAt: null,
peerUserId: null,
canPost: true,
canManageMembers: true,
};
}
@@ -417,6 +436,8 @@ export class ConversationsService {
},
});
await this.seedRoomMembers(organizationId, row.externalRoomId, userId, uniqueIds);
return {
id: row.id,
externalRoomId: row.externalRoomId,
@@ -427,6 +448,7 @@ export class ConversationsService {
lastMessageAt: null,
peerUserId: null,
canPost: true,
canManageMembers: true,
};
}
@@ -602,6 +624,240 @@ export class ConversationsService {
return new Set(rows.map((r) => r.externalRoomId));
}
private async userAdminRoomIds(organizationId: string, userId: string): Promise<Set<string>> {
const rows = await this.prisma.conversationRoomMember.findMany({
where: { organizationId, userId, role: 'admin' },
select: { externalRoomId: true },
take: 1000,
});
return new Set(rows.map((r) => r.externalRoomId));
}
private async seedRoomMembers(
organizationId: string,
externalRoomId: string,
adminUserId: string,
memberUserIds: string[],
) {
const rows = [
{
organizationId,
externalRoomId,
userId: adminUserId,
role: 'admin',
},
...memberUserIds.map((id) => ({
organizationId,
externalRoomId,
userId: id,
role: 'member',
})),
];
await this.prisma.conversationRoomMember.createMany({
data: rows,
skipDuplicates: true,
});
}
private async assertCanManageRoom(
userId: string,
organizationId: string,
role: string,
externalRoomId: string,
) {
await this.loadAccess(userId, organizationId, role);
const room = await this.prisma.conversationRoom.findFirst({
where: { organizationId, externalRoomId },
});
if (!room) throw new NotFoundException('گفتگو پیدا نشد');
if (room.kind === 'dm') {
throw new BadRequestException('مدیریت اعضا برای گفتگوی خصوصی نیست');
}
const orgAdmin = role === 'owner' || role === 'admin';
if (orgAdmin || room.createdByUserId === userId) return room;
const mine = await this.prisma.conversationRoomMember.findFirst({
where: { organizationId, externalRoomId, userId, role: 'admin' },
});
if (!mine) throw new ForbiddenException('اجازه مدیریت اعضای این گفتگو را ندارید');
return room;
}
async listRoomMembers(
userId: string,
organizationId: string,
role: string,
externalRoomId: string,
): Promise<ConversationRoomMemberDto[]> {
await this.loadAccess(userId, organizationId, role);
const roomId = externalRoomId.trim();
if (!roomId) throw new BadRequestException('شناسه اتاق خالی است');
const room = await this.prisma.conversationRoom.findFirst({
where: { organizationId, externalRoomId: roomId },
});
if (!room) throw new NotFoundException('گفتگو پیدا نشد');
let rows = await this.prisma.conversationRoomMember.findMany({
where: { organizationId, externalRoomId: roomId },
});
// اتاق‌های قدیمی بدون ردیف عضو — حداقل سازنده را ادمین نشان بده
if (!rows.length && room.createdByUserId) {
await this.seedRoomMembers(organizationId, roomId, room.createdByUserId, []);
rows = await this.prisma.conversationRoomMember.findMany({
where: { organizationId, externalRoomId: roomId },
});
}
const userIds = rows.map((r) => r.userId);
const users = await this.prisma.user.findMany({
where: { id: { in: userIds } },
select: { id: true, fullName: true, avatarStorageKey: true },
});
const byId = new Map(users.map((u) => [u.id, u]));
return rows
.map((r) => {
const u = byId.get(r.userId);
return {
userId: r.userId,
fullName: u?.fullName || 'کاربر',
role: (r.role === 'admin' ? 'admin' : 'member') as 'admin' | 'member',
hasAvatar: Boolean(u?.avatarStorageKey),
isMe: r.userId === userId,
};
})
.sort((a, b) => {
if (a.role !== b.role) return a.role === 'admin' ? -1 : 1;
return a.fullName.localeCompare(b.fullName, 'fa');
});
}
async addRoomMember(
userId: string,
organizationId: string,
role: string,
externalRoomId: string,
memberUserId: string,
memberRole: 'admin' | 'member' = 'member',
): Promise<ConversationRoomMemberDto[]> {
const room = await this.assertCanManageRoom(userId, organizationId, role, externalRoomId.trim());
const targetId = memberUserId.trim();
if (!targetId || targetId === userId) {
// اجازه افزودن خود لازم نیست
}
if (!targetId) throw new BadRequestException('شناسه عضو خالی است');
const membership = await this.prisma.membership.findFirst({
where: {
organizationId,
userId: targetId,
isActive: true,
user: { isActive: true },
},
});
if (!membership) throw new NotFoundException('همکار در این سازمان پیدا نشد');
await this.prisma.conversationRoomMember.upsert({
where: {
organizationId_externalRoomId_userId: {
organizationId,
externalRoomId: room.externalRoomId,
userId: targetId,
},
},
create: {
organizationId,
externalRoomId: room.externalRoomId,
userId: targetId,
role: memberRole === 'admin' ? 'admin' : 'member',
},
update: {
role: memberRole === 'admin' ? 'admin' : 'member',
},
});
try {
const creds = await this.getCredentials(organizationId);
await this.chat.addRoomMembers(creds.host, creds.apiKey, room.externalRoomId, [
buildChatUserId(organizationId, targetId),
]);
} catch {
// CRM به‌روز شده؛ همگام‌سازی خارجی best-effort است
}
return this.listRoomMembers(userId, organizationId, role, room.externalRoomId);
}
async removeRoomMember(
userId: string,
organizationId: string,
role: string,
externalRoomId: string,
memberUserId: string,
): Promise<ConversationRoomMemberDto[]> {
const room = await this.assertCanManageRoom(userId, organizationId, role, externalRoomId.trim());
const targetId = memberUserId.trim();
if (!targetId) throw new BadRequestException('شناسه عضو خالی است');
if (targetId === room.createdByUserId) {
throw new BadRequestException('نمی‌توان سازنده گفتگو را حذف کرد');
}
if (targetId === userId) {
throw new BadRequestException('نمی‌توانید خودتان را حذف کنید');
}
await this.prisma.conversationRoomMember.deleteMany({
where: {
organizationId,
externalRoomId: room.externalRoomId,
userId: targetId,
},
});
try {
const creds = await this.getCredentials(organizationId);
await this.chat.removeRoomMembers(creds.host, creds.apiKey, room.externalRoomId, [
buildChatUserId(organizationId, targetId),
]);
} catch {
// best-effort
}
return this.listRoomMembers(userId, organizationId, role, room.externalRoomId);
}
async updateRoomMemberRole(
userId: string,
organizationId: string,
role: string,
externalRoomId: string,
memberUserId: string,
memberRole: 'admin' | 'member',
): Promise<ConversationRoomMemberDto[]> {
const room = await this.assertCanManageRoom(userId, organizationId, role, externalRoomId.trim());
const targetId = memberUserId.trim();
if (!targetId) throw new BadRequestException('شناسه عضو خالی است');
if (targetId === room.createdByUserId && memberRole !== 'admin') {
throw new BadRequestException('نقش سازنده گفتگو را نمی‌توان از ادمین برداشت');
}
const existing = await this.prisma.conversationRoomMember.findFirst({
where: {
organizationId,
externalRoomId: room.externalRoomId,
userId: targetId,
},
});
if (!existing) throw new NotFoundException('عضو پیدا نشد');
await this.prisma.conversationRoomMember.update({
where: { id: existing.id },
data: { role: memberRole === 'admin' ? 'admin' : 'member' },
});
return this.listRoomMembers(userId, organizationId, role, room.externalRoomId);
}
async setRoomPinned(
userId: string,
organizationId: string,