diff --git a/app/Controllers/DomainController.php b/app/Controllers/DomainController.php index fba9774..ce77735 100644 --- a/app/Controllers/DomainController.php +++ b/app/Controllers/DomainController.php @@ -77,6 +77,14 @@ class DomainController extends Controller $allTags = $this->domainModel->getAllTags(); } + // Get available tags for bulk operations + $tagModel = new \App\Models\Tag(); + if ($isolationMode === 'isolated') { + $availableTags = $tagModel->getAllWithUsage($userId); + } else { + $availableTags = $tagModel->getAllWithUsage(); + } + // Format domains for display $formattedDomains = \App\Helpers\DomainHelper::formatMultiple($result['domains']); @@ -91,6 +99,7 @@ class DomainController extends Controller 'domains' => $formattedDomains, 'groups' => $groups, 'allTags' => $allTags, + 'availableTags' => $availableTags, 'users' => $users, 'filters' => [ 'search' => $search, @@ -117,9 +126,18 @@ class DomainController extends Controller } else { $groups = $this->groupModel->getAllWithChannelCount(); } + + // Get available tags for the new tag system + $tagModel = new \App\Models\Tag(); + if ($isolationMode === 'isolated') { + $availableTags = $tagModel->getAllWithUsage($userId); + } else { + $availableTags = $tagModel->getAllWithUsage(); + } $this->view('domains/create', [ 'groups' => $groups, + 'availableTags' => $availableTags, 'title' => 'Add Domain' ]); } @@ -237,7 +255,18 @@ class DomainController extends Controller public function edit($params = []) { $id = $params['id'] ?? 0; - $domain = $this->checkDomainAccess($id); + + // Get current user and isolation mode + $userId = \Core\Auth::id(); + $settingModel = new \App\Models\Setting(); + $isolationMode = $settingModel->getValue('user_isolation_mode', 'shared'); + + // Get domain with tags and groups + if ($isolationMode === 'isolated') { + $domain = $this->domainModel->getWithTagsAndGroups($id, $userId); + } else { + $domain = $this->domainModel->getWithTagsAndGroups($id); + } if (!$domain) { $_SESSION['error'] = 'Domain not found'; @@ -246,19 +275,28 @@ class DomainController extends Controller } // Get groups based on isolation mode - $userId = \Core\Auth::id(); - $settingModel = new \App\Models\Setting(); - $isolationMode = $settingModel->getValue('user_isolation_mode', 'shared'); - if ($isolationMode === 'isolated') { $groups = $this->groupModel->getAllWithChannelCount($userId); } else { $groups = $this->groupModel->getAllWithChannelCount(); } + + // Get available tags for the new tag system + $tagModel = new \App\Models\Tag(); + if ($isolationMode === 'isolated') { + $availableTags = $tagModel->getAllWithUsage($userId); + } else { + $availableTags = $tagModel->getAllWithUsage(); + } + // Get referrer for cancel button + $referrer = $_GET['from'] ?? '/domains/' . $domain['id']; + $this->view('domains/edit', [ 'domain' => $domain, 'groups' => $groups, + 'availableTags' => $availableTags, + 'referrer' => $referrer, 'title' => 'Edit Domain' ]); } @@ -319,7 +357,6 @@ class DomainController extends Controller $this->domainModel->update($id, [ 'notification_group_id' => $groupId, - 'tags' => $tags, 'is_active' => $isActive, 'expiration_date' => $manualExpirationDate ]); @@ -362,6 +399,16 @@ class DomainController extends Controller } } + // Handle tags using the new tag system + if (!empty($tags)) { + $tagModel = new \App\Models\Tag(); + $tagModel->updateDomainTags($id, $tags, $userId); + } else { + // Remove all tags from domain + $tagModel = new \App\Models\Tag(); + $tagModel->removeAllFromDomain($id); + } + $_SESSION['success'] = 'Domain updated successfully'; $this->redirect('/domains/' . $id); } @@ -471,11 +518,11 @@ class DomainController extends Controller $settingModel = new \App\Models\Setting(); $isolationMode = $settingModel->getValue('user_isolation_mode', 'shared'); - // Check domain access based on isolation mode + // Get domain with tags and groups if ($isolationMode === 'isolated') { - $domain = $this->domainModel->getWithChannels($id, $userId); + $domain = $this->domainModel->getWithTagsAndGroups($id, $userId); } else { - $domain = $this->domainModel->getWithChannels($id); + $domain = $this->domainModel->getWithTagsAndGroups($id); } if (!$domain) { @@ -502,10 +549,19 @@ class DomainController extends Controller if (!empty($domain['channels'])) { $formattedDomain['activeChannelCount'] = \App\Helpers\DomainHelper::getActiveChannelCount($domain['channels']); } + + // Get available tags for the new tag system + $tagModel = new \App\Models\Tag(); + if ($isolationMode === 'isolated') { + $availableTags = $tagModel->getAllWithUsage($userId); + } else { + $availableTags = $tagModel->getAllWithUsage(); + } $this->view('domains/view', [ 'domain' => $formattedDomain, 'logs' => $logs, + 'availableTags' => $availableTags, 'title' => $domain['domain_name'] ]); } @@ -524,8 +580,17 @@ class DomainController extends Controller $groups = $this->groupModel->getAllWithChannelCount(); } + // Get available tags for the new tag system + $tagModel = new \App\Models\Tag(); + if ($isolationMode === 'isolated') { + $availableTags = $tagModel->getAllWithUsage($userId); + } else { + $availableTags = $tagModel->getAllWithUsage(); + } + $this->view('domains/bulk-add', [ 'groups' => $groups, + 'availableTags' => $availableTags, 'title' => 'Bulk Add Domains' ]); return; @@ -1007,6 +1072,7 @@ class DomainController extends Controller $settingModel = new \App\Models\Setting(); $isolationMode = $settingModel->getValue('user_isolation_mode', 'shared'); + $tagModel = new \App\Models\Tag(); $updated = 0; foreach ($domainIds as $id) { // Check domain access based on isolation mode @@ -1016,7 +1082,7 @@ class DomainController extends Controller $domain = $this->domainModel->find($id); } - if ($domain && $this->domainModel->update($id, ['tags' => ''])) { + if ($domain && $tagModel->removeAllFromDomain($id)) { $updated++; } } @@ -1025,6 +1091,112 @@ class DomainController extends Controller $this->redirect('/domains'); } + /** + * Bulk remove specific tag from domains + */ + public function bulkRemoveSpecificTag() + { + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + $this->redirect('/domains'); + return; + } + + // CSRF Protection + $this->verifyCsrf('/domains'); + + $domainIds = $_POST['domain_ids'] ?? []; + $tagId = (int)($_POST['tag_id'] ?? 0); + + if (empty($domainIds) || !$tagId) { + $_SESSION['error'] = 'Invalid request'; + $this->redirect('/domains'); + return; + } + + $tagModel = new \App\Models\Tag(); + $tag = $tagModel->find($tagId); + if (!$tag) { + $_SESSION['error'] = 'Tag not found'; + $this->redirect('/domains'); + return; + } + + // Get current user and isolation mode + $userId = \Core\Auth::id(); + $settingModel = new \App\Models\Setting(); + $isolationMode = $settingModel->getValue('user_isolation_mode', 'shared'); + + $removed = 0; + foreach ($domainIds as $domainId) { + // Check domain access based on isolation mode + if ($isolationMode === 'isolated') { + $domain = $this->domainModel->findWithIsolation($domainId, $userId); + } else { + $domain = $this->domainModel->find($domainId); + } + + if ($domain && $tagModel->removeFromDomain($domainId, $tagId)) { + $removed++; + } + } + + $_SESSION['success'] = "Tag '{$tag['name']}' removed from $removed domain(s)"; + $this->redirect('/domains'); + } + + /** + * Bulk assign existing tag to domains + */ + public function bulkAssignExistingTag() + { + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + $this->redirect('/domains'); + return; + } + + // CSRF Protection + $this->verifyCsrf('/domains'); + + $domainIds = $_POST['domain_ids'] ?? []; + $tagId = (int)($_POST['tag_id'] ?? 0); + + if (empty($domainIds) || !$tagId) { + $_SESSION['error'] = 'Invalid request'; + $this->redirect('/domains'); + return; + } + + $tagModel = new \App\Models\Tag(); + $tag = $tagModel->find($tagId); + if (!$tag) { + $_SESSION['error'] = 'Tag not found'; + $this->redirect('/domains'); + return; + } + + // Get current user and isolation mode + $userId = \Core\Auth::id(); + $settingModel = new \App\Models\Setting(); + $isolationMode = $settingModel->getValue('user_isolation_mode', 'shared'); + + $added = 0; + foreach ($domainIds as $domainId) { + // Check domain access based on isolation mode + if ($isolationMode === 'isolated') { + $domain = $this->domainModel->findWithIsolation($domainId, $userId); + } else { + $domain = $this->domainModel->find($domainId); + } + + if ($domain && $tagModel->addToDomain($domainId, $tagId)) { + $added++; + } + } + + $_SESSION['success'] = "Tag '{$tag['name']}' added to $added domain(s)"; + $this->redirect('/domains'); + } + /** * Transfer domain to another user (Admin only) */ @@ -1125,5 +1297,34 @@ class DomainController extends Controller $_SESSION['success'] = "$transferred domain(s) transferred to {$targetUser['username']}"; $this->redirect('/domains'); } + + /** + * Get tags for specific domains (API endpoint) + */ + public function getTagsForDomains() + { + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + $this->json(['error' => 'Method not allowed'], 405); + return; + } + + // Get JSON input + $input = json_decode(file_get_contents('php://input'), true); + + if (!isset($input['domain_ids']) || !is_array($input['domain_ids'])) { + $this->json(['error' => 'Invalid domain IDs'], 400); + return; + } + + $domainIds = array_map('intval', $input['domain_ids']); + $userId = \Core\Auth::id(); + $settingModel = new \App\Models\Setting(); + $isolationMode = $settingModel->getValue('user_isolation_mode', 'shared'); + + // Get tags that are assigned to the specified domains + $tags = $this->domainModel->getTagsForDomains($domainIds, $isolationMode === 'isolated' ? $userId : null); + + $this->json(['tags' => $tags]); + } } diff --git a/app/Controllers/InstallerController.php b/app/Controllers/InstallerController.php index f954c18..b33b119 100644 --- a/app/Controllers/InstallerController.php +++ b/app/Controllers/InstallerController.php @@ -50,6 +50,7 @@ class InstallerController extends Controller '017_add_two_factor_authentication.sql', '018_add_user_isolation.sql', '019_add_webhook_channel_type.sql', + '020_create_tags_system.sql', ]; try { @@ -185,7 +186,8 @@ class InstallerController extends Controller '016_add_tags_to_domains.sql', '017_add_two_factor_authentication.sql', '018_add_user_isolation.sql', - '019_add_webhook_channel_type.sql' + '019_add_webhook_channel_type.sql', + '020_create_tags_system.sql' ]; } @@ -367,6 +369,7 @@ class InstallerController extends Controller '017_add_two_factor_authentication.sql', '018_add_user_isolation.sql', '019_add_webhook_channel_type.sql', + '020_create_tags_system.sql', ]; $stmt = $pdo->prepare("INSERT INTO migrations (migration) VALUES (?) ON DUPLICATE KEY UPDATE migration=migration"); diff --git a/app/Controllers/SettingsController.php b/app/Controllers/SettingsController.php index 83eb716..3012b9a 100644 --- a/app/Controllers/SettingsController.php +++ b/app/Controllers/SettingsController.php @@ -533,7 +533,7 @@ class SettingsController extends Controller return; } - $_SESSION['success'] = "Isolation mode enabled. {$migrationResult['domains_assigned']} domains and {$migrationResult['groups_assigned']} groups assigned to admin."; + $_SESSION['success'] = "Isolation mode enabled. {$migrationResult['domains_assigned']} domains, {$migrationResult['groups_assigned']} groups, and {$migrationResult['tags_assigned']} tags assigned to admin."; } else { // Switching back to shared mode $this->settingModel->setValue('user_isolation_mode', 'shared'); @@ -572,6 +572,10 @@ class SettingsController extends Controller $groupModel = new \App\Models\NotificationGroup(); $groupCount = $groupModel->assignUnassignedGroupsToUser($adminId); + // Assign all tags to admin + $tagModel = new \App\Models\Tag(); + $tagCount = $tagModel->assignUnassignedTagsToUser($adminId); + // Set isolation mode $this->settingModel->setValue('user_isolation_mode', 'isolated'); @@ -579,7 +583,8 @@ class SettingsController extends Controller 'success' => true, 'admin_id' => $adminId, 'domains_assigned' => $domainCount, - 'groups_assigned' => $groupCount + 'groups_assigned' => $groupCount, + 'tags_assigned' => $tagCount ]; } catch (\Exception $e) { diff --git a/app/Controllers/TagController.php b/app/Controllers/TagController.php new file mode 100644 index 0000000..f0a9ff0 --- /dev/null +++ b/app/Controllers/TagController.php @@ -0,0 +1,492 @@ +tagModel = new Tag(); + $this->domainModel = new Domain(); + } + + /** + * Show tag management page + */ + public function index() + { + $userId = \Core\Auth::id(); + $settingModel = new \App\Models\Setting(); + $isolationMode = $settingModel->getValue('user_isolation_mode', 'shared'); + + // Get filter parameters + $search = $_GET['search'] ?? ''; + $color = $_GET['color'] ?? ''; + $type = $_GET['type'] ?? ''; + $sortBy = $_GET['sort'] ?? 'name'; + $sortOrder = $_GET['order'] ?? 'asc'; + $page = max(1, (int)($_GET['page'] ?? 1)); + $perPage = max(10, min(100, (int)($_GET['per_page'] ?? 25))); // Between 10 and 100 + + // Prepare filters array + $filters = [ + 'search' => $search, + 'color' => $color, + 'type' => $type, + 'sort' => $sortBy, + 'order' => $sortOrder + ]; + + // Get filtered and paginated tags + $result = $this->tagModel->getFilteredPaginated($filters, $sortBy, $sortOrder, $page, $perPage, $isolationMode === 'isolated' ? $userId : null); + + $availableColors = $this->tagModel->getAvailableColors(); + + $this->view('tags/index', [ + 'tags' => $result['tags'], + 'pagination' => $result['pagination'], + 'filters' => $filters, + 'availableColors' => $availableColors, + 'isolationMode' => $isolationMode + ]); + } + + /** + * Create new tag + */ + public function create() + { + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + $this->redirect('/tags'); + return; + } + + $this->verifyCsrf('/tags'); + + $name = trim($_POST['name'] ?? ''); + $color = $_POST['color'] ?? 'bg-gray-100 text-gray-700 border-gray-300'; + $description = trim($_POST['description'] ?? ''); + $userId = \Core\Auth::id(); + + if (empty($name)) { + $_SESSION['error'] = 'Tag name is required'; + $this->redirect('/tags'); + return; + } + + // Validate tag name format + if (!preg_match('/^[a-z0-9-]+$/', $name)) { + $_SESSION['error'] = 'Invalid tag name format (use only letters, numbers, and hyphens)'; + $this->redirect('/tags'); + return; + } + + // Check isolation mode + $settingModel = new \App\Models\Setting(); + $isolationMode = $settingModel->getValue('user_isolation_mode', 'shared'); + + $data = [ + 'name' => $name, + 'color' => $color, + 'description' => $description, + 'user_id' => $isolationMode === 'isolated' ? $userId : null + ]; + + if ($this->tagModel->create($data)) { + $_SESSION['success'] = "Tag '$name' created successfully"; + } else { + $_SESSION['error'] = 'Failed to create tag (name may already exist)'; + } + + $this->redirect('/tags'); + } + + /** + * Update tag + */ + public function update() + { + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + $this->redirect('/tags'); + return; + } + + $this->verifyCsrf('/tags'); + + $id = (int)($_POST['id'] ?? 0); + $name = trim($_POST['name'] ?? ''); + $color = $_POST['color'] ?? 'bg-gray-100 text-gray-700 border-gray-300'; + $description = trim($_POST['description'] ?? ''); + $userId = \Core\Auth::id(); + + if (!$id || empty($name)) { + $_SESSION['error'] = 'Invalid request'; + $this->redirect('/tags'); + return; + } + + // Check if user can access this tag in isolation mode + $settingModel = new \App\Models\Setting(); + $isolationMode = $settingModel->getValue('user_isolation_mode', 'shared'); + + if ($isolationMode === 'isolated' && !$this->tagModel->canUserAccessTag($id, $userId, true)) { + $_SESSION['error'] = 'You do not have permission to edit this tag'; + $this->redirect('/tags'); + return; + } + + // Check if this is a global tag (user_id = NULL) - only admins can edit global tags + $tag = $this->tagModel->find($id); + if ($tag && $tag['user_id'] === null && !\Core\Auth::isAdmin()) { + $_SESSION['error'] = 'Only administrators can edit global tags'; + $this->redirect('/tags'); + return; + } + + // Validate tag name format + if (!preg_match('/^[a-z0-9-]+$/', $name)) { + $_SESSION['error'] = 'Invalid tag name format (use only letters, numbers, and hyphens)'; + $this->redirect('/tags'); + return; + } + + $data = [ + 'name' => $name, + 'color' => $color, + 'description' => $description + ]; + + if ($this->tagModel->update($id, $data)) { + $_SESSION['success'] = "Tag updated successfully"; + } else { + $_SESSION['error'] = 'Failed to update tag'; + } + + $this->redirect('/tags'); + } + + /** + * Delete tag + */ + public function delete() + { + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + $this->redirect('/tags'); + return; + } + + $this->verifyCsrf('/tags'); + + $id = (int)($_POST['id'] ?? 0); + $userId = \Core\Auth::id(); + + if (!$id) { + $_SESSION['error'] = 'Invalid request'; + $this->redirect('/tags'); + return; + } + + // Check if user can access this tag in isolation mode + $settingModel = new \App\Models\Setting(); + $isolationMode = $settingModel->getValue('user_isolation_mode', 'shared'); + + if ($isolationMode === 'isolated' && !$this->tagModel->canUserAccessTag($id, $userId, true)) { + $_SESSION['error'] = 'You do not have permission to delete this tag'; + $this->redirect('/tags'); + return; + } + + // Check if this is a global tag (user_id = NULL) - only admins can delete global tags + $tag = $this->tagModel->find($id); + if ($tag && $tag['user_id'] === null && !\Core\Auth::isAdmin()) { + $_SESSION['error'] = 'Only administrators can delete global tags'; + $this->redirect('/tags'); + return; + } + + $tag = $this->tagModel->find($id); + if (!$tag) { + $_SESSION['error'] = 'Tag not found'; + $this->redirect('/tags'); + return; + } + + if ($this->tagModel->deleteWithRelationships($id)) { + $_SESSION['success'] = "Tag '{$tag['name']}' deleted successfully"; + } else { + $_SESSION['error'] = 'Failed to delete tag'; + } + + $this->redirect('/tags'); + } + + /** + * Show domains for a specific tag + */ + public function show($params = []) + { + $id = (int)($params['id'] ?? 0); + + if (!$id) { + $_SESSION['error'] = 'Invalid tag ID'; + $this->redirect('/tags'); + return; + } + + $tag = $this->tagModel->find($id); + if (!$tag) { + $_SESSION['error'] = 'Tag not found'; + $this->redirect('/tags'); + return; + } + + $userId = \Core\Auth::id(); + $settingModel = new \App\Models\Setting(); + $isolationMode = $settingModel->getValue('user_isolation_mode', 'shared'); + + // Get domains for this tag with proper formatting + $domainModel = new \App\Models\Domain(); + $rawDomains = $this->tagModel->getDomainsForTag($id, $isolationMode === 'isolated' ? $userId : null); + + // Format domains using DomainHelper (same as other pages) + $domains = []; + foreach ($rawDomains as $domain) { + $domains[] = \App\Helpers\DomainHelper::formatForDisplay($domain); + } + + // Get current filters from request + $filters = [ + 'search' => $_GET['search'] ?? '', + 'status' => $_GET['status'] ?? '', + 'registrar' => $_GET['registrar'] ?? '', + 'sort' => $_GET['sort'] ?? 'domain_name', + 'order' => $_GET['order'] ?? 'asc' + ]; + + // Apply filters + if (!empty($filters['search'])) { + $domains = array_filter($domains, function($domain) use ($filters) { + return stripos($domain['domain_name'], $filters['search']) !== false; + }); + } + + if (!empty($filters['status'])) { + $domains = array_filter($domains, function($domain) use ($filters) { + return $domain['status'] === $filters['status']; + }); + } + + if (!empty($filters['registrar'])) { + $domains = array_filter($domains, function($domain) use ($filters) { + return stripos($domain['registrar'] ?? '', $filters['registrar']) !== false; + }); + } + + // Apply sorting + usort($domains, function($a, $b) use ($filters) { + $aVal = $a[$filters['sort']] ?? ''; + $bVal = $b[$filters['sort']] ?? ''; + + $comparison = strcasecmp($aVal, $bVal); + return $filters['order'] === 'desc' ? -$comparison : $comparison; + }); + + // Pagination + $page = max(1, (int)($_GET['page'] ?? 1)); + $perPage = max(10, min(100, (int)($_GET['per_page'] ?? 25))); + $total = count($domains); + $totalPages = ceil($total / $perPage); + $offset = ($page - 1) * $perPage; + $paginatedDomains = array_slice($domains, $offset, $perPage); + + $pagination = [ + 'current_page' => $page, + 'per_page' => $perPage, + 'total' => $total, + 'total_pages' => $totalPages, + 'showing_from' => $total > 0 ? $offset + 1 : 0, + 'showing_to' => min($offset + $perPage, $total) + ]; + + $this->view('tags/view', [ + 'tag' => $tag, + 'domains' => $paginatedDomains, + 'filters' => $filters, + 'pagination' => $pagination + ]); + } + + /** + * Bulk add tag to domains + */ + public function bulkAddToDomains() + { + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + $this->redirect('/domains'); + return; + } + + $this->verifyCsrf('/domains'); + + $domainIds = $_POST['domain_ids'] ?? []; + $tagId = (int)($_POST['tag_id'] ?? 0); + + if (empty($domainIds) || !$tagId) { + $_SESSION['error'] = 'Invalid request'; + $this->redirect('/domains'); + return; + } + + $tag = $this->tagModel->find($tagId); + if (!$tag) { + $_SESSION['error'] = 'Tag not found'; + $this->redirect('/domains'); + return; + } + + // Get current user and isolation mode + $userId = \Core\Auth::id(); + $settingModel = new \App\Models\Setting(); + $isolationMode = $settingModel->getValue('user_isolation_mode', 'shared'); + + $added = 0; + foreach ($domainIds as $domainId) { + // Check domain access based on isolation mode + if ($isolationMode === 'isolated') { + $domain = $this->domainModel->findWithIsolation($domainId, $userId); + } else { + $domain = $this->domainModel->find($domainId); + } + + if ($domain && $this->tagModel->addToDomain($domainId, $tagId)) { + $added++; + } + } + + $_SESSION['success'] = "Tag '{$tag['name']}' added to $added domain(s)"; + $this->redirect('/domains'); + } + + /** + * Bulk remove tag from domains + */ + public function bulkRemoveFromDomains() + { + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + $this->redirect('/domains'); + return; + } + + $this->verifyCsrf('/domains'); + + $domainIds = $_POST['domain_ids'] ?? []; + $tagId = (int)($_POST['tag_id'] ?? 0); + + if (empty($domainIds) || !$tagId) { + $_SESSION['error'] = 'Invalid request'; + $this->redirect('/domains'); + return; + } + + $tag = $this->tagModel->find($tagId); + if (!$tag) { + $_SESSION['error'] = 'Tag not found'; + $this->redirect('/domains'); + return; + } + + // Get current user and isolation mode + $userId = \Core\Auth::id(); + $settingModel = new \App\Models\Setting(); + $isolationMode = $settingModel->getValue('user_isolation_mode', 'shared'); + + $removed = 0; + foreach ($domainIds as $domainId) { + // Check domain access based on isolation mode + if ($isolationMode === 'isolated') { + $domain = $this->domainModel->findWithIsolation($domainId, $userId); + } else { + $domain = $this->domainModel->find($domainId); + } + + if ($domain && $this->tagModel->removeFromDomain($domainId, $tagId)) { + $removed++; + } + } + + $_SESSION['success'] = "Tag '{$tag['name']}' removed from $removed domain(s)"; + $this->redirect('/domains'); + } + + /** + * Bulk delete tags + */ + public function bulkDelete() + { + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + $this->redirect('/tags'); + return; + } + + // Verify CSRF token + if (!\Core\Csrf::verify($_POST['csrf_token'] ?? '')) { + $_SESSION['error'] = 'Invalid request'; + $this->redirect('/tags'); + return; + } + + $tagIds = $_POST['tag_ids'] ?? []; + if (empty($tagIds)) { + $_SESSION['error'] = 'No tags selected'; + $this->redirect('/tags'); + return; + } + + $userId = \Core\Auth::id(); + $settingModel = new \App\Models\Setting(); + $isolationMode = $settingModel->getValue('user_isolation_mode', 'shared'); + + $deleted = 0; + $errors = []; + + foreach ($tagIds as $tagId) { + $tagId = (int)$tagId; + + // Check if user can access this tag + if (!$this->tagModel->canUserAccessTag($tagId, $userId, $isolationMode === 'isolated')) { + $errors[] = "You don't have permission to delete tag ID $tagId"; + continue; + } + + // Check if it's a global tag and user is not admin + $tag = $this->tagModel->find($tagId); + if ($tag && $tag['user_id'] === null && !\Core\Auth::isAdmin()) { + $errors[] = "Only administrators can delete global tags"; + continue; + } + + if ($this->tagModel->delete($tagId)) { + $deleted++; + } else { + $errors[] = "Failed to delete tag ID $tagId"; + } + } + + if ($deleted > 0) { + $_SESSION['success'] = "$deleted tag(s) deleted successfully"; + } + + if (!empty($errors)) { + $_SESSION['error'] = implode(', ', $errors); + } + + $this->redirect('/tags'); + } +} diff --git a/app/Models/Domain.php b/app/Models/Domain.php index f6c4e53..fc0daac 100644 --- a/app/Models/Domain.php +++ b/app/Models/Domain.php @@ -21,16 +21,20 @@ class Domain extends Model */ public function getAllWithGroups(?int $userId = null): array { - $sql = "SELECT d.*, ng.name as group_name + $sql = "SELECT d.*, ng.name as group_name, + GROUP_CONCAT(t.name ORDER BY t.name SEPARATOR ',') as tags, + GROUP_CONCAT(t.color ORDER BY t.name SEPARATOR '|') as tag_colors FROM domains d - LEFT JOIN notification_groups ng ON d.notification_group_id = ng.id"; + LEFT JOIN notification_groups ng ON d.notification_group_id = ng.id + LEFT JOIN domain_tags dt ON d.id = dt.domain_id + LEFT JOIN tags t ON dt.tag_id = t.id"; if ($userId) { - $sql .= " WHERE d.user_id = ? ORDER BY d.status DESC, d.expiration_date ASC"; + $sql .= " WHERE d.user_id = ? AND (t.user_id = ? OR t.user_id IS NULL) GROUP BY d.id ORDER BY d.status DESC, d.expiration_date ASC"; $stmt = $this->db->prepare($sql); - $stmt->execute([$userId]); + $stmt->execute([$userId, $userId]); } else { - $sql .= " ORDER BY d.status DESC, d.expiration_date ASC"; + $sql .= " GROUP BY d.id ORDER BY d.status DESC, d.expiration_date ASC"; $stmt = $this->db->query($sql); } @@ -301,12 +305,24 @@ class Domain extends Model // Apply tag filter if (!empty($filters['tag'])) { - $domains = array_filter($domains, function($domain) use ($filters) { - if (empty($domain['tags'])) { - return false; - } - $domainTags = array_map('trim', explode(',', $domain['tags'])); - return in_array($filters['tag'], $domainTags); + // Get domain IDs that have the specified tag + $tagSql = "SELECT DISTINCT dt.domain_id + FROM domain_tags dt + JOIN tags t ON dt.tag_id = t.id + WHERE t.name = ?"; + $tagParams = [$filters['tag']]; + + if ($userId) { + $tagSql .= " AND dt.domain_id IN (SELECT id FROM domains WHERE user_id = ?)"; + $tagParams[] = $userId; + } + + $tagStmt = $this->db->prepare($tagSql); + $tagStmt->execute($tagParams); + $taggedDomainIds = array_column($tagStmt->fetchAll(), 'domain_id'); + + $domains = array_filter($domains, function($domain) use ($taggedDomainIds) { + return in_array($domain['id'], $taggedDomainIds); }); } @@ -348,30 +364,54 @@ class Domain extends Model */ public function getAllTags(?int $userId = null): array { - $sql = "SELECT DISTINCT tags FROM domains WHERE tags IS NOT NULL AND tags != ''"; + $sql = "SELECT DISTINCT t.name + FROM tags t + JOIN domain_tags dt ON t.id = dt.tag_id + JOIN domains d ON d.id = dt.domain_id"; $params = []; if ($userId) { - $sql .= " AND user_id = ?"; + $sql .= " WHERE d.user_id = ? AND (t.user_id = ? OR t.user_id IS NULL)"; + $params[] = $userId; $params[] = $userId; } + $sql .= " ORDER BY t.name"; + $stmt = $this->db->prepare($sql); $stmt->execute($params); $results = $stmt->fetchAll(); - $allTags = []; - foreach ($results as $row) { - if (!empty($row['tags'])) { - $tags = array_map('trim', explode(',', $row['tags'])); - $allTags = array_merge($allTags, $tags); - } + return array_column($results, 'name'); + } + + /** + * Get tags that are assigned to specific domains + */ + public function getTagsForDomains(array $domainIds, ?int $userId = null): array + { + if (empty($domainIds)) { + return []; + } + + $placeholders = str_repeat('?,', count($domainIds) - 1) . '?'; + $sql = "SELECT DISTINCT t.id, t.name, t.color + FROM tags t + JOIN domain_tags dt ON t.id = dt.tag_id + WHERE dt.domain_id IN ($placeholders)"; + + $params = $domainIds; + + if ($userId) { + $sql .= " AND (t.user_id = ? OR t.user_id IS NULL)"; + $params[] = $userId; } - // Return unique, sorted tags - $allTags = array_unique($allTags); - sort($allTags); - return $allTags; + $sql .= " ORDER BY t.name"; + + $stmt = $this->db->prepare($sql); + $stmt->execute($params); + return $stmt->fetchAll(); } @@ -471,5 +511,47 @@ class Domain extends Model return $stmt->rowCount(); } + + /** + * Get a single domain with tags and groups + */ + public function getWithTagsAndGroups(int $id, ?int $userId = null): ?array + { + $sql = "SELECT d.*, ng.name as group_name, ng.id as group_id, + GROUP_CONCAT(t.name ORDER BY t.name SEPARATOR ',') as tags, + GROUP_CONCAT(t.color ORDER BY t.name SEPARATOR '|') as tag_colors + FROM domains d + LEFT JOIN notification_groups ng ON d.notification_group_id = ng.id + LEFT JOIN domain_tags dt ON d.id = dt.domain_id + LEFT JOIN tags t ON dt.tag_id = t.id AND (t.user_id = ? OR t.user_id IS NULL) + WHERE d.id = ?"; + + $params = [$userId, $id]; + + if ($userId) { + $sql .= " AND d.user_id = ?"; + $params[] = $userId; + } + + $sql .= " GROUP BY d.id"; + + $stmt = $this->db->prepare($sql); + $stmt->execute($params); + $domain = $stmt->fetch(); + + if (!$domain) { + return null; + } + + // Get notification channels for this domain's group + if ($domain['group_id']) { + $channelModel = new NotificationChannel(); + $domain['channels'] = $channelModel->getByGroupId($domain['group_id']); + } else { + $domain['channels'] = []; + } + + return $domain; + } } diff --git a/app/Models/Tag.php b/app/Models/Tag.php new file mode 100644 index 0000000..a8cfb83 --- /dev/null +++ b/app/Models/Tag.php @@ -0,0 +1,424 @@ +db->prepare($sql); + $stmt->execute([$id]); + $result = $stmt->fetch(); + return $result ?: null; + } + + /** + * Get all tags with usage count + */ + public function getAllWithUsage(?int $userId = null): array + { + $sql = "SELECT t.*, + COALESCE(usage_stats.usage_count, 0) as usage_count + FROM tags t + LEFT JOIN ( + SELECT dt.tag_id, COUNT(*) as usage_count + FROM domain_tags dt + JOIN domains d ON d.id = dt.domain_id"; + + $params = []; + if ($userId) { + $sql .= " WHERE d.user_id = ?"; + $params[] = $userId; + } + + $sql .= " GROUP BY dt.tag_id + ) usage_stats ON t.id = usage_stats.tag_id"; + + // Add WHERE clause for tag visibility + if ($userId) { + $sql .= " WHERE (t.user_id = ? OR t.user_id IS NULL)"; + $params[] = $userId; + } + + $sql .= " ORDER BY usage_count DESC, t.name ASC"; + + $stmt = $this->db->prepare($sql); + $stmt->execute($params); + return $stmt->fetchAll(); + } + + /** + * Get tags for a specific domain + */ + public function getForDomain(int $domainId): array + { + $sql = "SELECT t.* FROM tags t + JOIN domain_tags dt ON t.id = dt.tag_id + WHERE dt.domain_id = ? + ORDER BY t.name"; + + $stmt = $this->db->prepare($sql); + $stmt->execute([$domainId]); + return $stmt->fetchAll(); + } + + /** + * Add tag to domain + */ + public function addToDomain(int $domainId, int $tagId): bool + { + try { + $sql = "INSERT IGNORE INTO domain_tags (domain_id, tag_id) VALUES (?, ?)"; + $stmt = $this->db->prepare($sql); + $result = $stmt->execute([$domainId, $tagId]); + + if ($result) { + $this->updateUsageCount($tagId); + } + + return $result; + } catch (\PDOException $e) { + return false; + } + } + + /** + * Remove tag from domain + */ + public function removeFromDomain(int $domainId, int $tagId): bool + { + $sql = "DELETE FROM domain_tags WHERE domain_id = ? AND tag_id = ?"; + $stmt = $this->db->prepare($sql); + $result = $stmt->execute([$domainId, $tagId]); + + if ($result) { + $this->updateUsageCount($tagId); + } + + return $result; + } + + /** + * Remove all tags from domain + */ + public function removeAllFromDomain(int $domainId): bool + { + $sql = "DELETE FROM domain_tags WHERE domain_id = ?"; + $stmt = $this->db->prepare($sql); + $result = $stmt->execute([$domainId]); + + if ($result) { + // Update usage counts for all affected tags + $this->updateAllUsageCounts(); + } + + return $result; + } + + /** + * Update tags for a domain (replace all existing tags) + */ + public function updateDomainTags(int $domainId, string $tagsString, int $userId): bool + { + // Remove all existing tags from domain + $this->removeAllFromDomain($domainId); + + if (empty(trim($tagsString))) { + return true; // No tags to add + } + + $tags = array_map('trim', explode(',', $tagsString)); + $tags = array_filter($tags); // Remove empty tags + + if (empty($tags)) { + return true; // No valid tags to add + } + + $added = 0; + foreach ($tags as $tagName) { + // Find or create tag + $tag = $this->findByName($tagName, $userId); + if (!$tag) { + // Create new tag + $tagId = $this->create([ + 'name' => $tagName, + 'color' => 'bg-gray-100 text-gray-700 border-gray-300', + 'description' => '', + 'user_id' => $userId + ]); + if ($tagId) { + $this->addToDomain($domainId, $tagId); + $added++; + } + } else { + // Use existing tag + $this->addToDomain($domainId, $tag['id']); + $added++; + } + } + + return $added > 0; + } + + /** + * Find tag by name for a specific user + */ + public function findByName(string $name, int $userId): ?array + { + $sql = "SELECT * FROM tags WHERE name = ? AND (user_id = ? OR user_id IS NULL) ORDER BY user_id DESC LIMIT 1"; + $stmt = $this->db->prepare($sql); + $stmt->execute([$name, $userId]); + $result = $stmt->fetch(); + return $result ?: null; + } + + /** + * Add tag to multiple domains + */ + public function addToDomains(array $domainIds, int $tagId): int + { + $added = 0; + foreach ($domainIds as $domainId) { + if ($this->addToDomain($domainId, $tagId)) { + $added++; + } + } + return $added; + } + + /** + * Remove tag from multiple domains + */ + public function removeFromDomains(array $domainIds, int $tagId): int + { + $removed = 0; + foreach ($domainIds as $domainId) { + if ($this->removeFromDomain($domainId, $tagId)) { + $removed++; + } + } + return $removed; + } + + /** + * Update usage count for a specific tag + */ + public function updateUsageCount(int $tagId): void + { + $sql = "UPDATE tags SET usage_count = ( + SELECT COUNT(*) FROM domain_tags WHERE tag_id = ? + ) WHERE id = ?"; + $stmt = $this->db->prepare($sql); + $stmt->execute([$tagId, $tagId]); + } + + /** + * Update usage counts for all tags + */ + public function updateAllUsageCounts(): void + { + $sql = "UPDATE tags SET usage_count = ( + SELECT COUNT(*) FROM domain_tags WHERE tag_id = tags.id + )"; + $stmt = $this->db->prepare($sql); + $stmt->execute(); + } + + /** + * Get domains for a specific tag + */ + public function getDomainsForTag(int $tagId, ?int $userId = null): array + { + $sql = "SELECT d.* FROM domains d + JOIN domain_tags dt ON d.id = dt.domain_id + WHERE dt.tag_id = ?"; + + $params = [$tagId]; + + if ($userId) { + $sql .= " AND d.user_id = ?"; + $params[] = $userId; + } + + $sql .= " ORDER BY d.domain_name"; + + $stmt = $this->db->prepare($sql); + $stmt->execute($params); + return $stmt->fetchAll(); + } + + /** + * Delete tag and all its relationships + */ + public function deleteWithRelationships(int $tagId): bool + { + try { + $this->db->beginTransaction(); + + // Remove all domain relationships + $sql = "DELETE FROM domain_tags WHERE tag_id = ?"; + $stmt = $this->db->prepare($sql); + $stmt->execute([$tagId]); + + // Delete the tag + $sql = "DELETE FROM tags WHERE id = ?"; + $stmt = $this->db->prepare($sql); + $result = $stmt->execute([$tagId]); + + $this->db->commit(); + return $result; + } catch (\Exception $e) { + $this->db->rollBack(); + return false; + } + } + + /** + * Get available colors for tags + */ + public function getAvailableColors(): array + { + return [ + 'bg-gray-100 text-gray-700 border-gray-300' => 'Gray', + 'bg-red-100 text-red-700 border-red-300' => 'Red', + 'bg-orange-100 text-orange-700 border-orange-300' => 'Orange', + 'bg-yellow-100 text-yellow-700 border-yellow-300' => 'Yellow', + 'bg-green-100 text-green-700 border-green-300' => 'Green', + 'bg-blue-100 text-blue-700 border-blue-300' => 'Blue', + 'bg-indigo-100 text-indigo-700 border-indigo-300' => 'Indigo', + 'bg-purple-100 text-purple-700 border-purple-300' => 'Purple', + 'bg-pink-100 text-pink-700 border-pink-300' => 'Pink', + 'bg-teal-100 text-teal-700 border-teal-300' => 'Teal', + 'bg-cyan-100 text-cyan-700 border-cyan-300' => 'Cyan', + 'bg-lime-100 text-lime-700 border-lime-300' => 'Lime', + ]; + } + + /** + * Check if user can access a tag + */ + public function canUserAccessTag(int $tagId, int $userId, bool $isolationMode = false): bool + { + if (!$isolationMode) { + return true; // In shared mode, everyone can access all tags + } + + $sql = "SELECT id FROM tags WHERE id = ? AND (user_id = ? OR user_id IS NULL)"; + $stmt = $this->db->prepare($sql); + $stmt->execute([$tagId, $userId]); + return $stmt->fetch() !== false; + } + + /** + * Assign all unassigned tags to a specific user (for isolation mode migration) + */ + public function assignUnassignedTagsToUser(int $userId): int + { + $stmt = $this->db->prepare("UPDATE tags SET user_id = ? WHERE user_id IS NULL"); + $stmt->execute([$userId]); + return $stmt->rowCount(); + } + + /** + * Get tags for user isolation mode + */ + public function getTagsForUser(int $userId, bool $isolationMode = false): array + { + if ($isolationMode) { + $sql = "SELECT * FROM tags WHERE user_id = ? OR user_id IS NULL ORDER BY name"; + $stmt = $this->db->prepare($sql); + $stmt->execute([$userId]); + } else { + $sql = "SELECT * FROM tags ORDER BY name"; + $stmt = $this->db->prepare($sql); + $stmt->execute(); + } + + return $stmt->fetchAll(); + } + + /** + * Get filtered, sorted, and paginated tags + */ + public function getFilteredPaginated(array $filters, string $sortBy, string $sortOrder, int $page, int $perPage, ?int $userId = null): array + { + // Get all tags with usage + $tags = $this->getAllWithUsage($userId); + + // Apply search filter + if (!empty($filters['search'])) { + $tags = array_filter($tags, function($tag) use ($filters) { + return stripos($tag['name'], $filters['search']) !== false || + stripos($tag['description'] ?? '', $filters['search']) !== false; + }); + } + + // Apply color filter + if (!empty($filters['color'])) { + $tags = array_filter($tags, function($tag) use ($filters) { + return $tag['color'] === $filters['color']; + }); + } + + // Apply type filter (global vs user) + if (!empty($filters['type'])) { + $tags = array_filter($tags, function($tag) use ($filters) { + if ($filters['type'] === 'global') { + return $tag['user_id'] === null; + } elseif ($filters['type'] === 'user') { + return $tag['user_id'] !== null; + } + return true; + }); + } + + // Get total count after filtering + $totalTags = count($tags); + + // Apply sorting + usort($tags, function($a, $b) use ($sortBy, $sortOrder) { + $aVal = $a[$sortBy] ?? ''; + $bVal = $b[$sortBy] ?? ''; + + // Handle numeric sorting for usage_count + if ($sortBy === 'usage_count') { + $aVal = (int)$aVal; + $bVal = (int)$bVal; + $comparison = $aVal <=> $bVal; + } else { + $comparison = strcasecmp($aVal, $bVal); + } + + return $sortOrder === 'desc' ? -$comparison : $comparison; + }); + + // Calculate pagination + $totalPages = ceil($totalTags / $perPage); + $page = min($page, max(1, $totalPages)); // Ensure page is within valid range + $offset = ($page - 1) * $perPage; + + // Slice array for current page + $paginatedTags = array_slice($tags, $offset, $perPage); + + return [ + 'tags' => $paginatedTags, + 'pagination' => [ + 'current_page' => $page, + 'per_page' => $perPage, + 'total' => $totalTags, + 'total_pages' => $totalPages, + 'showing_from' => $totalTags > 0 ? $offset + 1 : 0, + 'showing_to' => min($offset + $perPage, $totalTags) + ] + ]; + } +} diff --git a/app/Views/domains/bulk-add.php b/app/Views/domains/bulk-add.php index 1f12eaf..d61696f 100644 --- a/app/Views/domains/bulk-add.php +++ b/app/Views/domains/bulk-add.php @@ -68,22 +68,17 @@ ob_start(); All imported domains will be tagged with these tags. Type any custom tag or use suggestions below.

