diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index 3e19e5b..72cf0c2 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -267,4 +267,26 @@ export const migrations: Migration[] = [ } catch {} } } + , + { + version: 9, + name: 'allowed-groups', + checksum: 'v9-allowed-groups-2025-09-29', + up: (db: Database) => { + db.exec(` + CREATE TABLE IF NOT EXISTS allowed_groups ( + group_id TEXT PRIMARY KEY, + label TEXT NULL, + status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','allowed','blocked')), + discovered_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')), + discovered_by TEXT NULL + ); + `); + db.exec(` + CREATE INDEX IF NOT EXISTS idx_allowed_groups_status + ON allowed_groups (status); + `); + } + } ]; diff --git a/src/services/allowed-groups.ts b/src/services/allowed-groups.ts new file mode 100644 index 0000000..1469bd9 --- /dev/null +++ b/src/services/allowed-groups.ts @@ -0,0 +1,149 @@ +import type { Database } from 'bun:sqlite'; +import { db } from '../db'; + +type GroupStatus = 'pending' | 'allowed' | 'blocked'; + +type CacheEntry = { + status: GroupStatus; + label: string | null; +}; + +export class AllowedGroups { + static dbInstance: Database = db; + + // Caché en memoria: group_id (JID completo) -> { status, label } + private static cache = new Map(); + + private static nowExpr = "strftime('%Y-%m-%d %H:%M:%f','now')"; + + static clearCache() { + this.cache.clear(); + } + + static resetForTests() { + this.clearCache(); + } + + private static getRow(groupId: string): { group_id: string; label: string | null; status: GroupStatus } | null { + try { + const row = this.dbInstance + .prepare(`SELECT group_id, label, status FROM allowed_groups WHERE group_id = ?`) + .get(groupId) as any; + if (!row) return null; + return { + group_id: String(row.group_id), + label: row.label != null ? String(row.label) : null, + status: String(row.status) as GroupStatus, + }; + } catch { + // Tabla podría no existir en contextos muy tempranos: degradar a null + return null; + } + } + + static isAllowed(groupId: string | null | undefined): boolean { + const gid = String(groupId || '').trim(); + if (!gid) return false; + + const cached = this.cache.get(gid); + if (cached) return cached.status === 'allowed'; + + const row = this.getRow(gid); + const status = row?.status || 'pending'; + const label = row?.label ?? null; + this.cache.set(gid, { status, label }); + return status === 'allowed'; + } + + /** + * Inserta un grupo como pending si no existe. No degrada estados existentes (allowed/blocked). + * Actualiza label si se proporciona y cambió. + */ + static upsertPending(groupId: string, label?: string | null, discoveredBy?: string | null): void { + const gid = String(groupId || '').trim(); + if (!gid) return; + + const row = this.getRow(gid); + if (!row) { + // Insertar como pending + this.dbInstance + .prepare(` + INSERT INTO allowed_groups (group_id, label, status, discovered_at, updated_at, discovered_by) + VALUES (?, ?, 'pending', ${this.nowExpr}, ${this.nowExpr}, ?) + `) + .run(gid, label ?? null, discoveredBy ?? null); + this.cache.set(gid, { status: 'pending', label: label ?? null }); + return; + } + + // No cambiar status existente. Solo actualizar label si se aporta y cambió. + const newLabel = label ?? row.label; + if (label != null && String(row.label ?? '') !== String(label)) { + this.dbInstance + .prepare(` + UPDATE allowed_groups + SET label = ?, updated_at = ${this.nowExpr} + WHERE group_id = ? + `) + .run(newLabel, gid); + } + this.cache.set(gid, { status: row.status, label: newLabel ?? null }); + } + + /** + * Establece el estado de un grupo (upsert). Devuelve true si cambió algo (estado o label). + */ + static setStatus(groupId: string, status: GroupStatus, label?: string | null): boolean { + const gid = String(groupId || '').trim(); + if (!gid) return false; + + const before = this.getRow(gid); + this.dbInstance + .prepare(` + INSERT INTO allowed_groups (group_id, label, status, discovered_at, updated_at) + VALUES (?, ?, ?, ${this.nowExpr}, ${this.nowExpr}) + ON CONFLICT(group_id) DO UPDATE SET + status = excluded.status, + label = COALESCE(excluded.label, allowed_groups.label), + updated_at = excluded.updated_at + `) + .run(gid, label ?? null, status); + + const after = this.getRow(gid) || { group_id: gid, label: label ?? null, status }; + this.cache.set(gid, { status: after.status, label: after.label }); + + return ( + !before || + before.status !== after.status || + (label != null && String(before.label ?? '') !== String(label)) + ); + } + + static listByStatus(status: GroupStatus): Array<{ group_id: string; label: string | null }> { + const rows = this.dbInstance + .prepare( + `SELECT group_id, label FROM allowed_groups WHERE status = ? ORDER BY group_id` + ) + .all(status) as Array<{ group_id: string; label: string | null }>; + return rows.map(r => ({ + group_id: String((r as any).group_id), + label: (r as any).label != null ? String((r as any).label) : null, + })); + } + + /** + * Marca como allowed todos los group_ids provistos en una cadena separada por comas. + * Si env no se pasa, usa process.env.ALLOWED_GROUPS. + */ + static seedFromEnv(env?: string | null): void { + const val = (env ?? process?.env?.ALLOWED_GROUPS ?? '').trim(); + if (!val) return; + const ids = val + .split(',') + .map(s => s.trim()) + .filter(Boolean); + for (const gid of ids) { + this.setStatus(gid, 'allowed', null); + } + } +} diff --git a/tests/unit/db/migrations.allowed-groups.test.ts b/tests/unit/db/migrations.allowed-groups.test.ts new file mode 100644 index 0000000..e020f4c --- /dev/null +++ b/tests/unit/db/migrations.allowed-groups.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'bun:test'; +import Database from 'bun:sqlite'; +import { initializeDatabase } from '../../../src/db'; + +describe('Migración v9 - allowed_groups', () => { + it('crea la tabla allowed_groups', () => { + const memdb = new Database(':memory:'); + expect(() => initializeDatabase(memdb)).not.toThrow(); + + const row = memdb + .query(`SELECT name FROM sqlite_master WHERE type='table' AND name='allowed_groups'`) + .get() as any; + + expect(row?.name).toBe('allowed_groups'); + }); + + it('enforce CHECK de status', () => { + const memdb = new Database(':memory:'); + initializeDatabase(memdb); + + expect(() => + memdb.exec(` + INSERT INTO allowed_groups (group_id, status, discovered_at, updated_at) + VALUES ('123@g.us', 'invalid-status', strftime('%Y-%m-%d %H:%M:%f','now'), strftime('%Y-%m-%d %H:%M:%f','now')); + `) + ).toThrow(); + }); +}); diff --git a/tests/unit/services/allowed-groups.test.ts b/tests/unit/services/allowed-groups.test.ts new file mode 100644 index 0000000..3cf1a0b --- /dev/null +++ b/tests/unit/services/allowed-groups.test.ts @@ -0,0 +1,61 @@ +import { describe, it, beforeEach, expect } from 'bun:test'; +import { makeMemDb } from '../../helpers/db'; +import { AllowedGroups } from '../../../src/services/allowed-groups'; + +describe('AllowedGroups service', () => { + beforeEach(() => { + const memdb = makeMemDb(); + (AllowedGroups as any).dbInstance = memdb; + AllowedGroups.resetForTests(); + }); + + it('upsertPending inserta pending y es idempotente', () => { + const gid = '123@g.us'; + + AllowedGroups.upsertPending(gid, 'Grupo 123', 'tester'); + AllowedGroups.upsertPending(gid, 'Grupo 123', 'tester'); + + // No se expone la DB aquí; validamos por comportamiento + expect(AllowedGroups.isAllowed(gid)).toBe(false); + }); + + it('setStatus cambia a allowed y isAllowed refleja el estado', () => { + const gid = '456@g.us'; + + expect(AllowedGroups.isAllowed(gid)).toBe(false); + const changed = AllowedGroups.setStatus(gid, 'allowed', 'Grupo 456'); + expect(changed).toBe(true); + expect(AllowedGroups.isAllowed(gid)).toBe(true); + + // Repetir con el mismo estado no debe cambiar + const changedAgain = AllowedGroups.setStatus(gid, 'allowed', 'Grupo 456'); + expect(changedAgain).toBe(false); + }); + + it('listByStatus devuelve grupos por estado', () => { + AllowedGroups.setStatus('a@g.us', 'allowed', 'A'); + AllowedGroups.setStatus('b@g.us', 'pending', 'B'); + AllowedGroups.setStatus('c@g.us', 'blocked', 'C'); + + const allowed = AllowedGroups.listByStatus('allowed').map(r => r.group_id); + const pending = AllowedGroups.listByStatus('pending').map(r => r.group_id); + const blocked = AllowedGroups.listByStatus('blocked').map(r => r.group_id); + + expect(allowed).toContain('a@g.us'); + expect(pending).toContain('b@g.us'); + expect(blocked).toContain('c@g.us'); + }); + + it('seedFromEnv marca como allowed los ids provistos', () => { + const prev = process.env.ALLOWED_GROUPS; + process.env.ALLOWED_GROUPS = 'x@g.us, y@g.us , , z@g.us'; + + AllowedGroups.seedFromEnv(); + + expect(AllowedGroups.isAllowed('x@g.us')).toBe(true); + expect(AllowedGroups.isAllowed('y@g.us')).toBe(true); + expect(AllowedGroups.isAllowed('z@g.us')).toBe(true); + + process.env.ALLOWED_GROUPS = prev; + }); +});