feat: añadir /admin ver todos para listar tareas activas globales

Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
main
borja 4 weeks ago
parent 1d7c1e2f1d
commit c912ee362e

@ -4,6 +4,8 @@ import { AllowedGroups } from './allowed-groups';
import { GroupSyncService } from './group-sync';
import { normalizeWhatsAppId, isGroupId } from '../utils/whatsapp';
import { Metrics } from './metrics';
import { TaskService } from '../tasks/service';
import { codeId, formatDDMM } from '../utils/formatting';
type AdminContext = {
sender: string; // normalized user id (digits only)
@ -45,6 +47,7 @@ export class AdminService {
'- /admin allow-group <group_id@g.us> (alias: allow)',
'- /admin block-group <group_id@g.us> (alias: block)',
'- /admin sync-grupos (alias: group-sync, syncgroups)',
'- /admin ver todos (alias: listar, list all)',
].join('\n');
}
@ -134,6 +137,57 @@ export class AdminService {
}
}
// /admin ver todos [<limite>]
if (
rest === 'ver todos' ||
rest.startsWith('ver todos ') ||
rest === 'listar' ||
rest.startsWith('listar ') ||
rest === 'list all' ||
rest.startsWith('list all ') ||
rest === 'list-all' ||
rest.startsWith('list-all ')
) {
// Asegurar acceso a la misma DB para TaskService
try { (TaskService as any).dbInstance = this.dbInstance; } catch {}
const DEFAULT_LIMIT = 50;
let limit = DEFAULT_LIMIT;
const maybeNum = parseInt(rest.split(/\s+/).pop() || '', 10);
if (Number.isFinite(maybeNum) && maybeNum > 0) {
limit = Math.min(maybeNum, 500); // tope razonable
}
const tasks = TaskService.listAllActive(limit);
const total = TaskService.countAllActive();
if (!tasks || tasks.length === 0) {
return [{ recipient: sender, message: '✅ No hay tareas activas.' }];
}
const lines = tasks.map(t => {
const ddmm = formatDDMM(t.due_date);
const groupLabel = t.group_name || t.group_id || 'DM';
const parts: string[] = [
`${codeId(t.id, t.display_code)}`,
String(t.description || '').trim()
];
if (ddmm) parts.push(`vence ${ddmm}`);
if (groupLabel) parts.push(`[${groupLabel}]`);
return `- ${parts.join(' · ')}`;
});
const header = total > limit
? `Tareas activas (${total}) — mostrando ${tasks.length} primeras:`
: `Tareas activas (${total}):`;
try { Metrics.inc('admin_actions_total_list'); } catch {}
return [{
recipient: sender,
message: `${header}\n${lines.join('\n')}`
}];
}
// Ayuda por defecto
return [{ recipient: sender, message: this.help() }];
}

@ -557,4 +557,48 @@ export class TaskService {
}
return out;
}
// Listar todas las tareas activas en todos los grupos (ordenadas por due_date ASC, NULL al final)
static listAllActive(limit: number = 50): Array<{
id: number;
description: string;
due_date: string | null;
group_id: string | null;
group_name: string | null;
display_code: number | null;
}> {
const rows = this.dbInstance
.prepare(`
SELECT t.id, t.description, t.due_date, t.group_id, t.display_code, g.name AS group_name
FROM tasks t
LEFT JOIN groups g ON g.id = t.group_id
WHERE COALESCE(t.completed, 0) = 0 AND t.completed_at IS NULL
ORDER BY
CASE WHEN t.due_date IS NULL THEN 1 ELSE 0 END,
t.due_date ASC,
t.id ASC
LIMIT ?
`)
.all(limit) as any[];
return rows.map(r => ({
id: Number(r.id),
description: String(r.description || ''),
due_date: r.due_date ? String(r.due_date) : null,
group_id: r.group_id ? String(r.group_id) : null,
group_name: r.group_name ? String(r.group_name) : null,
display_code: r.display_code != null ? Number(r.display_code) : null,
}));
}
static countAllActive(): number {
const row = this.dbInstance
.prepare(`
SELECT COUNT(*) AS cnt
FROM tasks
WHERE COALESCE(completed, 0) = 0 AND completed_at IS NULL
`)
.get() as any;
return Number(row?.cnt || 0);
}
}