- +
-

💡 Suggestions:

+

💡 Available Tags:

- - - + + +
@@ -176,14 +171,12 @@ ob_start(); 'bg-green-100 text-green-700 border-green-200', - 'staging' => 'bg-yellow-100 text-yellow-700 border-yellow-200', - 'development' => 'bg-blue-100 text-blue-700 border-blue-200', - 'client' => 'bg-purple-100 text-purple-700 border-purple-200', - 'personal' => 'bg-orange-100 text-orange-700 border-orange-200', - 'archived' => 'bg-gray-100 text-gray-600 border-gray-200' - ]; - foreach ($tags as $tag): + $tagColors = !empty($domain['tag_colors']) ? explode('|', $domain['tag_colors']) : []; + + // Create a mapping of tag names to their colors + $tagColorMap = []; + foreach ($availableTags as $availableTag) { + $tagColorMap[$availableTag['name']] = $availableTag['color']; + } + + foreach ($tags as $index => $tag): $tag = trim($tag); - $colorClass = $tagColors[$tag] ?? 'bg-gray-100 text-gray-700 border-gray-200'; + // Use the color from the database if available, otherwise use the stored color, otherwise default + $colorClass = $tagColorMap[$tag] ?? (isset($tagColors[$index]) ? $tagColors[$index] : 'bg-gray-100 text-gray-700 border-gray-200'); ?> @@ -66,7 +67,7 @@ ob_start(); Refresh - + Edit @@ -307,7 +308,7 @@ ob_start();

