|
|
import type { Database } from 'bun:sqlite';
|
|
|
import { db, ensureUserExists } from '../db';
|
|
|
import { normalizeWhatsAppId } from '../utils/whatsapp';
|
|
|
import { Metrics } from './metrics';
|
|
|
import { IdentityService } from './identity';
|
|
|
import { AllowedGroups } from './allowed-groups';
|
|
|
|
|
|
// In-memory cache for active groups
|
|
|
// const activeGroupsCache = new Map<string, string>(); // groupId -> groupName
|
|
|
|
|
|
/**
|
|
|
* Represents a group from the Evolution API response
|
|
|
*
|
|
|
* API returns an array of groups in this format:
|
|
|
* [
|
|
|
* {
|
|
|
* id: string, // Group ID in @g.us format (primary key)
|
|
|
* subject: string, // Group name (displayed to users)
|
|
|
* linkedParent?: string, // Parent community ID if group belongs to one
|
|
|
* size?: number, // Current member count (unused in our system)
|
|
|
* creation?: number, // Unix timestamp of group creation (unused)
|
|
|
* desc?: string, // Group description text (unused)
|
|
|
* // ...other fields exist but are ignored by our implementation
|
|
|
* }
|
|
|
* ]
|
|
|
*
|
|
|
* Required fields for our implementation:
|
|
|
* - id (used as database primary key)
|
|
|
* - subject (used as group display name)
|
|
|
* - linkedParent (used for community filtering)
|
|
|
*/
|
|
|
type EvolutionGroup = {
|
|
|
id: string;
|
|
|
subject: string;
|
|
|
linkedParent?: string;
|
|
|
};
|
|
|
|
|
|
export class GroupSyncService {
|
|
|
// Static property for DB instance injection (defaults to global db)
|
|
|
static dbInstance: Database = db;
|
|
|
|
|
|
// In-memory cache for active groups (made public for tests)
|
|
|
public static readonly activeGroupsCache = new Map<string, string>(); // groupId -> groupName
|
|
|
|
|
|
/**
|
|
|
* Gets the sync interval duration in milliseconds.
|
|
|
*
|
|
|
* Priority:
|
|
|
* 1. GROUP_SYNC_INTERVAL_MS environment variable if set
|
|
|
* 2. Default 24 hour interval
|
|
|
*
|
|
|
* In development mode, enforces minimum 10 second interval
|
|
|
* to prevent accidental excessive API calls.
|
|
|
*
|
|
|
* @returns {number} Sync interval in milliseconds
|
|
|
*/
|
|
|
private static get SYNC_INTERVAL_MS(): number {
|
|
|
const interval = process.env.GROUP_SYNC_INTERVAL_MS
|
|
|
? Number(process.env.GROUP_SYNC_INTERVAL_MS)
|
|
|
: 24 * 60 * 60 * 1000; // Default 24 hours
|
|
|
|
|
|
// Ensure minimum 10 second interval in development
|
|
|
if (process.env.NODE_ENV === 'development' && interval < 10000) {
|
|
|
console.warn(`Sync interval too low (${interval}ms), using 10s minimum`);
|
|
|
return 10000;
|
|
|
}
|
|
|
return interval;
|
|
|
}
|
|
|
private static lastSyncAttempt = 0;
|
|
|
private static _groupsTimer: any = null;
|
|
|
private static _groupsSchedulerRunning = false;
|
|
|
private static _membersTimer: any = null;
|
|
|
private static _membersSchedulerRunning = false;
|
|
|
private static _groupsIntervalMs: number | null = null;
|
|
|
private static _groupsNextTickAt: number | null = null;
|
|
|
|
|
|
static async syncGroups(force: boolean = false): Promise<{ added: number; updated: number }> {
|
|
|
if (!this.shouldSync(force)) {
|
|
|
return { added: 0, updated: 0 };
|
|
|
}
|
|
|
const startedAt = Date.now();
|
|
|
Metrics.inc('sync_runs_total');
|
|
|
|
|
|
try {
|
|
|
const groups = await this.fetchGroupsFromAPI();
|
|
|
console.log('ℹ️ Grupos crudos de la API:', JSON.stringify(groups, null, 2));
|
|
|
console.log('ℹ️ Sin filtrar por comunidad (modo multicomunidad). Total grupos:', groups.length);
|
|
|
|
|
|
const dbGroupsBefore = this.dbInstance.prepare('SELECT id, active FROM groups').all();
|
|
|
console.log('ℹ️ Grupos en DB antes de upsert:', dbGroupsBefore);
|
|
|
|
|
|
const result = await this.upsertGroups(groups);
|
|
|
|
|
|
const dbGroupsAfter = this.dbInstance.prepare('SELECT id, active FROM groups').all();
|
|
|
console.log('ℹ️ Grupos en DB después de upsert:', dbGroupsAfter);
|
|
|
|
|
|
// Completar labels faltantes en allowed_groups usando todos los grupos devueltos por la API
|
|
|
try { (AllowedGroups as any).dbInstance = this.dbInstance; this.fillMissingAllowedGroupLabels(groups); } catch {}
|
|
|
|
|
|
// Actualizar métricas
|
|
|
this.cacheActiveGroups();
|
|
|
Metrics.set('active_groups', this.activeGroupsCache.size);
|
|
|
const rowM = this.dbInstance.prepare(`SELECT COUNT(*) AS c FROM group_members WHERE is_active = 1`).get() as any;
|
|
|
Metrics.set('active_members', Number(rowM?.c || 0));
|
|
|
Metrics.set('last_sync_timestamp_seconds', Math.floor(Date.now() / 1000));
|
|
|
Metrics.set('last_sync_ok', 1);
|
|
|
// Duración opcional
|
|
|
Metrics.set('last_sync_duration_ms', Date.now() - (typeof startedAt !== 'undefined' ? startedAt : Date.now()));
|
|
|
|
|
|
return result;
|
|
|
} catch (error) {
|
|
|
console.error('Group sync failed:', error);
|
|
|
Metrics.inc('sync_errors_total');
|
|
|
Metrics.set('last_sync_ok', 0);
|
|
|
throw error;
|
|
|
} finally {
|
|
|
this.lastSyncAttempt = Date.now();
|
|
|
}
|
|
|
}
|
|
|
|
|
|
private static shouldSync(force: boolean = false): boolean {
|
|
|
if (force) return true;
|
|
|
const timeSinceLastSync = Date.now() - this.lastSyncAttempt;
|
|
|
const shouldSync = timeSinceLastSync > this.SYNC_INTERVAL_MS;
|
|
|
|
|
|
if (!shouldSync) {
|
|
|
const nextSyncIn = this.SYNC_INTERVAL_MS - timeSinceLastSync;
|
|
|
console.debug(`Next sync available in ${Math.round(nextSyncIn / 1000)} seconds`);
|
|
|
}
|
|
|
|
|
|
return shouldSync;
|
|
|
}
|
|
|
|
|
|
private static async fetchGroupsFromAPI(): Promise<EvolutionGroup[]> {
|
|
|
const url = `${process.env.EVOLUTION_API_URL}/group/fetchAllGroups/${process.env.EVOLUTION_API_INSTANCE}?getParticipants=false`;
|
|
|
console.log('ℹ️ Fetching groups from API:', {
|
|
|
url: `${url}...`, // Log partial URL for security
|
|
|
communityId: process.env.WHATSAPP_COMMUNITY_ID,
|
|
|
time: new Date().toISOString()
|
|
|
});
|
|
|
|
|
|
try {
|
|
|
const response = await fetch(url, {
|
|
|
method: 'GET',
|
|
|
headers: {
|
|
|
apikey: process.env.EVOLUTION_API_KEY,
|
|
|
},
|
|
|
httpVersion: '2',
|
|
|
timeout: 320000 // 120 second timeout
|
|
|
});
|
|
|
|
|
|
if (!response.ok) {
|
|
|
const errorBody = await response.text().catch(() => 'Unable to read error body');
|
|
|
console.error('❌ API request failed:', {
|
|
|
status: response.status,
|
|
|
statusText: response.statusText,
|
|
|
headers: Object.fromEntries(response.headers.entries()),
|
|
|
body: errorBody
|
|
|
});
|
|
|
throw new Error(`API request failed: ${response.status} ${response.statusText}`);
|
|
|
}
|
|
|
|
|
|
const rawResponse = await response.text();
|
|
|
console.log('ℹ️ Raw API response length:', rawResponse.length);
|
|
|
|
|
|
// Parse response which could be either:
|
|
|
// 1. Direct array of groups: [{group1}, {group2}]
|
|
|
// 2. Or wrapped response: {status, message, response}
|
|
|
let groups;
|
|
|
try {
|
|
|
const parsed = JSON.parse(rawResponse);
|
|
|
if (Array.isArray(parsed)) {
|
|
|
// Case 1: Direct array response
|
|
|
groups = parsed;
|
|
|
console.log('ℹ️ Received direct array of', groups.length, 'groups');
|
|
|
} else if (parsed.response && Array.isArray(parsed.response)) {
|
|
|
// Case 2: Wrapped response
|
|
|
if (parsed.status !== 'success') {
|
|
|
throw new Error(`API error: ${parsed.message || 'Unknown error'}`);
|
|
|
}
|
|
|
groups = parsed.response;
|
|
|
console.log('ℹ️ Received wrapped response with', groups.length, 'groups');
|
|
|
} else {
|
|
|
throw new Error('Invalid API response format - expected array or wrapped response');
|
|
|
}
|
|
|
} catch (e) {
|
|
|
console.error('❌ Failed to parse API response:', {
|
|
|
error: e instanceof Error ? e.message : String(e),
|
|
|
responseSample: rawResponse.substring(0, 100) + '...'
|
|
|
});
|
|
|
throw e;
|
|
|
}
|
|
|
|
|
|
if (!groups.length) {
|
|
|
console.warn('⚠️ API returned empty group list');
|
|
|
}
|
|
|
return groups;
|
|
|
} catch (error) {
|
|
|
console.error('❌ Failed to fetch groups:', {
|
|
|
error: error instanceof Error ? error.message : String(error),
|
|
|
stack: error instanceof Error ? error.stack : undefined
|
|
|
});
|
|
|
throw error;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
private static cacheActiveGroups(): void {
|
|
|
const groups = this.dbInstance.prepare('SELECT id, name FROM groups WHERE active = TRUE').all();
|
|
|
this.activeGroupsCache.clear();
|
|
|
for (const group of groups) {
|
|
|
this.activeGroupsCache.set(group.id, group.name);
|
|
|
}
|
|
|
console.log(`Cached ${this.activeGroupsCache.size} active groups`);
|
|
|
}
|
|
|
|
|
|
// Rellena labels faltantes en allowed_groups a partir de los grupos devueltos por la API.
|
|
|
private static fillMissingAllowedGroupLabels(allGroups: EvolutionGroup[]): number {
|
|
|
try {
|
|
|
if (!Array.isArray(allGroups) || allGroups.length === 0) return 0;
|
|
|
const nameById = new Map<string, string>();
|
|
|
for (const g of allGroups) {
|
|
|
if (!g?.id) continue;
|
|
|
const name = String(g.subject || '').trim();
|
|
|
if (!name) continue;
|
|
|
nameById.set(String(g.id), name);
|
|
|
}
|
|
|
if (nameById.size === 0) return 0;
|
|
|
|
|
|
const rows = this.dbInstance.prepare(`
|
|
|
SELECT group_id AS id
|
|
|
FROM allowed_groups
|
|
|
WHERE label IS NULL OR TRIM(label) = ''
|
|
|
`).all() as any[];
|
|
|
if (!rows || rows.length === 0) return 0;
|
|
|
|
|
|
let filled = 0;
|
|
|
for (const r of rows) {
|
|
|
const id = r?.id ? String(r.id) : null;
|
|
|
if (!id) continue;
|
|
|
const label = nameById.get(id);
|
|
|
if (label) {
|
|
|
try { (AllowedGroups as any).dbInstance = this.dbInstance; AllowedGroups.upsertPending(id, label, null); } catch {}
|
|
|
filled++;
|
|
|
}
|
|
|
}
|
|
|
if (filled > 0) {
|
|
|
try { Metrics.inc('allowed_groups_labels_filled_total', filled); } catch {}
|
|
|
console.log(`ℹ️ Rellenadas ${filled} labels faltantes en allowed_groups`);
|
|
|
}
|
|
|
return filled;
|
|
|
} catch (e) {
|
|
|
console.warn('⚠️ No se pudieron rellenar labels faltantes en allowed_groups:', e);
|
|
|
return 0;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
private static getActiveGroupsCount(): number {
|
|
|
const result = this.dbInstance.prepare('SELECT COUNT(*) as count FROM groups WHERE active = TRUE').get();
|
|
|
return result?.count || 0;
|
|
|
}
|
|
|
|
|
|
static async checkInitialGroups(): Promise<void> {
|
|
|
const count = this.getActiveGroupsCount();
|
|
|
if (count > 0) {
|
|
|
this.cacheActiveGroups();
|
|
|
console.log(`✅ Using ${count} existing groups from database`);
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
const communityId = process.env.WHATSAPP_COMMUNITY_ID;
|
|
|
if (!communityId) {
|
|
|
console.log('ℹ️ WHATSAPP_COMMUNITY_ID no definido - mostrando comunidades disponibles');
|
|
|
try {
|
|
|
const allGroups = await this.fetchGroupsFromAPI();
|
|
|
const communities = allGroups.filter(g => g.linkedParent);
|
|
|
|
|
|
if (communities.length === 0) {
|
|
|
console.log('❌ No se encontraron comunidades (grupos con linkedParent)');
|
|
|
} else {
|
|
|
console.log('\n📋 Comunidades disponibles (copia el ID completo):');
|
|
|
console.log('='.repeat(80));
|
|
|
console.log('Nombre'.padEnd(30), 'ID Comunidad');
|
|
|
console.log('-'.repeat(30), '-'.repeat(48));
|
|
|
communities.forEach(c => {
|
|
|
console.log(c.subject.padEnd(30), c.id);
|
|
|
});
|
|
|
console.log('='.repeat(80));
|
|
|
console.log('⚠️ ATENCIÓN: Estos IDs son sensibles. No los compartas públicamente.');
|
|
|
console.log(`\n⏳ El proceso terminará automáticamente en 120 segundos...`);
|
|
|
|
|
|
// Cuenta regresiva de 120 segundos
|
|
|
await new Promise(resolve => {
|
|
|
setTimeout(resolve, 120000);
|
|
|
const interval = setInterval(() => {
|
|
|
const remaining = Math.ceil((120000 - (Date.now() - startTime)) / 1000);
|
|
|
process.stdout.write(`\r⏳ Tiempo restante: ${remaining}s `);
|
|
|
}, 1000);
|
|
|
const startTime = Date.now();
|
|
|
});
|
|
|
|
|
|
console.log('\n\n✅ Listado completado. Por favor configura WHATSAPP_COMMUNITY_ID');
|
|
|
}
|
|
|
process.exit(0);
|
|
|
} catch (error) {
|
|
|
console.error('❌ Error al obtener comunidades:', error instanceof Error ? error.message : error);
|
|
|
process.exit(1);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
console.log('⚠️ No groups found in database - performing initial sync');
|
|
|
try {
|
|
|
const { added } = await this.syncGroups();
|
|
|
if (added === 0) {
|
|
|
throw new Error('Initial group sync completed but no groups were added');
|
|
|
}
|
|
|
this.cacheActiveGroups();
|
|
|
console.log(`✅ Initial group sync completed - added ${added} groups`);
|
|
|
} catch (error) {
|
|
|
console.error('❌ Critical: Initial group sync failed - no groups available');
|
|
|
console.error(error instanceof Error ? error.message : 'Unknown error');
|
|
|
process.exit(1);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
private static async upsertGroups(groups: EvolutionGroup[]): Promise<{ added: number; updated: number }> {
|
|
|
let added = 0;
|
|
|
let updated = 0;
|
|
|
|
|
|
const transactionResult = this.dbInstance.transaction(() => {
|
|
|
// First mark all groups as inactive and update verification timestamp
|
|
|
const inactiveResult = this.dbInstance.prepare(`
|
|
|
UPDATE groups
|
|
|
SET active = FALSE,
|
|
|
last_verified = CURRENT_TIMESTAMP
|
|
|
WHERE active = TRUE
|
|
|
`).run();
|
|
|
console.log('ℹ️ Grupos marcados como inactivos:', {
|
|
|
count: inactiveResult.changes,
|
|
|
lastId: inactiveResult.lastInsertRowid
|
|
|
});
|
|
|
|
|
|
for (const group of groups) {
|
|
|
const existing = this.dbInstance.prepare('SELECT 1 FROM groups WHERE id = ?').get(group.id);
|
|
|
console.log('Checking group:', group.id, 'exists:', !!existing);
|
|
|
|
|
|
if (existing) {
|
|
|
const updateResult = this.dbInstance.prepare(
|
|
|
'UPDATE groups SET name = ?, community_id = COALESCE(?, community_id), active = TRUE, last_verified = CURRENT_TIMESTAMP WHERE id = ?'
|
|
|
).run(group.subject, group.linkedParent || null, group.id);
|
|
|
console.log('Updated group:', group.id, 'result:', updateResult);
|
|
|
updated++;
|
|
|
} else {
|
|
|
const insertResult = this.dbInstance.prepare(
|
|
|
'INSERT INTO groups (id, community_id, name, active) VALUES (?, ?, ?, TRUE)'
|
|
|
).run(group.id, (group.linkedParent ?? ''), group.subject);
|
|
|
console.log('Added group:', group.id, 'result:', insertResult);
|
|
|
added++;
|
|
|
}
|
|
|
// Propagar subject como label a allowed_groups (no degrada estado; actualiza label si cambia)
|
|
|
try { (AllowedGroups as any).dbInstance = this.dbInstance; AllowedGroups.upsertPending(group.id, group.subject, null); } catch {}
|
|
|
}
|
|
|
|
|
|
return { added, updated };
|
|
|
});
|
|
|
|
|
|
try {
|
|
|
const result = transactionResult();
|
|
|
console.log(`Group sync completed: ${result.added} added, ${result.updated} updated`);
|
|
|
return result;
|
|
|
} catch (error) {
|
|
|
console.error('Error in upsertGroups:', error);
|
|
|
throw error;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* Checks if a given group ID is active based on the in-memory cache.
|
|
|
*
|
|
|
* @param groupId The group ID to check (e.g., '123456789@g.us').
|
|
|
* @returns True if the group is active, false otherwise.
|
|
|
*/
|
|
|
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<Array<{ userId: string; isAdmin: boolean }>> {
|
|
|
// En tests se recomienda simular fetch; no retornamos temprano para permitir validar el parser
|
|
|
|
|
|
// 1) Intento preferente: endpoint de Evolution "Find Group Members"
|
|
|
// Documentación provista: GET /group/participants/{instance}
|
|
|
// Suponemos soporte de query param groupJid
|
|
|
try {
|
|
|
const url1 = `${process.env.EVOLUTION_API_URL}/group/participants/${process.env.EVOLUTION_API_INSTANCE}?groupJid=${encodeURIComponent(groupId)}`;
|
|
|
console.log('ℹ️ Fetching members via /group/participants:', { groupId });
|
|
|
|
|
|
const r1 = await fetch(url1, {
|
|
|
method: 'GET',
|
|
|
headers: { apikey: process.env.EVOLUTION_API_KEY },
|
|
|
httpVersion: '2',
|
|
|
timeout: 320000
|
|
|
});
|
|
|
|
|
|
if (r1.ok) {
|
|
|
const raw1 = await r1.text();
|
|
|
let parsed1: any;
|
|
|
try {
|
|
|
parsed1 = JSON.parse(raw1);
|
|
|
} catch (e) {
|
|
|
console.error('❌ Failed to parse /group/participants JSON:', String(e));
|
|
|
throw e;
|
|
|
}
|
|
|
|
|
|
const participantsArr = Array.isArray(parsed1?.participants) ? parsed1.participants : null;
|
|
|
if (participantsArr) {
|
|
|
const result: Array<{ userId: string; isAdmin: boolean }> = [];
|
|
|
for (const p of participantsArr) {
|
|
|
let jid: string | null = null;
|
|
|
let isAdmin = false;
|
|
|
|
|
|
if (typeof p === 'string') {
|
|
|
jid = p;
|
|
|
} else if (p && typeof p === 'object') {
|
|
|
const rawId = p.id || p?.user?.id || p.user || null;
|
|
|
const rawJid = p.jid || null; // preferir .jid cuando exista
|
|
|
jid = rawJid || rawId || null;
|
|
|
|
|
|
// Aprender mapping alias→número si vienen ambos
|
|
|
if (rawId && rawJid) {
|
|
|
try { IdentityService.upsertAlias(String(rawId), String(rawJid), 'group.participants'); } catch {}
|
|
|
}
|
|
|
|
|
|
if (typeof p.isAdmin === 'boolean') {
|
|
|
isAdmin = p.isAdmin;
|
|
|
} else if (typeof p.admin === 'string') {
|
|
|
isAdmin = p.admin === 'admin' || p.admin === 'superadmin';
|
|
|
} else if (typeof p.role === 'string') {
|
|
|
isAdmin = p.role.toLowerCase().includes('admin');
|
|
|
}
|
|
|
}
|
|
|
|
|
|
let norm = normalizeWhatsAppId(jid);
|
|
|
if (!norm) {
|
|
|
const digits = (jid || '').replace(/\D+/g, '');
|
|
|
norm = digits || null;
|
|
|
}
|
|
|
if (!norm) continue;
|
|
|
result.push({ userId: norm, isAdmin });
|
|
|
}
|
|
|
let resolved: Array<{ userId: string; isAdmin: boolean }>;
|
|
|
try {
|
|
|
const map = IdentityService.resolveMany(result.map(r => r.userId));
|
|
|
resolved = result.map(r => ({ userId: map.get(r.userId) || r.userId, isAdmin: r.isAdmin }));
|
|
|
} catch {
|
|
|
resolved = result;
|
|
|
}
|
|
|
return resolved;
|
|
|
}
|
|
|
// Si no viene en el formato esperado, caemos al plan B
|
|
|
console.warn('⚠️ /group/participants responded without participants array, falling back to fetchAllGroups');
|
|
|
} else {
|
|
|
const body = await r1.text().catch(() => '');
|
|
|
console.warn(`⚠️ /group/participants failed: ${r1.status} ${r1.statusText} - ${body.slice(0, 200)}. Falling back to fetchAllGroups`);
|
|
|
}
|
|
|
} catch (e) {
|
|
|
console.warn('⚠️ Error calling /group/participants, falling back to fetchAllGroups:', e instanceof Error ? e.message : String(e));
|
|
|
}
|
|
|
|
|
|
// 2) Fallback robusto: fetchAllGroups(getParticipants=true) y filtrar por groupId
|
|
|
const url = `${process.env.EVOLUTION_API_URL}/group/fetchAllGroups/${process.env.EVOLUTION_API_INSTANCE}?getParticipants=true`;
|
|
|
console.log('ℹ️ Fetching members via fetchAllGroups (participants=true):', { groupId });
|
|
|
|
|
|
const response = await fetch(url, {
|
|
|
method: 'GET',
|
|
|
headers: { apikey: process.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') {
|
|
|
const rawId = p.id || p?.user?.id || p.user || null;
|
|
|
const rawJid = p.jid || null; // preferir .jid cuando exista
|
|
|
jid = rawJid || rawId || null;
|
|
|
|
|
|
// Aprender mapping alias→número si vienen ambos
|
|
|
if (rawId && rawJid) {
|
|
|
try { IdentityService.upsertAlias(String(rawId), String(rawJid), 'group.participants'); } catch {}
|
|
|
}
|
|
|
|
|
|
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');
|
|
|
}
|
|
|
}
|
|
|
|
|
|
let norm = normalizeWhatsAppId(jid);
|
|
|
if (!norm) {
|
|
|
const digits = (jid || '').replace(/\D+/g, '');
|
|
|
norm = digits || null;
|
|
|
}
|
|
|
if (!norm) continue;
|
|
|
result.push({ userId: norm, isAdmin });
|
|
|
}
|
|
|
let resolved: Array<{ userId: string; isAdmin: boolean }>;
|
|
|
try {
|
|
|
const map = IdentityService.resolveMany(result.map(r => r.userId));
|
|
|
resolved = result.map(r => ({ userId: map.get(r.userId) || r.userId, isAdmin: r.isAdmin }));
|
|
|
} catch {
|
|
|
resolved = result;
|
|
|
}
|
|
|
return resolved;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 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<string, { isAdmin: boolean }>();
|
|
|
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++;
|
|
|
}
|
|
|
}
|
|
|
})();
|
|
|
|
|
|
try { this.computeAndPublishAliasCoverage(groupId); } catch {}
|
|
|
return { added, updated, deactivated };
|
|
|
}
|
|
|
|
|
|
private static computeAndPublishAliasCoverage(groupId: string): void {
|
|
|
try {
|
|
|
const rows = this.dbInstance.prepare(`
|
|
|
SELECT user_id
|
|
|
FROM group_members
|
|
|
WHERE group_id = ? AND is_active = 1
|
|
|
`).all(groupId) as Array<{ user_id: string }>;
|
|
|
|
|
|
const total = rows.length;
|
|
|
if (total === 0) {
|
|
|
try { Metrics.set('alias_coverage_ratio', 1, { group_id: groupId }); } catch {}
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
let resolvable = 0;
|
|
|
for (const r of rows) {
|
|
|
const uid = String(r.user_id || '');
|
|
|
if (/^\d+$/.test(uid)) {
|
|
|
resolvable++;
|
|
|
continue;
|
|
|
}
|
|
|
try {
|
|
|
const resolved = IdentityService.resolveAliasOrNull(uid);
|
|
|
if (resolved && /^\d+$/.test(resolved)) {
|
|
|
resolvable++;
|
|
|
}
|
|
|
} catch {}
|
|
|
}
|
|
|
const ratio = Math.max(0, Math.min(1, total > 0 ? resolvable / total : 1));
|
|
|
try { Metrics.set('alias_coverage_ratio', ratio, { group_id: groupId }); } catch {}
|
|
|
|
|
|
// A3: publicación condicional del mensaje de onboarding (sin spam)
|
|
|
try {
|
|
|
// Flags y parámetros
|
|
|
const isTest = String(process.env.NODE_ENV || '').toLowerCase() === 'test';
|
|
|
const enabled =
|
|
|
isTest
|
|
|
? String(process.env.ONBOARDING_ENABLE_IN_TEST || '').toLowerCase() === 'true'
|
|
|
: (() => {
|
|
|
const v = process.env.ONBOARDING_PROMPTS_ENABLED;
|
|
|
return v == null ? true : ['true', '1', 'yes'].includes(String(v).toLowerCase());
|
|
|
})();
|
|
|
|
|
|
if (!enabled) {
|
|
|
try { Metrics.inc('onboarding_prompts_skipped_total', 1, { group_id: groupId, reason: 'disabled' }); } catch {}
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
const thrRaw = Number(process.env.ONBOARDING_COVERAGE_THRESHOLD);
|
|
|
const threshold = Number.isFinite(thrRaw) ? Math.min(1, Math.max(0, thrRaw)) : 1;
|
|
|
if (ratio >= threshold) {
|
|
|
try { Metrics.inc('onboarding_prompts_skipped_total', 1, { group_id: groupId, reason: 'coverage_100' }); } catch {}
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
// Gating en modo enforce: no publicar en grupos no allowed
|
|
|
try {
|
|
|
const mode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase();
|
|
|
if (mode === 'enforce') {
|
|
|
try { (AllowedGroups as any).dbInstance = this.dbInstance; } catch {}
|
|
|
if (!AllowedGroups.isAllowed(groupId)) {
|
|
|
try { Metrics.inc('onboarding_prompts_skipped_total', 1, { group_id: groupId, reason: 'not_allowed' }); } catch {}
|
|
|
return;
|
|
|
}
|
|
|
}
|
|
|
} catch {}
|
|
|
|
|
|
// Grace y cooldown desde tabla groups
|
|
|
const rowG = this.dbInstance.prepare(`
|
|
|
SELECT last_verified, onboarding_prompted_at
|
|
|
FROM groups
|
|
|
WHERE id = ?
|
|
|
`).get(groupId) as any;
|
|
|
|
|
|
const nowMs = Date.now();
|
|
|
const graceRaw = Number(process.env.ONBOARDING_GRACE_SECONDS);
|
|
|
const graceSec = Number.isFinite(graceRaw) && graceRaw >= 0 ? Math.floor(graceRaw) : 90;
|
|
|
|
|
|
const lv = rowG?.last_verified ? String(rowG.last_verified) : null;
|
|
|
if (lv) {
|
|
|
const iso = lv.includes('T') ? lv : (lv.replace(' ', 'T') + 'Z');
|
|
|
const ms = Date.parse(iso);
|
|
|
if (Number.isFinite(ms)) {
|
|
|
const ageSec = Math.floor((nowMs - ms) / 1000);
|
|
|
if (ageSec < graceSec) {
|
|
|
try { Metrics.inc('onboarding_prompts_skipped_total', 1, { group_id: groupId, reason: 'grace_period' }); } catch {}
|
|
|
return;
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
const cdRaw = Number(process.env.ONBOARDING_COOLDOWN_DAYS);
|
|
|
const cdDays = Number.isFinite(cdRaw) && cdRaw >= 0 ? Math.floor(cdRaw) : 7;
|
|
|
const promptedAt = rowG?.onboarding_prompted_at ? String(rowG.onboarding_prompted_at) : null;
|
|
|
if (promptedAt) {
|
|
|
const iso = promptedAt.includes('T') ? promptedAt : (promptedAt.replace(' ', 'T') + 'Z');
|
|
|
const ms = Date.parse(iso);
|
|
|
if (Number.isFinite(ms)) {
|
|
|
const diffMs = nowMs - ms;
|
|
|
if (diffMs < cdDays * 24 * 60 * 60 * 1000) {
|
|
|
try { Metrics.inc('onboarding_prompts_skipped_total', 1, { group_id: groupId, reason: 'cooldown_active' }); } catch {}
|
|
|
return;
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// Número del bot para construir wa.me
|
|
|
const bot = String(process.env.CHATBOT_PHONE_NUMBER || '').trim();
|
|
|
if (!bot || !/^\d+$/.test(bot)) {
|
|
|
try { Metrics.inc('onboarding_prompts_skipped_total', 1, { group_id: groupId, reason: 'missing_bot_number' }); } catch {}
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
// Encolar mensaje en la cola persistente y marcar timestamp en groups
|
|
|
const msg = `Para poder asignarte tareas y acceder a la web, envía 'activar' al bot por privado. Solo hace falta una vez. Enlace: https://wa.me/${bot}`;
|
|
|
this.dbInstance.transaction(() => {
|
|
|
this.dbInstance.prepare(`
|
|
|
INSERT INTO response_queue (recipient, message, status, attempts, metadata, created_at, updated_at, next_attempt_at)
|
|
|
VALUES (?, ?, 'queued', 0, NULL, strftime('%Y-%m-%d %H:%M:%f','now'), strftime('%Y-%m-%d %H:%M:%f','now'), strftime('%Y-%m-%d %H:%M:%f','now'))
|
|
|
`).run(groupId, msg);
|
|
|
this.dbInstance.prepare(`
|
|
|
UPDATE groups
|
|
|
SET onboarding_prompted_at = strftime('%Y-%m-%d %H:%M:%f','now')
|
|
|
WHERE id = ?
|
|
|
`).run(groupId);
|
|
|
})();
|
|
|
|
|
|
try { Metrics.inc('onboarding_prompts_sent_total', 1, { group_id: groupId, reason: 'coverage_below_threshold' }); } catch {}
|
|
|
} catch (e) {
|
|
|
// Evitar romper el flujo si falla el encolado
|
|
|
if (process.env.NODE_ENV !== 'test') {
|
|
|
console.warn('⚠️ Onboarding prompt skipped due to internal error for', groupId, e);
|
|
|
}
|
|
|
}
|
|
|
} catch (e) {
|
|
|
console.warn('⚠️ No se pudo calcular alias_coverage_ratio para', groupId, e);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 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 }> {
|
|
|
if (process.env.NODE_ENV === 'test') {
|
|
|
return { groups: 0, added: 0, updated: 0, deactivated: 0 };
|
|
|
}
|
|
|
// ensure cache is populated
|
|
|
if (this.activeGroupsCache.size === 0) {
|
|
|
this.cacheActiveGroups();
|
|
|
}
|
|
|
|
|
|
// Etapa 3: gating también en el scheduler masivo
|
|
|
const mode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase();
|
|
|
const enforce = mode === 'enforce';
|
|
|
if (enforce) {
|
|
|
try { (AllowedGroups as any).dbInstance = this.dbInstance; } catch {}
|
|
|
}
|
|
|
|
|
|
let groups = 0, added = 0, updated = 0, deactivated = 0;
|
|
|
for (const [groupId] of this.activeGroupsCache.entries()) {
|
|
|
try {
|
|
|
if (enforce) {
|
|
|
try {
|
|
|
if (!AllowedGroups.isAllowed(groupId)) {
|
|
|
// Saltar grupos no permitidos en modo enforce
|
|
|
try { Metrics.inc('sync_skipped_group_total'); } catch {}
|
|
|
continue;
|
|
|
}
|
|
|
} catch {
|
|
|
// Si falla el check, no bloquear el grupo
|
|
|
}
|
|
|
}
|
|
|
|
|
|
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 };
|
|
|
}
|
|
|
|
|
|
public static refreshActiveGroupsCache(): void {
|
|
|
this.cacheActiveGroups();
|
|
|
}
|
|
|
|
|
|
public static startGroupsScheduler(): void {
|
|
|
if (process.env.NODE_ENV === 'test') return;
|
|
|
if (this._groupsSchedulerRunning) return;
|
|
|
this._groupsSchedulerRunning = true;
|
|
|
|
|
|
// Intervalo de grupos configurable; mínimo 10s en desarrollo
|
|
|
let interval = Number(process.env.GROUP_SYNC_INTERVAL_MS);
|
|
|
if (!Number.isFinite(interval) || interval <= 0) {
|
|
|
interval = 24 * 60 * 60 * 1000; // 24h por defecto
|
|
|
}
|
|
|
if (process.env.NODE_ENV === 'development' && interval < 10000) {
|
|
|
interval = 10000;
|
|
|
}
|
|
|
this._groupsIntervalMs = interval;
|
|
|
this._groupsNextTickAt = Date.now() + interval;
|
|
|
|
|
|
this._groupsTimer = setInterval(() => {
|
|
|
// Programar el siguiente tick antes de ejecutar la sincronización
|
|
|
this._groupsNextTickAt = Date.now() + (this._groupsIntervalMs ?? interval);
|
|
|
this.syncGroups().catch(err => {
|
|
|
console.error('❌ Groups scheduler run error:', err);
|
|
|
});
|
|
|
}, interval);
|
|
|
}
|
|
|
|
|
|
public static stopGroupsScheduler(): void {
|
|
|
this._groupsSchedulerRunning = false;
|
|
|
if (this._groupsTimer) {
|
|
|
clearInterval(this._groupsTimer);
|
|
|
this._groupsTimer = null;
|
|
|
}
|
|
|
this._groupsIntervalMs = null;
|
|
|
this._groupsNextTickAt = null;
|
|
|
}
|
|
|
|
|
|
public static getSecondsUntilNextGroupSync(nowMs: number = Date.now()): number | null {
|
|
|
const next = this._groupsNextTickAt;
|
|
|
if (next == null) return null;
|
|
|
const secs = (next - nowMs) / 1000;
|
|
|
return secs > 0 ? secs : 0;
|
|
|
}
|
|
|
|
|
|
public static startMembersScheduler(): void {
|
|
|
if (process.env.NODE_ENV === 'test') return;
|
|
|
if (this._membersSchedulerRunning) return;
|
|
|
this._membersSchedulerRunning = true;
|
|
|
|
|
|
// Intervalo por defecto 6h; configurable por env; mínimo 10s en desarrollo
|
|
|
const raw = process.env.GROUP_MEMBERS_SYNC_INTERVAL_MS;
|
|
|
let interval = Number.isFinite(Number(raw)) && Number(raw) > 0 ? Number(raw) : 6 * 60 * 60 * 1000;
|
|
|
if (process.env.NODE_ENV === 'development' && interval < 10000) {
|
|
|
interval = 10000;
|
|
|
}
|
|
|
|
|
|
this._membersTimer = setInterval(() => {
|
|
|
this.syncMembersForActiveGroups().catch(err => {
|
|
|
console.error('❌ Members scheduler run error:', err);
|
|
|
});
|
|
|
}, interval);
|
|
|
}
|
|
|
|
|
|
public static stopMembersScheduler(): void {
|
|
|
this._membersSchedulerRunning = false;
|
|
|
if (this._membersTimer) {
|
|
|
clearInterval(this._membersTimer);
|
|
|
this._membersTimer = null;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// ===== Helpers de membresía y snapshot (Etapa 3) =====
|
|
|
|
|
|
private static get MAX_SNAPSHOT_AGE_MS(): number {
|
|
|
const raw = Number(process.env.MAX_MEMBERS_SNAPSHOT_AGE_MS);
|
|
|
return Number.isFinite(raw) && raw > 0 ? raw : 24 * 60 * 60 * 1000; // 24h por defecto
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* Devuelve true si la snapshot de un grupo es "fresca" según MAX_SNAPSHOT_AGE_MS.
|
|
|
* Considera no fresca si no hay registro/fecha.
|
|
|
*/
|
|
|
public static isSnapshotFresh(groupId: string, nowMs: number = Date.now()): boolean {
|
|
|
try {
|
|
|
const row = this.dbInstance.prepare(`SELECT last_verified FROM groups WHERE id = ?`).get(groupId) as any;
|
|
|
const lv = row?.last_verified ? String(row.last_verified) : null;
|
|
|
if (!lv) return false;
|
|
|
// Persistimos 'YYYY-MM-DD HH:MM:SS[.mmm]'. Convertimos a ISO-like para Date.parse
|
|
|
const iso = lv.includes('T') ? lv : (lv.replace(' ', 'T') + 'Z');
|
|
|
const ms = Date.parse(iso);
|
|
|
if (!Number.isFinite(ms)) return false;
|
|
|
return (nowMs - ms) <= this.MAX_SNAPSHOT_AGE_MS;
|
|
|
} catch {
|
|
|
return false;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* ¿El usuario figura como miembro activo del grupo?
|
|
|
*/
|
|
|
public static isUserActiveInGroup(userId: string, groupId: string): boolean {
|
|
|
if (!userId || !groupId) return false;
|
|
|
const row = this.dbInstance.prepare(`
|
|
|
SELECT 1
|
|
|
FROM group_members
|
|
|
WHERE group_id = ? AND user_id = ? AND is_active = 1
|
|
|
LIMIT 1
|
|
|
`).get(groupId, userId);
|
|
|
return !!row;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* Devuelve todos los group_ids activos donde el usuario figura activo.
|
|
|
* Filtra también por grupos activos en la tabla groups.
|
|
|
*/
|
|
|
public static getActiveGroupIdsForUser(userId: string): string[] {
|
|
|
if (!userId) return [];
|
|
|
const rows = this.dbInstance.prepare(`
|
|
|
SELECT gm.group_id AS id
|
|
|
FROM group_members gm
|
|
|
JOIN groups g ON g.id = gm.group_id
|
|
|
WHERE gm.user_id = ? AND gm.is_active = 1 AND g.active = 1
|
|
|
`).all(userId) as any[];
|
|
|
const set = new Set<string>();
|
|
|
for (const r of rows) {
|
|
|
if (r?.id) set.add(String(r.id));
|
|
|
}
|
|
|
return Array.from(set);
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* Devuelve los group_ids donde el usuario es miembro activo y cuya snapshot es fresca.
|
|
|
*/
|
|
|
public static getFreshMemberGroupsForUser(userId: string): string[] {
|
|
|
const gids = this.getActiveGroupIdsForUser(userId);
|
|
|
return gids.filter(gid => this.isSnapshotFresh(gid));
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* Asegura un registro de grupo activo en la base de datos (upsert idempotente).
|
|
|
* Si no existe, lo crea con active=1. Si existe y estaba inactivo, lo reactiva.
|
|
|
* Puede actualizar el nombre si se proporciona.
|
|
|
*/
|
|
|
public static ensureGroupExists(groupId: string, name?: string | null): { created: boolean; reactivated: boolean; updatedName: boolean } {
|
|
|
if (!groupId) return { created: false, reactivated: false, updatedName: false };
|
|
|
let created = false, reactivated = false, updatedName = false;
|
|
|
|
|
|
this.dbInstance.transaction(() => {
|
|
|
const row = this.dbInstance.prepare(`SELECT id, active, name FROM groups WHERE id = ?`).get(groupId) as any;
|
|
|
if (!row) {
|
|
|
const community = process.env.WHATSAPP_COMMUNITY_ID || '';
|
|
|
this.dbInstance.prepare(`
|
|
|
INSERT INTO groups (id, community_id, name, active, last_verified)
|
|
|
VALUES (?, ?, ?, 1, CURRENT_TIMESTAMP)
|
|
|
`).run(groupId, community, name || null);
|
|
|
created = true;
|
|
|
} else {
|
|
|
// Reactivar si estaba inactivo y opcionalmente actualizar nombre
|
|
|
const shouldUpdateName = (typeof name === 'string' && name.trim().length > 0 && name !== row.name);
|
|
|
if (row.active !== 1 || shouldUpdateName) {
|
|
|
this.dbInstance.prepare(`
|
|
|
UPDATE groups
|
|
|
SET active = 1,
|
|
|
name = COALESCE(?, name),
|
|
|
last_verified = CURRENT_TIMESTAMP
|
|
|
WHERE id = ?
|
|
|
`).run(shouldUpdateName ? name : null, groupId);
|
|
|
reactivated = row.active !== 1;
|
|
|
updatedName = shouldUpdateName;
|
|
|
}
|
|
|
}
|
|
|
})();
|
|
|
|
|
|
// Actualizar caché
|
|
|
this.cacheActiveGroups();
|
|
|
Metrics.set('active_groups', this.activeGroupsCache.size);
|
|
|
|
|
|
return { created, reactivated, updatedName };
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* Asegura tener el nombre/label de un grupo (cache/DB/API) y lo persiste tanto en groups como en allowed_groups.
|
|
|
* Devuelve el nombre si se pudo resolver, o null en caso contrario.
|
|
|
*/
|
|
|
public static async ensureGroupLabelAndName(groupId: string): Promise<string | null> {
|
|
|
try {
|
|
|
if (!groupId) return null;
|
|
|
|
|
|
// 1) Cache en memoria
|
|
|
const cached = this.activeGroupsCache.get(groupId);
|
|
|
if (cached && cached.trim()) {
|
|
|
try { this.ensureGroupExists(groupId, cached); } catch {}
|
|
|
try { (AllowedGroups as any).dbInstance = this.dbInstance; AllowedGroups.upsertPending(groupId, cached, null); } catch {}
|
|
|
this.cacheActiveGroups();
|
|
|
return cached;
|
|
|
}
|
|
|
|
|
|
// 2) DB (tabla groups)
|
|
|
try {
|
|
|
const row = this.dbInstance.prepare('SELECT name FROM groups WHERE id = ?').get(groupId) as any;
|
|
|
const name = row?.name ? String(row.name).trim() : '';
|
|
|
if (name) {
|
|
|
try { this.ensureGroupExists(groupId, name); } catch {}
|
|
|
try { (AllowedGroups as any).dbInstance = this.dbInstance; AllowedGroups.upsertPending(groupId, name, null); } catch {}
|
|
|
this.cacheActiveGroups();
|
|
|
return name;
|
|
|
}
|
|
|
} catch {}
|
|
|
|
|
|
// 3) API (evitar en tests)
|
|
|
if (process.env.NODE_ENV !== 'test') {
|
|
|
const groups = await this.fetchGroupsFromAPI();
|
|
|
const g = groups.find((gg) => gg?.id === groupId);
|
|
|
const subject = g?.subject ? String(g.subject).trim() : '';
|
|
|
if (subject) {
|
|
|
try { this.ensureGroupExists(groupId, subject); } catch {}
|
|
|
try { (AllowedGroups as any).dbInstance = this.dbInstance; AllowedGroups.upsertPending(groupId, subject, null); } catch {}
|
|
|
this.cacheActiveGroups();
|
|
|
return subject;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
return null;
|
|
|
} catch {
|
|
|
return null;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* Sincroniza miembros para un grupo concreto (útil tras detectar un grupo nuevo).
|
|
|
*/
|
|
|
public static async syncMembersForGroup(groupId: string): Promise<{ added: number; updated: number; deactivated: number }> {
|
|
|
// Gating en modo 'enforce': solo sincronizar miembros para grupos permitidos
|
|
|
try {
|
|
|
const mode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase();
|
|
|
if (mode === 'enforce') {
|
|
|
try {
|
|
|
(AllowedGroups as any).dbInstance = this.dbInstance;
|
|
|
if (!AllowedGroups.isAllowed(groupId)) {
|
|
|
try { Metrics.inc('sync_skipped_group_total'); } catch {}
|
|
|
return { added: 0, updated: 0, deactivated: 0 };
|
|
|
}
|
|
|
} catch {
|
|
|
// Si el check falla, seguimos sin bloquear
|
|
|
}
|
|
|
}
|
|
|
} catch {}
|
|
|
|
|
|
try {
|
|
|
// Asegurar existencia del grupo en DB (FKs) antes de reconciliar
|
|
|
this.ensureGroupExists(groupId);
|
|
|
const snapshot = await (this as any).fetchGroupMembersFromAPI(groupId);
|
|
|
return this.reconcileGroupMembers(groupId, snapshot);
|
|
|
} catch (e) {
|
|
|
console.error(`❌ Failed to sync members for group ${groupId}:`, e instanceof Error ? e.message : String(e));
|
|
|
return { added: 0, updated: 0, deactivated: 0 };
|
|
|
}
|
|
|
}
|
|
|
}
|