add chat system

This commit is contained in:
Hesabix 2025-08-03 12:38:15 +00:00
parent d3e936c59f
commit a7636fbc42
17 changed files with 4572 additions and 813 deletions

View file

@ -1,127 +0,0 @@
# هوش مصنوعی حسابیکس - یکپارچه‌سازی با اطلاعات اشخاص
## خلاصه
این پروژه قابلیت‌های جدیدی به سیستم هوش مصنوعی حسابیکس اضافه کرده است که به کاربران امکان دسترسی پویا به اطلاعات اشخاص را می‌دهد. هوش مصنوعی حالا می‌تواند به سوالات مربوط به اشخاص، موجودی‌ها و تراکنش‌های مالی پاسخ دهد.
## ویژگی‌های جدید
### 1. دسترسی به اطلاعات اشخاص
- نمایش اطلاعات کامل اشخاص شامل نام، کد، آدرس، تلفن و غیره
- محاسبه و نمایش موجودی مالی اشخاص
- نمایش تراکنش‌های اخیر هر شخص
- نمایش کارت‌های بانکی و اطلاعات مالی
### 2. جستجوی هوشمند
- جستجو بر اساس نام، کد یا شماره تلفن
- پیشنهادات جستجو
- فیلتر بر اساس نوع اشخاص (مشتری، تامین‌کننده، کارمند)
### 3. امنیت و حریم خصوصی
- هر کاربر فقط به اطلاعات اشخاص کسب و کار خود دسترسی دارد
- بررسی دسترسی‌ها قبل از نمایش اطلاعات
- محافظت از اطلاعات حساس
## ساختار فایل‌ها
### Backend (PHP/Symfony)
#### سرویس‌های جدید:
- `PersonDataService.php`: مدیریت داده‌های اشخاص
- `AIService.php`: به‌روزرسانی شده برای پشتیبانی از اطلاعات اشخاص
#### کنترلرهای جدید:
- `wizardController.php`: اضافه شدن endpoint های جدید برای اشخاص
#### API Endpoints جدید:
- `POST /api/wizard/persons/search`: جستجوی اشخاص
- `GET /api/wizard/persons/{personId}`: دریافت اطلاعات شخص
- `GET /api/wizard/persons/{personId}/transactions`: دریافت تراکنش‌های شخص
### Frontend (Vue.js)
#### کامپوننت‌های جدید:
- `PersonInfo.vue`: نمایش اطلاعات کامل شخص
- `home.vue`: به‌روزرسانی شده برای پشتیبانی از قابلیت‌های جدید
## نحوه استفاده
### 1. سوالات مربوط به اشخاص
کاربران می‌توانند سوالاتی مانند موارد زیر بپرسند:
- "اطلاعات شخص احمد محمدی"
- "موجودی مشتری علی رضایی"
- "تراکنش‌های تامین‌کننده شرکت ABC"
- "لیست کارمندان"
### 2. جستجوی مستقیم
- استفاده از پیشنهادات موجود در رابط کاربری
- تایپ نام یا کد شخص در چت
### 3. نمایش اطلاعات
- اطلاعات شخص در دیالوگ جداگانه نمایش داده می‌شود
- شامل موجودی مالی، تراکنش‌ها و اطلاعات تماس
- امکان مشاهده جزئیات کامل
## امنیت
### بررسی دسترسی‌ها:
- هر درخواست ابتدا بررسی می‌شود که کاربر دسترسی لازم را داشته باشد
- اطلاعات فقط برای کسب و کار مربوطه نمایش داده می‌شود
- API endpoints محافظت شده با سیستم احراز هویت
### محافظت از داده‌ها:
- شماره کارت‌های بانکی ماسک می‌شوند
- اطلاعات حساس فیلتر می‌شوند
- لاگ تمام درخواست‌ها ثبت می‌شود
## تنظیمات
### پرامپ هوش مصنوعی:
سیستم به طور خودکار اطلاعات اشخاص را به پرامپ اضافه می‌کند تا هوش مصنوعی بتواند به سوالات مربوطه پاسخ دهد.
### محدودیت‌ها:
- حداکثر 20 نتیجه در جستجو
- حداکثر 10 تراکنش در نمایش
- محدودیت دسترسی بر اساس کسب و کار
## نمونه استفاده
```javascript
// جستجوی شخص
const persons = await this.searchPersons('احمد محمدی');
// دریافت اطلاعات شخص
const personDetails = await this.getPersonDetails(personId);
// دریافت تراکنش‌ها
const transactions = await this.getPersonTransactions(personId, 10);
```
## آینده‌نگری
### قابلیت‌های پیشنهادی:
1. گزارش‌گیری پیشرفته از اشخاص
2. تحلیل روند تراکنش‌ها
3. پیش‌بینی موجودی بر اساس الگوهای گذشته
4. یکپارچه‌سازی با سیستم اعلان‌ها
5. پشتیبانی از تصاویر پروفایل اشخاص
### بهبودهای فنی:
1. کش کردن اطلاعات پرکاربرد
2. بهینه‌سازی کوئری‌های دیتابیس
3. پشتیبانی از pagination برای لیست‌های بزرگ
4. اضافه کردن فیلترهای پیشرفته
## عیب‌یابی
### مشکلات رایج:
1. **خطای دسترسی**: بررسی کنید که کاربر دسترسی AI داشته باشد
2. **عدم یافتن شخص**: نام یا کد را بررسی کنید
3. **خطای شبکه**: اتصال اینترنت را بررسی کنید
### لاگ‌ها:
تمام خطاها در console مرورگر و لاگ‌های سرور ثبت می‌شوند.
## پشتیبانی
برای گزارش مشکلات یا درخواست ویژگی‌های جدید، لطفاً با تیم توسعه تماس بگیرید.

View file

@ -14,24 +14,89 @@ final class Version20241201000000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create postal_code_inquiry table';
return 'Create chat tables';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE postal_code_inquiry (
// Create chat_channel table
$this->addSql('CREATE TABLE chat_channel (
id INT AUTO_INCREMENT NOT NULL,
postal_code VARCHAR(10) NOT NULL,
address_data JSON NOT NULL,
name VARCHAR(255) NOT NULL,
description LONGTEXT DEFAULT NULL,
channel_id VARCHAR(50) NOT NULL,
is_public TINYINT(1) NOT NULL,
is_active TINYINT(1) NOT NULL,
created_by_id INT NOT NULL,
created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\',
updated_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\',
UNIQUE INDEX UNIQ_POSTAL_CODE (postal_code),
updated_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\',
avatar VARCHAR(255) DEFAULT NULL,
message_count INT NOT NULL,
last_message_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\',
UNIQUE INDEX UNIQ_CHANNEL_ID (channel_id),
INDEX IDX_CHANNEL_CREATED_BY (created_by_id),
PRIMARY KEY(id)
) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
// Create chat_channel_member table
$this->addSql('CREATE TABLE chat_channel_member (
id INT AUTO_INCREMENT NOT NULL,
channel_id INT NOT NULL,
user_id INT NOT NULL,
is_admin TINYINT(1) NOT NULL,
is_active TINYINT(1) NOT NULL,
joined_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\',
last_seen_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\',
unread_count INT NOT NULL,
INDEX IDX_MEMBER_CHANNEL (channel_id),
INDEX IDX_MEMBER_USER (user_id),
PRIMARY KEY(id)
) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
// Create chat_message table
$this->addSql('CREATE TABLE chat_message (
id INT AUTO_INCREMENT NOT NULL,
channel_id INT NOT NULL,
sender_id INT NOT NULL,
content LONGTEXT NOT NULL,
message_type VARCHAR(20) NOT NULL,
sent_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\',
edited_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\',
is_edited TINYINT(1) NOT NULL,
is_deleted TINYINT(1) NOT NULL,
quoted_message_id INT DEFAULT NULL,
attachments JSON DEFAULT NULL,
reactions JSON DEFAULT NULL,
reply_count INT NOT NULL,
view_count INT NOT NULL,
INDEX IDX_MESSAGE_CHANNEL (channel_id),
INDEX IDX_MESSAGE_SENDER (sender_id),
INDEX IDX_MESSAGE_QUOTED (quoted_message_id),
PRIMARY KEY(id)
) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
// Add foreign key constraints
$this->addSql('ALTER TABLE chat_channel ADD CONSTRAINT FK_CHANNEL_CREATED_BY FOREIGN KEY (created_by_id) REFERENCES user (id)');
$this->addSql('ALTER TABLE chat_channel_member ADD CONSTRAINT FK_MEMBER_CHANNEL FOREIGN KEY (channel_id) REFERENCES chat_channel (id)');
$this->addSql('ALTER TABLE chat_channel_member ADD CONSTRAINT FK_MEMBER_USER FOREIGN KEY (user_id) REFERENCES user (id)');
$this->addSql('ALTER TABLE chat_message ADD CONSTRAINT FK_MESSAGE_CHANNEL FOREIGN KEY (channel_id) REFERENCES chat_channel (id)');
$this->addSql('ALTER TABLE chat_message ADD CONSTRAINT FK_MESSAGE_SENDER FOREIGN KEY (sender_id) REFERENCES user (id)');
$this->addSql('ALTER TABLE chat_message ADD CONSTRAINT FK_MESSAGE_QUOTED FOREIGN KEY (quoted_message_id) REFERENCES chat_message (id)');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE postal_code_inquiry');
// Drop foreign key constraints
$this->addSql('ALTER TABLE chat_message DROP FOREIGN KEY FK_MESSAGE_QUOTED');
$this->addSql('ALTER TABLE chat_message DROP FOREIGN KEY FK_MESSAGE_SENDER');
$this->addSql('ALTER TABLE chat_message DROP FOREIGN KEY FK_MESSAGE_CHANNEL');
$this->addSql('ALTER TABLE chat_channel_member DROP FOREIGN KEY FK_MEMBER_USER');
$this->addSql('ALTER TABLE chat_channel_member DROP FOREIGN KEY FK_MEMBER_CHANNEL');
$this->addSql('ALTER TABLE chat_channel DROP FOREIGN KEY FK_CHANNEL_CREATED_BY');
// Drop tables
$this->addSql('DROP TABLE chat_message');
$this->addSql('DROP TABLE chat_channel_member');
$this->addSql('DROP TABLE chat_channel');
}
}

View file