Won't receive notifications

- + Assign Group diff --git a/app/Views/layout/sidebar.php b/app/Views/layout/sidebar.php index 62fcc49..ed2acc9 100644 --- a/app/Views/layout/sidebar.php +++ b/app/Views/layout/sidebar.php @@ -37,6 +37,11 @@ View + + + + Tag Management + diff --git a/app/Views/tags/index.php b/app/Views/tags/index.php new file mode 100644 index 0000000..83a1029 --- /dev/null +++ b/app/Views/tags/index.php @@ -0,0 +1,626 @@ +'; + } + $icon = $currentOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down'; + return ''; +} + +// Get current filters +$currentFilters = $filters ?? ['search' => '', 'color' => '', 'type' => '', 'sort' => 'name', 'order' => 'asc']; +?> + + +
+ +
+ + +
+
+
+
+ +
+ + +
+
+
+ + +
+
+ + +
+
+ + + + Clear + +
+
+ + +
+
+ + +
+
+ Showing to + of + tag(s) +
+ +
+ + + + + + + + + +
+
+ + + + + +
+ + + + + +
+ +
+
+ +
+
+ + + + + + + + Global + + +
+ +

+ +
+ + Used on domain +
+
+
+ + + + + + Global tag + + + + +
+
+
+ +
+ +
+
+ +
+