@ -0,0 +1,77 @@
import Database from 'bun:sqlite';
import { initializeDatabase, ensureUserExists } from '../../../src/db';
import { AdminService } from '../../../src/services/admin';
import { TaskService } from '../../../src/tasks/service';
describe('AdminService - /admin ver todos', () => {
let memdb: Database;
const ADMIN = '34600123456';
const OTHER = '34999999999';
beforeEach(() => {
process.env.NODE_ENV = 'test';
process.env.GROUP_GATING_MODE = 'off';
process.env.ADMIN_USERS = ADMIN;
memdb = new Database(':memory:');
initializeDatabase(memdb);
(AdminService as any).dbInstance = memdb;
(TaskService as any).dbInstance = memdb;
// seed groups
memdb.prepare(`
INSERT INTO groups (id, community_id, name, active, last_verified)
VALUES ('g1@g.us', 'comm', 'Grupo Uno', 1, strftime('%Y-%m-%d %H:%M:%f','now'))
`).run();
memdb.prepare(`
INSERT INTO groups (id, community_id, name, active, last_verified)
VALUES ('g2@g.us', 'comm', 'Grupo Dos', 1, strftime('%Y-%m-%d %H:%M:%f','now'))
`).run();
// seed tasks
const creator = ensureUserExists(ADMIN, memdb)!;
TaskService.createTask({ description: 'Alpha', due_date: '2025-10-10', group_id: 'g1@g.us', created_by: creator }, []);
TaskService.createTask({ description: 'Beta', due_date: '2025-10-05', group_id: 'g2@g.us', created_by: creator }, []);
TaskService.createTask({ description: 'Gamma', due_date: null, group_id: 'g1@g.us', created_by: creator }, []);
});
afterEach(() => {
try { memdb.close(); } catch {}
});
it('lista todas las tareas activas y responde por DM al admin', async () => {
const res = await AdminService.handle({
sender: ADMIN,
groupId: 'g1@g.us',
message: '/admin ver todos'
});
expect(res.length).toBe(1);
expect(res[0].recipient).toBe(ADMIN);
expect(res[0].message).toContain('Tareas activas');
expect(res[0].message).toContain('Alpha');
expect(res[0].message).toContain('Beta');
expect(res[0].message).toContain('Gamma');
});
it('respeta un límite numérico y menciona truncado cuando aplica', async () => {
const res = await AdminService.handle({
sender: ADMIN,
groupId: 'g1@g.us',
message: '/admin ver todos 2'
});
expect(res.length).toBe(1);
expect(res[0].recipient).toBe(ADMIN);
expect(res[0].message).toContain('mostrando 2');
});
it('rechaza usuarios no autorizados', async () => {
process.env.ADMIN_USERS = ADMIN; // sigue igual, pero probamos otro sender
const res = await AdminService.handle({
sender: OTHER,
groupId: 'g1@g.us',
message: '/admin ver todos'
});
expect(res.length).toBe(1);
expect(res[0].message).toContain('No estás autorizado');
});
});

@ -0,0 +1,63 @@
import Database from 'bun:sqlite';
import { initializeDatabase, ensureUserExists } from '../../../src/db';
import { TaskService } from '../../../src/tasks/service';
describe('TaskService - listAllActive', () => {
let memdb: Database;
beforeEach(() => {
process.env.NODE_ENV = 'test';
process.env.GROUP_GATING_MODE = 'off';
memdb = new Database(':memory:');
initializeDatabase(memdb);
(TaskService as any).dbInstance = memdb;
});
afterEach(() => {
try { memdb.close(); } catch {}
});
function seedGroup(id: string, name: string = 'Group') {
memdb.prepare(`
INSERT INTO groups (id, community_id, name, active, last_verified)
VALUES (?, 'comm-1', ?, 1, strftime('%Y-%m-%d %H:%M:%f','now'))
`).run(id, name);
}
function createTask(desc: string, due: string | null, groupId: string | null, creator: string) {
const ensured = ensureUserExists(creator, memdb)!;
return TaskService.createTask(
{ description: desc, due_date: due, group_id: groupId, created_by: ensured },
[]
);
}
it('devuelve solo tareas activas en orden por due_date (NULL al final)', () => {
seedGroup('g1@g.us', 'G1');
seedGroup('g2@g.us', 'G2');
const c = '34600123456';
const t1 = createTask('Tarea A', '2025-10-10', 'g1@g.us', c);
const t2 = createTask('Tarea B', '2025-10-05', 'g2@g.us', c);
const t3 = createTask('Tarea C', null, 'g1@g.us', c);
const t4 = createTask('Tarea D', '2025-10-01', 'g2@g.us', c);
// Completar una de ellas para que no aparezca
TaskService.completeTask(t4, c);
const rows = TaskService.listAllActive(10);
expect(rows.map(r => r.description)).toEqual(['Tarea B', 'Tarea A', 'Tarea C']);
expect(TaskService.countAllActive()).toBe(3);
});
it('respeta el límite indicado', () => {
seedGroup('g1@g.us', 'G1');
const c = '34600123456';
createTask('Tarea 1', '2025-10-02', 'g1@g.us', c);
createTask('Tarea 2', '2025-10-03', 'g1@g.us', c);
createTask('Tarea 3', '2025-10-04', 'g1@g.us', c);
const rows = TaskService.listAllActive(2);
expect(rows.length).toBe(2);
});
});
Loading…
Cancel
Save