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:
@@ -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
424
app/Models/Tag.php
Normal 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)
|
||||
]
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user