feat: Fase 3: listar pendientes y completar tareas por DM (dd/MM)

Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
pull/1/head
borja 2 months ago
parent 736f9f3b8f
commit 218080ae45

@ -54,7 +54,9 @@ export function initializeDatabase(instance: Database) {
completed_at TEXT NULL, completed_at TEXT NULL,
group_id TEXT NULL, -- Normalized group ID group_id TEXT NULL, -- Normalized group ID
created_by TEXT NOT NULL, -- Normalized user 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 (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 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) { } catch (e) {
console.warn('[initializeDatabase] Skipped adding response_queue.metadata column:', 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);
}
} }
/** /**

@ -38,6 +38,17 @@ export class WebhookServer {
return `${proto}://${host}`; 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<Response> { static async handleRequest(request: Request): Promise<Response> {
// Health check endpoint // Health check endpoint
const url = new URL(request.url); const url = new URL(request.url);
@ -118,7 +129,7 @@ export class WebhookServer {
} }
static async handleMessageUpsert(data: any) { 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') { if (process.env.NODE_ENV !== 'test') {
console.log('⚠️ Invalid message format - missing required fields'); console.log('⚠️ Invalid message format - missing required fields');
console.log(data); console.log(data);
@ -126,6 +137,14 @@ export class WebhookServer {
return; 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 // Normalize sender ID for consistency and validation
const normalizedSenderId = normalizeWhatsAppId(data.key.participant); const normalizedSenderId = normalizeWhatsAppId(data.key.participant);
if (!normalizedSenderId) { if (!normalizedSenderId) {
@ -160,14 +179,15 @@ export class WebhookServer {
return; return;
} }
// Forward to command service only if: // Forward to command service only if it's a text-ish message and starts with /t or /tarea
// 1. It's a text message (has conversation field) const messageTextTrimmed = messageText.trim();
// 2. Starts with /t or /tarea command if (messageTextTrimmed.startsWith('/tarea') || messageTextTrimmed.startsWith('/t')) {
const messageText = data.message.conversation; // Extraer menciones desde el mensaje (varios formatos)
const trimmedMessage = typeof messageText === 'string' ? messageText.trim() : ''; const mentions = data.message?.contextInfo?.mentionedJid
if (trimmedMessage.startsWith('/tarea') || trimmedMessage.startsWith('/t')) { || data.message?.extendedTextMessage?.contextInfo?.mentionedJid
// Extraer menciones desde el mensaje || data.message?.imageMessage?.contextInfo?.mentionedJid
const mentions = data.message?.contextInfo?.mentionedJid || []; || data.message?.videoMessage?.contextInfo?.mentionedJid
|| [];
// Asegurar que CommandService y TaskService usen la misma DB (tests/producción) // Asegurar que CommandService y TaskService usen la misma DB (tests/producción)
(CommandService as any).dbInstance = WebhookServer.dbInstance; (CommandService as any).dbInstance = WebhookServer.dbInstance;

@ -118,6 +118,17 @@ export class CommandService {
}; };
const action = ACTION_ALIASES[rawAction] || rawAction; 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') { if (!action || action === 'ayuda') {
const help = [ const help = [
'Guía rápida:', '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<string, typeof items>();
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 <id>'
}];
}
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') { if (action !== 'nueva') {
return [{ return [{
recipient: context.sender, recipient: context.sender,
@ -228,7 +366,8 @@ export class CommandService {
// 1) Ack al creador con formato compacto // 1) Ack al creador con formato compacto
const ackHeader = `${taskId} “*${description || '(sin descripción)'}*”`; const ackHeader = `${taskId} “*${description || '(sin descripción)'}*”`;
const ackLines: string[] = [ackHeader]; const ackLines: string[] = [ackHeader];
if (dueDate) ackLines.push(`📅 ${dueDate}`); const dueFmt = formatDDMM(dueDate);
if (dueFmt) ackLines.push(`📅 ${dueFmt}`);
if (assignmentUserIds.length === 0) { if (assignmentUserIds.length === 0) {
ackLines.push(`👥 sin dueño${groupName ? ` (${groupName})` : ''}`); ackLines.push(`👥 sin dueño${groupName ? ` (${groupName})` : ''}`);
} else { } else {

@ -1,3 +1,4 @@
import type { Database } from 'bun:sqlite';
import { db } from '../db'; import { db } from '../db';
// Environment variables will be mocked in tests // Environment variables will be mocked in tests
const env = process.env; const env = process.env;
@ -193,7 +194,7 @@ export class GroupSyncService {
return groups; return groups;
} catch (error) { } catch (error) {
console.error('❌ Failed to fetch groups:', { 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 stack: error instanceof Error ? error.stack : undefined
}); });
throw error; throw error;

@ -5,6 +5,7 @@ export interface Task {
due_date: Date | null; due_date: Date | null;
completed: boolean; completed: boolean;
completed_at: Date | null; completed_at: Date | null;
completed_by: string | null;
group_id: string; // WhatsApp group ID where task was created group_id: string; // WhatsApp group ID where task was created
created_by: string; // WhatsApp user ID of task creator created_by: string; // WhatsApp user ID of task creator
} }

@ -63,5 +63,136 @@ export class TaskService {
return runTx(); 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,
},
};
}
} }

Loading…
Cancel
Save