1137 lines
36 KiB
TypeScript
1137 lines
36 KiB
TypeScript
import {
|
|
BadRequestException,
|
|
ForbiddenException,
|
|
Injectable,
|
|
NotFoundException,
|
|
} from '@nestjs/common';
|
|
import type {
|
|
ConversationAccessDto,
|
|
ConversationPeerDto,
|
|
ConversationPinnedMessageDto,
|
|
ConversationRoomDto,
|
|
ConversationRoomMemberDto,
|
|
ConversationSessionDto,
|
|
ConversationSettingsDto,
|
|
PlatformConversationStatsDto,
|
|
UpdateConversationSettingsRequest,
|
|
} from '@mento/shared';
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
import { TahesabCryptoService } from '../integrations/tahesab/tahesab-crypto.service';
|
|
import { ChatExternalClient, type ExternalRoomRow } from './chat-external.client';
|
|
import { buildChatUserId, dmPairKey } from './chat-user-id.util';
|
|
import {
|
|
resolveConversationAccess,
|
|
type ConversationAccess,
|
|
} from './conversations-permissions';
|
|
|
|
@Injectable()
|
|
export class ConversationsService {
|
|
/** presence: org|room|userId → epoch ms (بدون DB؛ کافی برای typing کوتاهعمر) */
|
|
private readonly typingPresence = new Map<string, number>();
|
|
private typingSweepAt = 0;
|
|
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly crypto: TahesabCryptoService,
|
|
private readonly chat: ChatExternalClient,
|
|
) {}
|
|
|
|
private typingKey(organizationId: string, roomId: string, userId: string) {
|
|
return `${organizationId}|${roomId}|${userId}`;
|
|
}
|
|
|
|
private sweepTypingPresence(now = Date.now()) {
|
|
if (now - this.typingSweepAt < 10_000) return;
|
|
this.typingSweepAt = now;
|
|
for (const [k, at] of this.typingPresence) {
|
|
if (now - at > 8000) this.typingPresence.delete(k);
|
|
}
|
|
}
|
|
|
|
async signalTyping(
|
|
userId: string,
|
|
organizationId: string,
|
|
role: string,
|
|
externalRoomId: string,
|
|
): Promise<{ ok: true }> {
|
|
await this.loadAccess(userId, organizationId, role);
|
|
const roomId = externalRoomId.trim();
|
|
if (!roomId) throw new BadRequestException('شناسه اتاق خالی است');
|
|
const now = Date.now();
|
|
this.typingPresence.set(this.typingKey(organizationId, roomId, userId), now);
|
|
this.sweepTypingPresence(now);
|
|
return { ok: true };
|
|
}
|
|
|
|
async listTyping(
|
|
userId: string,
|
|
organizationId: string,
|
|
role: string,
|
|
externalRoomId: string,
|
|
): Promise<Array<{ userId: string; fullName: string; chatUserId: string }>> {
|
|
await this.loadAccess(userId, organizationId, role);
|
|
const roomId = externalRoomId.trim();
|
|
if (!roomId) return [];
|
|
const now = Date.now();
|
|
this.sweepTypingPresence(now);
|
|
const prefix = `${organizationId}|${roomId}|`;
|
|
const ids: string[] = [];
|
|
for (const [k, at] of this.typingPresence) {
|
|
if (!k.startsWith(prefix)) continue;
|
|
if (now - at > 4000) {
|
|
this.typingPresence.delete(k);
|
|
continue;
|
|
}
|
|
const uid = k.slice(prefix.length);
|
|
if (!uid || uid === userId) continue;
|
|
ids.push(uid);
|
|
}
|
|
if (!ids.length) return [];
|
|
const users = await this.prisma.user.findMany({
|
|
where: { id: { in: ids } },
|
|
select: { id: true, fullName: true },
|
|
});
|
|
return users.map((u) => ({
|
|
userId: u.id,
|
|
fullName: u.fullName || 'کاربر',
|
|
chatUserId: buildChatUserId(organizationId, u.id),
|
|
}));
|
|
}
|
|
|
|
async getAccess(userId: string, organizationId: string, role: string): Promise<ConversationAccessDto> {
|
|
const access = await this.loadAccess(userId, organizationId, role);
|
|
return access;
|
|
}
|
|
|
|
async getSettings(organizationId: string): Promise<ConversationSettingsDto> {
|
|
const row = await this.prisma.conversationSettings.findUnique({
|
|
where: { organizationId },
|
|
});
|
|
if (!row) {
|
|
return {
|
|
chatHost: '',
|
|
uploadUrl: '',
|
|
downloadUrl: '',
|
|
apiKeySet: false,
|
|
updatedAt: new Date(0).toISOString(),
|
|
};
|
|
}
|
|
return {
|
|
chatHost: row.chatHost,
|
|
uploadUrl: row.uploadUrl,
|
|
downloadUrl: row.downloadUrl,
|
|
apiKeySet: true,
|
|
updatedAt: row.updatedAt.toISOString(),
|
|
};
|
|
}
|
|
|
|
/** ذخیره تنظیمات اتصال چت — فقط از مسیر سوپرادمین */
|
|
async upsertSettingsForOrganization(
|
|
organizationId: string,
|
|
dto: UpdateConversationSettingsRequest,
|
|
): Promise<ConversationSettingsDto> {
|
|
const org = await this.prisma.organization.findUnique({
|
|
where: { id: organizationId },
|
|
select: { id: true },
|
|
});
|
|
if (!org) throw new NotFoundException('محیط پیدا نشد');
|
|
|
|
const host = this.chat.normalizeHost(dto.chatHost);
|
|
if (!host) throw new BadRequestException('آدرس سرویس گفتگو نامعتبر است');
|
|
|
|
const existing = await this.prisma.conversationSettings.findUnique({
|
|
where: { organizationId },
|
|
});
|
|
|
|
let enc = existing
|
|
? {
|
|
ciphertext: existing.apiKeyCipher,
|
|
iv: existing.apiKeyIv,
|
|
tag: existing.apiKeyTag,
|
|
}
|
|
: null;
|
|
|
|
const nextKey = dto.apiKey?.trim();
|
|
if (nextKey) {
|
|
enc = this.crypto.encrypt(nextKey);
|
|
} else if (!enc) {
|
|
throw new BadRequestException('API Key الزامی است');
|
|
}
|
|
|
|
const uploadUrl = dto.uploadUrl?.trim() ?? existing?.uploadUrl ?? '';
|
|
const downloadUrl = dto.downloadUrl?.trim() ?? existing?.downloadUrl ?? '';
|
|
|
|
await this.prisma.conversationSettings.upsert({
|
|
where: { organizationId },
|
|
create: {
|
|
organizationId,
|
|
chatHost: host,
|
|
apiKeyCipher: enc!.ciphertext,
|
|
apiKeyIv: enc!.iv,
|
|
apiKeyTag: enc!.tag,
|
|
uploadUrl,
|
|
downloadUrl,
|
|
},
|
|
update: {
|
|
chatHost: host,
|
|
apiKeyCipher: enc!.ciphertext,
|
|
apiKeyIv: enc!.iv,
|
|
apiKeyTag: enc!.tag,
|
|
uploadUrl,
|
|
downloadUrl,
|
|
},
|
|
});
|
|
|
|
return this.getSettings(organizationId);
|
|
}
|
|
|
|
async getSession(
|
|
userId: string,
|
|
organizationId: string,
|
|
role: string,
|
|
): Promise<ConversationSessionDto> {
|
|
await this.requireUse(userId, organizationId, role);
|
|
const creds = await this.getCredentials(organizationId);
|
|
const chatUserId = buildChatUserId(organizationId, userId);
|
|
const params = new URLSearchParams({
|
|
api_key: creds.apiKey,
|
|
user_id: chatUserId,
|
|
});
|
|
const host = creds.host;
|
|
return {
|
|
chatUserId,
|
|
wsUrl: `wss://${host}/ws?${params.toString()}`,
|
|
messagesBaseUrl: `https://${host}`,
|
|
uploadUrl:
|
|
creds.uploadUrl.trim() ||
|
|
`https://${host}/upload`,
|
|
downloadUrl:
|
|
creds.downloadUrl.trim() ||
|
|
`https://${host}/files`,
|
|
};
|
|
}
|
|
|
|
async listPeers(
|
|
userId: string,
|
|
organizationId: string,
|
|
role: string,
|
|
): Promise<ConversationPeerDto[]> {
|
|
await this.requireUse(userId, organizationId, role);
|
|
const rows = await this.prisma.membership.findMany({
|
|
where: {
|
|
organizationId,
|
|
isActive: true,
|
|
user: { isActive: true },
|
|
},
|
|
select: {
|
|
user: {
|
|
select: {
|
|
id: true,
|
|
fullName: true,
|
|
avatarStorageKey: true,
|
|
},
|
|
},
|
|
},
|
|
orderBy: { user: { fullName: 'asc' } },
|
|
});
|
|
return rows
|
|
.filter((r) => r.user.id !== userId)
|
|
.map((r) => ({
|
|
id: r.user.id,
|
|
fullName: r.user.fullName,
|
|
hasAvatar: Boolean(r.user.avatarStorageKey),
|
|
chatUserId: buildChatUserId(organizationId, r.user.id),
|
|
}));
|
|
}
|
|
|
|
async listRooms(
|
|
userId: string,
|
|
organizationId: string,
|
|
role: string,
|
|
): Promise<ConversationRoomDto[]> {
|
|
await this.requireUse(userId, organizationId, role);
|
|
const creds = await this.getCredentials(organizationId);
|
|
const chatUserId = buildChatUserId(organizationId, userId);
|
|
const tracked = await this.prisma.conversationRoom.findMany({
|
|
where: { organizationId },
|
|
});
|
|
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);
|
|
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);
|
|
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,
|
|
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: kind !== 'channel' ? true : Boolean(isRoomAdmin || orgAdmin),
|
|
canManageMembers: kind === 'dm' ? false : Boolean(isRoomAdmin || orgAdmin),
|
|
};
|
|
});
|
|
}
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
async createDm(
|
|
userId: string,
|
|
organizationId: string,
|
|
role: string,
|
|
peerUserId: string,
|
|
): Promise<ConversationRoomDto> {
|
|
await this.requireUse(userId, organizationId, role);
|
|
if (peerUserId === userId) {
|
|
throw new BadRequestException('نمیتوانید با خودتان گفتگو بسازید');
|
|
}
|
|
const peer = await this.prisma.membership.findFirst({
|
|
where: {
|
|
organizationId,
|
|
userId: peerUserId,
|
|
isActive: true,
|
|
user: { isActive: true },
|
|
},
|
|
include: { user: { select: { fullName: true } } },
|
|
});
|
|
if (!peer) throw new NotFoundException('همکار پیدا نشد');
|
|
|
|
const [minId, maxId] = dmPairKey(userId, peerUserId);
|
|
const existing = await this.prisma.conversationRoom.findFirst({
|
|
where: { organizationId, dmUserMinId: minId, dmUserMaxId: maxId },
|
|
});
|
|
if (existing) {
|
|
return {
|
|
id: existing.id,
|
|
externalRoomId: existing.externalRoomId,
|
|
kind: 'dm',
|
|
name: peer.user.fullName,
|
|
unreadCount: 0,
|
|
lastMessagePreview: null,
|
|
lastMessageAt: null,
|
|
peerUserId,
|
|
};
|
|
}
|
|
|
|
const creds = await this.getCredentials(organizationId);
|
|
const members = [
|
|
buildChatUserId(organizationId, userId),
|
|
buildChatUserId(organizationId, peerUserId),
|
|
];
|
|
const created = await this.chat.createRoom(creds.host, creds.apiKey, {
|
|
name: peer.user.fullName,
|
|
type: 'DIRECT',
|
|
members,
|
|
});
|
|
|
|
const row = await this.prisma.conversationRoom.create({
|
|
data: {
|
|
organizationId,
|
|
externalRoomId: created.room_id,
|
|
kind: 'dm',
|
|
name: peer.user.fullName,
|
|
dmUserMinId: minId,
|
|
dmUserMaxId: maxId,
|
|
createdByUserId: userId,
|
|
},
|
|
});
|
|
|
|
return {
|
|
id: row.id,
|
|
externalRoomId: row.externalRoomId,
|
|
kind: 'dm',
|
|
name: peer.user.fullName,
|
|
unreadCount: 0,
|
|
lastMessagePreview: null,
|
|
lastMessageAt: null,
|
|
peerUserId,
|
|
};
|
|
}
|
|
|
|
async createGroup(
|
|
userId: string,
|
|
organizationId: string,
|
|
role: string,
|
|
name: string,
|
|
memberUserIds: string[],
|
|
): Promise<ConversationRoomDto> {
|
|
const access = await this.loadAccess(userId, organizationId, role);
|
|
if (!access.canCreateGroup) {
|
|
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();
|
|
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: 'group',
|
|
name: trimmedName,
|
|
createdByUserId: userId,
|
|
},
|
|
});
|
|
|
|
await this.seedRoomMembers(organizationId, row.externalRoomId, userId, uniqueIds);
|
|
|
|
return {
|
|
id: row.id,
|
|
externalRoomId: row.externalRoomId,
|
|
kind: 'group',
|
|
name: trimmedName,
|
|
unreadCount: 0,
|
|
lastMessagePreview: null,
|
|
lastMessageAt: null,
|
|
peerUserId: null,
|
|
canPost: true,
|
|
canManageMembers: true,
|
|
};
|
|
}
|
|
|
|
async createChannel(
|
|
userId: string,
|
|
organizationId: string,
|
|
role: string,
|
|
name: string,
|
|
memberUserIds: string[],
|
|
): Promise<ConversationRoomDto> {
|
|
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,
|
|
},
|
|
});
|
|
|
|
await this.seedRoomMembers(organizationId, row.externalRoomId, userId, uniqueIds);
|
|
|
|
return {
|
|
id: row.id,
|
|
externalRoomId: row.externalRoomId,
|
|
kind: 'channel',
|
|
name: trimmedName,
|
|
unreadCount: 0,
|
|
lastMessagePreview: null,
|
|
lastMessageAt: null,
|
|
peerUserId: null,
|
|
canPost: true,
|
|
canManageMembers: true,
|
|
};
|
|
}
|
|
|
|
async platformStats(): Promise<PlatformConversationStatsDto> {
|
|
const [organizationsWithChat, totalRoomsTracked] = await Promise.all([
|
|
this.prisma.conversationSettings.count(),
|
|
this.prisma.conversationRoom.count(),
|
|
]);
|
|
const totalConfiguredOrgs = await this.prisma.organization.count({
|
|
where: { isActive: true },
|
|
});
|
|
return {
|
|
organizationsWithChat,
|
|
totalConfiguredOrgs,
|
|
totalRoomsTracked,
|
|
};
|
|
}
|
|
|
|
private async requireUse(userId: string, organizationId: string, role: string) {
|
|
const access = await this.loadAccess(userId, organizationId, role);
|
|
if (!access.canUse) {
|
|
throw new ForbiddenException('به بخش گفتگو دسترسی ندارید');
|
|
}
|
|
await this.getCredentials(organizationId);
|
|
}
|
|
|
|
private async loadAccess(
|
|
userId: string,
|
|
organizationId: string,
|
|
role: string,
|
|
): Promise<ConversationAccess> {
|
|
const membership = await this.prisma.membership.findFirst({
|
|
where: { userId, organizationId, isActive: true },
|
|
include: { orgRole: { select: { permissions: true } } },
|
|
});
|
|
return resolveConversationAccess(
|
|
membership?.role ?? role,
|
|
membership?.orgRole?.permissions ?? [],
|
|
);
|
|
}
|
|
|
|
private async getCredentials(organizationId: string) {
|
|
const row = await this.prisma.conversationSettings.findUnique({
|
|
where: { organizationId },
|
|
});
|
|
if (!row) {
|
|
throw new BadRequestException(
|
|
'تنظیمات گفتگو برای این محیط انجام نشده است. از سوپرادمین → محیطها → تنظیمات گفتگو پیکربندی کنید.',
|
|
);
|
|
}
|
|
try {
|
|
const apiKey = this.crypto.decrypt({
|
|
ciphertext: row.apiKeyCipher,
|
|
iv: row.apiKeyIv,
|
|
tag: row.apiKeyTag,
|
|
});
|
|
return {
|
|
host: this.chat.normalizeHost(row.chatHost),
|
|
apiKey,
|
|
uploadUrl: row.uploadUrl,
|
|
downloadUrl: row.downloadUrl,
|
|
};
|
|
} catch {
|
|
throw new BadRequestException(
|
|
'رمزگشایی API Key گفتگو ناموفق بود. INTEGRATIONS_ENCRYPTION_KEY سرور را بررسی کنید یا تنظیمات را دوباره ذخیره کنید.',
|
|
);
|
|
}
|
|
}
|
|
|
|
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<
|
|
string,
|
|
{
|
|
kind: string;
|
|
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 = this.normalizeKind(tracked?.kind);
|
|
|
|
const lastMsg = (row.last_message ?? row.lastMessage) as Record<string, unknown> | undefined;
|
|
const lastType = String(lastMsg?.type_content ?? lastMsg?.type ?? 'text')
|
|
.toLowerCase()
|
|
.trim();
|
|
let preview = lastMsg
|
|
? String(lastMsg.content ?? lastMsg.text ?? lastMsg.body ?? '').trim()
|
|
: '';
|
|
if (/^__MENTO_HIDE__:/i.test(preview) || /MENTO_DEL/i.test(preview) || /^__MENTO_PIN__:/i.test(preview) || /^__MENTO_UNPIN__$/i.test(preview) || /^__MENTO_TYPING__:/i.test(preview)) {
|
|
preview = '';
|
|
}
|
|
if (lastType === 'image' || lastType === 'photo' || lastType === 'img') {
|
|
if (/(?:^|[\\/._-])vid-\d+/i.test(preview) || /\.(mp4|mov|m4v|mkv|avi|3gp)(?:\?|$)/i.test(preview)) {
|
|
preview = 'ویدیو';
|
|
} else {
|
|
preview = 'تصویر';
|
|
}
|
|
} else if (lastType === 'voice' || lastType === 'audio') {
|
|
preview = 'پیام صوتی';
|
|
} else if (lastType === 'video') {
|
|
preview = 'ویدیو';
|
|
} else if (lastType === 'file' || lastType === 'document' || lastType === 'attachment') {
|
|
if (/(?:^|[\\/._-])vid-\d+/i.test(preview) || /\.(mp4|mov|m4v|mkv|avi|3gp)(?:\?|$)/i.test(preview)) {
|
|
preview = 'ویدیو';
|
|
} else if (
|
|
/(?:^|[\\/._-])voice(?:[\\/._-]|$)/i.test(preview) ||
|
|
/\.(webm|ogg|mp3|m4a|wav|aac|opus)(?:\?|$)/i.test(preview)
|
|
) {
|
|
preview = 'پیام صوتی';
|
|
} else {
|
|
preview = 'فایل';
|
|
}
|
|
}
|
|
const lastAtRaw =
|
|
lastMsg?.created_at ?? lastMsg?.createdAt ?? row.updated_at ?? row.updatedAt ?? null;
|
|
const unread = Number(row.unread_count ?? row.unreadCount ?? 0);
|
|
|
|
let name =
|
|
String(tracked?.name ?? row.name ?? row.title ?? '').trim() ||
|
|
(kind === 'channel' ? 'کانال' : kind === 'group' ? 'گروه' : 'گفتگو');
|
|
|
|
let peerUserId: string | null = null;
|
|
if (tracked?.kind === 'dm' && tracked.dmUserMinId && tracked.dmUserMaxId) {
|
|
if (tracked.dmUserMinId === currentUserId) peerUserId = tracked.dmUserMaxId;
|
|
else if (tracked.dmUserMaxId === currentUserId) peerUserId = tracked.dmUserMinId;
|
|
}
|
|
|
|
return {
|
|
id: externalRoomId,
|
|
externalRoomId,
|
|
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),
|
|
};
|
|
}
|
|
|
|
private async userPinnedRoomIds(organizationId: string, userId: string): Promise<Set<string>> {
|
|
const rows = await this.prisma.conversationRoomUserPin.findMany({
|
|
where: { organizationId, userId },
|
|
select: { externalRoomId: true },
|
|
take: 500,
|
|
});
|
|
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('گفتگو پیدا نشد');
|
|
|
|
// همیشه سازنده + کاربر فعلی را در لیست عضو داشته باش (اتاقهای قدیمی بدون ردیف)
|
|
const seedIds: Array<{ userId: string; role: string }> = [];
|
|
if (room.createdByUserId) {
|
|
seedIds.push({ userId: room.createdByUserId, role: 'admin' });
|
|
}
|
|
if (!seedIds.some((s) => s.userId === userId)) {
|
|
const orgAdmin = role === 'owner' || role === 'admin';
|
|
seedIds.push({
|
|
userId,
|
|
role: orgAdmin || room.createdByUserId === userId ? 'admin' : 'member',
|
|
});
|
|
}
|
|
if (seedIds.length) {
|
|
try {
|
|
await this.prisma.conversationRoomMember.createMany({
|
|
data: seedIds.map((s) => ({
|
|
organizationId,
|
|
externalRoomId: roomId,
|
|
userId: s.userId,
|
|
role: s.role,
|
|
})),
|
|
skipDuplicates: true,
|
|
});
|
|
} catch (e) {
|
|
const code = (e as { code?: string })?.code;
|
|
if (code === 'P2021' || code === 'P2022') {
|
|
throw new BadRequestException(
|
|
'جدول اعضای گفتگو هنوز migrate نشده است. از apps/api دستور prisma migrate deploy را اجرا کنید.',
|
|
);
|
|
}
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
let rows: Array<{ userId: string; role: string }> = [];
|
|
try {
|
|
rows = await this.prisma.conversationRoomMember.findMany({
|
|
where: { organizationId, externalRoomId: roomId },
|
|
select: { userId: true, role: true },
|
|
});
|
|
} catch (e) {
|
|
const code = (e as { code?: string })?.code;
|
|
if (code === 'P2021' || code === 'P2022') {
|
|
throw new BadRequestException(
|
|
'جدول اعضای گفتگو هنوز migrate نشده است. از apps/api دستور prisma migrate deploy را اجرا کنید.',
|
|
);
|
|
}
|
|
throw e;
|
|
}
|
|
|
|
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,
|
|
role: string,
|
|
externalRoomId: string,
|
|
pinned: boolean,
|
|
): Promise<{ externalRoomId: string; pinned: boolean }> {
|
|
await this.loadAccess(userId, organizationId, role);
|
|
const roomId = externalRoomId.trim();
|
|
if (!roomId) throw new BadRequestException('شناسه اتاق خالی است');
|
|
|
|
if (pinned) {
|
|
await this.prisma.conversationRoomUserPin.upsert({
|
|
where: {
|
|
organizationId_userId_externalRoomId: {
|
|
organizationId,
|
|
userId,
|
|
externalRoomId: roomId,
|
|
},
|
|
},
|
|
create: { organizationId, userId, externalRoomId: roomId },
|
|
update: {},
|
|
});
|
|
} else {
|
|
await this.prisma.conversationRoomUserPin.deleteMany({
|
|
where: { organizationId, userId, externalRoomId: roomId },
|
|
});
|
|
}
|
|
return { externalRoomId: roomId, pinned };
|
|
}
|
|
|
|
async getPinnedMessage(
|
|
userId: string,
|
|
organizationId: string,
|
|
role: string,
|
|
externalRoomId: string,
|
|
): Promise<ConversationPinnedMessageDto | null> {
|
|
await this.loadAccess(userId, organizationId, role);
|
|
const roomId = externalRoomId.trim();
|
|
if (!roomId) throw new BadRequestException('شناسه اتاق خالی است');
|
|
|
|
const row = await this.prisma.conversationPinnedMessage.findUnique({
|
|
where: {
|
|
organizationId_externalRoomId: { organizationId, externalRoomId: roomId },
|
|
},
|
|
});
|
|
if (!row) return null;
|
|
return {
|
|
messageId: row.messageId,
|
|
preview: row.preview,
|
|
pinnedByUserId: row.pinnedByUserId,
|
|
createdAt: row.createdAt.toISOString(),
|
|
};
|
|
}
|
|
|
|
async pinMessage(
|
|
userId: string,
|
|
organizationId: string,
|
|
role: string,
|
|
externalRoomId: string,
|
|
messageId: string,
|
|
preview?: string,
|
|
): Promise<ConversationPinnedMessageDto> {
|
|
await this.loadAccess(userId, organizationId, role);
|
|
const roomId = externalRoomId.trim();
|
|
const msgId = messageId.trim();
|
|
if (!roomId) throw new BadRequestException('شناسه اتاق خالی است');
|
|
if (!msgId) throw new BadRequestException('شناسه پیام خالی است');
|
|
|
|
const cleanPreview = String(preview || '')
|
|
.replace(/\s+/g, ' ')
|
|
.trim()
|
|
.slice(0, 240);
|
|
|
|
const row = await this.prisma.conversationPinnedMessage.upsert({
|
|
where: {
|
|
organizationId_externalRoomId: { organizationId, externalRoomId: roomId },
|
|
},
|
|
create: {
|
|
organizationId,
|
|
externalRoomId: roomId,
|
|
messageId: msgId,
|
|
preview: cleanPreview,
|
|
pinnedByUserId: userId,
|
|
},
|
|
update: {
|
|
messageId: msgId,
|
|
preview: cleanPreview,
|
|
pinnedByUserId: userId,
|
|
},
|
|
});
|
|
|
|
return {
|
|
messageId: row.messageId,
|
|
preview: row.preview,
|
|
pinnedByUserId: row.pinnedByUserId,
|
|
createdAt: row.createdAt.toISOString(),
|
|
};
|
|
}
|
|
|
|
async unpinMessage(
|
|
userId: string,
|
|
organizationId: string,
|
|
role: string,
|
|
externalRoomId: string,
|
|
): Promise<{ ok: true }> {
|
|
await this.loadAccess(userId, organizationId, role);
|
|
const roomId = externalRoomId.trim();
|
|
if (!roomId) throw new BadRequestException('شناسه اتاق خالی است');
|
|
await this.prisma.conversationPinnedMessage.deleteMany({
|
|
where: { organizationId, externalRoomId: roomId },
|
|
});
|
|
return { ok: true };
|
|
}
|
|
|
|
async hideMessages(
|
|
userId: string,
|
|
organizationId: string,
|
|
role: string,
|
|
externalRoomId: string,
|
|
messageIds: string[],
|
|
forEveryone = true,
|
|
): Promise<{ messageIds: string[]; scope: 'all' | 'self' }> {
|
|
await this.loadAccess(userId, organizationId, role);
|
|
const roomId = externalRoomId.trim();
|
|
if (!roomId) throw new BadRequestException('شناسه اتاق خالی است');
|
|
const ids = [
|
|
...new Set(
|
|
messageIds
|
|
.map((id) => String(id || '').trim())
|
|
.filter((id) => id.length > 0 && id.length <= 200),
|
|
),
|
|
].slice(0, 100);
|
|
if (!ids.length) throw new BadRequestException('شناسه پیام خالی است');
|
|
|
|
const scope = forEveryone ? 'all' : 'self';
|
|
await this.prisma.conversationHiddenMessage.createMany({
|
|
data: ids.map((messageId) => ({
|
|
organizationId,
|
|
externalRoomId: roomId,
|
|
messageId,
|
|
hiddenByUserId: userId,
|
|
scope,
|
|
})),
|
|
skipDuplicates: true,
|
|
});
|
|
|
|
return { messageIds: ids, scope };
|
|
}
|
|
|
|
async listHiddenMessageIds(
|
|
userId: string,
|
|
organizationId: string,
|
|
role: string,
|
|
externalRoomId: string,
|
|
): Promise<{ messageIds: string[] }> {
|
|
await this.loadAccess(userId, organizationId, role);
|
|
const roomId = externalRoomId.trim();
|
|
if (!roomId) throw new BadRequestException('شناسه اتاق خالی است');
|
|
|
|
const rows = await this.prisma.conversationHiddenMessage.findMany({
|
|
where: {
|
|
organizationId,
|
|
externalRoomId: roomId,
|
|
OR: [{ scope: 'all' }, { scope: 'self', hiddenByUserId: userId }],
|
|
},
|
|
select: { messageId: true },
|
|
take: 5000,
|
|
});
|
|
return { messageIds: [...new Set(rows.map((r) => r.messageId))] };
|
|
}
|
|
}
|