diff --git a/src/services/admin.ts b/src/services/admin.ts index 1809e3f..679cbb1 100644 --- a/src/services/admin.ts +++ b/src/services/admin.ts @@ -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 (alias: allow)', '- /admin block-group (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 [] + 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() }]; } diff --git a/src/tasks/service.ts b/src/tasks/service.ts index 18f34a7..c4645cc 100644 --- a/src/tasks/service.ts +++ b/src/tasks/service.ts @@ -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); + } } diff --git a/tests/unit/services/admin.list-all.test.ts b/tests/unit/services/admin.list-all.test.ts new file mode 100644 index 0000000..19f547a --- /dev/null +++ b/tests/unit/services/admin.list-all.test.ts @@ -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'); + }); +}); diff --git a/tests/unit/tasks/service.list-active.test.ts b/tests/unit/tasks/service.list-active.test.ts new file mode 100644 index 0000000..c357d6b --- /dev/null +++ b/tests/unit/tasks/service.list-active.test.ts @@ -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); + }); +});