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 @@
+
+
+
+
+
+
![]()
+
{{ avatarInitial }}
+
+
+
+
+ {{ title }}
+ {{ subtitle }}
+
+
+
+
{{ badge }}
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
-