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
This commit is contained in:
Hosteroid
2025-10-25 02:04:00 +03:00
parent 75f0ae35fb
commit 06596b8044
16 changed files with 2729 additions and 170 deletions

View File

@@ -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;
}
}

424
app/Models/Tag.php Normal file
View File

@@ -0,0 +1,424 @@
<?php
namespace App\Models;
use Core\Model;
class Tag extends Model
{
protected static string $table = 'tags';
protected $fillable = ['name', 'color', 'description'];
/**
* Find tag by ID
*/
public function find(int $id): ?array
{
$sql = "SELECT * FROM tags WHERE id = ?";
$stmt = $this->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)
]
];
}
}