@ -0,0 +1,619 @@
<?php
namespace App\Controller;
use App\Entity\ChatChannel;
use App\Entity\ChatMessage;
use App\Entity\User;
use App\Repository\ChatChannelRepository;
use App\Repository\ChatMessageRepository;
use App\Repository\UserRepository;
use App\Service\ChatService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Bundle\SecurityBundle\Security;
#[Route('/api/chat')]
class ChatController extends AbstractController
{
public function __construct(
private ChatService $chatService,
private EntityManagerInterface $entityManager,
private ChatChannelRepository $channelRepository,
private ChatMessageRepository $messageRepository,
private UserRepository $userRepository,
private Security $security
) {}
#[Route('/channels', name: 'chat_channels', methods: ['GET'])]
public function getUserChannels(): JsonResponse
{
/** @var User $user */
$user = $this->security->getUser();
if (!$user) {
return $this->json([
'success' => false,
'message' => 'کاربر احراز هویت نشده است'
], Response::HTTP_UNAUTHORIZED);
}
$channels = $this->chatService->getUserChannels($user);
$data = [];
foreach ($channels as $channel) {
$data[] = [
'id' => $channel->getId(),
'channelId' => $channel->getChannelId(),
'name' => $channel->getName(),
'description' => $channel->getDescription(),
'isPublic' => $channel->isPublic(),
'avatar' => $channel->getAvatar(),
'messageCount' => $channel->getMessageCount(),
'lastMessageAt' => $channel->getLastMessageAt()?->format('Y-m-d H:i:s'),
'createdAt' => $channel->getCreatedAt()->format('Y-m-d H:i:s'),
'isAdmin' => $this->chatService->isUserAdmin($channel, $user),
];
}
return $this->json([
'success' => true,
'data' => $data
]);
}
#[Route('/channels', name: 'chat_create_channel', methods: ['POST'])]
public function createChannel(Request $request): JsonResponse
{
$data = json_decode($request->getContent(), true);
if (!isset($data['name']) || empty($data['name'])) {
return $this->json([
'success' => false,
'message' => 'نام کانال الزامی است'
], Response::HTTP_BAD_REQUEST);
}
/** @var User $user */
$user = $this->security->getUser();
try {
$channel = $this->chatService->createChannel(
$data['name'],
$data['description'] ?? '',
$data['isPublic'] ?? true,
$user
);
return $this->json([
'success' => true,
'data' => [
'id' => $channel->getId(),
'channelId' => $channel->getChannelId(),
'name' => $channel->getName(),
'description' => $channel->getDescription(),
'isPublic' => $channel->isPublic(),
]
]);
} catch (\Exception $e) {
return $this->json([
'success' => false,
'message' => $e->getMessage()
], Response::HTTP_BAD_REQUEST);
}
}
#[Route('/channels/search', name: 'chat_search_channels', methods: ['GET'])]
public function searchChannels(Request $request): JsonResponse
{
$searchTerm = $request->query->get('q', '');
if (empty($searchTerm)) {
// Return popular public channels when search term is empty
$channels = $this->chatService->getPopularPublicChannels(10);
} else {
$channels = $this->chatService->searchPublicChannels($searchTerm);
}
$data = [];
foreach ($channels as $channel) {
$data[] = [
'id' => $channel->getId(),
'channelId' => $channel->getChannelId(),
'name' => $channel->getName(),
'description' => $channel->getDescription(),
'isPublic' => $channel->isPublic(),
'messageCount' => $channel->getMessageCount(),
'memberCount' => $this->chatService->getChannelStats($channel)['memberCount'],
'lastMessageAt' => $channel->getLastMessageAt()?->format('Y-m-d H:i:s'),
];
}
return $this->json([
'success' => true,
'data' => $data
]);
}
#[Route('/channels/{channelId}/join', name: 'chat_join_channel', methods: ['POST'])]
public function joinChannel(string $channelId): JsonResponse
{
$channel = $this->channelRepository->findByChannelId($channelId);
if (!$channel) {
return $this->json([
'success' => false,
'message' => 'کانال یافت نشد'
], Response::HTTP_NOT_FOUND);
}
/** @var User $user */
$user = $this->security->getUser();
try {
$success = $this->chatService->joinChannel($channel, $user);
if ($success) {
return $this->json([
'success' => true,
'message' => 'با موفقیت به کانال پیوستید'
]);
} else {
return $this->json([
'success' => false,
'message' => 'قبلاً عضو این کانال هستید'
], Response::HTTP_BAD_REQUEST);
}
} catch (\Exception $e) {
return $this->json([
'success' => false,
'message' => $e->getMessage()
], Response::HTTP_BAD_REQUEST);
}
}
#[Route('/channels/{channelId}/members', name: 'chat_add_member', methods: ['POST'])]
public function addMember(string $channelId, Request $request): JsonResponse
{
$channel = $this->channelRepository->findByChannelId($channelId);
if (!$channel) {
return $this->json([
'success' => false,
'message' => 'کانال یافت نشد'
], Response::HTTP_NOT_FOUND);
}
$data = json_decode($request->getContent(), true);
if (!isset($data['userId']) || empty($data['userId'])) {
return $this->json([
'success' => false,
'message' => 'شناسه کاربر الزامی است'
], Response::HTTP_BAD_REQUEST);
}
/** @var User $admin */
$admin = $this->security->getUser();
// Check if admin is actually an admin of this channel
if (!$this->chatService->isUserAdmin($channel, $admin)) {
return $this->json([
'success' => false,
'message' => 'شما دسترسی لازم برای اضافه کردن عضو ندارید'
], Response::HTTP_FORBIDDEN);
}
$user = $this->userRepository->find($data['userId']);
if (!$user) {
return $this->json([
'success' => false,
'message' => 'کاربر یافت نشد'
], Response::HTTP_NOT_FOUND);
}
try {
$success = $this->chatService->addMemberToChannel($channel, $user, $admin);
if ($success) {
return $this->json([
'success' => true,
'message' => 'عضو با موفقیت به کانال اضافه شد'
]);
} else {
return $this->json([
'success' => false,
'message' => 'کاربر قبلاً عضو این کانال است'
], Response::HTTP_BAD_REQUEST);
}
} catch (\Exception $e) {
return $this->json([
'success' => false,
'message' => $e->getMessage()
], Response::HTTP_BAD_REQUEST);
}
}
#[Route('/channels/{channelId}/members/{userId}', name: 'chat_remove_member', methods: ['DELETE'])]
public function removeMember(string $channelId, int $userId): JsonResponse
{
$channel = $this->channelRepository->findByChannelId($channelId);
if (!$channel) {
return $this->json([
'success' => false,
'message' => 'کانال یافت نشد'
], Response::HTTP_NOT_FOUND);
}
/** @var User $admin */
$admin = $this->security->getUser();
// Check if admin is actually an admin of this channel
if (!$this->chatService->isUserAdmin($channel, $admin)) {
return $this->json([
'success' => false,
'message' => 'شما دسترسی لازم برای حذف عضو ندارید'
], Response::HTTP_FORBIDDEN);
}
$user = $this->userRepository->find($userId);
if (!$user) {
return $this->json([
'success' => false,
'message' => 'کاربر یافت نشد'
], Response::HTTP_NOT_FOUND);
}
try {
$success = $this->chatService->removeMemberFromChannel($channel, $user, $admin);
if ($success) {
return $this->json([
'success' => true,
'message' => 'عضو با موفقیت از کانال حذف شد'
]);
} else {
return $this->json([
'success' => false,
'message' => 'کاربر عضو این کانال نیست'
], Response::HTTP_BAD_REQUEST);
}
} catch (\Exception $e) {
return $this->json([
'success' => false,
'message' => $e->getMessage()
], Response::HTTP_BAD_REQUEST);
}
}
#[Route('/channels/{channelId}/members', name: 'chat_get_members', methods: ['GET'])]
public function getChannelMembers(string $channelId): JsonResponse
{
$channel = $this->channelRepository->findByChannelId($channelId);
if (!$channel) {
return $this->json([
'success' => false,
'message' => 'کانال یافت نشد'
], Response::HTTP_NOT_FOUND);
}
/** @var User $user */
$user = $this->security->getUser();
// Check if user is member
if (!$this->chatService->isUserMember($channel, $user)) {
return $this->json([
'success' => false,
'message' => 'شما عضو این کانال نیستید'
], Response::HTTP_FORBIDDEN);
}
$members = $this->chatService->getChannelMembers($channel);
$data = [];
foreach ($members as $member) {
$data[] = [
'id' => $member->getUser()->getId(),
'fullName' => $member->getUser()->getFullName(),
'email' => $member->getUser()->getEmail(),
'isAdmin' => $member->isAdmin(),
'joinedAt' => $member->getJoinedAt()->format('Y-m-d H:i:s'),
];
}
return $this->json([
'success' => true,
'data' => $data
]);
}
#[Route('/channels/{channelId}/leave', name: 'chat_leave_channel', methods: ['POST'])]
public function leaveChannel(string $channelId): JsonResponse
{
$channel = $this->channelRepository->findByChannelId($channelId);
if (!$channel) {
return $this->json([
'success' => false,
'message' => 'کانال یافت نشد'
], Response::HTTP_NOT_FOUND);
}
/** @var User $user */
$user = $this->security->getUser();
try {
$success = $this->chatService->leaveChannel($channel, $user);
if ($success) {
return $this->json([
'success' => true,
'message' => 'با موفقیت از کانال خارج شدید'
]);
} else {
return $this->json([
'success' => false,
'message' => 'شما عضو این کانال نیستید'
], Response::HTTP_BAD_REQUEST);
}
} catch (\Exception $e) {
return $this->json([
'success' => false,
'message' => $e->getMessage()
], Response::HTTP_BAD_REQUEST);
}
}
#[Route('/channels/{channelId}/messages', name: 'chat_channel_messages', methods: ['GET'])]
public function getChannelMessages(string $channelId, Request $request): JsonResponse
{
$channel = $this->channelRepository->findByChannelId($channelId);
if (!$channel) {
return $this->json([
'success' => false,
'message' => 'کانال یافت نشد'
], Response::HTTP_NOT_FOUND);
}
/** @var User $user */
$user = $this->security->getUser();
// For private channels, check if user is member
if (!$channel->isPublic() && !$this->chatService->isUserMember($channel, $user)) {
return $this->json([
'success' => false,
'message' => 'شما عضو این کانال نیستید'
], Response::HTTP_FORBIDDEN);
}
$limit = (int) $request->query->get('limit', 50);
$offset = (int) $request->query->get('offset', 0);
$messages = $this->chatService->getChannelMessages($channel, $limit, $offset);
$data = [];
foreach ($messages as $message) {
$data[] = [
'id' => $message->getId(),
'content' => $message->getContent(),
'messageType' => $message->getMessageType(),
'sentAt' => $message->getSentAt()->format('Y-m-d H:i:s'),
'isEdited' => $message->isEdited(),
'editedAt' => $message->getEditedAt()?->format('Y-m-d H:i:s'),
'sender' => [
'id' => $message->getSender()->getId(),
'fullName' => $message->getSender()->getFullName(),
'email' => $message->getSender()->getEmail(),
],
'quotedMessage' => $message->getQuotedMessage() ? [
'id' => $message->getQuotedMessage()->getId(),
'content' => $message->getQuotedMessage()->getContent(),
'sender' => $message->getQuotedMessage()->getSender()->getFullName(),
] : null,
'reactions' => $message->getReactions() ?: [],
'attachments' => $message->getAttachments(),
];
}
return $this->json([
'success' => true,
'data' => $data
]);
}
#[Route('/channels/{channelId}/messages', name: 'chat_send_message', methods: ['POST'])]
public function sendMessage(string $channelId, Request $request): JsonResponse
{
$channel = $this->channelRepository->findByChannelId($channelId);
if (!$channel) {
return $this->json([
'success' => false,
'message' => 'کانال یافت نشد'
], Response::HTTP_NOT_FOUND);
}
$data = json_decode($request->getContent(), true);
if (!isset($data['content']) || empty($data['content'])) {
return $this->json([
'success' => false,
'message' => 'متن پیام الزامی است'
], Response::HTTP_BAD_REQUEST);
}
/** @var User $user */
$user = $this->security->getUser();
// Check if user is member (required for sending messages)
if (!$this->chatService->isUserMember($channel, $user)) {
return $this->json([
'success' => false,
'message' => 'برای ارسال پیام باید عضو کانال باشید'
], Response::HTTP_FORBIDDEN);
}
try {
$quotedMessage = null;
if (isset($data['quotedMessageId'])) {
$quotedMessage = $this->messageRepository->find($data['quotedMessageId']);
}
$message = $this->chatService->sendMessage(
$channel,
$user,
$data['content'],
$data['messageType'] ?? 'text',
$quotedMessage
);
return $this->json([
'success' => true,
'data' => [
'id' => $message->getId(),
'content' => $message->getContent(),
'messageType' => $message->getMessageType(),
'sentAt' => $message->getSentAt()->format('Y-m-d H:i:s'),
'sender' => [
'id' => $message->getSender()->getId(),
'fullName' => $message->getSender()->getFullName(),
],
]
]);
} catch (\Exception $e) {
return $this->json([
'success' => false,
'message' => $e->getMessage()
], Response::HTTP_BAD_REQUEST);
}
}
#[Route('/messages/{messageId}/edit', name: 'chat_edit_message', methods: ['PUT'])]
public function editMessage(int $messageId, Request $request): JsonResponse
{
$message = $this->messageRepository->find($messageId);
if (!$message) {
return $this->json([
'success' => false,
'message' => 'پیام یافت نشد'
], Response::HTTP_NOT_FOUND);
}
$data = json_decode($request->getContent(), true);
if (!isset($data['content']) || empty($data['content'])) {
return $this->json([
'success' => false,
'message' => 'متن پیام الزامی است'
], Response::HTTP_BAD_REQUEST);
}
/** @var User $user */
$user = $this->security->getUser();
$success = $this->chatService->editMessage($message, $user, $data['content']);
if ($success) {
return $this->json([
'success' => true,
'message' => 'پیام با موفقیت ویرایش شد'
]);
} else {
return $this->json([
'success' => false,
'message' => 'شما نمی‌توانید این پیام را ویرایش کنید'
], Response::HTTP_FORBIDDEN);
}
}
#[Route('/messages/{messageId}/reactions', name: 'chat_add_reaction', methods: ['POST'])]
public function addReaction(int $messageId, Request $request): JsonResponse
{
$message = $this->messageRepository->find($messageId);
if (!$message) {
return $this->json([
'success' => false,
'message' => 'پیام یافت نشد'
], Response::HTTP_NOT_FOUND);
}
$data = json_decode($request->getContent(), true);
if (!isset($data['emoji']) || empty($data['emoji'])) {
return $this->json([
'success' => false,
'message' => 'ایموجی الزامی است'
], Response::HTTP_BAD_REQUEST);
}
/** @var User $user */
$user = $this->security->getUser();
$success = $this->chatService->addReaction($message, $user, $data['emoji']);
if ($success) {
return $this->json([
'success' => true,
'message' => 'واکنش اضافه شد'
]);
} else {
return $this->json([
'success' => false,
'message' => 'خطا در اضافه کردن واکنش'
], Response::HTTP_BAD_REQUEST);
}
}
#[Route('/users/search', name: 'chat_search_users', methods: ['GET'])]
public function searchUsers(Request $request): JsonResponse
{
$searchTerm = $request->query->get('q', '');
if (empty($searchTerm)) {
return $this->json([
'success' => false,
'message' => 'عبارت جستجو الزامی است'
], Response::HTTP_BAD_REQUEST);
}
$users = $this->chatService->searchUsers($searchTerm);
$data = [];
foreach ($users as $user) {
$data[] = [
'id' => $user->getId(),
'fullName' => $user->getFullName(),
'email' => $user->getEmail(),
];
}
return $this->json([
'success' => true,
'data' => $data
]);
}
#[Route('/channels/{channelId}/stats', name: 'chat_channel_stats', methods: ['GET'])]
public function getChannelStats(string $channelId): JsonResponse
{
$channel = $this->channelRepository->findByChannelId($channelId);
if (!$channel) {
return $this->json([
'success' => false,
'message' => 'کانال یافت نشد'
], Response::HTTP_NOT_FOUND);
}
$stats = $this->chatService->getChannelStats($channel);
$messageStats = $this->chatService->getMessageStats($channel);
return $this->json([
'success' => true,
'data' => [
'memberCount' => $stats['memberCount'],
'messageCount' => $stats['messageCount'],
'messageStats' => $messageStats,
]
]);
}
}

