From 06596b8044cbca56164054119fd356551a3a4fdd Mon Sep 17 00:00:00 2001
From: Hosteroid
Date: Sat, 25 Oct 2025 02:04:00 +0300
Subject: [PATCH] Replace comma-separated tags with relational tag system.
- Add tags and domain_tags tables
- Support tag management
- Support user isolation (global/private tags)
- Add filtering all domain views to operations
- Update all domain views automatically
---
app/Controllers/DomainController.php | 221 ++++++-
app/Controllers/InstallerController.php | 5 +-
app/Controllers/SettingsController.php | 9 +-
app/Controllers/TagController.php | 492 ++++++++++++++
app/Models/Domain.php | 128 +++-
app/Models/Tag.php | 424 ++++++++++++
app/Views/domains/bulk-add.php | 37 +-
app/Views/domains/create.php | 45 +-
app/Views/domains/edit.php | 47 +-
app/Views/domains/index.php | 316 +++++++--
app/Views/domains/view.php | 25 +-
app/Views/layout/sidebar.php | 5 +
app/Views/tags/index.php | 626 ++++++++++++++++++
app/Views/tags/view.php | 414 ++++++++++++
.../migrations/020_create_tags_system.sql | 90 +++
routes/web.php | 15 +
16 files changed, 2729 insertions(+), 170 deletions(-)
create mode 100644 app/Controllers/TagController.php
create mode 100644 app/Models/Tag.php
create mode 100644 app/Views/tags/index.php
create mode 100644 app/Views/tags/view.php
create mode 100644 database/migrations/020_create_tags_system.sql
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
+
+
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'];
+?>
+
+
+
+
+
+
+
+
+
+
+
+
+ Showing = $pagination['showing_from'] ?? 1 ?> to
+ = $pagination['showing_to'] ?? count($tags) ?> of
+ = $pagination['total'] ?? count($tags) ?> tag(s)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ = htmlspecialchars($tag['name']) ?>
+
+
+
+
+ Global
+
+
+
+
+
= htmlspecialchars($tag['description']) ?>
+
+
+
+ Used on = $tag['usage_count'] ?> domain= $tag['usage_count'] !== 1 ? 's' : '' ?>
+
+
+
+
+
+
+
+
+
Global tag
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
No Tags Yet
+
Start organizing your domains by creating your first tag
+
+
+
+
+
+
+ 1): ?>
+
+
+
+ Page = $pagination['current_page'] ?? 1 ?> of
+ = $pagination['total_pages'] ?? 1 ?>
+
+
+
+
+
+
+
+ 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'];
+?>
+
+
+
+
+
+
+
+
+
+
+ = htmlspecialchars($tag['name']) ?>
+
+
+
+
Tag Description
+
+
+ = htmlspecialchars($tag['description']) ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Showing = $pagination['showing_from'] ?? 1 ?> to
+ = $pagination['showing_to'] ?? count($domains) ?> of
+ = $pagination['total'] ?? count($domains) ?> domain(s)
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+
+ Domain = sortIcon('domain_name', $currentFilters['sort'], $currentFilters['order']) ?>
+
+ |
+
+
+ Registrar = sortIcon('registrar', $currentFilters['sort'], $currentFilters['order']) ?>
+
+ |
+
+
+ Expiration = sortIcon('expiration_date', $currentFilters['sort'], $currentFilters['order']) ?>
+
+ |
+
+
+ Status = sortIcon('status', $currentFilters['sort'], $currentFilters['order']) ?>
+
+ |
+
+
+ Last Checked = sortIcon('last_checked', $currentFilters['sort'], $currentFilters['order']) ?>
+
+ |
+ Actions |
+
+
+
+
+
+
+ |
+
+ |
+
+
+
+
+ = htmlspecialchars($domain['registrar']) ?>
+
+
+ Unknown
+
+ |
+
+
+
+
+ = date('M d, Y', strtotime($domain['expiration_date'])) ?>
+
+
+ = $daysLeft ?> days
+
+
+
+ Not set
+
+ |
+
+
+
+ = $statusText ?>
+
+ |
+
+
+
+
+ = date('M d, H:i', strtotime($domain['last_checked'])) ?>
+
+
+ Never
+
+ |
+
+
+
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ = htmlspecialchars($domain['registrar']) ?>
+
+
+
+
+
+
+ Expires: = date('M d, Y', strtotime($domain['expiration_date'])) ?> (= $daysLeft ?> days)
+
+
+
+
+
+ = $domain['last_checked'] ? date('M d, H:i', strtotime($domain['last_checked'])) : 'Never checked' ?>
+
+
+
+
+
+ View
+
+
+
+
+
+
+
+
+
+
+
+
No Domains Found
+
This tag is not currently assigned to any domains
+
+
+ Add Domains
+
+
+
+
+
+
+ 1): ?>
+
+
+
+ Page = $pagination['current_page'] ?? 1 ?> of
+ = $pagination['total_pages'] ?? 1 ?>
+
+
+
+
+
+
+
+ 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']);
+
+