No Tags Yet

+

Start organizing your domains by creating your first tag

+ +
+ +
+ + + 1): ?> +
+ +
+ Page of + +
+ + +
+ + + + 1): ?> + + + + + + + 1): ?> + + Previous + + + + + 1) { + echo '1'; + if ($start > 2) { + echo '...'; + } + } + + // Page numbers + for ($i = $start; $i <= $end; $i++) { + if ($i == $currentPage) { + echo '' . $i . ''; + } else { + echo '' . $i . ''; + } + } + + // Show last page + ellipsis if needed + if ($end < $totalPages) { + if ($end < $totalPages - 1) { + echo '...'; + } + echo '' . $totalPages . ''; + } + ?> + + + + + Next + + + + + + + + + +
+
+ + + + + + + + + + + diff --git a/app/Views/tags/view.php b/app/Views/tags/view.php new file mode 100644 index 0000000..787a858 --- /dev/null +++ b/app/Views/tags/view.php @@ -0,0 +1,414 @@ +'; + } + $icon = $currentOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down'; + return ''; +} + +// Get current filters +$currentFilters = $filters ?? ['search' => '', 'status' => '', 'registrar' => '', 'sort' => 'domain_name', 'order' => 'asc']; +?> + + +
+ + + Back to Tags + +
+ + +
+
+
+ + + + +
+
+

