import { db, id, json, now, parseJson, visibleClause } from '../db.js'; import { hostGroupSchema } from '../forms/schemas.js'; import { camelHostGroup } from './mappers.js'; import { filterVisibleHostIds } from './hostModel.js'; function scopedGroupId(payload) { return payload.visibility === 'group' ? payload.groupId || null : null; } function normalizeHostGroup(body) { const parsed = hostGroupSchema.parse(body || {}); return { ...parsed, groupId: scopedGroupId(parsed), rules: parsed.mode === 'dynamic' ? parsed.rules : [], hostIds: parsed.mode === 'manual' ? parsed.hostIds : [] }; } export function listHostGroups(userId) { return db.prepare(` SELECT hg.*, g.name AS group_name, COALESCE((SELECT COUNT(*) FROM host_group_members hgm WHERE hgm.host_group_id = hg.id), 0) AS member_count, COALESCE((SELECT json_group_array(host_id) FROM host_group_members hgm WHERE hgm.host_group_id = hg.id), '[]') AS host_ids FROM host_groups hg LEFT JOIN groups g ON g.id = hg.group_id WHERE ${visibleClause('hg')} ORDER BY hg.name `).all(userId, userId).map(camelHostGroup); } export function getVisibleHostGroup(groupId, userId) { const row = db.prepare(` SELECT hg.*, g.name AS group_name, COALESCE((SELECT COUNT(*) FROM host_group_members hgm WHERE hgm.host_group_id = hg.id), 0) AS member_count, COALESCE((SELECT json_group_array(host_id) FROM host_group_members hgm WHERE hgm.host_group_id = hg.id), '[]') AS host_ids FROM host_groups hg LEFT JOIN groups g ON g.id = hg.group_id WHERE hg.id = ? AND ${visibleClause('hg')} `).get(groupId, userId, userId); return row ? camelHostGroup(row) : null; } export function createHostGroup(body, userId) { const payload = normalizeHostGroup(body); if (payload.mode === 'dynamic' && !payload.rules.length) throw new Error('Dynamic host groups require at least one rule.'); const groupId = id('hgrp'); db.prepare(` INSERT INTO host_groups ( id, name, description, mode, match_mode, rules, visibility, owner_user_id, group_id, created_by, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( groupId, payload.name, payload.description, payload.mode, payload.matchMode, json(payload.rules), payload.visibility, userId, payload.groupId, userId, now(), now() ); if (payload.mode === 'manual') replaceHostGroupMembers(groupId, filterVisibleHostIds(payload.hostIds, userId), 'manual'); else syncHostGroup(groupId); return getVisibleHostGroup(groupId, userId); } export function updateHostGroup(groupId, body, userId) { const existing = db.prepare(`SELECT * FROM host_groups WHERE id = ? AND ${visibleClause()}`).get(groupId, userId, userId); if (!existing) return null; const payload = normalizeHostGroup({ name: existing.name, description: existing.description || '', mode: existing.mode, matchMode: existing.match_mode, rules: parseJson(existing.rules, []), visibility: existing.visibility, groupId: existing.group_id, ...body }); if (payload.mode === 'dynamic' && !payload.rules.length) throw new Error('Dynamic host groups require at least one rule.'); db.prepare(` UPDATE host_groups SET name = ?, description = ?, mode = ?, match_mode = ?, rules = ?, visibility = ?, group_id = ?, updated_at = ? WHERE id = ? `).run( payload.name, payload.description, payload.mode, payload.matchMode, json(payload.rules), payload.visibility, payload.groupId, now(), groupId ); if (payload.mode === 'manual') replaceHostGroupMembers(groupId, filterVisibleHostIds(payload.hostIds, userId), 'manual'); else syncHostGroup(groupId); return getVisibleHostGroup(groupId, userId); } export function deleteHostGroup(groupId, userId) { db.prepare(`DELETE FROM host_groups WHERE id = ? AND ${visibleClause()}`).run(groupId, userId, userId); } export function filterVisibleHostGroupIds(groupIds = [], userId) { if (!groupIds.length) return []; const visible = new Set( db.prepare(`SELECT id FROM host_groups WHERE ${visibleClause()}`).all(userId, userId).map((row) => row.id) ); return groupIds.filter((groupId) => visible.has(groupId)); } export function syncHostGroup(groupId) { const group = db.prepare('SELECT * FROM host_groups WHERE id = ?').get(groupId); if (!group || group.mode !== 'dynamic') return { groupId, matched: 0, skipped: true }; const ownerId = group.owner_user_id || group.created_by; const hosts = ownerId ? db.prepare(`SELECT * FROM hosts WHERE ${visibleClause()} ORDER BY name`).all(ownerId, ownerId) : db.prepare("SELECT * FROM hosts WHERE visibility = 'shared' ORDER BY name").all(); const rules = parseJson(group.rules, []); const matched = hosts.filter((host) => hostMatchesRules(host, rules, group.match_mode)).map((host) => host.id); replaceHostGroupMembers(groupId, matched, 'rule'); db.prepare('UPDATE host_groups SET last_synced_at = ?, updated_at = ? WHERE id = ?').run(now(), now(), groupId); return { groupId, matched: matched.length, skipped: false }; } export function syncDynamicHostGroups() { const groups = db.prepare("SELECT id FROM host_groups WHERE mode = 'dynamic'").all(); const summary = { evaluated: 0, synced: 0, matched: 0, errors: 0 }; for (const group of groups) { summary.evaluated += 1; try { const result = syncHostGroup(group.id); if (!result.skipped) { summary.synced += 1; summary.matched += result.matched; } } catch { summary.errors += 1; } } return summary; } function replaceHostGroupMembers(groupId, hostIds, source) { db.prepare('DELETE FROM host_group_members WHERE host_group_id = ?').run(groupId); const stmt = db.prepare(` INSERT OR IGNORE INTO host_group_members (host_group_id, host_id, source, created_at) VALUES (?, ?, ?, ?) `); for (const hostId of [...new Set(hostIds)]) stmt.run(groupId, hostId, source, now()); } export function hostMatchesRules(host, rules = [], matchMode = 'all') { if (!rules.length) return false; const results = rules.map((rule) => hostMatchesRule(host, rule)); return matchMode === 'any' ? results.some(Boolean) : results.every(Boolean); } function hostMatchesRule(host, rule) { const values = hostRuleValues(host, rule.field); const needle = String(rule.value || '').toLowerCase(); return values.some((value) => compareRuleValue(String(value || '').toLowerCase(), needle, rule.operator)); } function hostRuleValues(host, field) { if (field === 'tags') return parseJson(host.tags, []); if (field === 'sourceType') return [host.source_type || 'manual']; if (field === 'osFamily') return [host.os_family || '']; return [host[field] || '']; } function compareRuleValue(value, needle, operator = 'contains') { if (operator === 'equals') return value === needle; if (operator === 'startsWith') return value.startsWith(needle); if (operator === 'endsWith') return value.endsWith(needle); return value.includes(needle); }