From 218080ae4582d0ead6833eae2b136c8ee1e2a538 Mon Sep 17 00:00:00 2001 From: borja Date: Sat, 6 Sep 2025 17:57:20 +0200 Subject: [PATCH] feat: Fase 3: listar pendientes y completar tareas por DM (dd/MM) Co-authored-by: aider (openrouter/openai/gpt-5) --- src/db.ts | 13 ++++ src/server.ts | 38 +++++++--- src/services/command.ts | 141 ++++++++++++++++++++++++++++++++++++- src/services/group-sync.ts | 3 +- src/tasks/model.ts | 1 + src/tasks/service.ts | 133 +++++++++++++++++++++++++++++++++- 6 files changed, 317 insertions(+), 12 deletions(-) diff --git a/src/db.ts b/src/db.ts index 21531f6..7ca548a 100644 --- a/src/db.ts +++ b/src/db.ts @@ -54,7 +54,9 @@ export function initializeDatabase(instance: Database) { completed_at TEXT NULL, group_id TEXT NULL, -- Normalized group ID created_by TEXT NOT NULL, -- Normalized user ID + completed_by TEXT NULL, -- Normalized user ID who completed the task FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (completed_by) REFERENCES users(id) ON DELETE SET NULL, FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE SET NULL -- Optional: Link task to group ); `); @@ -104,6 +106,17 @@ export function initializeDatabase(instance: Database) { } catch (e) { console.warn('[initializeDatabase] Skipped adding response_queue.metadata column:', e); } + + // Migration: ensure 'completed_by' column exists on tasks (to record who completed) + try { + const cols = instance.query(`PRAGMA table_info('tasks')`).all() as any[]; + const hasCompletedBy = Array.isArray(cols) && cols.some((c: any) => c.name === 'completed_by'); + if (!hasCompletedBy) { + instance.exec(`ALTER TABLE tasks ADD COLUMN completed_by TEXT NULL;`); + } + } catch (e) { + console.warn('[initializeDatabase] Skipped adding tasks.completed_by column:', e); + } } /** diff --git a/src/server.ts b/src/server.ts index 55ee4aa..c3702d8 100644 --- a/src/server.ts +++ b/src/server.ts @@ -38,6 +38,17 @@ export class WebhookServer { return `${proto}://${host}`; } + private static getMessageText(message: any): string { + if (!message || typeof message !== 'object') return ''; + const text = + message.conversation || + message?.extendedTextMessage?.text || + message?.imageMessage?.caption || + message?.videoMessage?.caption || + ''; + return typeof text === 'string' ? text.trim() : ''; + } + static async handleRequest(request: Request): Promise { // Health check endpoint const url = new URL(request.url); @@ -118,7 +129,7 @@ export class WebhookServer { } static async handleMessageUpsert(data: any) { - if (!data?.key?.remoteJid || !data.message || !data.message.conversation) { + if (!data?.key?.remoteJid || !data.message) { if (process.env.NODE_ENV !== 'test') { console.log('⚠️ Invalid message format - missing required fields'); console.log(data); @@ -126,6 +137,14 @@ export class WebhookServer { return; } + const messageText = WebhookServer.getMessageText(data.message); + if (!messageText) { + if (process.env.NODE_ENV !== 'test') { + console.log('⚠️ Empty or unsupported message content'); + } + return; + } + // Normalize sender ID for consistency and validation const normalizedSenderId = normalizeWhatsAppId(data.key.participant); if (!normalizedSenderId) { @@ -160,14 +179,15 @@ export class WebhookServer { return; } - // Forward to command service only if: - // 1. It's a text message (has conversation field) - // 2. Starts with /t or /tarea command - const messageText = data.message.conversation; - const trimmedMessage = typeof messageText === 'string' ? messageText.trim() : ''; - if (trimmedMessage.startsWith('/tarea') || trimmedMessage.startsWith('/t')) { - // Extraer menciones desde el mensaje - const mentions = data.message?.contextInfo?.mentionedJid || []; + // Forward to command service only if it's a text-ish message and starts with /t or /tarea + const messageTextTrimmed = messageText.trim(); + if (messageTextTrimmed.startsWith('/tarea') || messageTextTrimmed.startsWith('/t')) { + // Extraer menciones desde el mensaje (varios formatos) + const mentions = data.message?.contextInfo?.mentionedJid + || data.message?.extendedTextMessage?.contextInfo?.mentionedJid + || data.message?.imageMessage?.contextInfo?.mentionedJid + || data.message?.videoMessage?.contextInfo?.mentionedJid + || []; // Asegurar que CommandService y TaskService usen la misma DB (tests/producción) (CommandService as any).dbInstance = WebhookServer.dbInstance; diff --git a/src/services/command.ts b/src/services/command.ts index 353001d..de97063 100644 --- a/src/services/command.ts +++ b/src/services/command.ts @@ -118,6 +118,17 @@ export class CommandService { }; const action = ACTION_ALIASES[rawAction] || rawAction; + // Helper para fechas dd/MM + const formatDDMM = (ymd?: string | null): string | null => { + if (!ymd) return null; + const parts = String(ymd).split('-'); + if (parts.length >= 3) { + const [Y, M, D] = parts; + if (D && M) return `${D}/${M}`; + } + return String(ymd); + }; + if (!action || action === 'ayuda') { const help = [ 'Guía rápida:', @@ -132,6 +143,133 @@ export class CommandService { }]; } + // Listar pendientes + if (action === 'ver') { + const scope = (tokens[2] || '').toLowerCase() || (isGroupId(context.groupId) ? 'grupo' : 'mis'); + const LIMIT = 10; + + // Ver grupo + if (scope === 'grupo') { + if (!isGroupId(context.groupId)) { + return [{ + recipient: context.sender, + message: 'Este comando se usa en grupos. Prueba: /t ver mis' + }]; + } + if (!GroupSyncService.isGroupActive(context.groupId)) { + return [{ + recipient: context.sender, + message: '⚠️ Este grupo no está activo.' + }]; + } + const items = TaskService.listGroupPending(context.groupId, LIMIT); + const groupName = GroupSyncService.activeGroupsCache.get(context.groupId) || context.groupId; + + if (items.length === 0) { + return [{ + recipient: context.sender, + message: `No hay pendientes en ${groupName}.` + }]; + } + + const rendered = await Promise.all(items.map(async (t) => { + const names = await Promise.all( + (t.assignees || []).map(async (uid) => (await ContactsService.getDisplayName(uid)) || uid) + ); + const owner = + (t.assignees?.length || 0) === 0 + ? '👥 sin dueño' + : `${t.assignees!.length > 1 ? '👥' : '👤'} ${names.join(', ')}`; + const datePart = t.due_date ? ` — 📅 ${formatDDMM(t.due_date)}` : ''; + return `- ${t.id}) “*${t.description || '(sin descripción)'}*”${datePart} — ${owner}`; + })); + + return [{ + recipient: context.sender, + message: [groupName, ...rendered].join('\n') + }]; + } + + // Ver mis + const items = TaskService.listUserPending(context.sender, LIMIT); + if (items.length === 0) { + return [{ + recipient: context.sender, + message: 'No tienes tareas pendientes.' + }]; + } + + // Agrupar por grupo + const byGroup = new Map(); + for (const t of items) { + const key = t.group_id || '(sin grupo)'; + const arr = byGroup.get(key) || []; + arr.push(t); + byGroup.set(key, arr); + } + + const sections: string[] = []; + for (const [groupId, arr] of byGroup.entries()) { + const groupName = + (groupId && GroupSyncService.activeGroupsCache.get(groupId)) || + (groupId && groupId !== '(sin grupo)' ? groupId : 'Sin grupo'); + + sections.push(groupName); + const rendered = await Promise.all(arr.map(async (t) => { + const names = await Promise.all( + (t.assignees || []).map(async (uid) => (await ContactsService.getDisplayName(uid)) || uid) + ); + const owner = + (t.assignees?.length || 0) === 0 + ? '👥 sin dueño' + : `${t.assignees!.length > 1 ? '👥' : '👤'} ${names.join(', ')}`; + const datePart = t.due_date ? ` — 📅 ${formatDDMM(t.due_date)}` : ''; + return `- ${t.id}) “*${t.description || '(sin descripción)'}*”${datePart} — ${owner}`; + })); + sections.push(...rendered); + } + + return [{ + recipient: context.sender, + message: sections.join('\n') + }]; + } + + // Completar tarea + if (action === 'completar') { + const idToken = tokens[2]; + const id = idToken ? parseInt(idToken, 10) : NaN; + if (!id || Number.isNaN(id)) { + return [{ + recipient: context.sender, + message: 'Uso: /t x ' + }]; + } + + const res = TaskService.completeTask(id, context.sender); + if (res.status === 'not_found') { + return [{ + recipient: context.sender, + message: `⚠️ Tarea ${id} no encontrada.` + }]; + } + + const who = (await ContactsService.getDisplayName(context.sender)) || context.sender; + if (res.status === 'already') { + const due = res.task?.due_date ? ` — 📅 ${formatDDMM(res.task?.due_date)}` : ''; + return [{ + recipient: context.sender, + message: `ℹ️ ${id} ya estaba completada — “*${res.task?.description || '(sin descripción)'}*”${due}` + }]; + } + + const due = res.task?.due_date ? ` — 📅 ${formatDDMM(res.task?.due_date)}` : ''; + return [{ + recipient: context.sender, + message: `✔️ ${id} completada — “*${res.task?.description || '(sin descripción)'}*”${due}\nGracias, ${who}.` + }]; + } + if (action !== 'nueva') { return [{ recipient: context.sender, @@ -228,7 +366,8 @@ export class CommandService { // 1) Ack al creador con formato compacto const ackHeader = `✅ ${taskId} “*${description || '(sin descripción)'}*”`; const ackLines: string[] = [ackHeader]; - if (dueDate) ackLines.push(`📅 ${dueDate}`); + const dueFmt = formatDDMM(dueDate); + if (dueFmt) ackLines.push(`📅 ${dueFmt}`); if (assignmentUserIds.length === 0) { ackLines.push(`👥 sin dueño${groupName ? ` (${groupName})` : ''}`); } else { diff --git a/src/services/group-sync.ts b/src/services/group-sync.ts index 1165b4f..5e550b1 100644 --- a/src/services/group-sync.ts +++ b/src/services/group-sync.ts @@ -1,3 +1,4 @@ +import type { Database } from 'bun:sqlite'; import { db } from '../db'; // Environment variables will be mocked in tests const env = process.env; @@ -193,7 +194,7 @@ export class GroupSyncService { return groups; } catch (error) { console.error('❌ Failed to fetch groups:', { - error: error instanceof Error ? error.message : String(e), + error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined }); throw error; diff --git a/src/tasks/model.ts b/src/tasks/model.ts index b985a48..d050e3f 100644 --- a/src/tasks/model.ts +++ b/src/tasks/model.ts @@ -5,6 +5,7 @@ export interface Task { due_date: Date | null; completed: boolean; completed_at: Date | null; + completed_by: string | null; group_id: string; // WhatsApp group ID where task was created created_by: string; // WhatsApp user ID of task creator } diff --git a/src/tasks/service.ts b/src/tasks/service.ts index eaa6ac0..ea5361a 100644 --- a/src/tasks/service.ts +++ b/src/tasks/service.ts @@ -63,5 +63,136 @@ export class TaskService { return runTx(); } - // We'll add more methods here as we progress + // Listar pendientes del grupo (limite por defecto 10) + static listGroupPending(groupId: string, limit: number = 10): Array<{ + id: number; + description: string; + due_date: string | null; + group_id: string | null; + assignees: string[]; + }> { + const rows = this.dbInstance + .prepare(` + SELECT id, description, due_date, group_id + FROM tasks + WHERE group_id = ? + AND (completed = 0 OR completed_at IS NULL) + ORDER BY + CASE WHEN due_date IS NULL THEN 1 ELSE 0 END, + due_date ASC, + id ASC + LIMIT ? + `) + .all(groupId, limit) as any[]; + + const getAssignees = this.dbInstance.prepare(` + SELECT user_id FROM task_assignments + WHERE task_id = ? + ORDER BY assigned_at ASC + `); + + return rows.map((r) => { + const assigneesRows = getAssignees.all(r.id) as any[]; + const assignees = assigneesRows.map((a) => String(a.user_id)); + return { + 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, + assignees, + }; + }); + } + + // Listar pendientes asignadas al usuario (limite por defecto 10) + static listUserPending(userId: string, limit: number = 10): Array<{ + id: number; + description: string; + due_date: string | null; + group_id: string | null; + assignees: string[]; + }> { + const rows = this.dbInstance + .prepare(` + SELECT t.id, t.description, t.due_date, t.group_id + 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) + ORDER BY + CASE WHEN t.due_date IS NULL THEN 1 ELSE 0 END, + t.due_date ASC, + t.id ASC + LIMIT ? + `) + .all(userId, limit) as any[]; + + const getAssignees = this.dbInstance.prepare(` + SELECT user_id FROM task_assignments + WHERE task_id = ? + ORDER BY assigned_at ASC + `); + + return rows.map((r) => { + const assigneesRows = getAssignees.all(r.id) as any[]; + const assignees = assigneesRows.map((a) => String(a.user_id)); + return { + 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, + assignees, + }; + }); + } + + // Completar tarea: registra quién completó e idempotente + static completeTask(taskId: number, completedBy: string): { + status: 'updated' | 'already' | 'not_found'; + task?: { id: number; description: string; due_date: string | null }; + } { + const ensured = ensureUserExists(completedBy, this.dbInstance); + + const existing = this.dbInstance + .prepare(` + SELECT id, description, due_date, completed, completed_at + FROM tasks + WHERE id = ? + `) + .get(taskId) as any; + + if (!existing) { + return { status: 'not_found' }; + } + + if (existing.completed || existing.completed_at) { + return { + status: 'already', + task: { + id: Number(existing.id), + description: String(existing.description || ''), + due_date: existing.due_date ? String(existing.due_date) : null, + }, + }; + } + + this.dbInstance + .prepare(` + UPDATE tasks + SET completed = 1, + completed_at = strftime('%Y-%m-%d %H:%M:%f', 'now'), + completed_by = ? + WHERE id = ? + `) + .run(ensured, taskId); + + return { + status: 'updated', + task: { + id: Number(existing.id), + description: String(existing.description || ''), + due_date: existing.due_date ? String(existing.due_date) : null, + }, + }; + } }