Tag Description

+

+ + + +

+
+
+
+ + +
+
+
+
+ +
+ + +
+
+
+ + +
+
+ + +
+
+ + + + Clear + +
+
+ + +
+
+ + +
+
+ Showing to + of + domain(s) +
+ +
+ + + + + + + + + +
+
+ + +
+ + + + + +
+ + +
+
+ + + + + +
+ +
+ +
+ + +
+ + + +
+ + Expires: ( days) +
+ + +
+ + +
+
+ +
+ + View + +
+ + +
+
+
+ +
+ +
+
+ +
+

No Domains Found

+

This tag is not currently assigned to any domains

+ + + Add Domains + +
+ +
+ + + 1): ?> +
+ +
+ Page of + +
+ + +
+ + + + 1): ?> + + + + + + + 1): ?> + + Previous + + + + + 1) { + echo '1'; + if ($start > 2) { + echo '...'; + } + } + + // Page numbers + for ($i = $start; $i <= $end; $i++) { + if ($i == $currentPage) { + echo '' . $i . ''; + } else { + echo '' . $i . ''; + } + } + + // Show last page + ellipsis if needed + if ($end < $totalPages) { + if ($end < $totalPages - 1) { + echo '...'; + } + echo '' . $totalPages . ''; + } + ?> + + + + + Next + + + + + + + + + +
+
+ + + diff --git a/database/migrations/020_create_tags_system.sql b/database/migrations/020_create_tags_system.sql new file mode 100644 index 0000000..02eacbb --- /dev/null +++ b/database/migrations/020_create_tags_system.sql @@ -0,0 +1,90 @@ +-- Create comprehensive tags system with user isolation support +-- This migration creates the tags table, domain_tags junction table, migrates existing data, and adds user isolation + +-- Create tags table for better tag management +CREATE TABLE IF NOT EXISTS tags ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) NOT NULL, + color VARCHAR(50) DEFAULT 'bg-gray-100 text-gray-700 border-gray-300', + description TEXT NULL, + usage_count INT DEFAULT 0, + user_id INT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL, + INDEX idx_name (name), + INDEX idx_usage_count (usage_count), + INDEX idx_user_id (user_id), + UNIQUE KEY unique_user_tag (user_id, name) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Create domain_tags junction table for many-to-many relationship +CREATE TABLE IF NOT EXISTS domain_tags ( + id INT AUTO_INCREMENT PRIMARY KEY, + domain_id INT NOT NULL, + tag_id INT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (domain_id) REFERENCES domains(id) ON DELETE CASCADE, + FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE, + UNIQUE KEY unique_domain_tag (domain_id, tag_id), + INDEX idx_domain_id (domain_id), + INDEX idx_tag_id (tag_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Insert default tags with their colors (global tags) +INSERT INTO tags (name, color, description, user_id) VALUES +('production', 'bg-green-100 text-green-700 border-green-300', 'Production environment domains', NULL), +('staging', 'bg-yellow-100 text-yellow-700 border-yellow-300', 'Staging environment domains', NULL), +('development', 'bg-blue-100 text-blue-700 border-blue-300', 'Development environment domains', NULL), +('client', 'bg-purple-100 text-purple-700 border-purple-300', 'Client-related domains', NULL), +('personal', 'bg-orange-100 text-orange-700 border-orange-300', 'Personal domains', NULL), +('archived', 'bg-gray-100 text-gray-700 border-gray-300', 'Archived or inactive domains', NULL) +ON DUPLICATE KEY UPDATE color = VALUES(color), description = VALUES(description); + +-- Migrate existing comma-separated tags to the new tag system +-- Create a temporary table to store the migration data +CREATE TEMPORARY TABLE temp_domain_tags AS +SELECT + d.id as domain_id, + TRIM(SUBSTRING_INDEX(SUBSTRING_INDEX(d.tags, ',', n.n), ',', -1)) as tag_name +FROM domains d +CROSS JOIN ( + SELECT 1 as n UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5 + UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9 UNION SELECT 10 +) n +WHERE d.tags IS NOT NULL + AND d.tags != '' + AND CHAR_LENGTH(d.tags) - CHAR_LENGTH(REPLACE(d.tags, ',', '')) >= n.n - 1 + AND TRIM(SUBSTRING_INDEX(SUBSTRING_INDEX(d.tags, ',', n.n), ',', -1)) != ''; + +-- Insert new tags that don't exist yet (assign to domain owner) +-- Note: If the same tag name is used by multiple users, only the first one will be created +-- due to the UNIQUE constraint on (user_id, name). This is intentional to avoid conflicts. +INSERT IGNORE INTO tags (name, color, description, user_id) +SELECT DISTINCT + tdt.tag_name, + 'bg-gray-100 text-gray-700 border-gray-300' as color, + CONCAT('Tag: ', tdt.tag_name) as description, + d.user_id +FROM temp_domain_tags tdt +JOIN domains d ON d.id = tdt.domain_id +WHERE tdt.tag_name NOT IN (SELECT name FROM tags); + +-- Insert domain-tag relationships +INSERT IGNORE INTO domain_tags (domain_id, tag_id) +SELECT + tdt.domain_id, + t.id as tag_id +FROM temp_domain_tags tdt +JOIN tags t ON t.name = tdt.tag_name; + +-- Update usage counts +UPDATE tags t +SET usage_count = ( + SELECT COUNT(*) + FROM domain_tags dt + WHERE dt.tag_id = t.id +); + +-- Drop the old tags column from domains table +ALTER TABLE domains DROP COLUMN tags; diff --git a/routes/web.php b/routes/web.php index 87479c7..5e2b8cd 100644 --- a/routes/web.php +++ b/routes/web.php @@ -16,6 +16,7 @@ use App\Controllers\InstallerController; use App\Controllers\NotificationController; use App\Controllers\ErrorLogController; use App\Controllers\TwoFactorController; +use App\Controllers\TagController; $router = Application::$router; @@ -70,6 +71,9 @@ $router->post('/domains/bulk-assign-group', [DomainController::class, 'bulkAssig $router->post('/domains/bulk-toggle-status', [DomainController::class, 'bulkToggleStatus']); $router->post('/domains/bulk-add-tags', [DomainController::class, 'bulkAddTags']); $router->post('/domains/bulk-remove-tags', [DomainController::class, 'bulkRemoveTags']); +$router->post('/domains/bulk-remove-specific-tag', [DomainController::class, 'bulkRemoveSpecificTag']); +$router->post('/domains/bulk-assign-existing-tag', [DomainController::class, 'bulkAssignExistingTag']); +$router->post('/domains/get-tags-for-domains', [DomainController::class, 'getTagsForDomains']); $router->post('/domains/transfer', [DomainController::class, 'transfer']); $router->post('/domains/bulk-transfer', [DomainController::class, 'bulkTransfer']); $router->post('/domains/store', [DomainController::class, 'store']); @@ -171,3 +175,14 @@ $router->post('/errors/{id}/delete', [ErrorLogController::class, 'delete']); $router->post('/errors/bulk-delete', [ErrorLogController::class, 'bulkDelete']); $router->post('/errors/clear-resolved', [ErrorLogController::class, 'clearResolved']); +// Tag Management +$router->get('/tags', [TagController::class, 'index']); +$router->post('/tags/create', [TagController::class, 'create']); +$router->post('/tags/update', [TagController::class, 'update']); +$router->post('/tags/delete', [TagController::class, 'delete']); +$router->post('/tags/bulk-delete', [TagController::class, 'bulkDelete']); +$router->get('/tags/{id}', [TagController::class, 'show']); +$router->post('/tags/bulk-add-to-domains', [TagController::class, 'bulkAddToDomains']); +$router->post('/tags/bulk-remove-from-domains', [TagController::class, 'bulkRemoveFromDomains']); + +