feat: añadir migración v9_allowed_groups y servicio AllowedGroups
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>pull/1/head
							parent
							
								
									a553d5261c
								
							
						
					
					
						commit
						0fa985c145
					
				| @ -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<string, CacheEntry>(); | ||||||
|  | 
 | ||||||
|  |   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); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
| @ -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(); | ||||||
|  |   }); | ||||||
|  | }); | ||||||
| @ -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; | ||||||
|  |   }); | ||||||
|  | }); | ||||||
					Loading…
					
					
				
		Reference in New Issue