import type { Database } from 'bun:sqlite'; import { db, ensureUserExists } from '../db'; import { AllowedGroups } from '../services/allowed-groups'; import { isGroupId } from '../utils/whatsapp'; import { ResponseQueue } from '../services/response-queue'; import { Metrics } from '../services/metrics'; type CreateTaskInput = { description: string; due_date?: string | null; // Expect 'YYYY-MM-DD' or null group_id?: string | null; // Full JID (e.g., 'xxx@g.us') or null created_by: string; // Normalized user ID }; type CreateAssignmentInput = { user_id: string; // Normalized user ID assigned_by: string; // Normalized user ID (typically created_by) }; export class TaskService { static dbInstance: Database = db; static createTask(task: CreateTaskInput, assignments: CreateAssignmentInput[] = []): number { const MAX_DISPLAY_CODE = 9999; const runTx = this.dbInstance.transaction(() => { const pickNextDisplayCode = (): number => { const rows = this.dbInstance .prepare(` SELECT display_code FROM tasks WHERE display_code IS NOT NULL AND ( COALESCE(completed, 0) = 0 OR (completed_at IS NOT NULL AND completed_at >= strftime('%Y-%m-%d %H:%M:%f','now','-24 hours')) ) ORDER BY display_code ASC `) .all() as Array<{ display_code: number }>; let expect = 1; for (const r of rows) { const dc = Number(r.display_code || 0); if (dc < expect) continue; if (dc === expect) { expect++; if (expect > MAX_DISPLAY_CODE) break; continue; } // encontrado hueco break; } if (expect > MAX_DISPLAY_CODE) { throw new Error('No hay códigos disponibles (límite alcanzado)'); } return expect; }; const insertTask = this.dbInstance.prepare(` INSERT INTO tasks (description, due_date, group_id, created_by, display_code) VALUES (?, ?, ?, ?, ?) `); const ensuredCreator = ensureUserExists(task.created_by, this.dbInstance); if (!ensuredCreator) { throw new Error('No se pudo asegurar created_by'); } // Si el group_id no existe en la tabla groups, usar NULL para no violar la FK let groupIdToInsert = task.group_id ?? null; // Etapa 5: en modo 'enforce', si es un grupo no permitido, forzar a NULL (compatibilidad) try { const mode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase(); if (groupIdToInsert && isGroupId(groupIdToInsert) && mode === 'enforce') { try { (AllowedGroups as any).dbInstance = this.dbInstance; } catch {} if (!AllowedGroups.isAllowed(groupIdToInsert)) { groupIdToInsert = null; } } } catch {} if (groupIdToInsert) { const exists = this.dbInstance.prepare(`SELECT 1 FROM groups WHERE id = ? AND COALESCE(is_community,0) = 0`).get(groupIdToInsert); if (!exists) { groupIdToInsert = null; } } // Elegir display_code global reutilizable entre tareas activas const displayCode = pickNextDisplayCode(); const runResult = insertTask.run( task.description, task.due_date ?? null, groupIdToInsert, ensuredCreator, displayCode ); const taskId = Number((runResult as any).lastInsertRowid); if (assignments.length > 0) { const insertAssignment = this.dbInstance.prepare(` INSERT INTO task_assignments (task_id, user_id, assigned_by) VALUES (?, ?, ?) `); // Evitar duplicados por (task_id, user_id) tras asegurar usuarios const seen = new Set(); for (const a of assignments) { const ensuredUser = ensureUserExists(a.user_id, this.dbInstance); if (!ensuredUser) continue; if (seen.has(ensuredUser)) continue; seen.add(ensuredUser); const ensuredAssigner = ensureUserExists(a.assigned_by || ensuredCreator, this.dbInstance) || ensuredCreator; insertAssignment.run(taskId, ensuredUser, ensuredAssigner); } } return taskId; }); return runTx(); } // 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; display_code: number | null; assignees: string[]; }> { const rows = this.dbInstance .prepare(` SELECT id, description, due_date, group_id, display_code FROM tasks WHERE group_id = ? AND COALESCE(completed, 0) = 0 AND 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, display_code: r.display_code != null ? Number(r.display_code) : 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; display_code: number | null; assignees: string[]; }> { const rows = this.dbInstance .prepare(` SELECT t.id, t.description, t.due_date, t.group_id, t.display_code FROM tasks t INNER JOIN task_assignments a ON a.task_id = t.id WHERE a.user_id = ? AND 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(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, display_code: r.display_code != null ? Number(r.display_code) : null, assignees, }; }); } // 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 COALESCE(completed, 0) = 0 AND 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 COALESCE(t.completed, 0) = 0 AND 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'; task?: { id: number; description: string; due_date: string | null; display_code: number | null }; } { const ensured = ensureUserExists(completedBy, this.dbInstance); const existing = this.dbInstance .prepare(` SELECT id, description, due_date, completed, completed_at, display_code, group_id 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, display_code: existing.display_code != null ? Number(existing.display_code) : 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); // Fase 2: reacción ✅ al completar dentro del TTL y con gating try { const rxEnabled = String(process.env.REACTIONS_ENABLED || 'false').toLowerCase(); const enabled = ['true','1','yes','on'].includes(rxEnabled); if (enabled) { let origin: any = null; try { origin = this.dbInstance.prepare(` SELECT chat_id, message_id, created_at, participant, from_me FROM task_origins WHERE task_id = ? `).get(taskId) as any; } catch { origin = this.dbInstance.prepare(` SELECT chat_id, message_id, created_at FROM task_origins WHERE task_id = ? `).get(taskId) as any; } if (origin && origin.chat_id && origin.message_id) { const chatId = String(origin.chat_id); const scope = String(process.env.REACTIONS_SCOPE || 'groups').toLowerCase(); if (scope === 'all' || isGroupId(chatId)) { // TTL desde REACTIONS_TTL_DAYS (usar tal cual; default 14 si inválido) const ttlDaysEnv = Number(process.env.REACTIONS_TTL_DAYS); const ttlDays = Number.isFinite(ttlDaysEnv) && ttlDaysEnv > 0 ? ttlDaysEnv : 14; const maxAgeMs = ttlDays * 24 * 60 * 60 * 1000; const createdRaw = String(origin.created_at || ''); const createdIso = createdRaw.includes('T') ? createdRaw : (createdRaw.replace(' ', 'T') + 'Z'); const createdMs = Date.parse(createdIso); const withinTtl = Number.isFinite(createdMs) ? (Date.now() - createdMs <= maxAgeMs) : false; if (withinTtl) { // Gating 'enforce' para grupos let allowed = true; if (isGroupId(chatId)) { try { (AllowedGroups as any).dbInstance = this.dbInstance; } catch {} const mode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase(); if (mode === 'enforce') { try { allowed = AllowedGroups.isAllowed(chatId); } catch { allowed = true; } } } if (allowed) { // Encolar reacción ✅ con idempotencia; no bloquear si falla const participant = origin && origin.participant ? String(origin.participant) : undefined; const fromMe = (origin && (origin.from_me === 1 || origin.from_me === true)) ? true : undefined; ResponseQueue.enqueueReaction(chatId, String(origin.message_id), '✅', { participant, fromMe }) .catch(() => {}); } } } } } } catch {} return { status: 'updated', task: { id: Number(existing.id), description: String(existing.description || ''), due_date: existing.due_date ? String(existing.due_date) : null, display_code: existing.display_code != null ? Number(existing.display_code) : null, }, }; } // Listar pendientes sin dueño del grupo (limite por defecto 10) static listGroupUnassigned(groupId: string, limit: number = 10): Array<{ id: number; description: string; due_date: string | null; group_id: string | null; display_code: number | null; assignees: string[]; }> { const rows = this.dbInstance .prepare(` SELECT id, description, due_date, group_id, display_code FROM tasks WHERE group_id = ? AND COALESCE(completed, 0) = 0 AND completed_at IS NULL AND NOT EXISTS ( SELECT 1 FROM task_assignments ta WHERE ta.task_id = tasks.id ) ORDER BY CASE WHEN due_date IS NULL THEN 1 ELSE 0 END, due_date ASC, id ASC LIMIT ? `) .all(groupId, 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, display_code: r.display_code != null ? Number(r.display_code) : null, assignees: [], })); } // Contar pendientes sin dueño del grupo (sin límite) static countGroupUnassigned(groupId: string): number { const row = this.dbInstance .prepare(` SELECT COUNT(*) as cnt FROM tasks t WHERE t.group_id = ? AND COALESCE(t.completed, 0) = 0 AND t.completed_at IS NULL AND NOT EXISTS ( SELECT 1 FROM task_assignments a WHERE a.task_id = t.id ) `) .get(groupId) as any; return Number(row?.cnt || 0); } // Tomar tarea (claim): idempotente static claimTask(taskId: number, userId: string): { status: 'claimed' | 'already' | 'not_found' | 'completed'; task?: { id: number; description: string; due_date: string | null; display_code: number | null }; } { const ensuredUser = ensureUserExists(userId, this.dbInstance); if (!ensuredUser) { throw new Error('No se pudo asegurar el usuario'); } const existing = this.dbInstance .prepare(` SELECT id, description, due_date, completed, completed_at, display_code FROM tasks WHERE id = ? `) .get(taskId) as any; if (!existing) { return { status: 'not_found' }; } if (existing.completed || existing.completed_at) { return { status: 'completed', task: { id: Number(existing.id), description: String(existing.description || ''), due_date: existing.due_date ? String(existing.due_date) : null, display_code: existing.display_code != null ? Number(existing.display_code) : null, }, }; } const already = this.dbInstance .prepare(`SELECT 1 FROM task_assignments WHERE task_id = ? AND user_id = ?`) .get(taskId, ensuredUser); if (already) { return { status: 'already', task: { id: Number(existing.id), description: String(existing.description || ''), due_date: existing.due_date ? String(existing.due_date) : null, display_code: existing.display_code != null ? Number(existing.display_code) : null, }, }; } const insertAssignment = this.dbInstance.prepare(` INSERT OR IGNORE INTO task_assignments (task_id, user_id, assigned_by) VALUES (?, ?, ?) `); this.dbInstance.transaction(() => { insertAssignment.run(taskId, ensuredUser, ensuredUser); })(); return { status: 'claimed', task: { id: Number(existing.id), description: String(existing.description || ''), due_date: existing.due_date ? String(existing.due_date) : null, display_code: existing.display_code != null ? Number(existing.display_code) : null, }, }; } // Soltar tarea (unassign): idempotente static unassignTask(taskId: number, userId: string): { status: 'unassigned' | 'not_assigned' | 'not_found' | 'completed' | 'forbidden_personal'; task?: { id: number; description: string; due_date: string | null; display_code: number | null }; now_unassigned?: boolean; // true si tras soltar no quedan asignados } { const ensuredUser = ensureUserExists(userId, this.dbInstance); if (!ensuredUser) { throw new Error('No se pudo asegurar el usuario'); } const existing = this.dbInstance .prepare(` SELECT id, description, due_date, completed, completed_at, display_code FROM tasks WHERE id = ? `) .get(taskId) as any; if (!existing) { return { status: 'not_found' }; } if (existing.completed || existing.completed_at) { return { status: 'completed', task: { id: Number(existing.id), description: String(existing.description || ''), due_date: existing.due_date ? String(existing.due_date) : null, display_code: existing.display_code != null ? Number(existing.display_code) : null, }, }; } // Regla: no permitir soltar si es tarea personal y el usuario es el único asignatario try { const stats = this.dbInstance.prepare(` SELECT COUNT(*) AS cnt, SUM(CASE WHEN user_id = ? THEN 1 ELSE 0 END) AS mine FROM task_assignments WHERE task_id = ? `).get(ensuredUser, taskId) as any; const cnt = Number(stats?.cnt || 0); const mine = Number(stats?.mine || 0) > 0; if ((existing.group_id == null || existing.group_id === null) && cnt === 1 && mine) { return { status: 'forbidden_personal', task: { id: Number(existing.id), description: String(existing.description || ''), due_date: existing.due_date ? String(existing.due_date) : null, display_code: existing.display_code != null ? Number(existing.display_code) : null, }, now_unassigned: false, }; } } catch {} const deleteStmt = this.dbInstance.prepare(` DELETE FROM task_assignments WHERE task_id = ? AND user_id = ? `); const result = deleteStmt.run(taskId, ensuredUser) as any; const cntRow = this.dbInstance .prepare(`SELECT COUNT(*) as cnt FROM task_assignments WHERE task_id = ?`) .get(taskId) as any; const remaining = Number(cntRow?.cnt || 0); if (result.changes && result.changes > 0) { return { status: 'unassigned', task: { id: Number(existing.id), description: String(existing.description || ''), due_date: existing.due_date ? String(existing.due_date) : null, display_code: existing.display_code != null ? Number(existing.display_code) : null, }, now_unassigned: remaining === 0, }; } return { status: 'not_assigned', task: { id: Number(existing.id), description: String(existing.description || ''), due_date: existing.due_date ? String(existing.due_date) : null, display_code: existing.display_code != null ? Number(existing.display_code) : null, }, now_unassigned: remaining === 0, }; } // ===== Helpers adicionales para consumidores (Etapa 3) ===== // Devuelve datos básicos de una tarea, o null si no existe static getTaskById(taskId: number): { id: number; description: string; due_date: string | null; group_id: string | null; display_code: number | null; completed: number; completed_at: string | null; } | null { const row = this.dbInstance.prepare(` SELECT id, description, due_date, group_id, display_code, COALESCE(completed, 0) as completed, completed_at FROM tasks WHERE id = ? `).get(taskId) as any; if (!row) return null; return { id: Number(row.id), description: String(row.description || ''), due_date: row.due_date ? String(row.due_date) : null, group_id: row.group_id ? String(row.group_id) : null, display_code: row.display_code != null ? Number(row.display_code) : null, completed: Number(row.completed || 0), completed_at: row.completed_at ? String(row.completed_at) : null, }; } // Buscar tarea activa por display_code global static getActiveTaskByDisplayCode(displayCode: number): { id: number; description: string; due_date: string | null; display_code: number | null } | null { const row = this.dbInstance.prepare(` SELECT id, description, due_date, display_code FROM tasks WHERE display_code = ? AND COALESCE(completed, 0) = 0 AND completed_at IS NULL LIMIT 1 `).get(displayCode) as any; if (!row) return null; return { id: Number(row.id), description: String(row.description || ''), due_date: row.due_date ? String(row.due_date) : null, display_code: row.display_code != null ? Number(row.display_code) : null, }; } // Lista tareas sin responsable para múltiples grupos. // Implementación simple: reutiliza el método existente por grupo. static listUnassignedByGroups(groupIds: string[], limitPerGroup: number = 10): Map> { const out = new Map>(); if (!Array.isArray(groupIds) || groupIds.length === 0) return out; for (const gid of groupIds) { const rows = this.listGroupUnassigned(gid, limitPerGroup); if (rows.length > 0) { out.set(gid, rows); } } 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 AND (t.group_id IS NULL OR EXISTS ( SELECT 1 FROM groups g2 WHERE g2.id = t.group_id AND COALESCE(g2.active,1)=1 AND COALESCE(g2.archived,0)=0 AND COALESCE(g2.is_community,0)=0 )) 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 t WHERE COALESCE(t.completed, 0) = 0 AND t.completed_at IS NULL AND (t.group_id IS NULL OR EXISTS ( SELECT 1 FROM groups g2 WHERE g2.id = t.group_id AND COALESCE(g2.active,1)=1 AND COALESCE(g2.archived,0)=0 AND COALESCE(g2.is_community,0)=0 )) `) .get() as any; return Number(row?.cnt || 0); } }