Add mentions, pin messages and conversations, and search within chats

This commit is contained in:
2026-09-30 10:40:29 +03:30
parent 7c4b24fad6
commit f71e78f3a7
9 changed files with 1152 additions and 31 deletions
@@ -1,4 +1,4 @@
import { Body, Controller, Get, Post, Query, UseGuards } from '@nestjs/common';
import { Body, Controller, Delete, Get, Post, Query, 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';
@@ -7,6 +7,9 @@ import {
CreateConversationDmDto,
CreateConversationGroupDto,
HideConversationMessagesDto,
PinConversationMessageDto,
PinConversationRoomDto,
UnpinConversationMessageDto,
} from './dto';
import { ConversationsService } from './conversations.service';
@@ -63,6 +66,20 @@ export class ConversationsController {
);
}
@Post('rooms/pin')
pinRoom(
@CurrentUser() user: { userId: string; organizationId: string; role: string },
@Body() dto: PinConversationRoomDto,
) {
return this.conversations.setRoomPinned(
user.userId,
user.organizationId,
user.role,
dto.externalRoomId,
dto.pinned,
);
}
@Post('messages/hide')
hideMessages(
@CurrentUser() user: { userId: string; organizationId: string; role: string },
@@ -90,4 +107,58 @@ export class ConversationsController {
externalRoomId || '',
);
}
@Get('messages/pin')
getPinnedMessage(
@CurrentUser() user: { userId: string; organizationId: string; role: string },
@Query('externalRoomId') externalRoomId: string,
) {
return this.conversations.getPinnedMessage(
user.userId,
user.organizationId,
user.role,
externalRoomId || '',
);
}
@Post('messages/pin')
pinMessage(
@CurrentUser() user: { userId: string; organizationId: string; role: string },
@Body() dto: PinConversationMessageDto,
) {
return this.conversations.pinMessage(
user.userId,
user.organizationId,
user.role,
dto.externalRoomId,
dto.messageId,
dto.preview,
);
}
@Post('messages/unpin')
unpinMessage(
@CurrentUser() user: { userId: string; organizationId: string; role: string },
@Body() dto: UnpinConversationMessageDto,
) {
return this.conversations.unpinMessage(
user.userId,
user.organizationId,
user.role,
dto.externalRoomId,
);
}
@Delete('messages/pin')
unpinMessageDelete(
@CurrentUser() user: { userId: string; organizationId: string; role: string },
@Query('externalRoomId') externalRoomId: string,
) {
return this.conversations.unpinMessage(
user.userId,
user.organizationId,
user.role,
externalRoomId || '',
);
}
}
@@ -7,6 +7,7 @@ import {
import type {
ConversationAccessDto,
ConversationPeerDto,
ConversationPinnedMessageDto,
ConversationRoomDto,
ConversationSessionDto,
ConversationSettingsDto,
@@ -188,19 +189,21 @@ export class ConversationsService {
where: { organizationId },
});
const trackedByExternal = new Map(tracked.map((t) => [t.externalRoomId, t]));
const pinnedIds = await this.userPinnedRoomIds(organizationId, userId);
try {
const external = await this.chat.listUserRooms(creds.host, creds.apiKey, chatUserId);
return external.map((row) =>
this.toRoomDto(row, trackedByExternal, organizationId, userId),
);
return external.map((row) => {
const dto = this.toRoomDto(row, trackedByExternal, organizationId, userId);
return { ...dto, pinned: pinnedIds.has(dto.externalRoomId) };
});
} catch (e) {
// اگر سرویس چت در دسترس نبود، حداقل roomهای ثبت‌شده در CRM را نشان بده
if (tracked.length) {
return tracked.map((t) => ({
id: t.id,
externalRoomId: t.externalRoomId,
kind: t.kind === 'group' ? 'group' : 'dm',
kind: (t.kind === 'group' ? 'group' : 'dm') as 'dm' | 'group',
name: t.name || (t.kind === 'group' ? 'گروه' : 'گفتگو'),
unreadCount: 0,
lastMessagePreview: null,
@@ -213,6 +216,7 @@ export class ConversationsService {
? t.dmUserMinId
: null
: null,
pinned: pinnedIds.has(t.externalRoomId),
}));
}
throw e;
@@ -443,7 +447,7 @@ export class ConversationsService {
let preview = lastMsg
? String(lastMsg.content ?? lastMsg.text ?? lastMsg.body ?? '').trim()
: '';
if (/^__MENTO_HIDE__:/i.test(preview) || /MENTO_DEL/i.test(preview)) {
if (/^__MENTO_HIDE__:/i.test(preview) || /MENTO_DEL/i.test(preview) || /^__MENTO_PIN__:/i.test(preview) || /^__MENTO_UNPIN__$/i.test(preview)) {
preview = '';
}
if (lastType === 'image' || lastType === 'photo' || lastType === 'img') {
@@ -491,9 +495,134 @@ export class ConversationsService {
lastMessagePreview: preview || null,
lastMessageAt: lastAtRaw ? String(lastAtRaw) : null,
peerUserId,
pinned: false,
};
}
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));
}
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,
+34
View File
@@ -68,3 +68,37 @@ export class HideConversationMessagesDto {
@IsBoolean()
forEveryone?: boolean;
}
export class PinConversationMessageDto {
@IsString()
@MinLength(1)
@MaxLength(200)
externalRoomId!: string;
@IsString()
@MinLength(1)
@MaxLength(200)
messageId!: string;
@IsOptional()
@IsString()
@MaxLength(240)
preview?: string;
}
export class UnpinConversationMessageDto {
@IsString()
@MinLength(1)
@MaxLength(200)
externalRoomId!: string;
}
export class PinConversationRoomDto {
@IsString()
@MinLength(1)
@MaxLength(200)
externalRoomId!: string;
@IsBoolean()
pinned!: boolean;
}