Add internal org chat (phase 1) with DM, groups, and org settings.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "ConversationSettings" (
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"chatHost" TEXT NOT NULL,
|
||||
"apiKeyCipher" TEXT NOT NULL,
|
||||
"apiKeyIv" TEXT NOT NULL,
|
||||
"apiKeyTag" TEXT NOT NULL,
|
||||
"uploadUrl" TEXT NOT NULL DEFAULT '',
|
||||
"downloadUrl" TEXT NOT NULL DEFAULT '',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "ConversationSettings_pkey" PRIMARY KEY ("organizationId")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ConversationRoom" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"externalRoomId" TEXT NOT NULL,
|
||||
"kind" TEXT NOT NULL,
|
||||
"name" TEXT,
|
||||
"dmUserMinId" TEXT,
|
||||
"dmUserMaxId" TEXT,
|
||||
"createdByUserId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "ConversationRoom_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ConversationRoom_organizationId_externalRoomId_key" ON "ConversationRoom"("organizationId", "externalRoomId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ConversationRoom_organizationId_dmUserMinId_dmUserMaxId_key" ON "ConversationRoom"("organizationId", "dmUserMinId", "dmUserMaxId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ConversationRoom_organizationId_kind_idx" ON "ConversationRoom"("organizationId", "kind");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ConversationSettings" ADD CONSTRAINT "ConversationSettings_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ConversationRoom" ADD CONSTRAINT "ConversationRoom_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -207,6 +207,8 @@ model Organization {
|
||||
leadStages LeadStage[]
|
||||
leadEvents LeadEvent[]
|
||||
leadImportJobs LeadImportJob[]
|
||||
conversationSettings ConversationSettings?
|
||||
conversationRooms ConversationRoom[]
|
||||
}
|
||||
|
||||
model User {
|
||||
@@ -2060,3 +2062,36 @@ model LeadImportJob {
|
||||
@@index([organizationId, status, createdAt])
|
||||
@@index([status, createdAt])
|
||||
}
|
||||
|
||||
/// تنظیمات اتصال به سرویس گفتگوی خارجی (هر محیط CRM)
|
||||
model ConversationSettings {
|
||||
organizationId String @id
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
chatHost String
|
||||
apiKeyCipher String
|
||||
apiKeyIv String
|
||||
apiKeyTag String
|
||||
uploadUrl String @default("")
|
||||
downloadUrl String @default("")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
/// نگاشت roomهای سرویس چت (DM dedup و گروههای ساختهشده از CRM)
|
||||
model ConversationRoom {
|
||||
id String @id @default(cuid())
|
||||
organizationId String
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
externalRoomId String
|
||||
kind String
|
||||
name String?
|
||||
dmUserMinId String?
|
||||
dmUserMaxId String?
|
||||
createdByUserId String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([organizationId, externalRoomId])
|
||||
@@unique([organizationId, dmUserMinId, dmUserMaxId])
|
||||
@@index([organizationId, kind])
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -64,7 +64,7 @@ const salesActive = computed(() =>
|
||||
);
|
||||
const activitiesActive = computed(() => pathIn(['/activities']));
|
||||
const notesActive = computed(() => route.path === '/notes' || route.path.startsWith('/notes/'));
|
||||
const supportActive = computed(() => pathIn(['/tickets']));
|
||||
const supportActive = computed(() => pathIn(['/tickets', '/conversations']));
|
||||
const automationsActive = computed(
|
||||
() =>
|
||||
route.path.startsWith('/settings/automations') ||
|
||||
|
||||
@@ -247,6 +247,12 @@ export const NAV_HUBS: NavHubDef[] = [
|
||||
shortcuts: [
|
||||
{ to: '/tickets/new', title: 'تیکت جدید', icon: 'plus', module: 'tickets' },
|
||||
{ to: '/tickets', title: 'تیکتها', icon: 'chat', module: 'tickets' },
|
||||
{
|
||||
to: '/conversations',
|
||||
title: 'گفتگو',
|
||||
icon: 'chat',
|
||||
module: 'conversations',
|
||||
},
|
||||
],
|
||||
cards: [
|
||||
{
|
||||
@@ -257,6 +263,14 @@ export const NAV_HUBS: NavHubDef[] = [
|
||||
tint: 'peach',
|
||||
module: 'tickets',
|
||||
},
|
||||
{
|
||||
to: '/conversations',
|
||||
title: 'گفتگوی درونسازمانی',
|
||||
desc: 'پیام خصوصی و گروه بین همکاران',
|
||||
icon: 'chat',
|
||||
tint: 'violet',
|
||||
module: 'conversations',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import { computed, onUnmounted, ref, shallowRef } from 'vue';
|
||||
import type { ConversationSessionDto } from '@mento/shared';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
export type ChatMessage = {
|
||||
id: string;
|
||||
roomId: string;
|
||||
senderId: string;
|
||||
content: string;
|
||||
type: 'text' | 'image' | 'voice';
|
||||
createdAt: string;
|
||||
reply: string | null;
|
||||
optimistic?: boolean;
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
let socket: WebSocket | null = null;
|
||||
let sessionCache: ConversationSessionDto | null = null;
|
||||
const status = ref<'idle' | 'connecting' | 'open' | 'closed'>('idle');
|
||||
const messages = shallowRef<ChatMessage[]>([]);
|
||||
const activeRoomId = ref('');
|
||||
let pingTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
function normalizeIncoming(raw: Record<string, unknown>, roomFallback: string): ChatMessage | null {
|
||||
const base =
|
||||
raw.message && typeof raw.message === 'object'
|
||||
? (raw.message as Record<string, unknown>)
|
||||
: raw;
|
||||
const content = String(base.content ?? base.text ?? base.body ?? '').trim();
|
||||
if (!content && !base.attachments) return null;
|
||||
const id = String(base.id ?? base.message_id ?? crypto.randomUUID());
|
||||
return {
|
||||
id,
|
||||
roomId: String(base.room_id ?? raw.room_id ?? roomFallback),
|
||||
senderId: String(
|
||||
base.sender_id ?? base.user_id ?? base.from_user_id ?? raw.sender_id ?? '',
|
||||
),
|
||||
content,
|
||||
type: (String(base.type_content ?? base.type ?? 'text') as ChatMessage['type']) || 'text',
|
||||
createdAt: String(
|
||||
base.created_at ?? base.createdAt ?? raw.created_at ?? new Date().toISOString(),
|
||||
),
|
||||
reply: base.reply ? String(base.reply) : null,
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureSession(): Promise<ConversationSessionDto> {
|
||||
if (sessionCache) return sessionCache;
|
||||
const { data } = await api.get<ConversationSessionDto>('/conversations/session');
|
||||
sessionCache = data;
|
||||
return data;
|
||||
}
|
||||
|
||||
function clearPing() {
|
||||
if (pingTimer) {
|
||||
clearInterval(pingTimer);
|
||||
pingTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function disconnectSocket() {
|
||||
clearPing();
|
||||
if (socket) {
|
||||
socket.close();
|
||||
socket = null;
|
||||
}
|
||||
status.value = 'closed';
|
||||
}
|
||||
|
||||
async function connectSocket() {
|
||||
const session = await ensureSession();
|
||||
if (socket?.readyState === WebSocket.OPEN) return;
|
||||
|
||||
disconnectSocket();
|
||||
status.value = 'connecting';
|
||||
socket = new WebSocket(session.wsUrl);
|
||||
|
||||
socket.addEventListener('open', () => {
|
||||
status.value = 'open';
|
||||
pingTimer = setInterval(() => {
|
||||
if (socket?.readyState === WebSocket.OPEN) socket.send('ping');
|
||||
}, 15000);
|
||||
});
|
||||
|
||||
socket.addEventListener('close', () => {
|
||||
status.value = 'closed';
|
||||
clearPing();
|
||||
});
|
||||
|
||||
socket.addEventListener('message', (event) => {
|
||||
const raw = String(event.data ?? '').trim();
|
||||
if (!raw || raw === 'pong' || raw === 'ping') return;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
if (parsed.type === 'seen' || parsed.type === 'typing' || parsed.type === 'read') return;
|
||||
const msg = normalizeIncoming(parsed, activeRoomId.value);
|
||||
if (!msg || msg.roomId !== activeRoomId.value) return;
|
||||
messages.value = [...messages.value, msg];
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
if (!socket) return reject(new Error('socket missing'));
|
||||
const t = setTimeout(() => reject(new Error('ws timeout')), 12000);
|
||||
socket.addEventListener(
|
||||
'open',
|
||||
() => {
|
||||
clearTimeout(t);
|
||||
resolve();
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
socket.addEventListener(
|
||||
'error',
|
||||
() => {
|
||||
clearTimeout(t);
|
||||
reject(new Error('ws error'));
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function useOrgChat() {
|
||||
const loadingMessages = ref(false);
|
||||
|
||||
async function loadMessages(roomId: string) {
|
||||
activeRoomId.value = roomId;
|
||||
loadingMessages.value = true;
|
||||
try {
|
||||
const session = await ensureSession();
|
||||
await connectSocket();
|
||||
const params = new URLSearchParams({
|
||||
api_key: new URL(session.wsUrl).searchParams.get('api_key') ?? '',
|
||||
room_id: roomId,
|
||||
user_id: session.chatUserId,
|
||||
limit: String(PAGE_SIZE),
|
||||
offset: '0',
|
||||
});
|
||||
const url = `${session.messagesBaseUrl}/api/messages?${params.toString()}`;
|
||||
const res = await fetch(url, { headers: { Accept: 'application/json' }, cache: 'no-store' });
|
||||
if (!res.ok) throw new Error('messages failed');
|
||||
const body = await res.json();
|
||||
const list = Array.isArray(body) ? body : body?.data ?? [];
|
||||
messages.value = list
|
||||
.map((item: Record<string, unknown>) => normalizeIncoming(item, roomId))
|
||||
.filter(Boolean) as ChatMessage[];
|
||||
} finally {
|
||||
loadingMessages.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function sendText(roomId: string, text: string, reply: string | null = null) {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return;
|
||||
await connectSocket();
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) throw new Error('ws not open');
|
||||
const session = await ensureSession();
|
||||
const id = crypto.randomUUID();
|
||||
const payload: Record<string, string> = {
|
||||
id,
|
||||
type: 'text',
|
||||
room_id: roomId,
|
||||
content: trimmed,
|
||||
};
|
||||
if (reply) payload.reply = reply;
|
||||
const optimistic: ChatMessage = {
|
||||
id,
|
||||
roomId,
|
||||
senderId: session.chatUserId,
|
||||
content: trimmed,
|
||||
type: 'text',
|
||||
createdAt: new Date().toISOString(),
|
||||
reply,
|
||||
optimistic: true,
|
||||
};
|
||||
messages.value = [...messages.value, optimistic];
|
||||
socket.send(JSON.stringify(payload));
|
||||
sendRead(roomId);
|
||||
}
|
||||
|
||||
function sendRead(roomId: string) {
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) return;
|
||||
socket.send(JSON.stringify({ type: 'read', room_id: roomId }));
|
||||
}
|
||||
|
||||
function resetSession() {
|
||||
sessionCache = null;
|
||||
disconnectSocket();
|
||||
messages.value = [];
|
||||
activeRoomId.value = '';
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
disconnectSocket();
|
||||
});
|
||||
|
||||
return {
|
||||
status: computed(() => status.value),
|
||||
messages: computed(() => messages.value),
|
||||
loadingMessages,
|
||||
loadMessages,
|
||||
sendText,
|
||||
sendRead,
|
||||
resetSession,
|
||||
ensureSession,
|
||||
};
|
||||
}
|
||||
@@ -92,6 +92,11 @@ export const router = createRouter({
|
||||
name: 'tickets',
|
||||
component: () => import('@/views/TicketsView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'conversations',
|
||||
name: 'conversations',
|
||||
component: () => import('@/views/ConversationsView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'tickets/new',
|
||||
name: 'ticket-new',
|
||||
@@ -344,6 +349,12 @@ export const router = createRouter({
|
||||
component: () => import('@/views/VoipSettingsView.vue'),
|
||||
meta: { roles: ['owner', 'admin'] as UserRole[] },
|
||||
},
|
||||
{
|
||||
path: 'settings/conversations',
|
||||
name: 'conversations-settings',
|
||||
component: () => import('@/views/ConversationsSettingsView.vue'),
|
||||
meta: { roles: ['owner', 'admin'] as UserRole[] },
|
||||
},
|
||||
{
|
||||
path: 'settings/faq',
|
||||
name: 'faq-settings',
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import { RouterLink } from 'vue-router';
|
||||
import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import type { ConversationSettingsDto, UpdateConversationSettingsRequest } from '@mento/shared';
|
||||
import { UiButton, UiCard, UiField, UiInput, UiPageHeader } from '@/components/ui';
|
||||
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const error = ref('');
|
||||
const ok = ref('');
|
||||
|
||||
const form = reactive({
|
||||
chatHost: '',
|
||||
apiKey: '',
|
||||
uploadUrl: '',
|
||||
downloadUrl: '',
|
||||
});
|
||||
const apiKeySet = ref(false);
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const { data } = await api.get<ConversationSettingsDto>('/conversations/settings');
|
||||
form.chatHost = data.chatHost;
|
||||
form.uploadUrl = data.uploadUrl;
|
||||
form.downloadUrl = data.downloadUrl;
|
||||
apiKeySet.value = data.apiKeySet;
|
||||
form.apiKey = '';
|
||||
} catch (e) {
|
||||
if (axios.isAxiosError(e) && e.response?.status === 403) {
|
||||
error.value = 'ماژول گفتگو برای این محیط فعال نیست.';
|
||||
} else {
|
||||
error.value = 'بارگذاری تنظیمات ناموفق بود.';
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true;
|
||||
error.value = '';
|
||||
ok.value = '';
|
||||
try {
|
||||
const payload: UpdateConversationSettingsRequest = {
|
||||
chatHost: form.chatHost.trim(),
|
||||
uploadUrl: form.uploadUrl.trim() || undefined,
|
||||
downloadUrl: form.downloadUrl.trim() || undefined,
|
||||
};
|
||||
if (form.apiKey.trim()) payload.apiKey = form.apiKey.trim();
|
||||
const { data } = await api.patch<ConversationSettingsDto>('/conversations/settings', payload);
|
||||
apiKeySet.value = data.apiKeySet;
|
||||
form.apiKey = '';
|
||||
ok.value = 'ذخیره شد.';
|
||||
} catch (e) {
|
||||
if (axios.isAxiosError(e)) {
|
||||
error.value =
|
||||
(e.response?.data as { message?: string })?.message ?? 'ذخیره ناموفق بود.';
|
||||
} else {
|
||||
error.value = 'ذخیره ناموفق بود.';
|
||||
}
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => void load());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<UiPageHeader
|
||||
title="تنظیمات گفتگوی درونسازمانی"
|
||||
subtitle="اتصال به سرویس گفتگو (REST و WebSocket) — هر محیط CRM جداگانه."
|
||||
>
|
||||
<template #actions>
|
||||
<RouterLink to="/conversations" class="link-back">بازگشت به گفتگو</RouterLink>
|
||||
</template>
|
||||
</UiPageHeader>
|
||||
|
||||
<p v-if="error" class="banner err">{{ error }}</p>
|
||||
<p v-if="ok" class="banner ok">{{ ok }}</p>
|
||||
|
||||
<UiCard v-if="!loading" class="form-card">
|
||||
<UiField label="Host سرویس چت" hint="بدون https — مثال: chat.montest.ir">
|
||||
<UiInput v-model="form.chatHost" dir="ltr" autocomplete="off" />
|
||||
</UiField>
|
||||
<UiField
|
||||
:label="apiKeySet ? 'API Key (برای تغییر وارد کنید)' : 'API Key'"
|
||||
hint="فقط روی سرور ذخیره میشود."
|
||||
>
|
||||
<UiInput v-model="form.apiKey" type="password" dir="ltr" autocomplete="new-password" />
|
||||
</UiField>
|
||||
<UiField label="آدرس آپلود فایل">
|
||||
<UiInput v-model="form.uploadUrl" dir="ltr" />
|
||||
</UiField>
|
||||
<UiField label="آدرس پایه دانلود فایل">
|
||||
<UiInput v-model="form.downloadUrl" dir="ltr" />
|
||||
</UiField>
|
||||
<div class="actions">
|
||||
<UiButton :disabled="saving" @click="save">
|
||||
{{ saving ? 'در حال ذخیره…' : 'ذخیره' }}
|
||||
</UiButton>
|
||||
</div>
|
||||
</UiCard>
|
||||
<p v-else class="muted">در حال بارگذاری…</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem 1.25rem 2rem;
|
||||
}
|
||||
.link-back {
|
||||
font-size: 0.9rem;
|
||||
color: var(--brand);
|
||||
}
|
||||
.banner {
|
||||
padding: 0.65rem 0.85rem;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.banner.err {
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
.banner.ok {
|
||||
background: #ecfdf5;
|
||||
color: #047857;
|
||||
}
|
||||
.form-card {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,652 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { RouterLink } from 'vue-router';
|
||||
import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import type {
|
||||
ConversationAccessDto,
|
||||
ConversationPeerDto,
|
||||
ConversationRoomDto,
|
||||
} from '@mento/shared';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { formatClockFa, toFaDigits } from '@/lib/jalali';
|
||||
import { useOrgChat } from '@/lib/useOrgChat';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import UserAvatar from '@/components/UserAvatar.vue';
|
||||
|
||||
const auth = useAuthStore();
|
||||
const access = ref<ConversationAccessDto | null>(null);
|
||||
const rooms = ref<ConversationRoomDto[]>([]);
|
||||
const peers = ref<ConversationPeerDto[]>([]);
|
||||
const loading = ref(true);
|
||||
const error = ref('');
|
||||
const activeRoom = ref<ConversationRoomDto | null>(null);
|
||||
const draft = ref('');
|
||||
const showNewDm = ref(false);
|
||||
const showNewGroup = ref(false);
|
||||
const groupName = ref('');
|
||||
const groupMemberIds = ref<string[]>([]);
|
||||
const dmPeerId = ref('');
|
||||
|
||||
const { messages, loadingMessages, status, loadMessages, sendText, sendRead } = useOrgChat();
|
||||
|
||||
const canCreateGroup = computed(() => access.value?.canCreateGroup ?? false);
|
||||
|
||||
const sortedRooms = computed(() =>
|
||||
[...rooms.value].sort((a, b) => {
|
||||
const ta = a.lastMessageAt ? new Date(a.lastMessageAt).getTime() : 0;
|
||||
const tb = b.lastMessageAt ? new Date(b.lastMessageAt).getTime() : 0;
|
||||
return tb - ta;
|
||||
}),
|
||||
);
|
||||
|
||||
const chatUserId = ref('');
|
||||
|
||||
function roomTitle(room: ConversationRoomDto) {
|
||||
if (room.kind === 'group') return room.name;
|
||||
const peer = peers.value.find((p) => p.id === room.peerUserId);
|
||||
return peer?.fullName ?? room.name;
|
||||
}
|
||||
|
||||
function formatListTime(iso: string | null) {
|
||||
if (!iso) return '';
|
||||
try {
|
||||
return formatClockFa(new Date(iso));
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const [accessRes, roomsRes, peersRes] = await Promise.all([
|
||||
api.get<ConversationAccessDto>('/conversations/access'),
|
||||
api.get<ConversationRoomDto[]>('/conversations/rooms'),
|
||||
api.get<ConversationPeerDto[]>('/conversations/peers'),
|
||||
]);
|
||||
access.value = accessRes.data;
|
||||
if (!accessRes.data.canUse) {
|
||||
error.value = 'به گفتگوی درونسازمانی دسترسی ندارید.';
|
||||
return;
|
||||
}
|
||||
rooms.value = roomsRes.data;
|
||||
peers.value = peersRes.data;
|
||||
const sessionRes = await api.get<{ chatUserId: string }>('/conversations/session');
|
||||
chatUserId.value = sessionRes.data.chatUserId;
|
||||
} catch (e) {
|
||||
if (axios.isAxiosError(e) && e.response?.status === 403) {
|
||||
error.value = 'ماژول گفتگو برای این محیط فعال نیست.';
|
||||
} else if (axios.isAxiosError(e) && e.response?.status === 400) {
|
||||
error.value =
|
||||
(e.response.data as { message?: string })?.message ??
|
||||
'ابتدا تنظیمات گفتگو را تکمیل کنید.';
|
||||
} else {
|
||||
error.value = 'بارگذاری گفتگو ناموفق بود.';
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function openRoom(room: ConversationRoomDto) {
|
||||
activeRoom.value = room;
|
||||
await loadMessages(room.externalRoomId);
|
||||
sendRead(room.externalRoomId);
|
||||
}
|
||||
|
||||
async function submitMessage() {
|
||||
if (!activeRoom.value || !draft.value.trim()) return;
|
||||
try {
|
||||
await sendText(activeRoom.value.externalRoomId, draft.value);
|
||||
draft.value = '';
|
||||
} catch {
|
||||
error.value = 'ارسال پیام ناموفق بود.';
|
||||
}
|
||||
}
|
||||
|
||||
async function startDm() {
|
||||
if (!dmPeerId.value) return;
|
||||
try {
|
||||
const { data } = await api.post<ConversationRoomDto>('/conversations/rooms/dm', {
|
||||
peerUserId: dmPeerId.value,
|
||||
});
|
||||
rooms.value = [data, ...rooms.value.filter((r) => r.externalRoomId !== data.externalRoomId)];
|
||||
showNewDm.value = false;
|
||||
dmPeerId.value = '';
|
||||
await openRoom(data);
|
||||
} catch {
|
||||
error.value = 'شروع گفتگوی جدید ناموفق بود.';
|
||||
}
|
||||
}
|
||||
|
||||
async function startGroup() {
|
||||
if (!groupName.value.trim() || !groupMemberIds.value.length) return;
|
||||
try {
|
||||
const { data } = await api.post<ConversationRoomDto>('/conversations/rooms/group', {
|
||||
name: groupName.value.trim(),
|
||||
memberUserIds: groupMemberIds.value,
|
||||
});
|
||||
rooms.value = [data, ...rooms.value];
|
||||
showNewGroup.value = false;
|
||||
groupName.value = '';
|
||||
groupMemberIds.value = [];
|
||||
await openRoom(data);
|
||||
} catch (e) {
|
||||
if (axios.isAxiosError(e) && e.response?.status === 403) {
|
||||
error.value = 'اجازه ساخت گروه را ندارید.';
|
||||
} else {
|
||||
error.value = 'ساخت گروه ناموفق بود.';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toggleGroupMember(id: string) {
|
||||
if (groupMemberIds.value.includes(id)) {
|
||||
groupMemberIds.value = groupMemberIds.value.filter((x) => x !== id);
|
||||
} else {
|
||||
groupMemberIds.value = [...groupMemberIds.value, id];
|
||||
}
|
||||
}
|
||||
|
||||
watch(activeRoom, () => {
|
||||
draft.value = '';
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
void bootstrap();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="conv-page">
|
||||
<div class="conv-aurora" aria-hidden="true" />
|
||||
|
||||
<header class="conv-head">
|
||||
<div>
|
||||
<p class="eyebrow">درون سازمان</p>
|
||||
<h1>گفتگو</h1>
|
||||
<p class="sub">فضای زنده برای هماهنگی تیم — بدون پیامرسان بیرونی.</p>
|
||||
</div>
|
||||
<div class="head-actions">
|
||||
<span class="ws-pill" :data-state="status">{{ status === 'open' ? 'آنلاین' : '…' }}</span>
|
||||
<RouterLink v-if="auth.user?.role === 'owner' || auth.user?.role === 'admin'" to="/settings/conversations" class="gear">
|
||||
<UiIcon name="settings" :size="18" />
|
||||
</RouterLink>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<p v-if="error" class="err-banner">{{ error }}</p>
|
||||
<p v-else-if="loading" class="muted center">در حال آمادهسازی…</p>
|
||||
|
||||
<div v-else class="conv-shell">
|
||||
<aside class="conv-rail">
|
||||
<div class="rail-actions">
|
||||
<button type="button" class="fab" @click="showNewDm = true">+ گفتگو</button>
|
||||
<button
|
||||
v-if="canCreateGroup"
|
||||
type="button"
|
||||
class="fab ghost"
|
||||
@click="showNewGroup = true"
|
||||
>
|
||||
گروه
|
||||
</button>
|
||||
</div>
|
||||
<ul class="room-list">
|
||||
<li v-for="room in sortedRooms" :key="room.externalRoomId">
|
||||
<button
|
||||
type="button"
|
||||
class="room-chip"
|
||||
:class="{ active: activeRoom?.externalRoomId === room.externalRoomId }"
|
||||
@click="openRoom(room)"
|
||||
>
|
||||
<span class="avatar-ring">
|
||||
<UserAvatar :name="roomTitle(room)" :size="40" />
|
||||
</span>
|
||||
<span class="room-meta">
|
||||
<strong>{{ roomTitle(room) }}</strong>
|
||||
<small>{{ room.lastMessagePreview || 'بدون پیام' }}</small>
|
||||
</span>
|
||||
<span v-if="room.unreadCount" class="unread">{{ toFaDigits(room.unreadCount) }}</span>
|
||||
<time>{{ formatListTime(room.lastMessageAt) }}</time>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
<section class="conv-stage">
|
||||
<div v-if="!activeRoom" class="empty-stage">
|
||||
<div class="empty-glow" />
|
||||
<UiIcon name="chat" :size="48" />
|
||||
<h2>یک مکالمه را انتخاب کنید</h2>
|
||||
<p>یا گفتگوی تازه با همکاران خود بسازید.</p>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="stage-top">
|
||||
<h2>{{ roomTitle(activeRoom) }}</h2>
|
||||
<span class="kind-tag">{{ activeRoom.kind === 'group' ? 'گروه' : 'خصوصی' }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="loadingMessages" class="muted center pad">در حال دریافت پیامها…</div>
|
||||
<ul v-else class="msg-stream">
|
||||
<li
|
||||
v-for="msg in messages"
|
||||
:key="msg.id"
|
||||
class="msg-row"
|
||||
:class="{ mine: msg.senderId === chatUserId || msg.optimistic }"
|
||||
>
|
||||
<div class="bubble">
|
||||
<p>{{ msg.content }}</p>
|
||||
<time>{{ formatListTime(msg.createdAt) }}</time>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<form class="composer" @submit.prevent="submitMessage">
|
||||
<input v-model="draft" type="text" placeholder="پیام خود را بنویسید…" autocomplete="off" />
|
||||
<button type="submit" :disabled="!draft.trim()">
|
||||
<UiIcon name="send" :size="18" />
|
||||
</button>
|
||||
</form>
|
||||
</template>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<dialog v-if="showNewDm" class="modal" open @click.self="showNewDm = false">
|
||||
<div class="modal-card">
|
||||
<h3>گفتگوی جدید</h3>
|
||||
<select v-model="dmPeerId">
|
||||
<option value="">همکار را انتخاب کنید</option>
|
||||
<option v-for="p in peers" :key="p.id" :value="p.id">{{ p.fullName }}</option>
|
||||
</select>
|
||||
<div class="modal-actions">
|
||||
<button type="button" @click="showNewDm = false">انصراف</button>
|
||||
<button type="button" class="primary" @click="startDm">شروع</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<dialog v-if="showNewGroup" class="modal" open @click.self="showNewGroup = false">
|
||||
<div class="modal-card wide">
|
||||
<h3>گروه جدید</h3>
|
||||
<input v-model="groupName" type="text" placeholder="نام گروه" />
|
||||
<p class="hint">اعضا</p>
|
||||
<div class="member-grid">
|
||||
<label v-for="p in peers" :key="p.id" class="member-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="groupMemberIds.includes(p.id)"
|
||||
@change="toggleGroupMember(p.id)"
|
||||
/>
|
||||
{{ p.fullName }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" @click="showNewGroup = false">انصراف</button>
|
||||
<button type="button" class="primary" @click="startGroup">ساخت گروه</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.conv-page {
|
||||
position: relative;
|
||||
min-height: calc(100vh - 4rem);
|
||||
padding: 1rem 1.25rem 1.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
.conv-aurora {
|
||||
position: absolute;
|
||||
inset: -20% -10% auto;
|
||||
height: 320px;
|
||||
background:
|
||||
radial-gradient(ellipse 60% 80% at 85% 0%, rgba(244, 114, 182, 0.22), transparent 60%),
|
||||
radial-gradient(ellipse 50% 70% at 10% 10%, rgba(45, 212, 191, 0.2), transparent 55%);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
.conv-head {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.eyebrow {
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
margin: 0 0 0.25rem;
|
||||
}
|
||||
.conv-head h1 {
|
||||
margin: 0;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 800;
|
||||
background: linear-gradient(120deg, #0f766e, #7c3aed 55%, #db2777);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
.sub {
|
||||
margin: 0.35rem 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
.head-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.ws-pill {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.65rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(15, 118, 110, 0.12);
|
||||
color: #0f766e;
|
||||
}
|
||||
.ws-pill[data-state='open'] {
|
||||
box-shadow: 0 0 0 1px rgba(45, 212, 191, 0.35);
|
||||
}
|
||||
.gear {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border-radius: 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
color: var(--text);
|
||||
}
|
||||
.err-banner {
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
padding: 0.65rem 0.85rem;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.conv-shell {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 320px) 1fr;
|
||||
gap: 0.75rem;
|
||||
min-height: 520px;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.conv-shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.conv-rail {
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(148, 163, 184, 0.25);
|
||||
border-radius: 20px;
|
||||
padding: 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.rail-actions {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.fab {
|
||||
flex: 1;
|
||||
border: none;
|
||||
border-radius: 14px;
|
||||
padding: 0.55rem 0.65rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
background: linear-gradient(135deg, #0d9488, #6366f1);
|
||||
color: #fff;
|
||||
}
|
||||
.fab.ghost {
|
||||
background: transparent;
|
||||
border: 1px dashed rgba(99, 102, 241, 0.45);
|
||||
color: #4338ca;
|
||||
}
|
||||
.room-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: auto;
|
||||
max-height: calc(100vh - 14rem);
|
||||
}
|
||||
.room-chip {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
grid-template-rows: auto auto;
|
||||
gap: 0.15rem 0.65rem;
|
||||
align-items: center;
|
||||
text-align: right;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0.55rem 0.45rem;
|
||||
border-radius: 14px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
.room-chip:hover,
|
||||
.room-chip.active {
|
||||
background: rgba(99, 102, 241, 0.08);
|
||||
}
|
||||
.avatar-ring {
|
||||
grid-row: span 2;
|
||||
border-radius: 999px;
|
||||
padding: 2px;
|
||||
background: linear-gradient(135deg, #2dd4bf, #a78bfa);
|
||||
}
|
||||
.room-meta strong {
|
||||
display: block;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
.room-meta small {
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 160px;
|
||||
display: block;
|
||||
}
|
||||
.unread {
|
||||
grid-column: 3;
|
||||
grid-row: 1;
|
||||
background: #db2777;
|
||||
color: #fff;
|
||||
font-size: 0.7rem;
|
||||
min-width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border-radius: 999px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.room-chip time {
|
||||
grid-column: 3;
|
||||
grid-row: 2;
|
||||
font-size: 0.68rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
.conv-stage {
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
backdrop-filter: blur(14px);
|
||||
border-radius: 22px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.22);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 480px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.stage-top {
|
||||
padding: 1rem 1.15rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.stage-top h2 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.kind-tag {
|
||||
font-size: 0.72rem;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(124, 58, 237, 0.1);
|
||||
color: #6d28d9;
|
||||
}
|
||||
.msg-stream {
|
||||
flex: 1;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 1rem 1.15rem;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
.msg-row {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.msg-row.mine {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.bubble {
|
||||
max-width: min(420px, 85%);
|
||||
padding: 0.65rem 0.85rem;
|
||||
border-radius: 18px 18px 18px 4px;
|
||||
background: #f1f5f9;
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
.msg-row.mine .bubble {
|
||||
border-radius: 18px 18px 4px 18px;
|
||||
background: linear-gradient(135deg, rgba(13, 148, 136, 0.15), rgba(99, 102, 241, 0.18));
|
||||
}
|
||||
.bubble p {
|
||||
margin: 0 0 0.25rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.bubble time {
|
||||
font-size: 0.68rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
.composer {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem 1rem;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.composer input {
|
||||
flex: 1;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
padding: 0.65rem 0.85rem;
|
||||
font: inherit;
|
||||
}
|
||||
.composer button {
|
||||
border: none;
|
||||
border-radius: 14px;
|
||||
width: 2.75rem;
|
||||
background: #0d9488;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.empty-stage {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
text-align: center;
|
||||
gap: 0.35rem;
|
||||
color: var(--muted);
|
||||
position: relative;
|
||||
}
|
||||
.empty-glow {
|
||||
position: absolute;
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(167, 139, 250, 0.25), transparent 70%);
|
||||
filter: blur(8px);
|
||||
}
|
||||
.modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
border: none;
|
||||
background: rgba(15, 23, 42, 0.45);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
.modal-card {
|
||||
background: var(--surface);
|
||||
border-radius: 18px;
|
||||
padding: 1.25rem;
|
||||
width: min(400px, 100%);
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.modal-card.wide {
|
||||
width: min(480px, 100%);
|
||||
}
|
||||
.modal-card select,
|
||||
.modal-card input {
|
||||
width: 100%;
|
||||
padding: 0.55rem 0.65rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--line);
|
||||
font: inherit;
|
||||
}
|
||||
.member-grid {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
max-height: 200px;
|
||||
overflow: auto;
|
||||
}
|
||||
.member-check {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.modal-actions .primary {
|
||||
background: #6366f1;
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 0.45rem 0.85rem;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
.center {
|
||||
text-align: center;
|
||||
}
|
||||
.pad {
|
||||
padding: 2rem;
|
||||
}
|
||||
.hint {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { RouterLink } from 'vue-router';
|
||||
import type { PlatformStatsDto } from '@mento/shared';
|
||||
import type { PlatformConversationStatsDto, PlatformStatsDto } from '@mento/shared';
|
||||
import { platformApi } from '@/lib/platform-api';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import { usePlatformAuthStore } from '@/stores/platform-auth';
|
||||
@@ -11,13 +11,18 @@ const platform = usePlatformAuthStore();
|
||||
const loading = ref(true);
|
||||
const error = ref('');
|
||||
const stats = ref<PlatformStatsDto | null>(null);
|
||||
const chatStats = ref<PlatformConversationStatsDto | null>(null);
|
||||
|
||||
async function load() {
|
||||
error.value = '';
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await platformApi.get<PlatformStatsDto>('/platform/stats');
|
||||
stats.value = data;
|
||||
const [statsRes, chatRes] = await Promise.all([
|
||||
platformApi.get<PlatformStatsDto>('/platform/stats'),
|
||||
platformApi.get<PlatformConversationStatsDto>('/platform/conversations/stats'),
|
||||
]);
|
||||
stats.value = statsRes.data;
|
||||
chatStats.value = chatRes.data;
|
||||
} catch {
|
||||
error.value = 'بارگذاری آمار ناموفق بود';
|
||||
} finally {
|
||||
@@ -85,6 +90,14 @@ onMounted(() => {
|
||||
<small>نیاز به تمدید</small>
|
||||
</div>
|
||||
</article>
|
||||
<article v-if="chatStats" class="kpi">
|
||||
<span class="ico"><UiIcon name="chat" :size="18" /></span>
|
||||
<div>
|
||||
<p>گفتگو (پیکربندیشده)</p>
|
||||
<strong>{{ toFaDigits(chatStats.organizationsWithChat) }}</strong>
|
||||
<small>{{ toFaDigits(chatStats.totalRoomsTracked) }} room ثبتشده</small>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<section class="actions">
|
||||
|
||||
@@ -125,7 +125,8 @@ export type OrgModuleKey =
|
||||
| "woocommerce"
|
||||
| "voip"
|
||||
| "reservations"
|
||||
| "leads";
|
||||
| "leads"
|
||||
| "conversations";
|
||||
|
||||
export const ORG_MODULE_KEYS: OrgModuleKey[] = [
|
||||
"customers",
|
||||
@@ -161,6 +162,7 @@ export const ORG_MODULE_KEYS: OrgModuleKey[] = [
|
||||
"woocommerce",
|
||||
"voip",
|
||||
"reservations",
|
||||
"conversations",
|
||||
];
|
||||
|
||||
export const ORG_MODULE_LABELS: Record<OrgModuleKey, string> = {
|
||||
@@ -197,6 +199,7 @@ export const ORG_MODULE_LABELS: Record<OrgModuleKey, string> = {
|
||||
woocommerce: "ووکامرس",
|
||||
voip: "تلفن سازمانی",
|
||||
reservations: "رزرو و نوبتدهی",
|
||||
conversations: "گفتگوی درونسازمانی",
|
||||
};
|
||||
|
||||
export const ORG_MODULE_GROUPS: Array<{
|
||||
@@ -217,7 +220,7 @@ export const ORG_MODULE_GROUPS: Array<{
|
||||
],
|
||||
},
|
||||
{ title: "فعالیتها", keys: ["activities", "notes"] },
|
||||
{ title: "پشتیبانی", keys: ["tickets", "faq"] },
|
||||
{ title: "پشتیبانی", keys: ["tickets", "faq", "conversations"] },
|
||||
{
|
||||
title: "اتوماسیون",
|
||||
keys: [
|
||||
@@ -339,7 +342,9 @@ export type RolePermissionKey =
|
||||
| "activities"
|
||||
| "notes"
|
||||
| "reports"
|
||||
| "audit";
|
||||
| "audit"
|
||||
| "conversations"
|
||||
| "conversations_groups";
|
||||
|
||||
export const ROLE_PERMISSION_ITEMS: Array<{
|
||||
key: RolePermissionKey;
|
||||
@@ -353,6 +358,8 @@ export const ROLE_PERMISSION_ITEMS: Array<{
|
||||
{ key: "notes", label: "یادداشتها" },
|
||||
{ key: "reports", label: "گزارشها" },
|
||||
{ key: "audit", label: "لاگ فعالیت کاربران" },
|
||||
{ key: "conversations", label: "گفتگوی درونسازمانی" },
|
||||
{ key: "conversations_groups", label: "ساخت گروه گفتگو" },
|
||||
];
|
||||
|
||||
export interface OrgRoleMemberDto {
|
||||
@@ -3920,3 +3927,64 @@ export interface LeadBoardColumnDto {
|
||||
stage: LeadStageDto;
|
||||
leads: LeadDto[];
|
||||
}
|
||||
|
||||
export interface ConversationSettingsDto {
|
||||
chatHost: string;
|
||||
uploadUrl: string;
|
||||
downloadUrl: string;
|
||||
apiKeySet: boolean;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface UpdateConversationSettingsRequest {
|
||||
chatHost: string;
|
||||
apiKey?: string;
|
||||
uploadUrl?: string;
|
||||
downloadUrl?: string;
|
||||
}
|
||||
|
||||
export interface ConversationAccessDto {
|
||||
canUse: boolean;
|
||||
canCreateGroup: boolean;
|
||||
}
|
||||
|
||||
export interface ConversationSessionDto {
|
||||
chatUserId: string;
|
||||
wsUrl: string;
|
||||
messagesBaseUrl: string;
|
||||
uploadUrl: string;
|
||||
downloadUrl: string;
|
||||
}
|
||||
|
||||
export interface ConversationPeerDto {
|
||||
id: string;
|
||||
fullName: string;
|
||||
hasAvatar: boolean;
|
||||
chatUserId: string;
|
||||
}
|
||||
|
||||
export interface ConversationRoomDto {
|
||||
id: string;
|
||||
externalRoomId: string;
|
||||
kind: "dm" | "group";
|
||||
name: string;
|
||||
unreadCount: number;
|
||||
lastMessagePreview: string | null;
|
||||
lastMessageAt: string | null;
|
||||
peerUserId: string | null;
|
||||
}
|
||||
|
||||
export interface CreateConversationDmRequest {
|
||||
peerUserId: string;
|
||||
}
|
||||
|
||||
export interface CreateConversationGroupRequest {
|
||||
name: string;
|
||||
memberUserIds: string[];
|
||||
}
|
||||
|
||||
export interface PlatformConversationStatsDto {
|
||||
organizationsWithChat: number;
|
||||
totalConfiguredOrgs: number;
|
||||
totalRoomsTracked: number;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user