View file

@ -0,0 +1,268 @@
<?php
namespace App\Entity;
use App\Repository\ChatChannelRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: ChatChannelRepository::class)]
class ChatChannel
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
private ?string $name = null;
#[ORM\Column(length: 1000, nullable: true)]
private ?string $description = null;
#[ORM\Column(length: 50, unique: true)]
private ?string $channelId = null;
#[ORM\Column]
private bool $isPublic = true;
#[ORM\Column]
private bool $isActive = true;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: false)]
private ?User $createdBy = null;
#[ORM\Column]
private ?\DateTimeImmutable $createdAt = null;
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $updatedAt = null;
#[ORM\OneToMany(mappedBy: 'channel', targetEntity: ChatMessage::class, orphanRemoval: true)]
private Collection $messages;
#[ORM\OneToMany(mappedBy: 'channel', targetEntity: ChatChannelMember::class, orphanRemoval: true)]
private Collection $members;
#[ORM\Column(length: 255, nullable: true)]
private ?string $avatar = null;
#[ORM\Column]
private int $messageCount = 0;
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $lastMessageAt = null;
public function __construct()
{
$this->messages = new ArrayCollection();
$this->members = new ArrayCollection();
$this->createdAt = new \DateTimeImmutable();
$this->channelId = $this->generateChannelId();
}
public function getId(): ?int
{
return $this->id;
}
public function getName(): ?string
{
return $this->name;
}
public function setName(string $name): static
{
$this->name = $name;
return $this;
}
public function getDescription(): ?string
{
return $this->description;
}
public function setDescription(?string $description): static
{
$this->description = $description;
return $this;
}
public function getChannelId(): ?string
{
return $this->channelId;
}
public function setChannelId(string $channelId): static
{
$this->channelId = $channelId;
return $this;
}
public function isPublic(): bool
{
return $this->isPublic;
}
public function setIsPublic(bool $isPublic): static
{
$this->isPublic = $isPublic;
return $this;
}
public function isActive(): bool
{
return $this->isActive;
}
public function setIsActive(bool $isActive): static
{
$this->isActive = $isActive;
return $this;
}
public function getCreatedBy(): ?User
{
return $this->createdBy;
}
public function setCreatedBy(?User $createdBy): static
{
$this->createdBy = $createdBy;
return $this;
}
public function getCreatedAt(): ?\DateTimeImmutable
{
return $this->createdAt;
}
public function setCreatedAt(\DateTimeImmutable $createdAt): static
{
$this->createdAt = $createdAt;
return $this;
}
public function getUpdatedAt(): ?\DateTimeImmutable
{
return $this->updatedAt;
}
public function setUpdatedAt(?\DateTimeImmutable $updatedAt): static
{
$this->updatedAt = $updatedAt;
return $this;
}
/**
* @return Collection<int, ChatMessage>
*/
public function getMessages(): Collection
{
return $this->messages;
}
public function addMessage(ChatMessage $message): static
{
if (!$this->messages->contains($message)) {
$this->messages->add($message);
$message->setChannel($this);
}
return $this;
}
public function removeMessage(ChatMessage $message): static
{
if ($this->messages->removeElement($message)) {
if ($message->getChannel() === $this) {
$message->setChannel(null);
}
}
return $this;
}
/**
* @return Collection<int, ChatChannelMember>
*/
public function getMembers(): Collection
{
return $this->members;
}
public function addMember(ChatChannelMember $member): static
{
if (!$this->members->contains($member)) {
$this->members->add($member);
$member->setChannel($this);
}
return $this;
}
public function removeMember(ChatChannelMember $member): static
{
if ($this->members->removeElement($member)) {
if ($member->getChannel() === $this) {
$member->setChannel(null);
}
}
return $this;
}
public function getAvatar(): ?string
{
return $this->avatar;
}
public function setAvatar(?string $avatar): static
{
$this->avatar = $avatar;
return $this;
}
public function getMessageCount(): int
{
return $this->messageCount;
}
public function setMessageCount(int $messageCount): static
{
$this->messageCount = $messageCount;
return $this;
}
public function getLastMessageAt(): ?\DateTimeImmutable
{
return $this->lastMessageAt;
}
public function setLastMessageAt(?\DateTimeImmutable $lastMessageAt): static
{
$this->lastMessageAt = $lastMessageAt;
return $this;
}
private function generateChannelId(): string
{
return 'CH' . strtoupper(uniqid());
}
public function isUserMember(User $user): bool
{
return $this->members->exists(function($key, $member) use ($user) {
return $member->getUser() === $user && $member->isActive();
});
}
public function isUserAdmin(User $user): bool
{
return $this->members->exists(function($key, $member) use ($user) {
return $member->getUser() === $user && $member->isAdmin() && $member->isActive();
});
}
}

View file

@ -0,0 +1,137 @@
<?php
namespace App\Entity;
use App\Repository\ChatChannelMemberRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: ChatChannelMemberRepository::class)]
class ChatChannelMember
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: ChatChannel::class, inversedBy: 'members')]
#[ORM\JoinColumn(nullable: false)]
private ?ChatChannel $channel = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: false)]
private ?User $user = null;
#[ORM\Column]
private bool $isAdmin = false;
#[ORM\Column]
private bool $isActive = true;
#[ORM\Column]
private ?\DateTimeImmutable $joinedAt = null;
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $lastSeenAt = null;
#[ORM\Column]
private int $unreadCount = 0;
public function __construct()
{
$this->joinedAt = new \DateTimeImmutable();
}
public function getId(): ?int
{
return $this->id;
}
public function getChannel(): ?ChatChannel
{
return $this->channel;
}
public function setChannel(?ChatChannel $channel): static
{
$this->channel = $channel;
return $this;
}
public function getUser(): ?User
{
return $this->user;
}
public function setUser(?User $user): static
{
$this->user = $user;
return $this;
}
public function isAdmin(): bool
{
return $this->isAdmin;
}
public function setIsAdmin(bool $isAdmin): static
{
$this->isAdmin = $isAdmin;
return $this;
}
public function isActive(): bool
{
return $this->isActive;
}
public function setIsActive(bool $isActive): static
{
$this->isActive = $isActive;
return $this;
}
public function getJoinedAt(): ?\DateTimeImmutable
{
return $this->joinedAt;
}
public function setJoinedAt(\DateTimeImmutable $joinedAt): static
{
$this->joinedAt = $joinedAt;
return $this;
}
public function getLastSeenAt(): ?\DateTimeImmutable
{
return $this->lastSeenAt;
}
public function setLastSeenAt(?\DateTimeImmutable $lastSeenAt): static
{
$this->lastSeenAt = $lastSeenAt;
return $this;
}
public function getUnreadCount(): int
{
return $this->unreadCount;
}
public function setUnreadCount(int $unreadCount): static
{
$this->unreadCount = $unreadCount;
return $this;
}
public function incrementUnreadCount(): static
{
$this->unreadCount++;
return $this;
}
public function resetUnreadCount(): static
{
$this->unreadCount = 0;
return $this;
}
}

View file

