From a1df163db0a6bfc0f8109a07725bed9351a58578 Mon Sep 17 00:00:00 2001 From: borja Date: Sat, 6 Sep 2025 18:04:15 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20a=C3=B1adir=20conteos=20de=20pendientes?= =?UTF-8?q?=20y=20mostrar=E2=80=A6=20y=20X=20m=C3=A1s=20en=20/t=20ver?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: aider (openrouter/openai/gpt-5) --- README.md | 1 - STATUS.md | 6 +- src/services/command.ts | 13 ++- src/tasks/service.ts | 27 ++++++ tests/unit/server.test.ts | 40 +++++++++ tests/unit/services/command.test.ts | 127 ++++++++++++++++++++++++++++ 6 files changed, 209 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 47a5a25..927d85d 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,6 @@ Estado: la tabla response_queue ya está creada e incluida en los tests de DB. - **Database isolation in unit tests**: Using in-memory instances to avoid conflicts. ### Incomplete / Missing Core Functionality -- Additional commands: `/tarea mostrar` (list) y `/tarea completar`. - ResponseQueue reliability: reintentos con backoff, recuperación de `processing`, métricas y limpieza/retención. - ContactsService improvements (optional): refine caching policy and endpoints; basic friendly-name resolution is already implemented and used in outgoing texts. - Database migrations system (beyond current lightweight on-boot checks). diff --git a/STATUS.md b/STATUS.md index 9d5aed9..1a17abc 100644 --- a/STATUS.md +++ b/STATUS.md @@ -35,7 +35,7 @@ ## ⚠️ Funcionalidades Pendientes - **Gestión de Tareas** - - Listar (`/tarea mostrar`) y completar (`/tarea completar`) tareas; eliminación opcional + - Eliminación opcional de tareas y mejoras de edición - **Cola de Respuestas** - Reintentos con backoff y jitter - Recuperación de ítems en estado `processing` tras caídas @@ -48,9 +48,9 @@ - Sistema de migraciones de esquema ## ➡️ Próximos Pasos Prioritarios -1. Implementar `/tarea mostrar` y `/tarea completar`. +1. Añadir tests de Fase 3 (ver/x, entradas extendidas) y paginación “… y X más”. 2. Mejorar UX de menciones: resolver nombres y formateo de “asignados”. -3. Extender soporte de entrada: `extendedTextMessage` y captions de media. +3. Unificar y pulir formatos de mensajes (dd/MM en todos, compacidad). 4. Mejoras de fiabilidad de la cola: reintentos con backoff y recuperación de `processing`. 5. Métricas/observabilidad básicas y plan de migraciones de DB. diff --git a/src/services/command.ts b/src/services/command.ts index de97063..3a09208 100644 --- a/src/services/command.ts +++ b/src/services/command.ts @@ -184,6 +184,11 @@ export class CommandService { return `- ${t.id}) “*${t.description || '(sin descripción)'}*”${datePart} — ${owner}`; })); + const total = TaskService.countGroupPending(context.groupId); + if (total > items.length) { + rendered.push(`… y ${total - items.length} más`); + } + return [{ recipient: context.sender, message: [groupName, ...rendered].join('\n') @@ -199,6 +204,8 @@ export class CommandService { }]; } + const total = TaskService.countUserPending(context.sender); + // Agrupar por grupo const byGroup = new Map(); for (const t of items) { @@ -229,6 +236,10 @@ export class CommandService { sections.push(...rendered); } + if (total > items.length) { + sections.push(`… y ${total - items.length} más`); + } + return [{ recipient: context.sender, message: sections.join('\n') @@ -386,7 +397,7 @@ export class CommandService { responses.push({ recipient: uid, message: [ - `🔔 ${taskId}${dueDate ? ` — 📅 ${dueDate}` : ''}`, + `🔔 ${taskId}${formatDDMM(dueDate) ? ` — 📅 ${formatDDMM(dueDate)}` : ''}`, `“*${description || '(sin descripción)'}*”`, groupName ? `Grupo: ${groupName}` : null, `Completar: /t x ${taskId}` diff --git a/src/tasks/service.ts b/src/tasks/service.ts index ea5361a..a1a1d6f 100644 --- a/src/tasks/service.ts +++ b/src/tasks/service.ts @@ -146,6 +146,33 @@ export class TaskService { }); } + // Contar pendientes del grupo (sin límite) + static countGroupPending(groupId: string): number { + const row = this.dbInstance + .prepare(` + SELECT COUNT(*) as cnt + FROM tasks + WHERE group_id = ? + AND (completed = 0 OR completed_at IS NULL) + `) + .get(groupId) as any; + return Number(row?.cnt || 0); + } + + // Contar pendientes asignadas al usuario (sin límite) + static countUserPending(userId: string): number { + const row = this.dbInstance + .prepare(` + SELECT COUNT(*) as cnt + FROM tasks t + INNER JOIN task_assignments a ON a.task_id = t.id + WHERE a.user_id = ? + AND (t.completed = 0 OR t.completed_at IS NULL) + `) + .get(userId) as any; + return Number(row?.cnt || 0); + } + // Completar tarea: registra quién completó e idempotente static completeTask(taskId: number, completedBy: string): { status: 'updated' | 'already' | 'not_found'; diff --git a/tests/unit/server.test.ts b/tests/unit/server.test.ts index 5f9beff..8d91bf7 100644 --- a/tests/unit/server.test.ts +++ b/tests/unit/server.test.ts @@ -273,6 +273,46 @@ describe('WebhookServer', () => { expect(SimulatedResponseQueue.getQueue().length).toBe(0); }); + test('should process command from extendedTextMessage', async () => { + const payload = { + event: 'messages.upsert', + instance: 'test-instance', + data: { + key: { + remoteJid: 'group-id@g.us', + participant: 'sender-id@s.whatsapp.net' + }, + message: { + extendedTextMessage: { text: '/t n Test ext' } + } + } + }; + const request = createTestRequest(payload); + const response = await WebhookServer.handleRequest(request); + expect(response.status).toBe(200); + expect(SimulatedResponseQueue.getQueue().length).toBeGreaterThan(0); + }); + + test('should process command from image caption when caption starts with a command', async () => { + const payload = { + event: 'messages.upsert', + instance: 'test-instance', + data: { + key: { + remoteJid: 'group-id@g.us', + participant: 'sender-id@s.whatsapp.net' + }, + message: { + imageMessage: { caption: '/t n From caption' } + } + } + }; + const request = createTestRequest(payload); + const response = await WebhookServer.handleRequest(request); + expect(response.status).toBe(200); + expect(SimulatedResponseQueue.getQueue().length).toBeGreaterThan(0); + }); + test('should handle requests on configured port', async () => { const originalPort = process.env.PORT; process.env.PORT = '3007'; diff --git a/tests/unit/services/command.test.ts b/tests/unit/services/command.test.ts index a38d51d..6b74a45 100644 --- a/tests/unit/services/command.test.ts +++ b/tests/unit/services/command.test.ts @@ -3,6 +3,7 @@ import { Database } from 'bun:sqlite'; import { initializeDatabase } from '../../../src/db'; import { CommandService } from '../../../src/services/command'; import { TaskService } from '../../../src/tasks/service'; +import { GroupSyncService } from '../../../src/services/group-sync'; let memDb: Database; const testContextBase = { @@ -18,6 +19,132 @@ beforeEach(() => { (TaskService as any).dbInstance = memDb; }); +test('listar grupo por defecto con /t ver en grupo e incluir “… y X más”', async () => { + // Insert group and cache it as active + memDb.exec(` + INSERT OR IGNORE INTO groups (id, community_id, name, active) + VALUES ('test-group@g.us', 'test-community', 'Test Group', 1) + `); + GroupSyncService.activeGroupsCache.clear(); + GroupSyncService.activeGroupsCache.set('test-group@g.us', 'Test Group'); + + // Crear 12 tareas sin asignados en el grupo + for (let i = 1; i <= 12; i++) { + TaskService.createTask({ + description: `Task ${i}`, + due_date: '2025-12-31', + group_id: 'test-group@g.us', + created_by: '1234567890', + }); + } + + const responses = await CommandService.handle({ + sender: '1234567890', + groupId: 'test-group@g.us', + mentions: [], + message: '/t ver' + }); + + expect(responses.length).toBe(1); + expect(responses[0].recipient).toBe('1234567890'); + expect(responses[0].message).toContain('Test Group'); + // Debe indicar que hay 2 más (límite 10) + expect(responses[0].message).toContain('… y 2 más'); + // Debe mostrar “sin dueño” + expect(responses[0].message).toContain('sin dueño'); +}); + +test('listar “mis” por defecto en DM con /t ver', async () => { + // Insert groups and cache them + memDb.exec(` + INSERT OR REPLACE INTO groups (id, community_id, name, active) VALUES + ('test-group@g.us', 'test-community', 'Test Group', 1), + ('group-2@g.us', 'test-community', 'Group 2', 1) + `); + GroupSyncService.activeGroupsCache.clear(); + GroupSyncService.activeGroupsCache.set('test-group@g.us', 'Test Group'); + GroupSyncService.activeGroupsCache.set('group-2@g.us', 'Group 2'); + + // Crear 2 tareas asignadas al usuario en distintos grupos + const t1 = TaskService.createTask({ + description: 'G1 Task', + due_date: '2025-11-20', + group_id: 'test-group@g.us', + created_by: '1111111111', + }, [{ user_id: '1234567890', assigned_by: '1111111111' }]); + + const t2 = TaskService.createTask({ + description: 'G2 Task', + due_date: '2025-11-25', + group_id: 'group-2@g.us', + created_by: '2222222222', + }, [{ user_id: '1234567890', assigned_by: '2222222222' }]); + + const responses = await CommandService.handle({ + sender: '1234567890', + // Contexto de DM: usar un JID que NO sea de grupo + groupId: '1234567890@s.whatsapp.net', + mentions: [], + message: '/t ver' + }); + + expect(responses.length).toBe(1); + expect(responses[0].recipient).toBe('1234567890'); + const msg = responses[0].message; + expect(msg).toContain('Test Group'); + expect(msg).toContain('Group 2'); + expect(msg).toMatch(/- \d+\) “\*G1 Task\*”/); + expect(msg).toMatch(/- \d+\) “\*G2 Task\*”/); +}); + +test('completar tarea: camino feliz, ya completada y no encontrada', async () => { + // Insertar grupo y cache + memDb.exec(` + INSERT OR IGNORE INTO groups (id, community_id, name, active) + VALUES ('test-group@g.us', 'test-community', 'Test Group', 1) + `); + GroupSyncService.activeGroupsCache.clear(); + GroupSyncService.activeGroupsCache.set('test-group@g.us', 'Test Group'); + + const taskId = TaskService.createTask({ + description: 'Completar yo', + due_date: '2025-10-10', + group_id: 'test-group@g.us', + created_by: '1111111111', + }); + + // 1) Camino feliz + let responses = await CommandService.handle({ + sender: '1234567890', + groupId: 'test-group@g.us', + mentions: [], + message: `/t x ${taskId}` + }); + expect(responses.length).toBe(1); + expect(responses[0].recipient).toBe('1234567890'); + expect(responses[0].message).toContain(`✔️ ${taskId} completada`); + + // 2) Ya completada + responses = await CommandService.handle({ + sender: '1234567890', + groupId: 'test-group@g.us', + mentions: [], + message: `/t x ${taskId}` + }); + expect(responses.length).toBe(1); + expect(responses[0].message).toContain('ya estaba completada'); + + // 3) No encontrada + responses = await CommandService.handle({ + sender: '1234567890', + groupId: 'test-group@g.us', + mentions: [], + message: `/t x 999999` + }); + expect(responses.length).toBe(1); + expect(responses[0].message).toContain('no encontrada'); +}); + afterEach(() => { try { memDb.close(); } catch {} });