From 190b4d0a5e7a68ff10d25189864ce0157aeb06aa Mon Sep 17 00:00:00 2001 From: Falknat Date: Sun, 18 Jan 2026 20:17:02 +0700 Subject: [PATCH] =?UTF-8?q?=D0=91=D0=BE=D0=BB=D1=8C=D1=88=D0=BE=D0=B5=20?= =?UTF-8?q?=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Создание личных проектов 2. Управление командой 3. Приглашение участников 4. Уведомления и многое другое... --- backend/api/comment.php | 98 +- backend/api/project.php | 19 +- backend/api/projectAccess.php | 85 ++ backend/api/projectInvite.php | 134 +++ backend/api/task.php | 42 +- backend/api/user.php | 47 +- backend/app/class/enity/class_comment.php | 25 +- backend/app/class/enity/class_project.php | 95 +- .../app/class/enity/class_projectAccess.php | 540 ++++++++++ .../app/class/enity/class_projectInvite.php | 237 +++++ backend/app/class/enity/class_task.php | 3 + backend/app/class/enity/class_user.php | 51 +- backend/app/config.php | 6 +- front_vue/public/config.js | 13 +- front_vue/public/favicon.ico | Bin 15086 -> 8158 bytes front_vue/src/App.vue | 14 + front_vue/src/api.js | 117 ++- front_vue/src/components/Board.vue | 27 +- front_vue/src/components/Card.vue | 73 +- front_vue/src/components/Column.vue | 4 + front_vue/src/components/DatePicker.vue | 21 +- front_vue/src/components/Header.vue | 80 +- front_vue/src/components/MemberPanel.vue | 955 ++++++++++++++++++ front_vue/src/components/ProjectPanel.vue | 15 + front_vue/src/components/ProjectSelector.vue | 2 +- front_vue/src/components/Sidebar.vue | 37 +- .../TaskPanel/ContentEditorPanel.vue | 3 - .../components/TaskPanel/TaskCommentsTab.vue | 8 +- .../src/components/TaskPanel/TaskEditTab.vue | 63 +- .../src/components/TaskPanel/TaskPanel.vue | 36 +- front_vue/src/components/ui/FileUploader.vue | 20 +- front_vue/src/components/ui/LogoutButton.vue | 121 +++ .../src/components/ui/NotificationCard.vue | 271 +++++ .../src/components/ui/RichTextEditor.vue | 14 +- .../src/components/ui/SelectDropdown.vue | 72 +- front_vue/src/components/ui/TagsSelect.vue | 25 +- front_vue/src/components/ui/TextInput.vue | 11 + .../src/components/ui/ToastContainer.vue | 431 ++++++++ front_vue/src/composables/useLucideIcons.js | 39 +- front_vue/src/composables/useToast.js | 79 ++ front_vue/src/main.js | 11 + front_vue/src/router.js | 60 +- front_vue/src/stores/dialogs.js | 24 + front_vue/src/stores/projects.js | 290 +++++- front_vue/src/views/ArchivePage.vue | 1 + front_vue/src/views/InvitesPage.vue | 548 ++++++++++ front_vue/src/views/LoginPage.vue | 378 ++++++- front_vue/src/views/MainApp.vue | 27 +- front_vue/src/views/NoProjectsPage.vue | 621 ++++++++++++ front_vue/src/views/TeamPage.vue | 661 +++++++++--- taskboard.sql | 51 +- 51 files changed, 6179 insertions(+), 426 deletions(-) create mode 100644 backend/api/projectAccess.php create mode 100644 backend/api/projectInvite.php create mode 100644 backend/app/class/enity/class_projectAccess.php create mode 100644 backend/app/class/enity/class_projectInvite.php create mode 100644 front_vue/src/components/MemberPanel.vue create mode 100644 front_vue/src/components/ui/LogoutButton.vue create mode 100644 front_vue/src/components/ui/NotificationCard.vue create mode 100644 front_vue/src/components/ui/ToastContainer.vue create mode 100644 front_vue/src/composables/useToast.js create mode 100644 front_vue/src/views/InvitesPage.vue create mode 100644 front_vue/src/views/NoProjectsPage.vue diff --git a/backend/api/comment.php b/backend/api/comment.php index c5c15b9..5169778 100644 --- a/backend/api/comment.php +++ b/backend/api/comment.php @@ -12,9 +12,25 @@ if ($method === 'POST') { // Создание комментария if ($action === 'create') { - $comment->id_task = $data['id_task'] ?? null; + $id_task = $data['id_task'] ?? null; + + // Проверяем доступ к проекту через задачу + $taskData = Task::check_task($id_task); + ProjectAccess::requireAccess($taskData['id_project'], $current_user_id); + + // Проверяем право на создание комментариев (с учётом create_comment_own_task_only) + if (!ProjectAccess::canCreateComment( + $taskData['id_project'], + $current_user_id, + (int)($taskData['id_account'] ?? 0), + (int)($taskData['create_id_account'] ?? 0) + )) { + RestApi::response(['success' => false, 'errors' => ['access' => 'Нет прав на создание комментария']], 403); + } + + $comment->id_task = $id_task; $comment->id_accounts = $current_user_id; - $comment->id_answer = $data['id_answer'] ?? null; // Ответ на комментарий + $comment->id_answer = $data['id_answer'] ?? null; $comment->text = $data['text'] ?? ''; $result = $comment->create(); @@ -23,7 +39,14 @@ if ($method === 'POST') { // Обновление комментария if ($action === 'update') { - $comment->id = $data['id'] ?? null; + $comment_id = $data['id'] ?? null; + + // Проверяем доступ к проекту через комментарий -> задачу + $commentData = Comment::checkComment($comment_id); + $taskData = Task::check_task($commentData['id_task']); + ProjectAccess::requireAccess($taskData['id_project'], $current_user_id); + + $comment->id = $comment_id; $comment->id_accounts = $current_user_id; $comment->text = $data['text'] ?? ''; @@ -34,26 +57,80 @@ if ($method === 'POST') { // Удаление комментария if ($action === 'delete') { $id = $data['id'] ?? null; - $result = Comment::delete($id, $current_user_id); + + // Проверяем доступ к проекту через комментарий -> задачу + $commentData = Comment::checkComment($id); + $taskData = Task::check_task($commentData['id_task']); + $project_id = $taskData['id_project']; + ProjectAccess::requireAccess($project_id, $current_user_id); + + // Проверяем права на удаление + $isAuthor = (int)$commentData['id_accounts'] === (int)$current_user_id; + $isAdmin = ProjectAccess::isAdmin($project_id, $current_user_id); + $canDeleteAll = ProjectAccess::can($project_id, $current_user_id, 'delete_all_comments'); + $canDeleteOwn = ProjectAccess::can($project_id, $current_user_id, 'delete_own_comments'); + + if (!$isAdmin && !$canDeleteAll && !($isAuthor && $canDeleteOwn)) { + RestApi::response([ + 'success' => false, + 'errors' => ['access' => 'Нет прав на удаление комментария'] + ], 403); + } + + $result = Comment::deleteWithAccess($id); RestApi::response($result); } - // Загрузка файла к комментарию (только автор) + // Загрузка файла к комментарию if ($action === 'upload_image') { $comment_id = $data['comment_id'] ?? null; $file_base64 = $data['file_data'] ?? null; $file_name = $data['file_name'] ?? null; + + // Проверяем доступ к проекту + $commentData = Comment::checkComment($comment_id); + $taskData = Task::check_task($commentData['id_task']); + $project_id = $taskData['id_project']; + ProjectAccess::requireAccess($project_id, $current_user_id); + + // Проверяем право на загрузку картинок + ProjectAccess::requirePermission($project_id, $current_user_id, 'upload_images'); + + // Только автор может загружать к своему комментарию + if ((int)$commentData['id_accounts'] !== (int)$current_user_id) { + RestApi::response([ + 'success' => false, + 'errors' => ['access' => 'Вы можете загружать файлы только к своим комментариям'] + ], 403); + } - $result = Comment::uploadFile($comment_id, $file_base64, $file_name, $current_user_id); + $result = Comment::uploadFileSimple($comment_id, $file_base64, $file_name); RestApi::response($result); } - // Удаление файлов комментария (автор или админ проекта) + // Удаление файлов комментария if ($action === 'delete_image') { $comment_id = $data['comment_id'] ?? null; $file_names = $data['file_names'] ?? $data['file_name'] ?? null; + + // Проверяем доступ к проекту + $commentData = Comment::checkComment($comment_id); + $taskData = Task::check_task($commentData['id_task']); + $project_id = $taskData['id_project']; + ProjectAccess::requireAccess($project_id, $current_user_id); + + // Проверка прав: автор комментария ИЛИ админ проекта + $isAuthor = (int)$commentData['id_accounts'] === (int)$current_user_id; + $isAdmin = ProjectAccess::isAdmin($project_id, $current_user_id); + + if (!$isAuthor && !$isAdmin) { + RestApi::response([ + 'success' => false, + 'errors' => ['access' => 'Нет прав на удаление файлов'] + ], 403); + } - $result = Comment::deleteFile($comment_id, $file_names, $current_user_id); + $result = Comment::deleteFileSimple($comment_id, $file_names); RestApi::response($result); } @@ -66,12 +143,17 @@ if ($method === 'POST') { if ($method === 'GET') { // Получение комментариев задачи // ?id_task=X (обязательный) + $current_user_id = RestApi::getCurrentUserId(); $id_task = $_GET['id_task'] ?? null; if (!$id_task) { RestApi::response(['success' => false, 'errors' => ['id_task' => 'Задача не указана']], 400); } + // Проверяем доступ к проекту через задачу + $taskData = Task::check_task($id_task); + ProjectAccess::requireAccess($taskData['id_project'], $current_user_id); + $comment = new Comment(); $comments = $comment->getByTask($id_task); diff --git a/backend/api/project.php b/backend/api/project.php index d1a460e..54d8227 100644 --- a/backend/api/project.php +++ b/backend/api/project.php @@ -10,6 +10,10 @@ if ($method === 'POST') { // Получение данных проекта (проект + колонки + отделы) if ($action === 'get_project_data') { $project_id = $data['id_project'] ?? null; + + // Проверяем доступ к проекту + ProjectAccess::requireAccess($project_id, $user_id); + $result = Project::getProjectData($project_id); if ($result) { RestApi::response(['success' => true, 'data' => $result]); @@ -136,16 +140,23 @@ if ($method === 'POST') { } if ($method === 'GET') { - // Получение всех проектов + // Получение всех проектов (только те, где пользователь участник) // ?active=ID — дополнительно вернуть данные активного проекта + $user_id = RestApi::getCurrentUserId(); + $project = new Project(); $projects = $project->getAll(); $active_id = $_GET['active'] ?? null; - if ($active_id) { - // Возвращаем список проектов + данные активного + $activeData = null; + + if ($active_id && ProjectAccess::isMember((int)$active_id, $user_id)) { + // Есть доступ — возвращаем данные активного проекта $activeData = Project::getProjectData((int)$active_id); + } + + if ($activeData) { RestApi::response([ 'success' => true, 'data' => [ @@ -154,7 +165,7 @@ if ($method === 'GET') { ] ]); } else { - // Только список проектов + // Нет active или нет доступа — возвращаем только список RestApi::response(['success' => true, 'data' => $projects]); } } diff --git a/backend/api/projectAccess.php b/backend/api/projectAccess.php new file mode 100644 index 0000000..a33e35f --- /dev/null +++ b/backend/api/projectAccess.php @@ -0,0 +1,85 @@ + false, 'errors' => ['data' => 'Укажите project_id и user_id']], 400); + } + + // Приглашать участников могут только админы + ProjectAccess::requireAdmin($project_id, $current_user_id); + + $result = ProjectAccess::addMember($project_id, $user_id, $current_user_id, $is_admin, $permissions); + RestApi::response($result, $result['success'] ? 200 : 400); + } + + // Обновление прав участника (только админ) + if ($action === 'update_member') { + $project_id = (int)($data['project_id'] ?? 0); + $user_id = (int)($data['user_id'] ?? 0); + $is_admin = isset($data['is_admin']) ? (bool)$data['is_admin'] : null; + $permissions = $data['permissions'] ?? null; + + if (!$project_id || !$user_id) { + RestApi::response(['success' => false, 'errors' => ['data' => 'Укажите project_id и user_id']], 400); + } + + // Редактировать права могут только админы + ProjectAccess::requireAdmin($project_id, $current_user_id); + + // Обновляем is_admin если передан + if ($is_admin !== null) { + $result = ProjectAccess::setAdmin($project_id, $user_id, $is_admin, $current_user_id); + if (!$result['success']) { + RestApi::response($result, 400); + } + } + + // Обновляем права если переданы + if ($permissions !== null) { + $result = ProjectAccess::setPermissions($project_id, $user_id, $permissions, $current_user_id); + if (!$result['success']) { + RestApi::response($result, 400); + } + } + + RestApi::response(['success' => true]); + } + + // Удаление участника из проекта (право remove_members или админ, или сам себя) + if ($action === 'remove_member') { + $project_id = (int)($data['project_id'] ?? 0); + $user_id = (int)($data['user_id'] ?? 0); + + if (!$project_id || !$user_id) { + RestApi::response(['success' => false, 'errors' => ['data' => 'Укажите project_id и user_id']], 400); + } + + // Пользователь может удалить сам себя, иначе нужно право remove_members + if ((int)$user_id !== (int)$current_user_id) { + ProjectAccess::requirePermission($project_id, $current_user_id, 'remove_members'); + } + + $result = ProjectAccess::removeMember($project_id, $user_id, $current_user_id); + RestApi::response($result, $result['success'] ? 200 : 400); + } + + // Метод не указан + if (!$action) { + RestApi::response(['success' => false, 'error' => 'Укажите метод'], 400); + } +} + +?> diff --git a/backend/api/projectInvite.php b/backend/api/projectInvite.php new file mode 100644 index 0000000..ab7def3 --- /dev/null +++ b/backend/api/projectInvite.php @@ -0,0 +1,134 @@ + false, 'errors' => ['auth' => 'Требуется авторизация']], 401); + } + + $invites = ProjectInvite::getMyPending($current_user_id); + $count = count($invites); + + RestApi::response([ + 'success' => true, + 'data' => $invites, + 'count' => $count + ]); +} + +// POST — действия с приглашениями +if ($method === 'POST') { + $data = RestApi::getInput(); + $action = $data['action'] ?? null; + $current_user_id = RestApi::getCurrentUserId(); + + if (!$current_user_id) { + RestApi::response(['success' => false, 'errors' => ['auth' => 'Требуется авторизация']], 401); + } + + // Отправить приглашение (только админ проекта) + if ($action === 'send') { + $project_id = (int)($data['project_id'] ?? 0); + $user_id = (int)($data['user_id'] ?? 0); + $is_admin = (bool)($data['is_admin'] ?? false); + $permissions = $data['permissions'] ?? null; + + if (!$project_id || !$user_id) { + RestApi::response(['success' => false, 'errors' => ['data' => 'Укажите project_id и user_id']], 400); + } + + // Только админ может приглашать + ProjectAccess::requireAdmin($project_id, $current_user_id); + + $result = ProjectInvite::create($project_id, $user_id, $current_user_id, $is_admin, $permissions); + RestApi::response($result, $result['success'] ? 200 : 400); + } + + // Проверить, есть ли pending-приглашение для пользователя в проект + if ($action === 'check_pending') { + $project_id = (int)($data['project_id'] ?? 0); + $user_id = (int)($data['user_id'] ?? 0); + + if (!$project_id || !$user_id) { + RestApi::response(['success' => false, 'errors' => ['data' => 'Укажите project_id и user_id']], 400); + } + + // Проверяем доступ к проекту + ProjectAccess::requireAccess($project_id, $current_user_id); + + $hasPending = ProjectInvite::hasPending($project_id, $user_id); + RestApi::response(['success' => true, 'has_pending' => $hasPending]); + } + + // Получить pending-приглашения проекта (для админа) + if ($action === 'get_project_pending') { + $project_id = (int)($data['project_id'] ?? 0); + + if (!$project_id) { + RestApi::response(['success' => false, 'errors' => ['data' => 'Укажите project_id']], 400); + } + + // Только админ может видеть + ProjectAccess::requireAdmin($project_id, $current_user_id); + + $invites = ProjectInvite::getProjectPending($project_id); + RestApi::response(['success' => true, 'data' => $invites]); + } + + // Получить количество pending-приглашений для текущего пользователя + if ($action === 'get_count') { + $count = ProjectInvite::getPendingCount($current_user_id); + RestApi::response(['success' => true, 'count' => $count]); + } + + // Принять приглашение + if ($action === 'accept') { + $invite_id = (int)($data['invite_id'] ?? 0); + + if (!$invite_id) { + RestApi::response(['success' => false, 'errors' => ['data' => 'Укажите invite_id']], 400); + } + + $result = ProjectInvite::accept($invite_id, $current_user_id); + RestApi::response($result, $result['success'] ? 200 : 400); + } + + // Отклонить приглашение + if ($action === 'decline') { + $invite_id = (int)($data['invite_id'] ?? 0); + + if (!$invite_id) { + RestApi::response(['success' => false, 'errors' => ['data' => 'Укажите invite_id']], 400); + } + + $result = ProjectInvite::decline($invite_id, $current_user_id); + RestApi::response($result, $result['success'] ? 200 : 400); + } + + // Отменить приглашение (только админ) + if ($action === 'cancel') { + $invite_id = (int)($data['invite_id'] ?? 0); + $project_id = (int)($data['project_id'] ?? 0); + + if (!$invite_id || !$project_id) { + RestApi::response(['success' => false, 'errors' => ['data' => 'Укажите invite_id и project_id']], 400); + } + + // Только админ может отменять + ProjectAccess::requireAdmin($project_id, $current_user_id); + + $result = ProjectInvite::cancel($invite_id, $project_id); + RestApi::response($result, $result['success'] ? 200 : 400); + } + + // Метод не указан + if (!$action) { + RestApi::response(['success' => false, 'error' => 'Укажите action'], 400); + } +} + +?> diff --git a/backend/api/task.php b/backend/api/task.php index ecdad80..5a63582 100644 --- a/backend/api/task.php +++ b/backend/api/task.php @@ -5,6 +5,7 @@ $method = $_SERVER['REQUEST_METHOD']; if ($method === 'POST') { $data = RestApi::getInput(); $action = $data['action'] ?? null; + $user_id = RestApi::getCurrentUserId(); $task = new Task(); // Загрузка изображения @@ -13,15 +14,23 @@ if ($method === 'POST') { $file_base64 = $data['file_data'] ?? null; $file_name = $data['file_name'] ?? null; + // Проверяем право на редактирование задачи + $taskData = Task::check_task($task_id); + ProjectAccess::requireEditTask($taskData['id_project'], $user_id, (int)($taskData['id_account'] ?? 0), (int)($taskData['create_id_account'] ?? 0)); + $result = Task::uploadFile($task_id, $file_base64, $file_name); RestApi::response($result); } - // Удаление изображений (принимает file_names массив или file_name строку) + // Удаление изображений if ($action === 'delete_image') { $task_id = $data['task_id'] ?? null; $file_names = $data['file_names'] ?? $data['file_name'] ?? null; + // Проверяем право на редактирование задачи + $taskData = Task::check_task($task_id); + ProjectAccess::requireEditTask($taskData['id_project'], $user_id, (int)($taskData['id_account'] ?? 0), (int)($taskData['create_id_account'] ?? 0)); + $result = Task::deleteFile($task_id, $file_names); RestApi::response($result); } @@ -32,6 +41,10 @@ if ($method === 'POST') { $column_id = $data['column_id'] ?? null; $to_index = $data['to_index'] ?? 0; + // Проверяем право на перемещение (с учётом move_own_task_only) + $taskData = Task::check_task($id); + ProjectAccess::requireMoveTask($taskData['id_project'], $user_id, (int)($taskData['id_account'] ?? 0), (int)($taskData['create_id_account'] ?? 0)); + $result = Task::updateOrder($id, $column_id, $to_index); RestApi::response($result); } @@ -39,6 +52,11 @@ if ($method === 'POST') { // Обновление задачи if ($action === 'update') { $task->id = $data['id'] ?? null; + + // Проверяем право на редактирование + $taskData = Task::check_task($task->id); + ProjectAccess::requireEditTask($taskData['id_project'], $user_id, (int)($taskData['id_account'] ?? 0), (int)($taskData['create_id_account'] ?? 0)); + $task->id_department = $data['id_department'] ?? null; $task->id_label = $data['id_label'] ?? null; $task->id_account = $data['id_account'] ?? null; @@ -55,10 +73,16 @@ if ($method === 'POST') { // Создание задачи if ($action === 'create') { - $task->id_project = $data['id_project'] ?? null; + $project_id = $data['id_project'] ?? null; + + // Проверяем право на создание задач + ProjectAccess::requirePermission($project_id, $user_id, 'create_task'); + + $task->id_project = $project_id; $task->id_department = $data['id_department'] ?? null; $task->id_label = $data['id_label'] ?? null; $task->id_account = $data['id_account'] ?? null; + $task->create_id_account = $user_id; // Создатель задачи $task->column_id = $data['column_id'] ?? null; $task->order = $data['order'] ?? 0; $task->date = $data['date'] ?? null; @@ -74,6 +98,11 @@ if ($method === 'POST') { // Удаление задачи if ($action === 'delete') { $id = $data['id'] ?? null; + + // Проверяем право на удаление + $taskData = Task::check_task($id); + ProjectAccess::requirePermission($taskData['id_project'], $user_id, 'delete_task'); + $result = Task::delete($id); RestApi::response($result); } @@ -82,6 +111,11 @@ if ($method === 'POST') { if ($action === 'set_archive') { $id = $data['id'] ?? null; $archive = $data['archive'] ?? 1; + + // Проверяем право на архивирование + $taskData = Task::check_task($id); + ProjectAccess::requirePermission($taskData['id_project'], $user_id, 'archive_task'); + $result = Task::setArchive($id, $archive); RestApi::response($result); } @@ -96,12 +130,16 @@ if ($method === 'GET') { // Получение задач проекта // ?id_project=1 (обязательный) // ?archive=0 (неархивные, по умолчанию), ?archive=1 (архивные), ?archive=all (все) + $user_id = RestApi::getCurrentUserId(); $id_project = $_GET['id_project'] ?? null; if (!$id_project) { RestApi::response(['success' => false, 'errors' => ['id_project' => 'Проект не указан']], 400); } + // Проверяем доступ к проекту + ProjectAccess::requireAccess((int)$id_project, $user_id); + $archive = $_GET['archive'] ?? 0; if ($archive === 'all') { $archive = null; diff --git a/backend/api/user.php b/backend/api/user.php index e04bcd0..822d5bc 100644 --- a/backend/api/user.php +++ b/backend/api/user.php @@ -6,13 +6,6 @@ if ($method === 'POST') { $data = RestApi::getInput(); $action = $data['action'] ?? null; - // Получение конфигурации приложения - if ($action === 'get_config') { - RestApi::response(['success' => true, 'data' => [ - 'COLUMN_DONE_ID' => COLUMN_DONE_ID - ]]); - } - // Авторизация if ($action === 'auth_login') { $account = new Account(); @@ -53,6 +46,27 @@ if ($method === 'POST') { RestApi::response($result); } + // Поиск пользователя по логину + if ($action === 'search') { + $current_user_id = RestApi::getCurrentUserId(); + $username = trim($data['username'] ?? ''); + + if (!$username) { + RestApi::response(['success' => false, 'errors' => ['username' => 'Введите логин']], 400); + } + + // Ищем пользователя по username + $user = Database::get('accounts', ['id', 'name', 'username', 'avatar_url'], [ + 'username' => $username + ]); + + if (!$user) { + RestApi::response(['success' => false, 'errors' => ['username' => 'Пользователь не найден']]); + } + + RestApi::response(['success' => true, 'data' => $user]); + } + // Проверяем, что метод не пустой if (!$action) { RestApi::response(['success' => false, 'error' => 'Укажите метод'], 400); @@ -60,11 +74,22 @@ if ($method === 'POST') { } if ($method === 'GET') { - // Получение всех пользователей - $account = new Account(); - $users = $account->getAll(); + // Получение участников проекта + // ?id_project=X (обязательный) + $current_user_id = RestApi::getCurrentUserId(); + $id_project = $_GET['id_project'] ?? null; - RestApi::response(['success' => true, 'data' => $users]); + if (!$id_project) { + RestApi::response(['success' => false, 'errors' => ['id_project' => 'Проект не указан']], 400); + } + + // Проверяем доступ к проекту + ProjectAccess::requireAccess((int)$id_project, $current_user_id); + + // Получаем участников проекта + $members = ProjectAccess::getMembers((int)$id_project); + + RestApi::response(['success' => true, 'data' => $members]); } diff --git a/backend/app/class/enity/class_comment.php b/backend/app/class/enity/class_comment.php index cdd6ce7..ed2c754 100644 --- a/backend/app/class/enity/class_comment.php +++ b/backend/app/class/enity/class_comment.php @@ -104,7 +104,7 @@ class Comment extends BaseEntity { // Проверяем права: автор комментария ИЛИ админ проекта $isAuthor = (int)$comment['id_accounts'] === (int)$id_accounts; - $isProjectAdmin = $task && Project::isAdmin($task['id_project'], $id_accounts); + $isProjectAdmin = $task && ProjectAccess::isAdmin($task['id_project'], $id_accounts); if (!$isAuthor && !$isProjectAdmin) { RestApi::response([ @@ -138,9 +138,26 @@ class Comment extends BaseEntity { Database::delete('comments', ['id' => $id]); } + // Удаление комментария (проверка прав уже выполнена на уровне API) + public static function deleteWithAccess($id) { + // Рекурсивно удаляем все дочерние комментарии + self::deleteWithChildren($id); + return ['success' => true]; + } + // === МЕТОДЫ ДЛЯ РАБОТЫ С ФАЙЛАМИ === - // Загрузка файла к комментарию (только автор может загружать) + // Загрузка файла к комментарию (проверка прав уже выполнена на уровне API) + public static function uploadFileSimple($comment_id, $file_base64, $file_name) { + return FileUpload::upload('comment', $comment_id, $file_base64, $file_name); + } + + // Удаление файлов комментария (проверка прав уже выполнена на уровне API) + public static function deleteFileSimple($comment_id, $file_names) { + return FileUpload::delete('comment', $comment_id, $file_names); + } + + // Загрузка файла к комментарию (только автор может загружать) - DEPRECATED public static function uploadFile($comment_id, $file_base64, $file_name, $user_id) { // Проверка что комментарий существует $comment = self::checkComment($comment_id); @@ -156,7 +173,7 @@ class Comment extends BaseEntity { return FileUpload::upload('comment', $comment_id, $file_base64, $file_name); } - // Удаление файлов комментария (автор или админ проекта) + // Удаление файлов комментария (автор или админ проекта) - DEPRECATED public static function deleteFile($comment_id, $file_names, $user_id) { // Проверка что комментарий существует $comment = self::checkComment($comment_id); @@ -166,7 +183,7 @@ class Comment extends BaseEntity { // Проверка прав: автор комментария ИЛИ админ проекта $isAuthor = (int)$comment['id_accounts'] === (int)$user_id; - $isProjectAdmin = $task && Project::isAdmin($task['id_project'], $user_id); + $isProjectAdmin = $task && ProjectAccess::isAdmin($task['id_project'], $user_id); if (!$isAuthor && !$isProjectAdmin) { RestApi::response([ diff --git a/backend/app/class/enity/class_project.php b/backend/app/class/enity/class_project.php index 2273ccc..3477567 100644 --- a/backend/app/class/enity/class_project.php +++ b/backend/app/class/enity/class_project.php @@ -4,55 +4,49 @@ class Project extends BaseEntity { protected $db_name = 'project'; - // Получение всех проектов + // Получение всех проектов (только те, где пользователь участник) public function getAll() { $current_user_id = RestApi::getCurrentUserId(); + if (!$current_user_id) { + return []; + } + + // Получаем ID проектов где пользователь участник + $projectIds = ProjectAccess::getUserProjectIds($current_user_id); + + if (empty($projectIds)) { + return []; + } + $projects = Database::select($this->db_name, [ 'id', 'id_order', 'name', - 'id_ready', - 'id_admin' + 'id_ready' ], [ + 'id' => $projectIds, 'ORDER' => ['id_order' => 'ASC'] ]); - // Обрабатываем id_admin для каждого проекта + // Добавляем флаг is_admin для каждого проекта return array_map(function($project) use ($current_user_id) { - $admins = $project['id_admin'] ? json_decode($project['id_admin'], true) : []; - - if ($current_user_id && in_array((int)$current_user_id, $admins, true)) { - $project['id_admin'] = true; - } else { - unset($project['id_admin']); - } - + $project['is_admin'] = ProjectAccess::isAdmin($project['id'], $current_user_id); return $project; }, $projects); } // Получение одного проекта - // $current_user_id — ID текущего пользователя для проверки админства public static function get($id, $current_user_id = null) { $project = Database::get('project', [ 'id', 'id_order', 'name', - 'id_ready', - 'id_admin' + 'id_ready' ], ['id' => $id]); - if ($project) { - $admins = $project['id_admin'] ? json_decode($project['id_admin'], true) : []; - - // Если передан user_id — проверяем админство - if ($current_user_id && in_array((int)$current_user_id, $admins, true)) { - $project['id_admin'] = true; - } else { - // Не админ — убираем поле - unset($project['id_admin']); - } + if ($project && $current_user_id) { + $project['is_admin'] = ProjectAccess::isAdmin($id, $current_user_id); } return $project; @@ -114,17 +108,6 @@ class Project extends BaseEntity { ]; } - // Проверка является ли пользователь админом проекта - public static function isAdmin($project_id, $user_id): bool { - $project = Database::get('project', ['id_admin'], ['id' => $project_id]); - if (!$project || !$project['id_admin']) { - return false; - } - - $admins = json_decode($project['id_admin'], true) ?: []; - return in_array((int)$user_id, $admins, true); - } - // ==================== CRUD ПРОЕКТОВ ==================== // Создание проекта с дефолтными колонками @@ -132,12 +115,11 @@ class Project extends BaseEntity { // Получаем максимальный id_order $maxOrder = Database::max('project', 'id_order') ?? 0; - // Создаём проект + // Создаём проект с создателем как владельцем (id_admin) Database::insert('project', [ 'name' => $name, 'id_order' => $maxOrder + 1, - 'id_admin' => json_encode([(int)$user_id]), - 'id_member' => json_encode([]) + 'id_admin' => json_encode([$user_id]) ]); $projectId = Database::id(); @@ -172,14 +154,14 @@ class Project extends BaseEntity { ['id' => $firstColumnId, 'name_columns' => 'К выполнению', 'color' => '#6366f1', 'id_order' => 1], ['id' => $readyColumnId, 'name_columns' => 'Готово', 'color' => '#22c55e', 'id_order' => 2] ], - 'id_ready' => $readyColumnId + 'id_ready' => $readyColumnId, + 'is_admin' => true ]; } // Обновление проекта public static function update($id, $name, $user_id) { - // Проверяем права - if (!self::isAdmin($id, $user_id)) { + if (!ProjectAccess::isAdmin($id, $user_id)) { return ['success' => false, 'errors' => ['access' => 'Нет прав на редактирование']]; } @@ -189,8 +171,7 @@ class Project extends BaseEntity { // Удаление проекта (каскадно: колонки, задачи, комментарии, файлы) public static function delete($id, $user_id) { - // Проверяем права - if (!self::isAdmin($id, $user_id)) { + if (!ProjectAccess::isAdmin($id, $user_id)) { return ['success' => false, 'errors' => ['access' => 'Нет прав на удаление']]; } @@ -225,6 +206,12 @@ class Project extends BaseEntity { // Удаляем отделы проекта Database::delete('departments', ['id_project' => $id]); + // Удаляем участников проекта + Database::delete('project_members', ['id_project' => $id]); + + // Удаляем приглашения в проект + Database::delete('project_invites', ['id_project' => $id]); + // Удаляем проект Database::delete('project', ['id' => $id]); @@ -243,9 +230,8 @@ class Project extends BaseEntity { // Добавление колонки public static function addColumn($project_id, $name, $color, $user_id) { - // Проверяем права - if (!self::isAdmin($project_id, $user_id)) { - return ['success' => false, 'errors' => ['access' => 'Нет прав на редактирование']]; + if (!ProjectAccess::can($project_id, $user_id, 'create_column')) { + return ['success' => false, 'errors' => ['access' => 'Нет прав на создание колонок']]; } // Получаем максимальный id_order для проекта @@ -279,9 +265,8 @@ class Project extends BaseEntity { return ['success' => false, 'errors' => ['column' => 'Колонка не найдена']]; } - // Проверяем права - if (!self::isAdmin($column['id_project'], $user_id)) { - return ['success' => false, 'errors' => ['access' => 'Нет прав на редактирование']]; + if (!ProjectAccess::can($column['id_project'], $user_id, 'edit_column')) { + return ['success' => false, 'errors' => ['access' => 'Нет прав на редактирование колонок']]; } $updateData = []; @@ -308,9 +293,8 @@ class Project extends BaseEntity { return ['success' => false, 'errors' => ['column' => 'Колонка не найдена']]; } - // Проверяем права - if (!self::isAdmin($column['id_project'], $user_id)) { - return ['success' => false, 'errors' => ['access' => 'Нет прав на удаление']]; + if (!ProjectAccess::can($column['id_project'], $user_id, 'delete_column')) { + return ['success' => false, 'errors' => ['access' => 'Нет прав на удаление колонок']]; } // Проверяем, не является ли это последней колонкой перед "Готово" @@ -360,9 +344,8 @@ class Project extends BaseEntity { // Обновление порядка колонок public static function updateColumnsOrder($project_id, $ids, $user_id) { - // Проверяем права - if (!self::isAdmin($project_id, $user_id)) { - return ['success' => false, 'errors' => ['access' => 'Нет прав на редактирование']]; + if (!ProjectAccess::can($project_id, $user_id, 'edit_column')) { + return ['success' => false, 'errors' => ['access' => 'Нет прав на редактирование колонок']]; } foreach ($ids as $order => $id) { diff --git a/backend/app/class/enity/class_projectAccess.php b/backend/app/class/enity/class_projectAccess.php new file mode 100644 index 0000000..6f42d16 --- /dev/null +++ b/backend/app/class/enity/class_projectAccess.php @@ -0,0 +1,540 @@ + true, // создание задач + 'edit_task' => false, // редактирование любых задач + 'edit_own_task_only' => true, // редактирование только назначенных на себя + 'delete_task' => false, // удаление задач + 'move_task' => false, // перемещение любых задач между колонками + 'move_own_task_only' => true, // перемещение только назначенных на себя + 'archive_task' => true, // архивирование задач + 'create_column' => false, // создание колонок + 'edit_column' => false, // редактирование колонок + 'delete_column' => false, // удаление колонок + 'manage_departments' => false, // управление отделами + 'remove_members' => false, // удаление участников + 'create_comment' => false, // создание комментариев в любых задачах + 'create_comment_own_task_only' => true, // создание комментариев только в назначенных на себя + 'delete_own_comments' => true, // удаление своих комментариев + 'delete_all_comments' => false, // удаление любых комментариев + 'upload_files' => true, // загрузка файлов + 'upload_images' => true // загрузка картинок + ]; + + // ==================== ПРОВЕРКИ ДОСТУПА ==================== + + // Проверить, является ли пользователь владельцем проекта (в id_admin таблицы projects) + public static function isOwner(int $project_id, int $user_id): bool { + $project = Database::get('project', ['id_admin'], ['id' => $project_id]); + if (!$project || !$project['id_admin']) { + return false; + } + $owners = json_decode($project['id_admin'], true); + return is_array($owners) && in_array($user_id, $owners); + } + + // Проверить, является ли пользователь участником проекта + public static function isMember(int $project_id, int $user_id): bool { + // Владелец всегда имеет доступ + if (self::isOwner($project_id, $user_id)) { + return true; + } + return Database::has('project_members', [ + 'id_project' => $project_id, + 'id_user' => $user_id + ]); + } + + // Проверить, является ли пользователь админом проекта + public static function isAdmin(int $project_id, int $user_id): bool { + // Владелец = безоговорочный админ + if (self::isOwner($project_id, $user_id)) { + return true; + } + return Database::has('project_members', [ + 'id_project' => $project_id, + 'id_user' => $user_id, + 'is_admin' => 1 + ]); + } + + // Проверить конкретное право (владелец/админ имеет все права) + public static function can(int $project_id, int $user_id, string $permission): bool { + // Владелец может всё + if (self::isOwner($project_id, $user_id)) { + return true; + } + + $member = Database::get('project_members', ['is_admin', 'permissions'], [ + 'id_project' => $project_id, + 'id_user' => $user_id + ]); + + if (!$member) { + return false; + } + + // Админ может всё + if ((int)$member['is_admin'] === 1) { + return true; + } + + $permissions = $member['permissions'] ? json_decode($member['permissions'], true) : []; + return isset($permissions[$permission]) && $permissions[$permission] === true; + } + + // Проверить право на редактирование задачи (с учётом edit_own_task_only) + // "Своя задача" = назначен на пользователя ИЛИ создана пользователем + public static function canEditTask(int $project_id, int $user_id, ?int $task_assignee_id, ?int $task_creator_id = null): bool { + if (self::isAdmin($project_id, $user_id)) { + return true; + } + + if (self::can($project_id, $user_id, 'edit_task')) { + return true; + } + + // Создатель + право создания → может редактировать свои созданные + if ($task_creator_id === $user_id && self::can($project_id, $user_id, 'create_task')) { + return true; + } + + // Назначена на себя + право edit_own_task_only + if ($task_assignee_id === $user_id && self::can($project_id, $user_id, 'edit_own_task_only')) { + return true; + } + + return false; + } + + // Проверить право на перемещение задачи (с учётом move_own_task_only) + // "Своя задача" = назначен на пользователя ИЛИ создана пользователем + public static function canMoveTask(int $project_id, int $user_id, ?int $task_assignee_id, ?int $task_creator_id = null): bool { + if (self::isAdmin($project_id, $user_id)) { + return true; + } + + if (self::can($project_id, $user_id, 'move_task')) { + return true; + } + + // Создатель + право создания → может перемещать свои созданные + if ($task_creator_id === $user_id && self::can($project_id, $user_id, 'create_task')) { + return true; + } + + // Назначена на себя + право move_own_task_only + if ($task_assignee_id === $user_id && self::can($project_id, $user_id, 'move_own_task_only')) { + return true; + } + + return false; + } + + // Проверить право на создание комментария в задаче + public static function canCreateComment(int $project_id, int $user_id, ?int $task_assignee_id, ?int $task_creator_id = null): bool { + if (self::isAdmin($project_id, $user_id)) { + return true; + } + + // Полное право — комментировать любые задачи + if (self::can($project_id, $user_id, 'create_comment')) { + return true; + } + + // Создатель + право создания → может комментировать свои созданные + if ($task_creator_id === $user_id && self::can($project_id, $user_id, 'create_task')) { + return true; + } + + // Назначена на себя + право create_comment_own_task_only + if ($task_assignee_id === $user_id && self::can($project_id, $user_id, 'create_comment_own_task_only')) { + return true; + } + + return false; + } + + // ==================== ПРОВЕРКИ С EXIT ==================== + + // Требовать доступ к проекту (при отказе — 403 и exit) + public static function requireAccess(int $project_id, ?int $user_id): void { + if (!$user_id) { + RestApi::response(['success' => false, 'errors' => ['auth' => 'Требуется авторизация']], 401); + } + + if (!self::isMember($project_id, $user_id)) { + RestApi::response(['success' => false, 'errors' => ['access' => 'Нет доступа к проекту']], 403); + } + } + + // Требовать права админа + public static function requireAdmin(int $project_id, ?int $user_id): void { + if (!$user_id) { + RestApi::response(['success' => false, 'errors' => ['auth' => 'Требуется авторизация']], 401); + } + + if (!self::isAdmin($project_id, $user_id)) { + RestApi::response(['success' => false, 'errors' => ['access' => 'Требуются права администратора']], 403); + } + } + + // Требовать конкретное право + public static function requirePermission(int $project_id, ?int $user_id, string $permission): void { + if (!$user_id) { + RestApi::response(['success' => false, 'errors' => ['auth' => 'Требуется авторизация']], 401); + } + + if (!self::can($project_id, $user_id, $permission)) { + RestApi::response(['success' => false, 'errors' => ['access' => 'Недостаточно прав']], 403); + } + } + + // Требовать право на редактирование задачи + public static function requireEditTask(int $project_id, ?int $user_id, ?int $task_assignee_id, ?int $task_creator_id = null): void { + if (!$user_id) { + RestApi::response(['success' => false, 'errors' => ['auth' => 'Требуется авторизация']], 401); + } + + if (!self::canEditTask($project_id, $user_id, $task_assignee_id, $task_creator_id)) { + RestApi::response(['success' => false, 'errors' => ['access' => 'Нет прав на редактирование задачи']], 403); + } + } + + // Требовать право на перемещение задачи + public static function requireMoveTask(int $project_id, ?int $user_id, ?int $task_assignee_id, ?int $task_creator_id = null): void { + if (!$user_id) { + RestApi::response(['success' => false, 'errors' => ['auth' => 'Требуется авторизация']], 401); + } + + if (!self::canMoveTask($project_id, $user_id, $task_assignee_id, $task_creator_id)) { + RestApi::response(['success' => false, 'errors' => ['access' => 'Нет прав на перемещение задачи']], 403); + } + } + + // ==================== УПРАВЛЕНИЕ УЧАСТНИКАМИ ==================== + + // Добавить участника в проект + public static function addMember(int $project_id, int $user_id, int $current_user_id, bool $is_admin = false, ?array $permissions = null): array { + // Нельзя пригласить себя + if ($user_id === $current_user_id) { + return ['success' => false, 'errors' => ['member' => 'Нельзя пригласить себя в проект']]; + } + + if (self::isMember($project_id, $user_id)) { + return ['success' => false, 'errors' => ['member' => 'Пользователь уже участник проекта']]; + } + + $perms = $permissions ?? self::DEFAULT_PERMISSIONS; + + Database::insert('project_members', [ + 'id_project' => $project_id, + 'id_user' => $user_id, + 'is_admin' => $is_admin ? 1 : 0, + 'permissions' => json_encode($perms), + 'created_at' => date('Y-m-d H:i:s') + ]); + + return ['success' => true, 'id' => Database::id()]; + } + + // Удалить участника из проекта (владельца удалить нельзя, админ не может удалить себя) + public static function removeMember(int $project_id, int $user_id, int $current_user_id): array { + // Владельца удалить нельзя + if (self::isOwner($project_id, $user_id)) { + return ['success' => false, 'errors' => ['member' => 'Нельзя удалить владельца проекта']]; + } + + $member = Database::get('project_members', ['is_admin'], [ + 'id_project' => $project_id, + 'id_user' => $user_id + ]); + + if (!$member) { + return ['success' => false, 'errors' => ['member' => 'Участник не найден']]; + } + + // Админ может выйти сам, если есть другие админы или владелец + // (владелец всегда есть в id_admin проекта, так что это безопасно) + + Database::delete('project_members', [ + 'id_project' => $project_id, + 'id_user' => $user_id + ]); + + return ['success' => true]; + } + + // Получить всех участников проекта (включая владельцев из id_admin) + public static function getMembers(int $project_id): array { + $result = []; + $addedUserIds = []; + + // Получаем владельцев из id_admin проекта + $project = Database::get('project', ['id_admin'], ['id' => $project_id]); + if ($project && $project['id_admin']) { + $ownerIds = json_decode($project['id_admin'], true); + if (is_array($ownerIds) && count($ownerIds) > 0) { + $owners = Database::select('accounts', ['id', 'name', 'username', 'avatar_url', 'telegram'], ['id' => $ownerIds]); + foreach ($owners as $owner) { + $result[] = [ + 'id' => null, + 'id_user' => (int)$owner['id'], + 'is_admin' => true, + 'is_owner' => true, + 'permissions' => [], + 'created_at' => null, + 'name' => $owner['name'], + 'username' => $owner['username'], + 'avatar_url' => $owner['avatar_url'], + 'telegram' => $owner['telegram'] + ]; + $addedUserIds[] = (int)$owner['id']; + } + } + } + + // Получаем участников из project_members + $members = Database::select('project_members', [ + '[>]accounts' => ['id_user' => 'id'] + ], [ + 'project_members.id', + 'project_members.id_user', + 'project_members.is_admin', + 'project_members.permissions', + 'project_members.created_at', + 'accounts.name', + 'accounts.username', + 'accounts.avatar_url', + 'accounts.telegram' + ], [ + 'project_members.id_project' => $project_id + ]); + + foreach ($members as $member) { + $userId = (int)$member['id_user']; + // Пропускаем если уже добавлен как владелец + if (in_array($userId, $addedUserIds)) { + continue; + } + $result[] = [ + 'id' => $member['id'], + 'id_user' => $userId, + 'is_admin' => (int)$member['is_admin'] === 1, + 'is_owner' => false, + 'permissions' => $member['permissions'] ? json_decode($member['permissions'], true) : [], + 'created_at' => $member['created_at'], + 'name' => $member['name'], + 'username' => $member['username'], + 'avatar_url' => $member['avatar_url'], + 'telegram' => $member['telegram'] + ]; + } + + return $result; + } + + // Получить ID всех проектов пользователя (включая где он владелец) + public static function getUserProjectIds(int $user_id): array { + $projectIds = []; + + // Проекты из project_members + $rows = Database::select('project_members', ['id_project'], [ + 'id_user' => $user_id + ]); + foreach ($rows as $row) { + $projectIds[] = (int)$row['id_project']; + } + + // Проекты где пользователь в id_admin + $allProjects = Database::select('project', ['id', 'id_admin']); + foreach ($allProjects as $project) { + if ($project['id_admin']) { + $owners = json_decode($project['id_admin'], true); + if (is_array($owners) && in_array($user_id, $owners)) { + $pid = (int)$project['id']; + if (!in_array($pid, $projectIds)) { + $projectIds[] = $pid; + } + } + } + } + + return $projectIds; + } + + // ==================== УПРАВЛЕНИЕ ПРАВАМИ ==================== + + // Установить конкретное право + public static function setPermission(int $project_id, int $user_id, string $permission, bool $value, int $current_user_id): array { + // Нельзя редактировать свои права + if ($user_id === $current_user_id) { + return ['success' => false, 'errors' => ['member' => 'Нельзя изменять свои права']]; + } + + if (!in_array($permission, self::PERMISSIONS)) { + return ['success' => false, 'errors' => ['permission' => 'Неизвестное право: ' . $permission]]; + } + + $member = Database::get('project_members', ['permissions'], [ + 'id_project' => $project_id, + 'id_user' => $user_id + ]); + + if (!$member) { + return ['success' => false, 'errors' => ['member' => 'Участник не найден']]; + } + + $permissions = $member['permissions'] ? json_decode($member['permissions'], true) : []; + $permissions[$permission] = $value; + + Database::update('project_members', [ + 'permissions' => json_encode($permissions) + ], [ + 'id_project' => $project_id, + 'id_user' => $user_id + ]); + + return ['success' => true, 'permissions' => $permissions]; + } + + // Установить несколько прав сразу + public static function setPermissions(int $project_id, int $user_id, array $permissions, int $current_user_id): array { + // Нельзя редактировать свои права + if ($user_id === $current_user_id) { + return ['success' => false, 'errors' => ['member' => 'Нельзя изменять свои права']]; + } + + // Нельзя редактировать права владельца + if (self::isOwner($project_id, $user_id)) { + return ['success' => false, 'errors' => ['member' => 'Нельзя изменять права владельца проекта']]; + } + + foreach (array_keys($permissions) as $perm) { + if (!in_array($perm, self::PERMISSIONS)) { + return ['success' => false, 'errors' => ['permission' => 'Неизвестное право: ' . $perm]]; + } + } + + $member = Database::get('project_members', ['permissions'], [ + 'id_project' => $project_id, + 'id_user' => $user_id + ]); + + if (!$member) { + return ['success' => false, 'errors' => ['member' => 'Участник не найден']]; + } + + $currentPerms = $member['permissions'] ? json_decode($member['permissions'], true) : []; + $mergedPerms = array_merge($currentPerms, $permissions); + + Database::update('project_members', [ + 'permissions' => json_encode($mergedPerms) + ], [ + 'id_project' => $project_id, + 'id_user' => $user_id + ]); + + return ['success' => true, 'permissions' => $mergedPerms]; + } + + // Получить права участника + public static function getPermissions(int $project_id, int $user_id): ?array { + // Владелец имеет все права + if (self::isOwner($project_id, $user_id)) { + $allPerms = []; + foreach (self::PERMISSIONS as $perm) { + $allPerms[$perm] = true; + } + return $allPerms; + } + + $member = Database::get('project_members', ['is_admin', 'permissions'], [ + 'id_project' => $project_id, + 'id_user' => $user_id + ]); + + if (!$member) { + return null; + } + + // Админ имеет все права + if ((int)$member['is_admin'] === 1) { + $allPerms = []; + foreach (self::PERMISSIONS as $perm) { + $allPerms[$perm] = true; + } + return $allPerms; + } + + return $member['permissions'] ? json_decode($member['permissions'], true) : []; + } + + // Назначить/снять админа + public static function setAdmin(int $project_id, int $user_id, bool $is_admin, int $current_user_id): array { + // Нельзя редактировать свои права + if ($user_id === $current_user_id) { + return ['success' => false, 'errors' => ['member' => 'Нельзя изменять свои права']]; + } + + // Нельзя изменять статус владельца + if (self::isOwner($project_id, $user_id)) { + return ['success' => false, 'errors' => ['member' => 'Нельзя изменять статус владельца проекта']]; + } + + $member = Database::get('project_members', ['id'], [ + 'id_project' => $project_id, + 'id_user' => $user_id + ]); + + if (!$member) { + return ['success' => false, 'errors' => ['member' => 'Участник не найден']]; + } + + Database::update('project_members', [ + 'is_admin' => $is_admin ? 1 : 0 + ], [ + 'id_project' => $project_id, + 'id_user' => $user_id + ]); + + return ['success' => true]; + } + + // Получить список всех доступных прав (для UI) + public static function getAvailablePermissions(): array { + return self::PERMISSIONS; + } + + // Получить дефолтные права (для UI) + public static function getDefaultPermissions(): array { + return self::DEFAULT_PERMISSIONS; + } +} + +?> diff --git a/backend/app/class/enity/class_projectInvite.php b/backend/app/class/enity/class_projectInvite.php new file mode 100644 index 0000000..518d4ae --- /dev/null +++ b/backend/app/class/enity/class_projectInvite.php @@ -0,0 +1,237 @@ + false, 'errors' => ['invite' => 'Нельзя пригласить себя в проект']]; + } + + // Проверяем, что пользователь существует + $user = Database::get('accounts', ['id'], ['id' => $to_user_id]); + if (!$user) { + return ['success' => false, 'errors' => ['invite' => 'Пользователь не найден']]; + } + + // Проверяем, что пользователь ещё не участник проекта + if (ProjectAccess::isMember($project_id, $to_user_id)) { + return ['success' => false, 'errors' => ['invite' => 'Пользователь уже участник проекта']]; + } + + // Проверяем, нет ли уже pending-приглашения + if (self::hasPending($project_id, $to_user_id)) { + return ['success' => false, 'errors' => ['invite' => 'Пользователь уже приглашён, ожидается ответ']]; + } + + // Права по умолчанию + $perms = $permissions ?? ProjectAccess::DEFAULT_PERMISSIONS; + + // Сохраняем is_admin в permissions для передачи при accept + $inviteData = [ + 'is_admin' => $is_admin, + 'permissions' => $perms + ]; + + Database::insert('project_invites', [ + 'id_project' => $project_id, + 'id_from_user' => $from_user_id, + 'id_to_user' => $to_user_id, + 'status' => self::STATUS_PENDING, + 'permissions' => json_encode($inviteData), + 'created_at' => date('Y-m-d H:i:s') + ]); + + return ['success' => true, 'id' => Database::id()]; + } + + // ==================== ПРОВЕРКИ ==================== + + /** + * Есть ли pending-приглашение для пользователя в проект + */ + public static function hasPending(int $project_id, int $to_user_id): bool { + return Database::has('project_invites', [ + 'id_project' => $project_id, + 'id_to_user' => $to_user_id, + 'status' => self::STATUS_PENDING + ]); + } + + /** + * Получить количество pending-приглашений для пользователя + */ + public static function getPendingCount(int $user_id): int { + return Database::count('project_invites', [ + 'id_to_user' => $user_id, + 'status' => self::STATUS_PENDING + ]); + } + + // ==================== ПОЛУЧЕНИЕ ПРИГЛАШЕНИЙ ==================== + + /** + * Получить все pending-приглашения для пользователя (входящие) + */ + public static function getMyPending(int $user_id): array { + $invites = Database::select('project_invites', [ + '[>]project' => ['id_project' => 'id'], + '[>]accounts' => ['id_from_user' => 'id'] + ], [ + 'project_invites.id', + 'project_invites.id_project', + 'project_invites.created_at', + 'project_invites.permissions', + 'project.name(project_name)', + 'accounts.name(from_user_name)', + 'accounts.username(from_user_username)', + 'accounts.avatar_url(from_user_avatar)' + ], [ + 'project_invites.id_to_user' => $user_id, + 'project_invites.status' => self::STATUS_PENDING, + 'ORDER' => ['project_invites.created_at' => 'DESC'] + ]); + + return array_map(function($invite) { + $permData = $invite['permissions'] ? json_decode($invite['permissions'], true) : []; + return [ + 'id' => (int)$invite['id'], + 'project_id' => (int)$invite['id_project'], + 'project_name' => $invite['project_name'], + 'from_user' => [ + 'name' => $invite['from_user_name'], + 'username' => $invite['from_user_username'], + 'avatar_url' => $invite['from_user_avatar'] + ], + 'is_admin' => $permData['is_admin'] ?? false, + 'created_at' => $invite['created_at'] + ]; + }, $invites); + } + + /** + * Получить pending-приглашения для проекта (для админа) + */ + public static function getProjectPending(int $project_id): array { + $invites = Database::select('project_invites', [ + '[>]accounts' => ['id_to_user' => 'id'] + ], [ + 'project_invites.id', + 'project_invites.id_to_user', + 'project_invites.created_at', + 'project_invites.permissions', + 'accounts.name', + 'accounts.username', + 'accounts.avatar_url' + ], [ + 'project_invites.id_project' => $project_id, + 'project_invites.status' => self::STATUS_PENDING, + 'ORDER' => ['project_invites.created_at' => 'DESC'] + ]); + + return array_map(function($invite) { + $permData = $invite['permissions'] ? json_decode($invite['permissions'], true) : []; + return [ + 'id' => (int)$invite['id'], + 'id_user' => (int)$invite['id_to_user'], + 'name' => $invite['name'], + 'username' => $invite['username'], + 'avatar_url' => $invite['avatar_url'], + 'is_admin' => $permData['is_admin'] ?? false, + 'created_at' => $invite['created_at'], + 'status' => 'pending' + ]; + }, $invites); + } + + // ==================== ОТВЕТ НА ПРИГЛАШЕНИЕ ==================== + + /** + * Принять приглашение + */ + public static function accept(int $invite_id, int $user_id): array { + $invite = Database::get('project_invites', '*', [ + 'id' => $invite_id, + 'id_to_user' => $user_id, + 'status' => self::STATUS_PENDING + ]); + + if (!$invite) { + return ['success' => false, 'errors' => ['invite' => 'Приглашение не найдено или уже обработано']]; + } + + $project_id = (int)$invite['id_project']; + $permData = $invite['permissions'] ? json_decode($invite['permissions'], true) : []; + $is_admin = $permData['is_admin'] ?? false; + $permissions = $permData['permissions'] ?? ProjectAccess::DEFAULT_PERMISSIONS; + + // Добавляем в project_members + Database::insert('project_members', [ + 'id_project' => $project_id, + 'id_user' => $user_id, + 'is_admin' => $is_admin ? 1 : 0, + 'permissions' => json_encode($permissions), + 'created_at' => date('Y-m-d H:i:s') + ]); + + // Обновляем статус приглашения + Database::update('project_invites', [ + 'status' => self::STATUS_ACCEPTED, + 'responded_at' => date('Y-m-d H:i:s') + ], ['id' => $invite_id]); + + return ['success' => true, 'project_id' => $project_id]; + } + + /** + * Отклонить приглашение + */ + public static function decline(int $invite_id, int $user_id): array { + $invite = Database::get('project_invites', ['id'], [ + 'id' => $invite_id, + 'id_to_user' => $user_id, + 'status' => self::STATUS_PENDING + ]); + + if (!$invite) { + return ['success' => false, 'errors' => ['invite' => 'Приглашение не найдено или уже обработано']]; + } + + Database::update('project_invites', [ + 'status' => self::STATUS_DECLINED, + 'responded_at' => date('Y-m-d H:i:s') + ], ['id' => $invite_id]); + + return ['success' => true]; + } + + /** + * Отменить приглашение (для админа) + */ + public static function cancel(int $invite_id, int $project_id): array { + $invite = Database::get('project_invites', ['id'], [ + 'id' => $invite_id, + 'id_project' => $project_id, + 'status' => self::STATUS_PENDING + ]); + + if (!$invite) { + return ['success' => false, 'errors' => ['invite' => 'Приглашение не найдено']]; + } + + Database::delete('project_invites', ['id' => $invite_id]); + + return ['success' => true]; + } +} + +?> diff --git a/backend/app/class/enity/class_task.php b/backend/app/class/enity/class_task.php index 6d35688..d0a173b 100644 --- a/backend/app/class/enity/class_task.php +++ b/backend/app/class/enity/class_task.php @@ -14,6 +14,7 @@ class Task extends BaseEntity { public $date; public $date_closed; public $id_account; + public $create_id_account; // ID создателя задачи public $title; public $descript; public $descript_full; @@ -69,6 +70,7 @@ class Task extends BaseEntity { 'column_id' => $this->column_id, 'date' => $this->date ?: null, 'id_account' => $this->id_account, + 'create_id_account' => $this->create_id_account, 'title' => $this->title, 'descript' => $this->descript ?: null, 'descript_full' => $this->descript_full ?: null, @@ -255,6 +257,7 @@ class Task extends BaseEntity { 'id_department', 'id_label', 'id_account', + 'create_id_account', 'order', 'column_id', 'date', diff --git a/backend/app/class/enity/class_user.php b/backend/app/class/enity/class_user.php index 6d1c5ba..db0652c 100644 --- a/backend/app/class/enity/class_user.php +++ b/backend/app/class/enity/class_user.php @@ -16,27 +16,69 @@ class Account extends BaseEntity { // Валидация данных при создании аккаунта protected function validate() { - $this->error_message = []; + static::$error_message = []; + // === ИМЯ === if (!$this->name) { $this->addError('name', 'Имя не может быть пустым'); + } elseif (mb_strlen($this->name) < 2) { + $this->addError('name', 'Имя слишком короткое'); + } elseif (mb_strlen($this->name) > 50) { + $this->addError('name', 'Имя слишком длинное'); + } elseif (!preg_match('/^[\p{L}\s\-]+$/u', $this->name)) { + // Только буквы (любого языка), пробелы и дефис + $this->addError('name', 'Имя может содержать только буквы'); } + + // === ЛОГИН === if (!$this->username) { $this->addError('username', 'Логин не может быть пустым'); + } elseif (strlen($this->username) < 3) { + $this->addError('username', 'Логин минимум 3 символа'); + } elseif (strlen($this->username) > 32) { + $this->addError('username', 'Логин максимум 32 символа'); + } elseif (!preg_match('/^[a-zA-Z][a-zA-Z0-9_]*$/', $this->username)) { + // Начинается с буквы, далее a-z, 0-9, _ + $this->addError('username', 'Логин: латиница, цифры и _ (начинается с буквы)'); + } elseif (Database::get($this->db_name, ['id'], ['username' => $this->username])) { + $this->addError('username', 'Этот логин уже занят'); } - if ($this->username && Database::get($this->db_name, ['id'], ['username' => $this->username])) { - $this->addError('username', 'Пользователь с таким логином уже существует'); - } + + // === ПАРОЛЬ === if (!$this->password) { $this->addError('password', 'Пароль не может быть пустым'); + } elseif (strlen($this->password) < 6) { + $this->addError('password', 'Пароль минимум 6 символов'); + } elseif (strlen($this->password) > 100) { + $this->addError('password', 'Пароль слишком длинный'); } return $this->getErrors(); } + // Очистка данных перед сохранением + protected function sanitize() { + // Имя: убираем лишние пробелы + $this->name = trim(preg_replace('/\s+/', ' ', $this->name)); + + // Логин: только допустимые символы (регистр сохраняется) + $this->username = preg_replace('/[^a-zA-Z0-9_]/', '', $this->username); + + // Telegram: убираем @, только допустимые символы + if ($this->telegram) { + $this->telegram = str_replace('@', '', $this->telegram); + $this->telegram = preg_replace('/[^a-zA-Z0-9_]/', '', $this->telegram); + $this->telegram = strtolower($this->telegram); + $this->telegram = $this->telegram ?: null; + } + } + // Создание нового аккаунта public function create() { + // Очищаем данные + $this->sanitize(); + // Валидация данных if ($errors = $this->validate()) { return $errors; @@ -133,6 +175,7 @@ class Account extends BaseEntity { // Получаем данные пользователя $user = Database::get($this->db_name, [ + 'id', 'id_department', 'name', 'username', diff --git a/backend/app/config.php b/backend/app/config.php index 8415775..d90be70 100644 --- a/backend/app/config.php +++ b/backend/app/config.php @@ -16,6 +16,8 @@ require_once __DIR__ . '/class/enity/class_base.php'; require_once __DIR__ . '/class/enity/class_fileUpload.php'; require_once __DIR__ . '/class/enity/class_user.php'; + require_once __DIR__ . '/class/enity/class_projectAccess.php'; + require_once __DIR__ . '/class/enity/class_projectInvite.php'; require_once __DIR__ . '/class/enity/class_project.php'; require_once __DIR__ . '/class/enity/class_task.php'; require_once __DIR__ . '/class/enity/class_comment.php'; @@ -35,10 +37,12 @@ '/api/user' => __DIR__ . '/../api/user.php', '/api/task' => __DIR__ . '/../api/task.php', '/api/project' => __DIR__ . '/../api/project.php', + '/api/projectAccess' => __DIR__ . '/../api/projectAccess.php', + '/api/projectInvite' => __DIR__ . '/../api/projectInvite.php', '/api/comment' => __DIR__ . '/../api/comment.php', '/api/server' => __DIR__ . '/../api/server.php', ]; - $publicActions = ['auth_login', 'check_session', 'get_settings']; + $publicActions = ['auth_login', 'check_session', 'create_user', 'get_settings']; ?> \ No newline at end of file diff --git a/front_vue/public/config.js b/front_vue/public/config.js index 31be1f6..ceefce1 100644 --- a/front_vue/public/config.js +++ b/front_vue/public/config.js @@ -2,10 +2,17 @@ window.APP_CONFIG = { API_BASE: 'http://192.168.1.6', - // Интервал автообновления данных (в секундах) - IDLE_REFRESH_SECONDS: 1, + // Интервалы автообновления данных (в секундах) + REFRESH_INTERVALS: { + cards: 2, // Карточки на доске + comments: 5, // Комментарии к задаче + invites: 10 // Приглашения на странице без проектов + }, // Брейкпоинт для мобильной версии (px) - MOBILE_BREAKPOINT: 1400 + MOBILE_BREAKPOINT: 1400, + + // Автообновление страницы (F5) — интервал в секундах, 0 = отключено + AUTO_REFRESH_SECONDS: 500 } diff --git a/front_vue/public/favicon.ico b/front_vue/public/favicon.ico index 3e4b9f1871fa717786906dea6d34eb5626795d01..bb766f82624d32375a51fd2d4d0e07ba16499eb0 100644 GIT binary patch literal 8158 zcmeHMy-ve05I#U4!2nE*z{t>nfd^n>WJUt<04#K2V<=C+12Djd*ic@e2njJGcKQZ@ zR5qmGFXYnWaNHb})`8Dv45Xd8W?uYP#S{70uV2Le~>>cc1* z7wEeTm$q;3&us`Ehk?=8M*&I{0*)h`V;U#x+1$fAEqG^Nl#=(Yy5JRiY?ORz9)Gfr-97E4 z&##^It=s)pV27XkvBdH{eI);ODf;LWUy7CS12eZ#_FTSLzNe2gYL;Yx<)@9!zpNv!WG2Y1bx6n9h zpVsBrhI}$k)y?@}ne&!dg{y3zG2^wYsg~HPcw;_?($4^__#au@W7YXwJ63!e7oWj9 zJMy?J$78V)@~IXp{G?vUhD?aJqNM3ok_PPEI-L{<2F4 nvPsm2T<^&i^hF|;Q2+C%g@+T*+lDUq=s<7wcw-(@X9lky)FbQV literal 15086 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 T+&@S!Y21> + + + + diff --git a/front_vue/src/components/ProjectPanel.vue b/front_vue/src/components/ProjectPanel.vue index 891b610..0b617b8 100644 --- a/front_vue/src/components/ProjectPanel.vue +++ b/front_vue/src/components/ProjectPanel.vue @@ -162,6 +162,7 @@ + + diff --git a/front_vue/src/components/ui/NotificationCard.vue b/front_vue/src/components/ui/NotificationCard.vue new file mode 100644 index 0000000..04804b7 --- /dev/null +++ b/front_vue/src/components/ui/NotificationCard.vue @@ -0,0 +1,271 @@ + + + + + diff --git a/front_vue/src/components/ui/RichTextEditor.vue b/front_vue/src/components/ui/RichTextEditor.vue index 6fece6e..df0aef5 100644 --- a/front_vue/src/components/ui/RichTextEditor.vue +++ b/front_vue/src/components/ui/RichTextEditor.vue @@ -31,8 +31,8 @@
@@ -56,7 +56,16 @@ > @@ -115,7 +124,7 @@ v-for="option in filteredOptions" :key="option.value" class="mobile-select-item" - :class="{ active: modelValue === option.value }" + :class="{ active: isActive(option.value) }" @click="selectOption(option.value)" > - + @@ -247,17 +265,24 @@ const closeDropdown = () => { searchQuery.value = '' } +// Сравнение значений (приводим к числу для корректного сравнения) +const isActive = (optionValue) => { + if (props.modelValue === null || props.modelValue === undefined) return false + return Number(optionValue) === Number(props.modelValue) +} + // Выбранная опция const selectedOption = computed(() => { - if (!props.modelValue) return null - return props.options.find(opt => opt.value === props.modelValue) + if (props.modelValue === null || props.modelValue === undefined) return null + return props.options.find(opt => isActive(opt.value)) }) -// Отфильтрованные опции +// Отфильтрованные опции (исключаем disabled, они только для отображения выбранного) const filteredOptions = computed(() => { - if (!searchQuery.value.trim()) return props.options + let opts = props.options.filter(opt => !opt.disabled) + if (!searchQuery.value.trim()) return opts const query = searchQuery.value.toLowerCase() - return props.options.filter(opt => + return opts.filter(opt => opt.label?.toLowerCase().includes(query) || opt.subtitle?.toLowerCase().includes(query) ) @@ -500,12 +525,30 @@ onUpdated(refreshIcons) .option-subtitle { font-size: 11px; color: var(--text-muted); + text-decoration: none; + display: inline-flex; + align-items: center; + gap: 3px; + transition: color 0.15s; +} + +.option-subtitle:hover { + color: var(--accent); +} + +.option-subtitle :deep(svg) { + width: 10px !important; + height: 10px !important; } .dropdown-item.active .option-subtitle { color: rgba(0, 0, 0, 0.5); } +.dropdown-item.active .option-subtitle:hover { + color: rgba(0, 0, 0, 0.8); +} + .no-selection-icon { width: 28px; height: 28px; @@ -678,6 +721,15 @@ onUpdated(refreshIcons) .mobile-select-item .option-subtitle { font-size: 13px; color: var(--text-muted); + text-decoration: none; + display: inline-flex; + align-items: center; + gap: 4px; +} + +.mobile-select-item .option-subtitle :deep(svg) { + width: 12px !important; + height: 12px !important; } .mobile-select-item.active .option-subtitle { diff --git a/front_vue/src/components/ui/TagsSelect.vue b/front_vue/src/components/ui/TagsSelect.vue index 7243a75..2737fad 100644 --- a/front_vue/src/components/ui/TagsSelect.vue +++ b/front_vue/src/components/ui/TagsSelect.vue @@ -1,12 +1,13 @@ @@ -403,6 +671,10 @@ watch(showSuccess, () => { transform: scale(1.1); } +.input-group.has-error .input-icon { + color: #f87171; +} + .input-group input { flex: 1; background: transparent; @@ -455,6 +727,29 @@ watch(showSuccess, () => { transform: scaleX(1); } +.input-group.has-error .input-line::after { + background: #f87171; + transform: scaleX(1); +} + +/* Анимация тряски при неверном вводе */ +@keyframes shake { + 0%, 100% { transform: translateX(0); } + 20% { transform: translateX(-4px); } + 40% { transform: translateX(4px); } + 60% { transform: translateX(-3px); } + 80% { transform: translateX(3px); } +} + +.input-group.shake { + animation: shake 0.4s ease-out; +} + +.input-group.shake .input-line::after { + background: #f87171 !important; + transform: scaleX(1) !important; +} + /* Ошибка */ .error-message { display: flex; @@ -559,6 +854,31 @@ watch(showSuccess, () => { transform: scale(1); } +/* Переключение между входом и регистрацией */ +.form-switch { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + font-size: 14px; + color: var(--text-muted); +} + +.switch-btn { + background: none; + border: none; + color: var(--accent); + font-family: inherit; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: opacity 0.2s; +} + +.switch-btn:hover { + opacity: 0.8; +} + /* Состояние успеха */ .success-state { display: flex; diff --git a/front_vue/src/views/MainApp.vue b/front_vue/src/views/MainApp.vue index e472f93..02a8e7e 100644 --- a/front_vue/src/views/MainApp.vue +++ b/front_vue/src/views/MainApp.vue @@ -78,6 +78,7 @@ @stats-updated="stats = $event" @open-task="openTaskPanel" @create-task="openNewTaskPanel" + @cards-moved="onCardsMoved" /> @@ -158,6 +159,11 @@ const onProjectChange = async () => { await fetchCards() } +// После перемещения карточки — тихо обновляем данные с сервера +const onCardsMoved = async () => { + await fetchCards(true) +} + // ==================== СТАТИСТИКА ==================== const stats = ref({ total: 0, inProgress: 0, done: 0 }) @@ -231,15 +237,26 @@ const onProjectSaved = async (projectId) => { } // ==================== АВТООБНОВЛЕНИЕ (POLLING) ==================== -const REFRESH_INTERVAL = (window.APP_CONFIG?.IDLE_REFRESH_SECONDS ?? 30) * 1000 +const CARDS_REFRESH_INTERVAL = (window.APP_CONFIG?.REFRESH_INTERVALS?.cards ?? 30) * 1000 +const INVITES_REFRESH_INTERVAL = (window.APP_CONFIG?.REFRESH_INTERVALS?.invites ?? 30) * 1000 let pollTimer = null +let invitesPollTimer = null const startPolling = () => { + // Polling карточек if (pollTimer) clearInterval(pollTimer) pollTimer = setInterval(async () => { + // Не обновляем когда открыта модалка — это может прерывать клики + if (panelOpen.value || projectPanelOpen.value) return console.log('[AutoRefresh] Обновление данных...') await fetchCards(true) // silent = true, без Loader - }, REFRESH_INTERVAL) + }, CARDS_REFRESH_INTERVAL) + + // Polling приглашений (для бейджа) + if (invitesPollTimer) clearInterval(invitesPollTimer) + invitesPollTimer = setInterval(async () => { + await store.fetchPendingInvitesCount() + }, INVITES_REFRESH_INTERVAL) } const stopPolling = () => { @@ -247,11 +264,15 @@ const stopPolling = () => { clearInterval(pollTimer) pollTimer = null } + if (invitesPollTimer) { + clearInterval(invitesPollTimer) + invitesPollTimer = null + } } // ==================== ИНИЦИАЛИЗАЦИЯ ==================== onMounted(async () => { - // Store уже мог быть инициализирован при логине (prefetch) + // Store уже мог быть инициализирован при логине (prefetch) или в роутере await store.init() // Проверяем предзагруженные карточки diff --git a/front_vue/src/views/NoProjectsPage.vue b/front_vue/src/views/NoProjectsPage.vue new file mode 100644 index 0000000..1e47a3c --- /dev/null +++ b/front_vue/src/views/NoProjectsPage.vue @@ -0,0 +1,621 @@ + + + + + diff --git a/front_vue/src/views/TeamPage.vue b/front_vue/src/views/TeamPage.vue index f4e52e8..98d686c 100644 --- a/front_vue/src/views/TeamPage.vue +++ b/front_vue/src/views/TeamPage.vue @@ -1,38 +1,100 @@ -
- - + + + + + + + + + +