@ -0,0 +1,274 @@
<?php
namespace App\Entity;
use App\Repository\ChatMessageRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: ChatMessageRepository::class)]
class ChatMessage
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: ChatChannel::class, inversedBy: 'messages')]
#[ORM\JoinColumn(nullable: false)]
private ?ChatChannel $channel = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: false)]
private ?User $sender = null;
#[ORM\Column(type: 'text')]
private ?string $content = null;
#[ORM\Column(length: 20)]
private ?string $messageType = 'text';
#[ORM\Column]
private ?\DateTimeImmutable $sentAt = null;
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $editedAt = null;
#[ORM\Column]
private bool $isEdited = false;
#[ORM\Column]
private bool $isDeleted = false;
#[ORM\ManyToOne(targetEntity: self::class)]
private ?self $quotedMessage = null;
#[ORM\Column(type: 'json', nullable: true)]
private array $attachments = [];
#[ORM\Column(type: 'json', nullable: true)]
private array $reactions = [];
#[ORM\Column]
private int $replyCount = 0;
#[ORM\Column]
private int $viewCount = 0;
public function __construct()
{
$this->sentAt = new \DateTimeImmutable();
$this->messageType = 'text';
$this->attachments = [];
$this->reactions = [];
}
public function getId(): ?int
{
return $this->id;
}
public function getChannel(): ?ChatChannel
{
return $this->channel;
}
public function setChannel(?ChatChannel $channel): static
{
$this->channel = $channel;
return $this;
}
public function getSender(): ?User
{
return $this->sender;
}
public function setSender(?User $sender): static
{
$this->sender = $sender;
return $this;
}
public function getContent(): ?string
{
return $this->content;
}
public function setContent(string $content): static
{
$this->content = $content;
return $this;
}
public function getMessageType(): ?string
{
return $this->messageType;
}
public function setMessageType(string $messageType): static
{
$this->messageType = $messageType;
return $this;
}
public function getSentAt(): ?\DateTimeImmutable
{
return $this->sentAt;
}
public function setSentAt(\DateTimeImmutable $sentAt): static
{
$this->sentAt = $sentAt;
return $this;
}
public function getEditedAt(): ?\DateTimeImmutable
{
return $this->editedAt;
}
public function setEditedAt(?\DateTimeImmutable $editedAt): static
{
$this->editedAt = $editedAt;
return $this;
}
public function isEdited(): bool
{
return $this->isEdited;
}
public function setIsEdited(bool $isEdited): static
{
$this->isEdited = $isEdited;
return $this;
}
public function isDeleted(): bool
{
return $this->isDeleted;
}
public function setIsDeleted(bool $isDeleted): static
{
$this->isDeleted = $isDeleted;
return $this;
}
public function getQuotedMessage(): ?self
{
return $this->quotedMessage;
}
public function setQuotedMessage(?self $quotedMessage): static
{
$this->quotedMessage = $quotedMessage;
return $this;
}
public function getAttachments(): array
{
return $this->attachments;
}
public function setAttachments(array $attachments): static
{
$this->attachments = $attachments;
return $this;
}
public function addAttachment(array $attachment): static
{
$this->attachments[] = $attachment;
return $this;
}
public function getReactions(): array
{
return $this->reactions;
}
public function setReactions(array $reactions): static
{
$this->reactions = $reactions;
return $this;
}
public function addReaction(string $emoji, int $userId): static
{
if (!isset($this->reactions[$emoji])) {
$this->reactions[$emoji] = [];
}
if (!in_array($userId, $this->reactions[$emoji])) {
$this->reactions[$emoji][] = $userId;
}
return $this;
}
public function removeReaction(string $emoji, int $userId): static
{
if (isset($this->reactions[$emoji])) {
$this->reactions[$emoji] = array_filter(
$this->reactions[$emoji],
fn($id) => $id !== $userId
);
if (empty($this->reactions[$emoji])) {
unset($this->reactions[$emoji]);
}
}
return $this;
}
public function getReplyCount(): int
{
return $this->replyCount;
}
public function setReplyCount(int $replyCount): static
{
$this->replyCount = $replyCount;
return $this;
}
public function getViewCount(): int
{
return $this->viewCount;
}
public function setViewCount(int $viewCount): static
{
$this->viewCount = $viewCount;
return $this;
}
public function incrementViewCount(): static
{
$this->viewCount++;
return $this;
}
public function isEmoji(): bool
{
return $this->messageType === 'emoji';
}
public function isFile(): bool
{
return $this->messageType === 'file';
}
public function isImage(): bool
{
return $this->messageType === 'image';
}
public function isVideo(): bool
{
return $this->messageType === 'video';
}
public function isAudio(): bool
{
return $this->messageType === 'audio';
}
}

View file

@ -0,0 +1,196 @@
<?php
namespace App\Repository;
use App\Entity\ChatChannel;
use App\Entity\ChatChannelMember;
use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<ChatChannelMember>
*
* @method ChatChannelMember|null find($id, $lockMode = null, $lockVersion = null)
* @method ChatChannelMember|null findOneBy(array $criteria, array $orderBy = null)
* @method ChatChannelMember[] findAll()
* @method ChatChannelMember[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
class ChatChannelMemberRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ChatChannelMember::class);
}
public function save(ChatChannelMember $entity, bool $flush = false): void
{
$this->getEntityManager()->persist($entity);
if ($flush) {
$this->getEntityManager()->flush();
}
}
public function remove(ChatChannelMember $entity, bool $flush = false): void
{
$this->getEntityManager()->remove($entity);
if ($flush) {
$this->getEntityManager()->flush();
}
}
/**
* Find member by channel and user
*/
public function findByChannelAndUser(ChatChannel $channel, User $user): ?ChatChannelMember
{
return $this->createQueryBuilder('m')
->where('m.channel = :channel')
->andWhere('m.user = :user')
->setParameter('channel', $channel)
->setParameter('user', $user)
->getQuery()
->getOneOrNullResult();
}
/**
* Find active members of a channel
*/
public function findActiveMembers(ChatChannel $channel): array
{
return $this->createQueryBuilder('m')
->join('m.user', 'u')
->where('m.channel = :channel')
->andWhere('m.isActive = :isActive')
->setParameter('channel', $channel)
->setParameter('isActive', true)
->orderBy('m.isAdmin', 'DESC')
->addOrderBy('m.joinedAt', 'ASC')
->getQuery()
->getResult();
}
/**
* Find admin members of a channel
*/
public function findAdminMembers(ChatChannel $channel): array
{
return $this->createQueryBuilder('m')
->join('m.user', 'u')
->where('m.channel = :channel')
->andWhere('m.isAdmin = :isAdmin')
->andWhere('m.isActive = :isActive')
->setParameter('channel', $channel)
->setParameter('isAdmin', true)
->setParameter('isActive', true)
->orderBy('m.joinedAt', 'ASC')
->getQuery()
->getResult();
}
/**
* Check if user is member of channel
*/
public function isUserMember(ChatChannel $channel, User $user): bool
{
$member = $this->findByChannelAndUser($channel, $user);
return $member !== null && $member->isActive();
}
/**
* Check if user is admin of channel
*/
public function isUserAdmin(ChatChannel $channel, User $user): bool
{
$member = $this->findByChannelAndUser($channel, $user);
return $member !== null && $member->isActive() && $member->isAdmin();
}
/**
* Get member count for a channel
*/
public function getMemberCount(ChatChannel $channel): int
{
return $this->createQueryBuilder('m')
->select('COUNT(m.id)')
->where('m.channel = :channel')
->andWhere('m.isActive = :isActive')
->setParameter('channel', $channel)
->setParameter('isActive', true)
->getQuery()
->getSingleScalarResult();
}
/**
* Get admin count for a channel
*/
public function getAdminCount(ChatChannel $channel): int
{
return $this->createQueryBuilder('m')
->select('COUNT(m.id)')
->where('m.channel = :channel')
->andWhere('m.isAdmin = :isAdmin')
->andWhere('m.isActive = :isActive')
->setParameter('channel', $channel)
->setParameter('isAdmin', true)
->setParameter('isActive', true)
->getQuery()
->getSingleScalarResult();
}
/**
* Find channels where user is member
*/
public function findUserChannels(User $user): array
{
return $this->createQueryBuilder('m')
->join('m.channel', 'c')
->where('m.user = :user')
->andWhere('m.isActive = :isActive')
->andWhere('c.isActive = :channelActive')
->setParameter('user', $user)
->setParameter('isActive', true)
->setParameter('channelActive', true)
->orderBy('c.lastMessageAt', 'DESC')
->getQuery()
->getResult();
}
/**
* Update last seen time for member
*/
public function updateLastSeen(ChatChannelMember $member): void
{
$member->setLastSeenAt(new \DateTimeImmutable());
$this->save($member, true);
}
/**
* Reset unread count for member
*/
public function resetUnreadCount(ChatChannelMember $member): void
{
$member->resetUnreadCount();
$this->save($member, true);
}
/**
* Increment unread count for all members except sender
*/
public function incrementUnreadCountForChannel(ChatChannel $channel, User $sender): void
{
$this->createQueryBuilder('m')
->update()
->set('m.unreadCount', 'm.unreadCount + 1')
->where('m.channel = :channel')
->andWhere('m.user != :sender')
->andWhere('m.isActive = :isActive')
->setParameter('channel', $channel)
->setParameter('sender', $sender)
->setParameter('isActive', true)
->getQuery()
->execute();
}
}

View file

@ -0,0 +1,172 @@
<?php
namespace App\Repository;
use App\Entity\ChatChannel;
use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<ChatChannel>
*
* @method ChatChannel|null find($id, $lockMode = null, $lockVersion = null)
* @method ChatChannel|null findOneBy(array $criteria, array $orderBy = null)
* @method ChatChannel[] findAll()
* @method ChatChannel[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
class ChatChannelRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ChatChannel::class);
}
public function save(ChatChannel $entity, bool $flush = false): void
{
$this->getEntityManager()->persist($entity);
if ($flush) {
$this->getEntityManager()->flush();
}
}
public function remove(ChatChannel $entity, bool $flush = false): void
{
$this->getEntityManager()->remove($entity);
if ($flush) {
$this->getEntityManager()->flush();
}
}
/**
* Find public channels that match search term
*/
public function findPublicChannelsBySearch(string $searchTerm, int $limit = 20): array
{
return $this->createQueryBuilder('c')
->where('c.isPublic = :isPublic')
->andWhere('c.isActive = :isActive')
->andWhere('c.name LIKE :searchTerm OR c.description LIKE :searchTerm OR c.channelId LIKE :searchTerm')
->setParameter('isPublic', true)
->setParameter('isActive', true)
->setParameter('searchTerm', '%' . $searchTerm . '%')
->orderBy('c.lastMessageAt', 'DESC')
->addOrderBy('c.createdAt', 'DESC')
->setMaxResults($limit)
->getQuery()
->getResult();
}
/**
* Find channels that user is member of
*/
public function findUserChannels(User $user, int $limit = 50): array
{
return $this->createQueryBuilder('c')
->join('c.members', 'm')
->where('m.user = :user')
->andWhere('m.isActive = :isActive')
->andWhere('c.isActive = :channelActive')
->setParameter('user', $user)
->setParameter('isActive', true)
->setParameter('channelActive', true)
->orderBy('c.lastMessageAt', 'DESC')
->addOrderBy('c.createdAt', 'DESC')
->setMaxResults($limit)
->getQuery()
->getResult();
}
/**
* Find channel by channel ID
*/
public function findByChannelId(string $channelId): ?ChatChannel
{
return $this->createQueryBuilder('c')
->where('c.channelId = :channelId')
->andWhere('c.isActive = :isActive')
->setParameter('channelId', $channelId)
->setParameter('isActive', true)
->getQuery()
->getOneOrNullResult();
}
/**
* Find channels created by user
*/
public function findChannelsCreatedBy(User $user): array
{
return $this->createQueryBuilder('c')
->where('c.createdBy = :user')
->andWhere('c.isActive = :isActive')
->setParameter('user', $user)
->setParameter('isActive', true)
->orderBy('c.createdAt', 'DESC')
->getQuery()
->getResult();
}
/**
* Get channel statistics
*/
public function getChannelStats(ChatChannel $channel): array
{
// Get member count
$memberCount = $this->getEntityManager()->createQueryBuilder()
->select('COUNT(m.id)')
->from('App\Entity\ChatChannelMember', 'm')
->where('m.channel = :channel')
->andWhere('m.isActive = :isActive')
->setParameter('channel', $channel)
->setParameter('isActive', true)
->getQuery()
->getSingleScalarResult();
// Get message count
$messageCount = $this->getEntityManager()->createQueryBuilder()
->select('COUNT(msg.id)')
->from('App\Entity\ChatMessage', 'msg')
->where('msg.channel = :channel')
->andWhere('msg.isDeleted = :isDeleted')
->setParameter('channel', $channel)
->setParameter('isDeleted', false)
->getQuery()
->getSingleScalarResult();
return [
'memberCount' => $memberCount,
'messageCount' => $messageCount
];
}
/**
* Find popular public channels ordered by member count and message count
*/
public function findPopularPublicChannels(int $limit = 10): array
{
$qb = $this->getEntityManager()->createQueryBuilder();
return $qb->select('c')
->from('App\Entity\ChatChannel', 'c')
->leftJoin('c.members', 'm')
->leftJoin('c.messages', 'msg')
->where('c.isPublic = :isPublic')
->andWhere('c.isActive = :isActive')
->andWhere('m.isActive = :memberActive OR m.id IS NULL')
->andWhere('msg.isDeleted = :msgDeleted OR msg.id IS NULL')
->setParameter('isPublic', true)
->setParameter('isActive', true)
->setParameter('memberActive', true)
->setParameter('msgDeleted', false)
->groupBy('c.id')
->orderBy('COUNT(DISTINCT m.id)', 'DESC')
->addOrderBy('COUNT(DISTINCT msg.id)', 'DESC')
->addOrderBy('c.lastMessageAt', 'DESC')
->addOrderBy('c.createdAt', 'DESC')
->setMaxResults($limit)
->getQuery()
->getResult();
}
}

