Add internal org chat (phase 1) with DM, groups, and org settings.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-29 10:34:01 +03:30
co-authored by Cursor
parent 589c763ef8
commit a1655432fc
21 changed files with 1912 additions and 8 deletions
+2
View File
@@ -43,6 +43,7 @@ import { WooCommerceModule } from './integrations/woocommerce/woocommerce.module
import { VoipModule } from './integrations/voip/voip.module';
import { ReservationsModule } from './reservations/reservations.module';
import { LeadsModule } from './leads/leads.module';
import { ConversationsModule } from './conversations/conversations.module';
@Module({
imports: [
@@ -93,6 +94,7 @@ import { LeadsModule } from './leads/leads.module';
WooCommerceModule,
VoipModule,
ReservationsModule,
ConversationsModule,
],
})
export class AppModule {}
@@ -0,0 +1,88 @@
import { BadGatewayException, Injectable } from '@nestjs/common';
export type ExternalRoomRow = Record<string, unknown>;
@Injectable()
export class ChatExternalClient {
normalizeHost(host: string): string {
return host
.trim()
.replace(/^wss?:\/\//, '')
.replace(/^https?:\/\//, '')
.replace(/\/+$/, '');
}
async createRoom(
host: string,
apiKey: string,
payload: {
name: string;
type: string;
members: string[];
},
): Promise<{ room_id: string }> {
const base = this.normalizeHost(host);
const url = `https://${base}/api/rooms/create`;
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({
api_key: apiKey,
name: payload.name,
type: payload.type,
members: payload.members,
}),
});
if (!response.ok) {
const body = await response.text().catch(() => '');
throw new BadGatewayException(
`سرویس گفتگو room ایجاد نکرد (${response.status})${body ? `: ${body.slice(0, 200)}` : ''}`,
);
}
const data = (await response.json()) as { room_id?: string; id?: string };
const roomId = String(data.room_id ?? data.id ?? '').trim();
if (!roomId) {
throw new BadGatewayException('پاسخ سرویس گفتگو فاقد room_id است');
}
return { room_id: roomId };
}
async listUserRooms(
host: string,
apiKey: string,
chatUserId: string,
): Promise<ExternalRoomRow[]> {
const base = this.normalizeHost(host);
const params = new URLSearchParams({
api_key: apiKey,
user_id: chatUserId,
});
const url = `https://${base}/api/users/rooms?${params.toString()}`;
const response = await fetch(url, {
method: 'GET',
headers: { Accept: 'application/json' },
cache: 'no-store',
});
if (!response.ok) {
const body = await response.text().catch(() => '');
throw new BadGatewayException(
`دریافت لیست گفتگو ناموفق (${response.status})${body ? `: ${body.slice(0, 200)}` : ''}`,
);
}
const result = await response.json();
if (Array.isArray(result)) return result as ExternalRoomRow[];
if (Array.isArray(result?.data)) return result.data as ExternalRoomRow[];
if (Array.isArray(result?.rooms)) return result.rooms as ExternalRoomRow[];
if (Array.isArray(result?.items)) return result.items as ExternalRoomRow[];
return [];
}
async ping(host: string, apiKey: string, chatUserId: string): Promise<boolean> {
try {
await this.listUserRooms(host, apiKey, chatUserId);
return true;
} catch {
return false;
}
}
}
@@ -0,0 +1,10 @@
/** شناسه پایدار کاربر در سرویس چت خارجی (scoped به org) */
export function buildChatUserId(organizationId: string, userId: string): string {
const o = organizationId.replace(/-/g, '').slice(0, 12);
const u = userId.replace(/-/g, '').slice(0, 12);
return `m${o}u${u}`;
}
export function dmPairKey(userA: string, userB: string): [string, string] {
return userA < userB ? [userA, userB] : [userB, userA];
}
@@ -0,0 +1,21 @@
import type { RolePermissionKey, UserRole } from '@mento/shared';
import { sanitizePermissions } from '../org-roles/role-defaults';
export type ConversationAccess = {
canUse: boolean;
canCreateGroup: boolean;
};
export function resolveConversationAccess(
membershipRole: UserRole | string,
orgRolePermissions: unknown,
): ConversationAccess {
if (membershipRole === 'owner' || membershipRole === 'admin') {
return { canUse: true, canCreateGroup: true };
}
const permissions = sanitizePermissions(orgRolePermissions) as RolePermissionKey[];
return {
canUse: permissions.includes('conversations'),
canCreateGroup: permissions.includes('conversations_groups'),
};
}
@@ -0,0 +1,78 @@
import { Body, Controller, Get, Patch, Post, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { RequireOrgModule } from '../billing/org-module.decorator';
import { OrgModuleGuard } from '../billing/org-module.guard';
import {
CreateConversationDmDto,
CreateConversationGroupDto,
UpdateConversationSettingsDto,
} from './dto';
import { ConversationsService } from './conversations.service';
@Controller('conversations')
@UseGuards(JwtAuthGuard, OrgModuleGuard)
@RequireOrgModule('conversations')
export class ConversationsController {
constructor(private readonly conversations: ConversationsService) {}
@Get('access')
access(@CurrentUser() user: { userId: string; organizationId: string; role: string }) {
return this.conversations.getAccess(user.userId, user.organizationId, user.role);
}
@Get('settings')
settings(@CurrentUser() user: { organizationId: string; role: string }) {
return this.conversations.getSettings(user.organizationId);
}
@Patch('settings')
updateSettings(
@CurrentUser() user: { organizationId: string; role: string },
@Body() dto: UpdateConversationSettingsDto,
) {
return this.conversations.updateSettings(user.organizationId, user.role, dto);
}
@Get('session')
session(@CurrentUser() user: { userId: string; organizationId: string; role: string }) {
return this.conversations.getSession(user.userId, user.organizationId, user.role);
}
@Get('peers')
peers(@CurrentUser() user: { userId: string; organizationId: string; role: string }) {
return this.conversations.listPeers(user.userId, user.organizationId, user.role);
}
@Get('rooms')
rooms(@CurrentUser() user: { userId: string; organizationId: string; role: string }) {
return this.conversations.listRooms(user.userId, user.organizationId, user.role);
}
@Post('rooms/dm')
createDm(
@CurrentUser() user: { userId: string; organizationId: string; role: string },
@Body() dto: CreateConversationDmDto,
) {
return this.conversations.createDm(
user.userId,
user.organizationId,
user.role,
dto.peerUserId,
);
}
@Post('rooms/group')
createGroup(
@CurrentUser() user: { userId: string; organizationId: string; role: string },
@Body() dto: CreateConversationGroupDto,
) {
return this.conversations.createGroup(
user.userId,
user.organizationId,
user.role,
dto.name,
dto.memberUserIds,
);
}
}
@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { BillingModule } from '../billing/billing.module';
import { TahesabModule } from '../integrations/tahesab/tahesab.module';
import { ChatExternalClient } from './chat-external.client';
import { ConversationsController } from './conversations.controller';
import { ConversationsService } from './conversations.service';
@Module({
imports: [AuthModule, BillingModule, TahesabModule],
controllers: [ConversationsController],
providers: [ConversationsService, ChatExternalClient],
exports: [ConversationsService],
})
export class ConversationsModule {}
@@ -0,0 +1,431 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type {
ConversationAccessDto,
ConversationPeerDto,
ConversationRoomDto,
ConversationSessionDto,
ConversationSettingsDto,
PlatformConversationStatsDto,
UpdateConversationSettingsRequest,
UserRole,
} from '@mento/shared';
import { canManageSettings } 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 {
constructor(
private readonly prisma: PrismaService,
private readonly crypto: TahesabCryptoService,
private readonly chat: ChatExternalClient,
) {}
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 updateSettings(
organizationId: string,
role: UserRole | string,
dto: UpdateConversationSettingsRequest,
): Promise<ConversationSettingsDto> {
if (!canManageSettings(role)) {
throw new ForbiddenException('فقط مدیر می‌تواند تنظیمات گفتگو را تغییر دهد');
}
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,
downloadUrl: creds.downloadUrl,
};
}
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 external = await this.chat.listUserRooms(creds.host, creds.apiKey, chatUserId);
const tracked = await this.prisma.conversationRoom.findMany({
where: { organizationId },
});
const trackedByExternal = new Map(tracked.map((t) => [t.externalRoomId, t]));
return external.map((row) => this.toRoomDto(row, trackedByExternal, organizationId, userId));
}
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,
},
});
return {
id: row.id,
externalRoomId: row.externalRoomId,
kind: 'group',
name: trimmedName,
unreadCount: 0,
lastMessagePreview: null,
lastMessageAt: null,
peerUserId: null,
};
}
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(
'تنظیمات گفتگو برای این محیط انجام نشده است. از بخش تنظیمات پیکربندی کنید.',
);
}
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,
};
}
private toRoomDto(
row: ExternalRoomRow,
trackedByExternal: Map<
string,
{
kind: string;
name: string | null;
dmUserMinId: string | null;
dmUserMaxId: string | null;
}
>,
organizationId: string,
currentUserId: string,
): 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 lastMsg = (row.last_message ?? row.lastMessage) as Record<string, unknown> | undefined;
const preview = lastMsg
? String(lastMsg.content ?? lastMsg.text ?? lastMsg.body ?? '').trim()
: '';
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 === '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: kind as 'dm' | 'group',
name,
unreadCount: Number.isFinite(unread) ? Math.max(0, unread) : 0,
lastMessagePreview: preview || null,
lastMessageAt: lastAtRaw ? String(lastAtRaw) : null,
peerUserId,
};
}
}
+51
View File
@@ -0,0 +1,51 @@
import {
IsArray,
IsOptional,
IsString,
MaxLength,
MinLength,
ArrayMinSize,
ArrayMaxSize,
} from 'class-validator';
export class UpdateConversationSettingsDto {
@IsString()
@MinLength(3)
@MaxLength(200)
chatHost!: string;
@IsOptional()
@IsString()
@MinLength(8)
@MaxLength(500)
apiKey?: string;
@IsOptional()
@IsString()
@MaxLength(500)
uploadUrl?: string;
@IsOptional()
@IsString()
@MaxLength(500)
downloadUrl?: string;
}
export class CreateConversationDmDto {
@IsString()
@MinLength(1)
peerUserId!: string;
}
export class CreateConversationGroupDto {
@IsString()
@MinLength(2)
@MaxLength(120)
name!: string;
@IsArray()
@ArrayMinSize(1)
@ArrayMaxSize(50)
@IsString({ each: true })
memberUserIds!: string[];
}
@@ -22,6 +22,7 @@ import { TahesabController } from './tahesab.controller';
TahesabDocumentsService,
],
exports: [
TahesabCryptoService,
TahesabConnectionsService,
TahesabPersonLinkService,
TahesabProductLinkService,
+10 -1
View File
@@ -23,17 +23,26 @@ import {
import { PlatformAuthGuard } from './platform-auth.guard';
import type { PlatformActor } from './platform-jwt.strategy';
import { PlatformService } from './platform.service';
import { ConversationsService } from '../conversations/conversations.service';
@Controller('platform')
@UseGuards(PlatformAuthGuard)
export class PlatformController {
constructor(private readonly platform: PlatformService) {}
constructor(
private readonly platform: PlatformService,
private readonly conversations: ConversationsService,
) {}
@Get('stats')
stats() {
return this.platform.stats();
}
@Get('conversations/stats')
conversationStats() {
return this.conversations.platformStats();
}
@Get('admins')
listAdmins() {
return this.platform.listAdmins();
+2
View File
@@ -11,6 +11,7 @@ import { PlatformAuthService } from './platform-auth.service';
import { PlatformJwtStrategy } from './platform-jwt.strategy';
import { PlatformController } from './platform.controller';
import { PlatformService } from './platform.service';
import { ConversationsModule } from '../conversations/conversations.module';
@Module({
imports: [
@@ -20,6 +21,7 @@ import { PlatformService } from './platform.service';
PipelinesModule,
BillingModule,
PassportModule,
ConversationsModule,
],
controllers: [PlatformAuthController, PlatformController],
providers: [