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