View file

@ -0,0 +1,176 @@
<?php
namespace App\Repository;
use App\Entity\ChatChannel;
use App\Entity\ChatMessage;
use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<ChatMessage>
*
* @method ChatMessage|null find($id, $lockMode = null, $lockVersion = null)
* @method ChatMessage|null findOneBy(array $criteria, array $orderBy = null)
* @method ChatMessage[] findAll()
* @method ChatMessage[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
class ChatMessageRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ChatMessage::class);
}
public function save(ChatMessage $entity, bool $flush = false): void
{
$this->getEntityManager()->persist($entity);
if ($flush) {
$this->getEntityManager()->flush();
}
}
public function remove(ChatMessage $entity, bool $flush = false): void
{
$this->getEntityManager()->remove($entity);
if ($flush) {
$this->getEntityManager()->flush();
}
}
/**
* Find messages for a channel with pagination
*/
public function findChannelMessages(ChatChannel $channel, int $limit = 50, int $offset = 0): array
{
return $this->createQueryBuilder('m')
->where('m.channel = :channel')
->andWhere('m.isDeleted = :isDeleted')
->setParameter('channel', $channel)
->setParameter('isDeleted', false)
->orderBy('m.sentAt', 'DESC')
->setMaxResults($limit)
->setFirstResult($offset)
->getQuery()
->getResult();
}
/**
* Find messages after a specific message ID
*/
public function findMessagesAfter(ChatChannel $channel, int $messageId, int $limit = 50): array
{
return $this->createQueryBuilder('m')
->where('m.channel = :channel')
->andWhere('m.id > :messageId')
->andWhere('m.isDeleted = :isDeleted')
->setParameter('channel', $channel)
->setParameter('messageId', $messageId)
->setParameter('isDeleted', false)
->orderBy('m.sentAt', 'ASC')
->setMaxResults($limit)
->getQuery()
->getResult();
}
/**
* Find messages before a specific message ID
*/
public function findMessagesBefore(ChatChannel $channel, int $messageId, int $limit = 50): array
{
return $this->createQueryBuilder('m')
->where('m.channel = :channel')
->andWhere('m.id < :messageId')
->andWhere('m.isDeleted = :isDeleted')
->setParameter('channel', $channel)
->setParameter('messageId', $messageId)
->setParameter('isDeleted', false)
->orderBy('m.sentAt', 'DESC')
->setMaxResults($limit)
->getQuery()
->getResult();
}
/**
* Search messages in a channel
*/
public function searchMessages(ChatChannel $channel, string $searchTerm, int $limit = 50): array
{
return $this->createQueryBuilder('m')
->where('m.channel = :channel')
->andWhere('m.isDeleted = :isDeleted')
->andWhere('m.content LIKE :searchTerm')
->setParameter('channel', $channel)
->setParameter('isDeleted', false)
->setParameter('searchTerm', '%' . $searchTerm . '%')
->orderBy('m.sentAt', 'DESC')
->setMaxResults($limit)
->getQuery()
->getResult();
}
/**
* Find messages by user in a channel
*/
public function findUserMessagesInChannel(ChatChannel $channel, User $user, int $limit = 50): array
{
return $this->createQueryBuilder('m')
->where('m.channel = :channel')
->andWhere('m.sender = :user')
->andWhere('m.isDeleted = :isDeleted')
->setParameter('channel', $channel)
->setParameter('user', $user)
->setParameter('isDeleted', false)
->orderBy('m.sentAt', 'DESC')
->setMaxResults($limit)
->getQuery()
->getResult();
}
/**
* Get message statistics for a channel
*/
public function getChannelMessageStats(ChatChannel $channel): array
{
$qb = $this->createQueryBuilder('m')
->select('COUNT(m.id) as total, COUNT(CASE WHEN m.messageType = :emoji THEN 1 END) as emoji_count')
->where('m.channel = :channel')
->andWhere('m.isDeleted = :isDeleted')
->setParameter('channel', $channel)
->setParameter('isDeleted', false)
->setParameter('emoji', 'emoji');
$result = $qb->getQuery()->getSingleResult();
return [
'totalMessages' => $result['total'],
'emojiMessages' => $result['emoji_count'],
'textMessages' => $result['total'] - $result['emoji_count']
];
}
/**
* Find recent messages for user across all channels
*/
public function findRecentMessagesForUser(User $user, int $limit = 20): array
{
return $this->createQueryBuilder('m')
->join('m.channel', 'c')
->join('c.members', 'cm')
->where('cm.user = :user')
->andWhere('cm.isActive = :memberActive')
->andWhere('c.isActive = :channelActive')
->andWhere('m.isDeleted = :isDeleted')
->setParameter('user', $user)
->setParameter('memberActive', true)
->setParameter('channelActive', true)
->setParameter('isDeleted', false)
->orderBy('m.sentAt', 'DESC')
->setMaxResults($limit)
->getQuery()
->getResult();
}
}

View file

@ -0,0 +1,316 @@
<?php
namespace App\Service;
use App\Entity\ChatChannel;
use App\Entity\ChatChannelMember;
use App\Entity\ChatMessage;
use App\Entity\User;
use App\Repository\ChatChannelMemberRepository;
use App\Repository\ChatChannelRepository;
use App\Repository\ChatMessageRepository;
use App\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
class ChatService
{
public function __construct(
private EntityManagerInterface $entityManager,
private ChatChannelRepository $channelRepository,
private ChatMessageRepository $messageRepository,
private ChatChannelMemberRepository $memberRepository,
private UserRepository $userRepository,
private Security $security
) {}
/**
* Create a new channel
*/
public function createChannel(string $name, string $description, bool $isPublic, User $creator): ChatChannel
{
$channel = new ChatChannel();
$channel->setName($name);
$channel->setDescription($description);
$channel->setIsPublic($isPublic);
$channel->setCreatedBy($creator);
// Add creator as admin member
$member = new ChatChannelMember();
$member->setChannel($channel);
$member->setUser($creator);
$member->setIsAdmin(true);
$this->entityManager->persist($channel);
$this->entityManager->persist($member);
$this->entityManager->flush();
return $channel;
}
/**
* Join a channel
*/
public function joinChannel(ChatChannel $channel, User $user): bool
{
// Check if already a member
if ($this->memberRepository->isUserMember($channel, $user)) {
return false;
}
// For private channels, only admins can add members
if (!$channel->isPublic()) {
$currentUser = $this->security->getUser();
if (!$this->memberRepository->isUserAdmin($channel, $currentUser)) {
return false;
}
}
$member = new ChatChannelMember();
$member->setChannel($channel);
$member->setUser($user);
$member->setIsAdmin(false);
$this->entityManager->persist($member);
$this->entityManager->flush();
return true;
}
/**
* Leave a channel
*/
public function leaveChannel(ChatChannel $channel, User $user): bool
{
$member = $this->memberRepository->findByChannelAndUser($channel, $user);
if (!$member) {
return false;
}
$member->setIsActive(false);
$this->entityManager->flush();
return true;
}
/**
* Add member to channel (admin only)
*/
public function addMemberToChannel(ChatChannel $channel, User $user, User $admin): bool
{
if (!$this->memberRepository->isUserAdmin($channel, $admin)) {
return false;
}
return $this->joinChannel($channel, $user);
}
/**
* Remove member from channel (admin only)
*/
public function removeMemberFromChannel(ChatChannel $channel, User $user, User $admin): bool
{
if (!$this->memberRepository->isUserAdmin($channel, $admin)) {
return false;
}
$member = $this->memberRepository->findByChannelAndUser($channel, $user);
if (!$member) {
return false;
}
$member->setIsActive(false);
$this->entityManager->flush();
return true;
}
/**
* Send message to channel
*/
public function sendMessage(ChatChannel $channel, User $sender, string $content, string $messageType = 'text', ?ChatMessage $quotedMessage = null): ChatMessage
{
// Check if user is member
if (!$this->memberRepository->isUserMember($channel, $sender)) {
throw new \Exception('User is not a member of this channel');
}
$message = new ChatMessage();
$message->setChannel($channel);
$message->setSender($sender);
$message->setContent($content);
$message->setMessageType($messageType);
if ($quotedMessage) {
$message->setQuotedMessage($quotedMessage);
}
$this->entityManager->persist($message);
// Update channel stats
$channel->setMessageCount($channel->getMessageCount() + 1);
$channel->setLastMessageAt(new \DateTimeImmutable());
// Increment unread count for other members
$this->memberRepository->incrementUnreadCountForChannel($channel, $sender);
$this->entityManager->flush();
return $message;
}
/**
* Edit message
*/
public function editMessage(ChatMessage $message, User $user, string $newContent): bool
{
if ($message->getSender() !== $user) {
return false;
}
$message->setContent($newContent);
$message->setIsEdited(true);
$message->setEditedAt(new \DateTimeImmutable());
$this->entityManager->flush();
return true;
}
/**
* Delete message
*/
public function deleteMessage(ChatMessage $message, User $user): bool
{
if ($message->getSender() !== $user) {
return false;
}
$message->setIsDeleted(true);
$this->entityManager->flush();
return true;
}
/**
* Add reaction to message
*/
public function addReaction(ChatMessage $message, User $user, string $emoji): bool
{
$message->addReaction($emoji, $user->getId());
$this->entityManager->flush();
return true;
}
/**
* Remove reaction from message
*/
public function removeReaction(ChatMessage $message, User $user, string $emoji): bool
{
$message->removeReaction($emoji, $user->getId());
$this->entityManager->flush();
return true;
}
/**
* Mark messages as read
*/
public function markMessagesAsRead(ChatChannel $channel, User $user): void
{
$member = $this->memberRepository->findByChannelAndUser($channel, $user);
if ($member) {
$this->memberRepository->resetUnreadCount($member);
$this->memberRepository->updateLastSeen($member);
}
}
/**
* Search users by email or name
*/
public function searchUsers(string $searchTerm, int $limit = 20): array
{
return $this->userRepository->createQueryBuilder('u')
->where('u.email LIKE :searchTerm OR u.fullName LIKE :searchTerm')
->andWhere('u.active = :active')
->setParameter('searchTerm', '%' . $searchTerm . '%')
->setParameter('active', true)
->orderBy('u.fullName', 'ASC')
->setMaxResults($limit)
->getQuery()
->getResult();
}
/**
* Get user's channels
*/
public function getUserChannels(User $user): array
{
return $this->channelRepository->findUserChannels($user);
}
/**
* Get channel messages
*/
public function getChannelMessages(ChatChannel $channel, int $limit = 50, int $offset = 0): array
{
return $this->messageRepository->findChannelMessages($channel, $limit, $offset);
}
/**
* Search public channels
*/
public function searchPublicChannels(string $searchTerm): array
{
return $this->channelRepository->findPublicChannelsBySearch($searchTerm);
}
/**
* Get popular public channels
*/
public function getPopularPublicChannels(int $limit = 10): array
{
return $this->channelRepository->findPopularPublicChannels($limit);
}
/**
* Get channel statistics
*/
public function getChannelStats(ChatChannel $channel): array
{
return $this->channelRepository->getChannelStats($channel);
}
/**
* Get message statistics
*/
public function getMessageStats(ChatChannel $channel): array
{
return $this->messageRepository->getChannelMessageStats($channel);
}
/**
* Check if user is member of channel
*/
public function isUserMember(ChatChannel $channel, User $user): bool
{
return $this->memberRepository->isUserMember($channel, $user);
}
/**
* Check if user is admin of channel
*/
public function isUserAdmin(ChatChannel $channel, User $user): bool
{
return $this->memberRepository->isUserAdmin($channel, $user);
}
/**
* Get channel members
*/
public function getChannelMembers(ChatChannel $channel): array
{
return $this->memberRepository->findActiveMembers($channel);
}
}

