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
@@ -128,4 +128,81 @@ export class ChatExternalClient {
return false;
}
}
/** تلاش برای افزودن عضو به اتاق روی سرویس خارجی (endpointهای رایج). */
async addRoomMembers(
host: string,
apiKey: string,
roomId: string,
members: string[],
): Promise<boolean> {
const base = this.normalizeHost(host);
if (!base || !roomId || !members.length) return false;
const attempts: Array<{ path: string; body: Record<string, unknown> }> = [
{
path: '/api/rooms/add_members',
body: { api_key: apiKey, room_id: roomId, members },
},
{
path: '/api/rooms/members/add',
body: { api_key: apiKey, room_id: roomId, members },
},
{
path: '/api/rooms/members',
body: { api_key: apiKey, room_id: roomId, members, action: 'add' },
},
];
for (const attempt of attempts) {
try {
const { ok, status } = await this.fetchJson(`https://${base}${attempt.path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(attempt.body),
});
if (ok || status === 204) return true;
if (status === 404 || status === 405) continue;
} catch {
// try next
}
}
return false;
}
async removeRoomMembers(
host: string,
apiKey: string,
roomId: string,
members: string[],
): Promise<boolean> {
const base = this.normalizeHost(host);
if (!base || !roomId || !members.length) return false;
const attempts: Array<{ path: string; body: Record<string, unknown> }> = [
{
path: '/api/rooms/remove_members',
body: { api_key: apiKey, room_id: roomId, members },
},
{
path: '/api/rooms/members/remove',
body: { api_key: apiKey, room_id: roomId, members },
},
{
path: '/api/rooms/members',
body: { api_key: apiKey, room_id: roomId, members, action: 'remove' },
},
];
for (const attempt of attempts) {
try {
const { ok, status } = await this.fetchJson(`https://${base}${attempt.path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(attempt.body),
});
if (ok || status === 204) return true;
if (status === 404 || status === 405) continue;
} catch {
// try next
}
}
return false;
}
}
@@ -11,6 +11,9 @@ import {
PinConversationMessageDto,
PinConversationRoomDto,
UnpinConversationMessageDto,
AddConversationRoomMemberDto,
RemoveConversationRoomMemberDto,
UpdateConversationRoomMemberRoleDto,
} from './dto';
import { ConversationsService } from './conversations.service';
@@ -81,6 +84,63 @@ export class ConversationsController {
);
}
@Get('rooms/members')
listMembers(
@CurrentUser() user: { userId: string; organizationId: string; role: string },
@Query('externalRoomId') externalRoomId: string,
) {
return this.conversations.listRoomMembers(
user.userId,
user.organizationId,
user.role,
externalRoomId || '',
);
}
@Post('rooms/members')
addMember(
@CurrentUser() user: { userId: string; organizationId: string; role: string },
@Body() dto: AddConversationRoomMemberDto,
) {
return this.conversations.addRoomMember(
user.userId,
user.organizationId,
user.role,
dto.externalRoomId,
dto.userId,
dto.role === 'admin' ? 'admin' : 'member',
);
}
@Post('rooms/members/remove')
removeMember(
@CurrentUser() user: { userId: string; organizationId: string; role: string },
@Body() dto: RemoveConversationRoomMemberDto,
) {
return this.conversations.removeRoomMember(
user.userId,
user.organizationId,
user.role,
dto.externalRoomId,
dto.userId,
);
}
@Post('rooms/members/role')
updateMemberRole(
@CurrentUser() user: { userId: string; organizationId: string; role: string },
@Body() dto: UpdateConversationRoomMemberRoleDto,
) {
return this.conversations.updateRoomMemberRole(
user.userId,
user.organizationId,
user.role,
dto.externalRoomId,
dto.userId,
dto.role === 'admin' ? 'admin' : 'member',
);
}
@Post('rooms/pin')
pinRoom(
@CurrentUser() user: { userId: string; organizationId: string; role: string },
@@ -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,
+47
View File
@@ -115,3 +115,50 @@ export class PinConversationRoomDto {
@IsBoolean()
pinned!: boolean;
}
export class ConversationRoomMembersQueryDto {
@IsString()
@MinLength(1)
@MaxLength(200)
externalRoomId!: string;
}
export class AddConversationRoomMemberDto {
@IsString()
@MinLength(1)
@MaxLength(200)
externalRoomId!: string;
@IsString()
@MinLength(1)
userId!: string;
@IsOptional()
@IsString()
role?: 'admin' | 'member';
}
export class RemoveConversationRoomMemberDto {
@IsString()
@MinLength(1)
@MaxLength(200)
externalRoomId!: string;
@IsString()
@MinLength(1)
userId!: string;
}
export class UpdateConversationRoomMemberRoleDto {
@IsString()
@MinLength(1)
@MaxLength(200)
externalRoomId!: string;
@IsString()
@MinLength(1)
userId!: string;
@IsString()
role!: 'admin' | 'member';
}