diff --git a/src/db.ts b/src/db.ts index 879fd3b..4081380 100644 --- a/src/db.ts +++ b/src/db.ts @@ -60,6 +60,32 @@ export function initializeDatabase(instance: Database) { ); `); + // Create group_members table + instance.exec(` + CREATE TABLE IF NOT EXISTS group_members ( + group_id TEXT NOT NULL, + user_id TEXT NOT NULL, + is_admin BOOLEAN NOT NULL DEFAULT 0, + is_active BOOLEAN NOT NULL DEFAULT 1, + first_seen_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now')), + last_seen_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now')), + last_role_change_at TEXT NULL, + PRIMARY KEY (group_id, user_id), + FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + `); + + // Indexes for membership lookups + instance.exec(` + CREATE INDEX IF NOT EXISTS idx_group_members_group_active + ON group_members (group_id, is_active); + `); + instance.exec(` + CREATE INDEX IF NOT EXISTS idx_group_members_user_active + ON group_members (user_id, is_active); + `); + // Create tasks table instance.exec(` CREATE TABLE IF NOT EXISTS tasks ( diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index 95e74b1..61b2ede 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -147,5 +147,35 @@ export const migrations: Migration[] = [ ON user_preferences (reminder_freq, reminder_time); `); } + }, + { + version: 5, + name: 'group-membership', + checksum: 'v5-group-membership-2025-09-07', + up: (db: Database) => { + db.exec(`PRAGMA foreign_keys = ON;`); + db.exec(` + CREATE TABLE IF NOT EXISTS group_members ( + group_id TEXT NOT NULL, + user_id TEXT NOT NULL, + is_admin BOOLEAN NOT NULL DEFAULT 0, + is_active BOOLEAN NOT NULL DEFAULT 1, + first_seen_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now')), + last_seen_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now')), + last_role_change_at TEXT NULL, + PRIMARY KEY (group_id, user_id), + FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + `); + db.exec(` + CREATE INDEX IF NOT EXISTS idx_group_members_group_active + ON group_members (group_id, is_active); + `); + db.exec(` + CREATE INDEX IF NOT EXISTS idx_group_members_user_active + ON group_members (user_id, is_active); + `); + } } ]; diff --git a/src/services/group-sync.ts b/src/services/group-sync.ts index 5e550b1..139af23 100644 --- a/src/services/group-sync.ts +++ b/src/services/group-sync.ts @@ -1,5 +1,6 @@ import type { Database } from 'bun:sqlite'; -import { db } from '../db'; +import { db, ensureUserExists } from '../db'; +import { normalizeWhatsAppId } from '../utils/whatsapp'; // Environment variables will be mocked in tests const env = process.env; @@ -336,4 +337,178 @@ export class GroupSyncService { static isGroupActive(groupId: string): boolean { return this.activeGroupsCache.has(groupId); } + + // Fetch members for a single group from Evolution API. Uses a robust parser to accept multiple payload shapes. + private static async fetchGroupMembersFromAPI(groupId: string): Promise> { + const url = `${env.EVOLUTION_API_URL}/group/fetchAllGroups/${env.EVOLUTION_API_INSTANCE}?getParticipants=true`; + console.log('ℹ️ Fetching members via fetchAllGroups (participants=true):', { groupId }); + + const response = await fetch(url, { + method: 'GET', + headers: { apikey: env.EVOLUTION_API_KEY }, + httpVersion: '2', + timeout: 320000 + }); + if (!response.ok) { + const body = await response.text().catch(() => ''); + throw new Error(`Failed to fetch groups with participants: ${response.status} ${response.statusText} - ${body.slice(0,200)}`); + } + const raw = await response.text(); + let parsed: any; + try { + parsed = JSON.parse(raw); + } catch (e) { + console.error('❌ Failed to parse members response JSON:', String(e)); + throw e; + } + + let groups: any[] = []; + if (Array.isArray(parsed)) { + groups = parsed; + } else if (parsed && Array.isArray(parsed.response)) { + groups = parsed.response; + } else { + throw new Error('Invalid response format for groups with participants'); + } + + const g = groups.find((g: any) => g?.id === groupId); + if (!g) { + console.warn(`⚠️ Group ${groupId} not present in fetchAllGroups(getParticipants=true) response`); + return []; + } + const participants = Array.isArray(g.participants) ? g.participants : []; + + const result: Array<{ userId: string; isAdmin: boolean }> = []; + for (const p of participants) { + let jid: string | null = null; + let isAdmin = false; + + if (typeof p === 'string') { + jid = p; + } else if (p && typeof p === 'object') { + jid = p.id || p.jid || p.user || p?.user?.id || null; + if (typeof p.isAdmin === 'boolean') { + isAdmin = p.isAdmin; + } else if (typeof p.admin === 'string') { + // common shapes: 'admin', 'superadmin' or null + isAdmin = p.admin === 'admin' || p.admin === 'superadmin'; + } else if (typeof p.role === 'string') { + isAdmin = p.role.toLowerCase().includes('admin'); + } + } + + const norm = normalizeWhatsAppId(jid); + if (!norm) continue; + result.push({ userId: norm, isAdmin }); + } + return result; + } + + /** + * Reconciles current DB membership state for a group with a fresh snapshot. + * Idempotente y atómico por grupo. + */ + static reconcileGroupMembers(groupId: string, snapshot: Array<{ userId: string; isAdmin: boolean }>, nowIso?: string): { added: number; updated: number; deactivated: number } { + if (!groupId || !Array.isArray(snapshot)) { + throw new Error('Invalid arguments for reconcileGroupMembers'); + } + const now = nowIso || new Date().toISOString().replace('T', ' ').replace('Z', ''); + let added = 0, updated = 0, deactivated = 0; + + // Build quick lookup from snapshot + const incoming = new Map(); + for (const m of snapshot) { + if (!m?.userId) continue; + incoming.set(m.userId, { isAdmin: !!m.isAdmin }); + } + + this.dbInstance.transaction(() => { + // Load existing membership for group + const existingRows = this.dbInstance.prepare(` + SELECT user_id, is_admin, is_active + FROM group_members + WHERE group_id = ? + `).all(groupId) as Array<{ user_id: string; is_admin: number; is_active: number }>; + + const existing = new Map(existingRows.map(r => [r.user_id, { isAdmin: !!r.is_admin, isActive: !!r.is_active }])); + + // Upsert present members + for (const [userId, { isAdmin }] of incoming.entries()) { + // Ensure user exists (FK) + ensureUserExists(userId, this.dbInstance); + + const row = existing.get(userId); + if (!row) { + // insert + this.dbInstance.prepare(` + INSERT INTO group_members (group_id, user_id, is_admin, is_active, first_seen_at, last_seen_at) + VALUES (?, ?, ?, 1, ?, ?) + `).run(groupId, userId, isAdmin ? 1 : 0, now, now); + added++; + } else { + // update if needed + let roleChanged = row.isAdmin !== isAdmin; + if (!row.isActive || roleChanged) { + this.dbInstance.prepare(` + UPDATE group_members + SET is_active = 1, + is_admin = ?, + last_seen_at = ?, + last_role_change_at = CASE WHEN ? THEN ? ELSE last_role_change_at END + WHERE group_id = ? AND user_id = ? + `).run(isAdmin ? 1 : 0, now, roleChanged ? 1 : 0, roleChanged ? now : null, groupId, userId); + updated++; + } else { + // still update last_seen_at to reflect presence + this.dbInstance.prepare(` + UPDATE group_members + SET last_seen_at = ? + WHERE group_id = ? AND user_id = ? + `).run(now, groupId, userId); + } + } + } + + // Deactivate absent members + for (const [userId, state] of existing.entries()) { + if (!incoming.has(userId) && state.isActive) { + this.dbInstance.prepare(` + UPDATE group_members + SET is_active = 0, + last_seen_at = ? + WHERE group_id = ? AND user_id = ? + `).run(now, groupId, userId); + deactivated++; + } + } + })(); + + return { added, updated, deactivated }; + } + + /** + * Sync members for all active groups by calling Evolution API and reconciling. + * Devuelve contadores agregados. + */ + static async syncMembersForActiveGroups(): Promise<{ groups: number; added: number; updated: number; deactivated: number }> { + // ensure cache is populated + if (this.activeGroupsCache.size === 0) { + this.cacheActiveGroups(); + } + let groups = 0, added = 0, updated = 0, deactivated = 0; + for (const [groupId] of this.activeGroupsCache.entries()) { + try { + const snapshot = await this.fetchGroupMembersFromAPI(groupId); + const res = this.reconcileGroupMembers(groupId, snapshot); + groups++; + added += res.added; + updated += res.updated; + deactivated += res.deactivated; + } catch (e) { + console.error(`❌ Failed to sync members for group ${groupId}:`, e instanceof Error ? e.message : String(e)); + } + } + console.log('ℹ️ Members sync summary:', { groups, added, updated, deactivated }); + return { groups, added, updated, deactivated }; + } }