View file

@ -74,6 +74,12 @@
},
},
methods: {
openCalculator() {
this.dialog = true;
},
closeCalculator() {
this.dialog = false;
},
handleButtonClick(btn) {
this.handleInput(btn);
this.activeButton = btn;

View file

@ -0,0 +1,214 @@
<template>
<div>
<!-- دیالوگ پاپآپ ماشین حساب -->
<v-dialog v-model="dialog" max-width="350" @keydown="handleKeydown">
<v-card>
<!-- نوار ابزار در بالای ماشین حساب -->
<v-toolbar color="primary" dark>
<v-toolbar-title>ماشین حساب</v-toolbar-title>
<v-spacer></v-spacer>
<v-btn icon @click="dialog = false" @keyup.enter="dialog = false" tabindex="0">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-toolbar>
<v-card-text>
<!-- نمایشگر ماشین حساب -->
<v-text-field
:value="formattedDisplay"
readonly
class="display mb-4"
variant="outlined"
tabindex="-1"
></v-text-field>
<!-- دکمههای ماشین حساب -->
<v-row class="mt-2">
<v-col cols="3" v-for="btn in buttons" :key="btn" class="pa-1">
<v-btn
:class="{ 'active-btn': activeButton === btn }"
:color="getButtonColor(btn)"
flat
block
height="50"
@click="handleButtonClick(btn)"
@keyup.enter="handleButtonClick(btn)"
tabindex="0"
>
{{ btn }}
</v-btn>
</v-col>
</v-row>
</v-card-text>
</v-card>
</v-dialog>
</div>
</template>
<script>
export default {
data() {
return {
dialog: false,
display: "0",
current: "",
previous: "",
operation: null,
activeButton: null,
waitingForPercent: false,
numberButtons: ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "."], // دکمههای اعداد
operatorButtons: ["+", "-", "*", "/", "%"], // دکمههای عملگر
actionButtons: ["C", "="], // دکمههای عملیاتی
buttons: ["7", "8", "9", "/", "4", "5", "6", "*", "1", "2", "3", "-", "0", ".", "%", "+", "C", "="],
};
},
computed: {
formattedDisplay() {
if (this.display === "خطا") return this.display;
const num = parseFloat(this.display);
return isNaN(num) ? this.display : num.toLocaleString("fa-IR");
},
},
methods: {
openCalculator() {
this.dialog = true;
},
closeCalculator() {
this.dialog = false;
},
handleButtonClick(btn) {
this.handleInput(btn);
this.activeButton = btn;
setTimeout(() => {
this.activeButton = null;
}, 100);
},
handleInput(btn) {
if (btn === "C") {
this.clear();
} else if (btn === "=") {
this.calculate();
} else if (this.isOperator(btn)) {
this.setOperation(btn);
} else {
this.appendNumber(btn);
}
},
isOperator(btn) {
return this.operatorButtons.includes(btn);
},
appendNumber(btn) {
if (this.current === "0" && btn !== ".") {
this.current = btn;
} else {
this.current += btn;
}
this.display = this.current;
},
setOperation(op) {
if (this.current === "") return;
if (op === "%") {
if (this.operation === "+" || this.operation === "-") {
const base = parseFloat(this.previous);
const percentage = (base * parseFloat(this.current)) / 100;
this.current = percentage.toString();
this.calculate();
} else {
const curr = parseFloat(this.current);
this.current = (curr / 100).toString();
}
} else {
if (this.previous !== "") {
this.calculate();
}
this.operation = op;
this.previous = this.current;
this.current = "";
}
},
calculate() {
if (this.current === "" || this.previous === "") return;
const prev = parseFloat(this.previous);
const curr = parseFloat(this.current);
let result = 0;
switch (this.operation) {
case "+":
result = prev + curr;
break;
case "-":
result = prev - curr;
break;
case "*":
result = prev * curr;
break;
case "/":
if (curr === 0) {
this.display = "خطا";
return;
}
result = prev / curr;
break;
default:
return;
}
this.display = result.toString();
this.current = result.toString();
this.previous = "";
this.operation = null;
},
clear() {
this.display = "0";
this.current = "";
this.previous = "";
this.operation = null;
},
handleKeydown(event) {
const key = event.key;
if (key >= "0" && key <= "9" || key === ".") {
this.appendNumber(key);
} else if (["+", "-", "*", "/", "%"].includes(key)) {
this.setOperation(key);
} else if (key === "Enter" || key === "=") {
this.calculate();
} else if (key === "Escape" || key === "c" || key === "C") {
this.clear();
}
},
getButtonColor(btn) {
if (this.actionButtons.includes(btn)) {
return "error";
} else if (this.operatorButtons.includes(btn)) {
return "primary";
} else {
return "default";
}
},
},
};
</script>
<style scoped>
.display {
font-size: 1.5rem;
text-align: right;
direction: ltr;
margin-bottom: 16px;
}
.active-btn {
transform: scale(0.95);
}
.v-card-text {
padding: 20px;
}
.v-btn {
font-size: 1.2rem;
font-weight: 500;
}
</style>

View file

@ -737,6 +737,12 @@ const router = createRouter({
component: () =>
import('../views/wizard/home.vue'),
},
{
path: 'chat/home',
name: 'chat_home',
component: () =>
import('../views/chat/home.vue'),
},
{
path: 'plugin-center/list',
name: 'plugin_center_list',

View file

@ -0,0 +1,431 @@
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import axios from 'axios';
export interface User {
id: number;
fullName: string;
email: string;
}
export interface Channel {
id: number;
channelId: string;
name: string;
description: string;
isPublic: boolean;
messageCount: number;
lastMessageAt?: string;
createdAt: string;
isAdmin: boolean;
}
export interface Message {
id: number;
content: string;
messageType: string;
sentAt: string;
isEdited: boolean;
editedAt?: string;
sender: User;
quotedMessage?: {
id: number;
content: string;
sender: string;
};
reactions: Record<string, number[]>;
attachments: any[];
}
export const useChatStore = defineStore('chat', () => {
// State
const channels = ref<Channel[]>([]);
const currentChannel = ref<Channel | null>(null);
const messages = ref<Message[]>([]);
const loading = ref(false);
const error = ref<string | null>(null);
// Computed
const userChannels = computed(() => channels.value);
const currentMessages = computed(() => messages.value);
const isLoading = computed(() => loading.value);
const hasError = computed(() => error.value !== null);
// Actions
const loadUserChannels = async () => {
try {
loading.value = true;
error.value = null;
const response = await axios.get('/api/chat/channels');
if (response.data.success) {
channels.value = response.data.data;
} else {
error.value = 'خطا در بارگذاری کانال‌ها';
}
} catch (err) {
console.error('Error loading channels:', err);
error.value = 'خطا در بارگذاری کانال‌ها';
} finally {
loading.value = false;
}
};
const selectChannel = async (channel: Channel) => {
currentChannel.value = channel;
await loadChannelMessages(channel.channelId);
};
const loadChannelMessages = async (channelId: string) => {
try {
loading.value = true;
error.value = null;
const response = await axios.get(`/api/chat/channels/${channelId}/messages`);
if (response.data.success) {
messages.value = response.data.data.reverse(); // Show newest first
} else {
error.value = 'خطا در بارگذاری پیام‌ها';
}
} catch (err) {
console.error('Error loading messages:', err);
error.value = 'خطا در بارگذاری پیام‌ها';
} finally {
loading.value = false;
}
};
const sendMessage = async (content: string, messageType: string = 'text', quotedMessageId?: number) => {
if (!currentChannel.value) return;
try {
loading.value = true;
error.value = null;
const payload: any = {
content,
messageType
};
if (quotedMessageId) {
payload.quotedMessageId = quotedMessageId;
}
const response = await axios.post(`/api/chat/channels/${currentChannel.value.channelId}/messages`, payload);
if (response.data.success) {
const newMessage = response.data.data;
messages.value.push(newMessage);
return newMessage;
} else {
error.value = 'خطا در ارسال پیام';
}
} catch (err) {
console.error('Error sending message:', err);
error.value = 'خطا در ارسال پیام';
} finally {
loading.value = false;
}
};
const createChannel = async (name: string, description: string, isPublic: boolean) => {
try {
loading.value = true;
error.value = null;
const response = await axios.post('/api/chat/channels', {
name,
description,
isPublic
});
if (response.data.success) {
const newChannel = response.data.data;
channels.value.push(newChannel);
return newChannel;
} else {
error.value = 'خطا در ایجاد کانال';
}
} catch (err) {
console.error('Error creating channel:', err);
error.value = 'خطا در ایجاد کانال';
} finally {
loading.value = false;
}
};
const joinChannel = async (channelId: string) => {
try {
loading.value = true;
error.value = null;
const response = await axios.post(`/api/chat/channels/${channelId}/join`);
if (response.data.success) {
await loadUserChannels(); // Reload channels to include the new one
return true;
} else {
error.value = response.data.message || 'خطا در پیوستن به کانال';
return false;
}
} catch (err) {
console.error('Error joining channel:', err);
error.value = 'خطا در پیوستن به کانال';
return false;
} finally {
loading.value = false;
}
};
const searchChannels = async (searchTerm: string) => {
try {
loading.value = true;
error.value = null;
const response = await axios.get(`/api/chat/channels/search?q=${encodeURIComponent(searchTerm)}`);
if (response.data.success) {
return response.data.data;
} else {
error.value = 'خطا در جستجوی کانال‌ها';
return [];
}
} catch (err) {
console.error('Error searching channels:', err);
error.value = 'خطا در جستجوی کانال‌ها';
return [];
} finally {
loading.value = false;
}
};
const searchUsers = async (searchTerm: string) => {
try {
loading.value = true;
error.value = null;
const response = await axios.get(`/api/chat/users/search?q=${encodeURIComponent(searchTerm)}`);
if (response.data.success) {
return response.data.data;
} else {
error.value = 'خطا در جستجوی کاربران';
return [];
}
} catch (err) {
console.error('Error searching users:', err);
error.value = 'خطا در جستجوی کاربران';
return [];
} finally {
loading.value = false;
}
};
const editMessage = async (messageId: number, newContent: string) => {
try {
loading.value = true;
error.value = null;
const response = await axios.put(`/api/chat/messages/${messageId}/edit`, {
content: newContent
});
if (response.data.success) {
// Update the message in the store
const messageIndex = messages.value.findIndex(m => m.id === messageId);
if (messageIndex !== -1) {
messages.value[messageIndex].content = newContent;
messages.value[messageIndex].isEdited = true;
messages.value[messageIndex].editedAt = new Date().toISOString();
}
return true;
} else {
error.value = response.data.message || 'خطا در ویرایش پیام';
return false;
}
} catch (err) {
console.error('Error editing message:', err);
error.value = 'خطا در ویرایش پیام';
return false;
} finally {
loading.value = false;
}
};
const addReaction = async (messageId: number, emoji: string) => {
try {
loading.value = true;
error.value = null;
const response = await axios.post(`/api/chat/messages/${messageId}/reactions`, {
emoji
});
if (response.data.success) {
// Update the message reactions in the store
const messageIndex = messages.value.findIndex(m => m.id === messageId);
if (messageIndex !== -1) {
if (!messages.value[messageIndex].reactions[emoji]) {
messages.value[messageIndex].reactions[emoji] = [];
}
// Add current user to reactions (you'll need to get current user ID)
const currentUserId = 1; // This should come from auth store
if (!messages.value[messageIndex].reactions[emoji].includes(currentUserId)) {
messages.value[messageIndex].reactions[emoji].push(currentUserId);
}
}
return true;
} else {
error.value = response.data.message || 'خطا در اضافه کردن واکنش';
return false;
}
} catch (err) {
console.error('Error adding reaction:', err);
error.value = 'خطا در اضافه کردن واکنش';
return false;
} finally {
loading.value = false;
}
};
const addMember = async (channelId: string, userId: number) => {
try {
loading.value = true;
error.value = null;
const response = await axios.post(`/api/chat/channels/${channelId}/members`, {
userId
});
if (response.data.success) {
return true;
} else {
error.value = response.data.message || 'خطا در اضافه کردن عضو';
return false;
}
} catch (err) {
console.error('Error adding member:', err);
error.value = 'خطا در اضافه کردن عضو';
return false;
} finally {
loading.value = false;
}
};
const removeMember = async (channelId: string, userId: number) => {
try {
loading.value = true;
error.value = null;
const response = await axios.delete(`/api/chat/channels/${channelId}/members/${userId}`);
if (response.data.success) {
return true;
} else {
error.value = response.data.message || 'خطا در حذف عضو';
return false;
}
} catch (err) {
console.error('Error removing member:', err);
error.value = 'خطا در حذف عضو';
return false;
} finally {
loading.value = false;
}
};
const getChannelMembers = async (channelId: string) => {
try {
loading.value = true;
error.value = null;
const response = await axios.get(`/api/chat/channels/${channelId}/members`);
if (response.data.success) {
return response.data.data;
} else {
error.value = 'خطا در دریافت لیست اعضا';
return [];
}
} catch (err) {
console.error('Error getting channel members:', err);
error.value = 'خطا در دریافت لیست اعضا';
return [];
} finally {
loading.value = false;
}
};
const leaveChannel = async (channelId: string) => {
try {
loading.value = true;
error.value = null;
const response = await axios.post(`/api/chat/channels/${channelId}/leave`);
if (response.data.success) {
// Remove channel from user channels
channels.value = channels.value.filter(c => c.channelId !== channelId);
if (currentChannel.value?.channelId === channelId) {
currentChannel.value = null;
messages.value = [];
}
return true;
} else {
error.value = response.data.message || 'خطا در خروج از کانال';
return false;
}
} catch (err) {
console.error('Error leaving channel:', err);
error.value = 'خطا در خروج از کانال';
return false;
} finally {
loading.value = false;
}
};
const clearError = () => {
error.value = null;
};
const reset = () => {
channels.value = [];
currentChannel.value = null;
messages.value = [];
loading.value = false;
error.value = null;
};
return {
// State
channels,
currentChannel,
messages,
loading,
error,
// Computed
userChannels,
currentMessages,
isLoading,
hasError,
// Actions
loadUserChannels,
selectChannel,
loadChannelMessages,
sendMessage,
createChannel,
joinChannel,
searchChannels,
searchUsers,
editMessage,
addReaction,
addMember,
removeMember,
getChannelMembers,
leaveChannel,
clearError,
reset
};
});

View file

@ -10,9 +10,10 @@ import Notifications_btn from '@/components/application/buttons/notifications_bt
import Year_cob from '@/components/application/combobox/year_cob.vue';
import Currency_cob from '@/components/application/combobox/currency_cob.vue';
import clock from '@/components/application/clock.vue';
import CalculatorButton from '@/components/application/buttons/CalculatorButton.vue'
import CalculatorDialog from '@/components/application/buttons/CalculatorDialog.vue'
import SecretDialog from '@/components/application/buttons/SecretDialog.vue';
import ShortcutsButton from '@/components/application/buttons/ShortcutsButton.vue';
export default {
data() {
return {
@ -292,7 +293,8 @@ export default {
getShortcutKey(path) {
const shortcut = this.shortcuts.find(s => s.path === path);
return shortcut ? shortcut.key : '';
}
},
},
components: {
Profile_btn,
@ -300,7 +302,7 @@ export default {
Year_cob,
Currency_cob,
clock,
CalculatorButton,
CalculatorDialog,
SecretDialog,
ShortcutsButton
}
@ -971,15 +973,35 @@ export default {
<span class="d-none d-sm-flex">{{ business.name }}</span>
</v-app-bar-title>
<v-spacer></v-spacer>
<v-tooltip text="هوش مصنوعی" location="bottom" v-if="permissions.ai">
<v-menu>
<template v-slot:activator="{ props }">
<v-btn class="" stacked v-bind="props" to="/acc/wizard/home">
<v-icon>mdi-robot</v-icon>
<v-btn stacked v-bind="props">
<v-icon>mdi-toolbox</v-icon>
</v-btn>
</template>
</v-tooltip>
<v-list>
<v-list-item v-if="permissions.ai" to="/acc/wizard/home">
<template v-slot:prepend>
<v-icon>mdi-robot</v-icon>
</template>
<v-list-item-title>هوش مصنوعی</v-list-item-title>
</v-list-item>
<v-list-item to="/acc/chat/home">
<template v-slot:prepend>
<v-icon>mdi-chat</v-icon>
</template>
<v-list-item-title>گفتوگو</v-list-item-title>
</v-list-item>
<v-list-item @click="$refs.calculatorDialog.openCalculator()">
<template v-slot:prepend>
<v-icon>mdi-calculator</v-icon>
</template>
<v-list-item-title>ماشین حساب</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
<ShortcutsButton />
<CalculatorButton />
<CalculatorDialog ref="calculatorDialog" />
<SecretDialog />
<v-dialog v-model="showShortcutsDialog" max-width="800" scrollable>
<v-card>
@ -1054,6 +1076,8 @@ export default {
<Notifications_btn />
<Profile_btn />
</v-app-bar>
<v-main>
<div class="position-relative">
<RouterView />

View file

@ -1,671 +0,0 @@
import { createRouter, createWebHashHistory, createWebHistory } from 'vue-router'
import HomeView from '../views/dashboard.vue'
import PersonHome from '../views/persons/list.vue'
const router = createRouter({
history: createWebHashHistory(
import.meta.env.BASE_URL),
routes: [{
path: '/',
name: 'app_home',
component: HomeView
},
{
path: '/acc/business/printtemplates',
name: 'business_printtemplates',
component: () =>
import ('../views/printers/templates.vue')
},
{
path: '/acc/printers/list',
name: 'printers_list',
component: () =>
import ('../views/printers/list.vue')
}, {
path: '/acc/reports/list',
name: 'reports_list',
component: () =>
import ('../views/reports/reports.vue')
},
{
path: '/acc/reports/persons/debtors',
name: 'person_debtors_list',
component: () =>
import ('../views/reports/persons/debtors.vue'),
},
{
path: '/acc/reports/acc/balance_sheet',
name: 'acc_balanceSheet_list',
component: () =>
import ('../views/reports/balanceSheet.vue'),
},
{
path: '/acc/reports/commodity/buysell',
name: 'commodity_report_buysell',
component: () =>
import ('../views/reports/commodity/buysellByCommodity.vue'),
},
{
path: '/acc/reports/persons/depositors',
name: 'person_depositors_list',
component: () =>
import ('../views/reports/persons/depositors.vue'),
},
{
path: '/acc/reports/persons/buysell',
name: 'person_buysell_by_person',
component: () =>
import ('../views/reports/persons/buysellByPerson.vue'),
},
{
path: '/acc/costs/list',
name: 'costs_list',
component: () =>
import ('../views/costs/list.vue'),
},
{
path: '/acc/costs/mod/:id?',
name: 'costs_mod',
component: () =>
import ('../views/costs/mod.vue'),
},
{
path: '/acc/incomes/list',
name: 'incomes_list',
component: () =>
import ('../views/incomes/list.vue'),
},
{
path: '/acc/incomes/mod/:id?',
name: 'incomes_mod',
component: () =>
import ('../views/incomes/mod.vue'),
},
{
path: '/acc/accounting/list',
name: 'accounting_list_doc',
component: () =>
import ('../views/accounting/list.vue'),
},
{
path: '/acc/accounting/table',
name: 'accounting_table',
component: () =>
import ('../views/accounting/table.vue'),
},
{
path: '/acc/accounting/close_year',
name: 'accounting_close_year',
component: () =>
import ('../views/accounting/closeyear.vue'),
},
{
path: '/acc/accounting/view/:id?',
name: 'accounting_view_doc',
component: () =>
import ('../views/accounting/viewDoc.vue'),
},
{
path: '/acc/banks/list',
name: 'banks_list',
component: () =>
import ('../views/bank/list.vue')
},
{
path: '/acc/banks/card/view/:id?',
name: 'bank_card_view',
component: () =>
import ('../views/bank/card.vue'),
},
{
path: '/acc/banks/mod/:id?',
name: 'bank_mod',
component: () =>
import ('../views/bank/mod.vue'),
},
{
path: '/acc/salary/list',
name: 'salary_list',
component: () =>
import ('../views/salary/list.vue')
},
{
path: '/acc/salary/card/view/:id?',
name: 'salary_card_view',
component: () =>
import ('../views/salary/card.vue'),
},
{
path: '/acc/salary/mod/:id?',
name: 'salary_mod',
component: () =>
import ('../views/salary/mod.vue'),
},
{
path: '/acc/wallet/view',
name: 'wallet_view',
component: () =>
import ('../views/wallet/view.vue')
},
{
path: '/acc/cashdesk/list',
name: 'cashdesk_list',
component: () =>
import ('../views/cashdesk/list.vue')
},
{
path: '/acc/cashdesk/card/view/:id?',
name: 'cashdesk_card_view',
component: () =>
import ('../views/cashdesk/card.vue'),
},
{
path: '/acc/cashdesk/mod/:id?',
name: 'cashdesk_mod',
component: () =>
import ('../views/cashdesk/mod.vue'),
},
{
path: '/acc/transfer/list',
name: 'transfer_list',
component: () =>
import ('../views/transfer/list.vue')
},
{
path: '/acc/transfer/mod/:id?',
name: 'transfer_mod',
component: () =>
import ('../views/transfer/mod.vue'),
},
{
path: '/acc/persons/receive/list',
name: 'person_receive_list',
component: () =>
import ('../views/persons/receive/list.vue')
},
{
path: '/acc/persons/send/list',
name: 'person_send_list',
component: () =>
import ('../views/persons/send/list.vue')
},
{
path: '/acc/persons/receive/mod/:id?',
name: 'person_receive_mod',
component: () =>
import ('../views/persons/receive/mod.vue'),
},
{
path: '/acc/persons/send/mod/:id?',
name: 'person_send_mod',
component: () =>
import ('../views/persons/send/mod.vue'),
},
{
path: '/acc/persons/card/view/:id?',
name: 'person_card_view',
component: () =>
import ('../views/persons/card.vue'),
},
{
path: '/acc/persons/list',
name: 'person_list',
component: PersonHome
},
{
path: '/acc/persons/mod/:id?',
name: 'person_new',
component: () =>
import ('../views/persons/insert.vue'),
},
{
path: '/acc/business/settings',
name: 'business_settings',
component: () =>
import ('../views/settings/bussiness.vue'),
},
{
path: '/acc/business/printoptions',
name: 'print_settings',
component: () =>
import ('../views/settings/print.vue'),
},
{
path: '/acc/business/avatar',
name: 'business_avatar',
component: () =>
import ('../views/settings/avatar.vue'),
},
{
path: '/acc/business/extramoneys',
name: 'business_extramoneys',
component: () =>
import ('../views/settings/extramoneys.vue'),
},
{
path: '/acc/business/tax-settings',
name: 'business_tax_settings',
component: () =>
import ('../views/settings/tax-settings.vue'),
},
{
path: '/acc/business/logs',
name: 'business_logs',
component: () =>
import ('../views/settings/logs.vue'),
},
{
path: '/acc/business/apis',
name: 'business_apis',
component: () =>
import ('../views/api/list.vue'),
},
{
path: '/acc/business/users',
name: 'business_users',
component: () =>
import ('../views/settings/user_rolls.vue'),
},
{
path: '/acc/business/user/roll/edit/:email',
name: 'business_user_roll_edit',
component: () =>
import ('../views/settings/user_perm_edit.vue'),
},
{
path: '/acc/commodity/cat/list',
name: 'commodity_cat_list',
component: () =>
import ('../views/commodity/cat/list.vue'),
},
{
path: '/acc/commodity/pricelist/list',
name: 'commodity_pricelist_list',
component: () =>
import ('../views/commodity/priceList/list.vue'),
},
{
path: '/acc/commodity/pricelist/mod/:id?',
name: 'commodity_pricelist_mod',
component: () =>
import ('../views/commodity/priceList/mod.vue'),
},
{
path: '/acc/commodity/pricelist/view/:id?',
name: 'commodity_pricelist_view',
component: () =>
import ('../views/commodity/priceList/view.vue'),
}, {
path: '/acc/commodity/pricelist/list/mod/:id?',
name: 'commodity_pricelist_list_mod',
component: () =>
import ('../views/commodity/priceList/pricelistedit.vue'),
},
{
path: '/acc/commodity/drop/list',
name: 'commodity_drop_list',
component: () =>
import ('../views/commodity/drop/list.vue'),
},
{
path: '/acc/commodity/drop/mod/:id?',
name: 'commodity_drop_mod',
component: () =>
import ('../views/commodity/drop/mod.vue'),
},
{
path: '/acc/commodity/list',
name: 'commodity_list',
component: () =>
import ('../views/commodity/list.vue'),
},
{
path: '/acc/commodity/mod/:id?',
name: 'commodity_mod',
component: () =>
import ('../views/commodity/mod.vue'),
},
{
path: '/acc/cheque/mod/:id?',
name: 'cheque_mod',
component: () =>
import ('../views/cheque/mod.vue'),
},
{
path: '/acc/cheque/list',
name: 'cheque_list',
component: () =>
import ('../views/cheque/list.vue'),
},
{
path: '/acc/buy/mod/:id?',
name: 'buy_mod',
component: () =>
import ('../views/buy/mod.vue'),
},
{
path: '/acc/buy/list',
name: 'buy_list',
component: () =>
import ('../views/buy/list.vue'),
},
{
path: '/acc/buy/view/:id?',
name: 'buy_view',
component: () =>
import ('../views/buy/viewInvoice.vue'),
},
{
path: '/acc/sell/mod/:id?',
name: 'sell_mod',
component: () =>
import ('../views/sell/mod.vue'),
},
{
path: '/acc/sell/fast-mod/:id?',
name: 'sell_fast_mod',
component: () =>
import ('../views/sell/fastMod.vue'),
},
{
path: '/acc/sell/list',
name: 'sell_list',
component: () =>
import ('../views/sell/list.vue'),
},
{
path: '/acc/sell/view/:id?',
name: 'sell_view',
component: () =>
import ('../views/sell/viewInvoice.vue'),
},
{
path: '/acc/presell/mod/:id?',
name: 'presell_mod',
component: () =>
import ('../views/presell/mod.vue'),
},
{
path: '/acc/presell/list',
name: 'presell_list',
component: () =>
import ('../views/presell/list.vue'),
},
{
path: '/acc/presell/view/:id?',
name: 'presell_view',
component: () =>
import ('../views/presell/viewInvoice.vue'),
},
{
path: '/acc/rfbuy/mod/:id?',
name: 'rfbuy_mod',
component: () =>
import ('../views/rfbuy/mod.vue'),
},
{
path: '/acc/rfbuy/list',
name: 'rfbuy_list',
component: () =>
import ('../views/rfbuy/list.vue'),
},
{
path: '/acc/rfbuy/view/:id?',
name: 'rfbuy_view',
component: () =>
import ('../views/rfbuy/viewInvoice.vue'),
},
{
path: '/acc/rfsell/mod/:id?',
name: 'rfsell_mod',
component: () =>
import ('../views/rfsell/mod.vue'),
},
{
path: '/acc/rfsell/list',
name: 'rfsell_list',
component: () =>
import ('../views/rfsell/list.vue'),
},
{
path: '/acc/rfsell/view/:id?',
name: 'rfsell_view',
component: () =>
import ('../views/rfsell/viewInvoice.vue'),
},
{
path: '/acc/plugin-center/list',
name: 'plugin_center_list',
component: () =>
import ('../views/store/plugin-world.vue'),
},
{
path: '/acc/plugin-center/my',
name: 'plugin_center_my',
component: () =>
import ('../views/store/plugin-my.vue'),
},
{
path: '/acc/plugin-center/invoice',
name: 'plugin_center_invoice',
component: () =>
import ('../views/store/plugin-invoice.vue'),
},
{
path: '/acc/plugin-center/view-end/:id?',
name: 'plugin_center_view_prodect',
component: () =>
import ('../views/store/viewProdect.vue'),
},
{
path: '/acc/plugins/apartemanma/intro',
name: 'plugin_apartemanma_intro',
component: () =>
import ('../views/plugins/amartemanma/intro.vue'),
},
{
path: '/acc/plugins/accpro/intro',
name: 'plugin_accpro_intro',
component: () =>
import ('../views/plugins/accpro/intro.vue'),
},
{
path: '/acc/plugins/repservice/intro',
name: 'plugin_repservice_intro',
component: () =>
import ('../views/plugins/repservice/intro.vue'),
}, {
path: '/acc/plugin/repservice/order/mod/:id?',
name: 'plugin_repservice_order_mod',
component: () =>
import ('../views/plugins/repservice/mod.vue'),
},
{
path: '/acc/plugin/repservice/order/view/:id?',
name: 'plugin_repservice_order_view',
component: () =>
import ('../views/plugins/repservice/view.vue'),
},
{
path: '/acc/plugin/repservice/order/list',
name: 'plugin_repservice_order_list',
component: () =>
import ('../views/plugins/repservice/list.vue'),
},
{
path: '/acc/plugins/restamap/intro',
name: 'plugin_restamap_intro',
component: () =>
import ('../views/plugins/resamap/intro.vue'),
},
{
path: '/acc/plugins/noghre/intro',
name: 'plugin_noghre_intro',
component: () =>
import ('../views/plugins/noghre/intro.vue'),
},
{
path: '/acc/plugins/cc/intro',
name: 'plugin_cc_intro',
component: () =>
import ('../views/plugins/cc/intro.vue'),
},
{
path: '/acc/plugins/onlinestore/intro',
name: 'plugin_onlinestore_intro',
component: () =>
import ('../views/plugins/onlinestore/intro.vue'),
},
{
path: '/acc/notifications/list',
name: 'notification_list',
component: () =>
import ('../views/notifications/notifications.vue'),
},
{
path: '/acc/sms/panel',
name: 'sms_panel_dashboard',
component: () =>
import ('../views/smspanel/smspanel.vue'),
},
{
path: '/acc/plugin/noghre/employees/list',
name: 'plugin_noghre_employees_list',
component: () =>
import ('../views/plugins/noghre/employess/list.vue'),
},
{
path: '/acc/plugin/noghre/employees/mod/:id?',
name: 'plugin_noghre_employees_mod',
component: () =>
import ('../views/plugins/noghre/employess/mod.vue'),
},
{
path: '/acc/plugin/noghre/order/list',
name: 'plugin_noghre_order_list',
component: () =>
import ('../views/plugins/noghre/order/list.vue'),
},
{
path: '/acc/plugin/noghre/order/mod/:id?',
name: 'plugin_noghre_order_mod',
component: () =>
import ('../views/plugins/noghre/order/mod.vue'),
},
{
path: '/acc/plugin/noghre/order/view/:id?',
name: 'plugin_noghre_order_view',
component: () =>
import ('../views/plugins/noghre/order/view.vue'),
},
{
path: '/acc/plugin/noghre/pays/view/:id?',
name: 'plugin_noghre_pays_view',
component: () =>
import ('../views/plugins/noghre/pays.vue'),
},
{
path: '/acc/storeroom/commodity/check/exist',
name: 'storeroom_commodity_check_exist',
component: () =>
import ('../views/storeroom/commodityCheck/checkByStoreroom.vue'),
},
{
path: '/acc/storeroom/new/ticket/type',
name: 'storeroom_new_ticket_type',
component: () =>
import ('../views/storeroom/io/modalNew.vue'),
},
{
path: '/acc/storeroom/tickets/list',
name: 'storeroom_tickets_list',
component: () =>
import ('../views/storeroom/io/ticketList.vue'),
},
{
path: '/acc/storeroom/ticket/view/:id',
name: 'storeroom_ticket_view',
component: () =>
import ('../views/storeroom/io/view.vue'),
},
{
path: '/acc/storeroom/new/ticket/buy/:doc/:storeID',
name: 'storeroom_new_ticket_buy',
component: () =>
import ('../views/storeroom/io/buy.vue'),
},
{
path: '/acc/storeroom/new/ticket/sell/:doc/:storeID',
name: 'storeroom_new_ticket_sell',
component: () =>
import ('../views/storeroom/io/sell.vue'),
},
{
path: '/acc/storeroom/new/ticket/rfbuy/:doc/:storeID',
name: 'storeroom_new_ticket_rfbuy',
component: () =>
import ('../views/storeroom/io/rfbuy.vue'),
},
{
path: '/acc/storeroom/new/ticket/rfsell/:doc/:storeID',
name: 'storeroom_new_ticket_rfsell',
component: () =>
import ('../views/storeroom/io/rfsell.vue'),
},
{
path: '/acc/storeroom/list',
name: 'storeroom_list',
component: () =>
import ('../views/storeroom/list.vue'),
},
{
path: '/acc/storeroom/mod/:id?',
name: 'storeroom_mod',
component: () =>
import ('../views/storeroom/mod.vue'),
},
{
path: '/acc/archive/list',
name: 'archive_list',
component: () =>
import ('../views/archive/view_files.vue'),
},
{
path: '/acc/archive/order/new',
name: 'order_new',
component: () =>
import ('../views/archive/order_new.vue'),
},
{
path: '/acc/archive/order/list',
name: 'order_list',
component: () =>
import ('../views/archive/orders_list.vue'),
},
{
path: '/acc/shareholders/list',
name: 'shareholders_list',
component: () =>
import ('../views/shareholder/list.vue'),
},
{
path: "/:catchAll(.*)",
name: "not-found",
component: () =>
import ("../views/NotFound.vue"),
meta: {
'title': 'صفحه یافت نشد',
}
},
]
})
router.beforeEach((to, from) => {
const width = Math.max(
document.documentElement.clientWidth,
window.innerWidth || 0
)
if (width <= 992) {
Dashmix.layout('sidebar_close');
}
return true
})
export default router

File diff suppressed because it is too large Load diff