Adding the typing... indicator, loading older messages, and managing members/admins after creation.
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user