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 3e4b9f1..bb766f8 100644 Binary files a/front_vue/public/favicon.ico and b/front_vue/public/favicon.ico differ diff --git a/front_vue/src/App.vue b/front_vue/src/App.vue index 0d6f976..d344765 100644 --- a/front_vue/src/App.vue +++ b/front_vue/src/App.vue @@ -2,11 +2,14 @@
+ + + + 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 @@ -
- - + + + + + + + + + +