diff --git a/.env.example b/.env.example index b8beb6b..2cd8f7f 100644 --- a/.env.example +++ b/.env.example @@ -21,6 +21,14 @@ TZ="Europe/Madrid" # Zona horaria usada para "hoy/mañana" y render de fe # Intervalo en milisegundos; por defecto 86400000 (24h). En desarrollo puede bajarse (mínimo recomendable 10000ms). # GROUP_SYNC_INTERVAL_MS=86400000 +# Membresías (opcional) +# Edad máxima (ms) para considerar "fresca" la snapshot de miembros de un grupo. Por defecto 86400000 (24h). +# MAX_MEMBERS_SNAPSHOT_AGE_MS=86400000 +# Si "true", se aplica validación estricta de membresía (solo con snapshot fresca). Por defecto false. +# GROUP_MEMBERS_ENFORCE=false +# Si "true", los recordatorios incluirán una sección de "sin responsable" filtrada por tus grupos con membresía activa (snapshot fresca). Por defecto false. +# REMINDERS_INCLUDE_UNASSIGNED_FROM_MEMBERSHIP=false + # Notificaciones (opcional) # Si se pone a "true", el bot enviará un breve resumen al grupo al crear una tarea (por defecto false). # NOTIFY_GROUP_ON_CREATE=false diff --git a/README.md b/README.md index 0401b4e..09558ed 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,9 @@ Un chatbot de WhatsApp para gestionar tareas en grupos, integrado con Evolution - NOTIFY_GROUP_ON_CREATE: si “true”, envía resumen al grupo al crear (por defecto false). - GROUP_SYNC_INTERVAL_MS: intervalo de sync de grupos; por defecto 24h (mín 10s en desarrollo). - GROUP_MEMBERS_SYNC_INTERVAL_MS: intervalo de sync de miembros; por defecto 6h (mín 10s en desarrollo). + - MAX_MEMBERS_SNAPSHOT_AGE_MS: edad máxima (ms) para considerar "fresca" la snapshot de miembros; por defecto 24h. + - GROUP_MEMBERS_ENFORCE: si "true", aplica validación estricta de membresía cuando la snapshot es fresca; por defecto false. + - REMINDERS_INCLUDE_UNASSIGNED_FROM_MEMBERSHIP: si "true", añade sección "sin responsable" en recordatorios solo de tus grupos con membresía activa; por defecto false. - RATE_LIMIT_PER_MIN: límite por usuario (tokens/min); por defecto 15. - RATE_LIMIT_BURST: capacidad del bucket; por defecto = RATE_LIMIT_PER_MIN. - Opcionales — cola de respuestas diff --git a/src/services/command.ts b/src/services/command.ts index 59fae17..0b103cf 100644 --- a/src/services/command.ts +++ b/src/services/command.ts @@ -227,7 +227,7 @@ export class CommandService { }]; } - // Ver todos: "tus tareas" + "sin dueño (grupo actual)" si estás en un grupo + // Ver todos: "tus tareas" + "sin responsable" de grupos donde eres miembro activo (snapshot fresca) if (scope === 'todos') { const sections: string[] = []; @@ -275,13 +275,16 @@ export class CommandService { sections.push('No tienes tareas pendientes.'); } - // Si se invoca en un grupo activo, añadir "sin dueño" de ese grupo - if (isGroupId(context.groupId)) { - if (!GroupSyncService.isGroupActive(context.groupId)) { - sections.push('⚠️ Este grupo no está activo.'); - } else { - const groupName = GroupSyncService.activeGroupsCache.get(context.groupId) || context.groupId; - const unassigned = TaskService.listGroupUnassigned(context.groupId, LIMIT); + // Añadir "sin responsable" de grupos donde el usuario es miembro activo y snapshot fresca + const memberGroups = GroupSyncService.getFreshMemberGroupsForUser(context.sender); + if (memberGroups.length > 0) { + const perGroup = TaskService.listUnassignedByGroups(memberGroups, LIMIT); + for (const gid of perGroup.keys()) { + const unassigned = perGroup.get(gid)!; + const groupName = + (gid && GroupSyncService.activeGroupsCache.get(gid)) || + gid; + if (unassigned.length > 0) { sections.push(`${groupName} — Sin responsable`); const renderedUnassigned = unassigned.map((t) => { @@ -291,16 +294,14 @@ export class CommandService { }); sections.push(...renderedUnassigned); - const totalUnassigned = TaskService.countGroupUnassigned(context.groupId); + const totalUnassigned = TaskService.countGroupUnassigned(gid); if (totalUnassigned > unassigned.length) { sections.push(`… y ${totalUnassigned - unassigned.length} más`); } - } else { - sections.push(`${groupName} — Sin responsable\n(no hay tareas sin responsable)`); } } } else { - // En DM: nota instructiva + // Si no hay snapshot fresca de membresía, mantenemos una nota instructiva mínima sections.push('ℹ️ Para ver tareas sin responsable de un grupo, usa “/t ver sin” desde ese grupo.'); } @@ -324,6 +325,16 @@ export class CommandService { message: '⚠️ Este grupo no está activo.' }]; } + // Enforcement opcional basado en membresía si la snapshot es fresca + const enforce = String(process.env.GROUP_MEMBERS_ENFORCE || '').toLowerCase() === 'true'; + const fresh = GroupSyncService.isSnapshotFresh(context.groupId); + if (enforce && fresh && !GroupSyncService.isUserActiveInGroup(context.sender, context.groupId)) { + return [{ + recipient: context.sender, + message: 'No puedes ver las tareas de este grupo porque no apareces como miembro activo. Pide acceso a un admin si crees que es un error.' + }]; + } + const items = TaskService.listGroupPending(context.groupId, LIMIT); const groupName = GroupSyncService.activeGroupsCache.get(context.groupId) || context.groupId; @@ -410,7 +421,7 @@ export class CommandService { }]; } - // Completar tarea + // Completar tarea (con validación opcional de membresía) if (action === 'completar') { const idToken = tokens[2]; const id = idToken ? parseInt(idToken, 10) : NaN; @@ -421,15 +432,29 @@ export class CommandService { }]; } - const res = TaskService.completeTask(id, context.sender); - if (res.status === 'not_found') { + const task = TaskService.getTaskById(id); + if (!task) { return [{ recipient: context.sender, message: `⚠️ Tarea ${id} no encontrada.` }]; } + const enforce = String(process.env.GROUP_MEMBERS_ENFORCE || '').toLowerCase() === 'true'; + if (task.group_id && GroupSyncService.isSnapshotFresh(task.group_id) && enforce && !GroupSyncService.isUserActiveInGroup(context.sender, task.group_id)) { + return [{ + recipient: context.sender, + message: 'No puedes completar esta tarea porque no apareces como miembro activo del grupo.' + }]; + } + const res = TaskService.completeTask(id, context.sender); const who = (await ContactsService.getDisplayName(context.sender)) || context.sender; + if (res.status === 'not_found') { + return [{ + recipient: context.sender, + message: `⚠️ Tarea ${id} no encontrada.` + }]; + } if (res.status === 'already') { const due = res.task?.due_date ? ` — 📅 ${formatDDMM(res.task?.due_date)}` : ''; return [{ @@ -445,7 +470,7 @@ export class CommandService { }]; } - // Tomar tarea + // Tomar tarea (con validación opcional de membresía) if (action === 'tomar') { const idToken = tokens[2]; const id = idToken ? parseInt(idToken, 10) : NaN; @@ -456,6 +481,21 @@ export class CommandService { }]; } + const task = TaskService.getTaskById(id); + if (!task) { + return [{ + recipient: context.sender, + message: `⚠️ Tarea ${id} no encontrada.` + }]; + } + const enforce = String(process.env.GROUP_MEMBERS_ENFORCE || '').toLowerCase() === 'true'; + if (task.group_id && GroupSyncService.isSnapshotFresh(task.group_id) && enforce && !GroupSyncService.isUserActiveInGroup(context.sender, task.group_id)) { + return [{ + recipient: context.sender, + message: 'No puedes tomar esta tarea: no apareces como miembro activo del grupo. Pide acceso a un admin si crees que es un error.' + }]; + } + const res = TaskService.claimTask(id, context.sender); const due = res.task?.due_date ? ` — 📅 ${formatDDMM(res.task?.due_date)}` : ''; @@ -484,7 +524,7 @@ export class CommandService { }]; } - // Soltar tarea + // Soltar tarea (con validación opcional de membresía) if (action === 'soltar') { const idToken = tokens[2]; const id = idToken ? parseInt(idToken, 10) : NaN; @@ -495,6 +535,21 @@ export class CommandService { }]; } + const task = TaskService.getTaskById(id); + if (!task) { + return [{ + recipient: context.sender, + message: `⚠️ Tarea ${id} no encontrada.` + }]; + } + const enforce = String(process.env.GROUP_MEMBERS_ENFORCE || '').toLowerCase() === 'true'; + if (task.group_id && GroupSyncService.isSnapshotFresh(task.group_id) && enforce && !GroupSyncService.isUserActiveInGroup(context.sender, task.group_id)) { + return [{ + recipient: context.sender, + message: 'No puedes soltar esta tarea porque no apareces como miembro activo del grupo.' + }]; + } + const res = TaskService.unassignTask(id, context.sender); const due = res.task?.due_date ? ` — 📅 ${formatDDMM(res.task?.due_date)}` : ''; diff --git a/src/services/group-sync.ts b/src/services/group-sync.ts index 80f5f18..4367c10 100644 --- a/src/services/group-sync.ts +++ b/src/services/group-sync.ts @@ -609,4 +609,71 @@ export class GroupSyncService { this._membersTimer = null; } } + + // ===== Helpers de membresía y snapshot (Etapa 3) ===== + + private static get MAX_SNAPSHOT_AGE_MS(): number { + const raw = Number(process.env.MAX_MEMBERS_SNAPSHOT_AGE_MS); + return Number.isFinite(raw) && raw > 0 ? raw : 24 * 60 * 60 * 1000; // 24h por defecto + } + + /** + * Devuelve true si la snapshot de un grupo es "fresca" según MAX_SNAPSHOT_AGE_MS. + * Considera no fresca si no hay registro/fecha. + */ + public static isSnapshotFresh(groupId: string, nowMs: number = Date.now()): boolean { + try { + const row = this.dbInstance.prepare(`SELECT last_verified FROM groups WHERE id = ?`).get(groupId) as any; + const lv = row?.last_verified ? String(row.last_verified) : null; + if (!lv) return false; + // Persistimos 'YYYY-MM-DD HH:MM:SS[.mmm]'. Convertimos a ISO-like para Date.parse + const iso = lv.includes('T') ? lv : (lv.replace(' ', 'T') + 'Z'); + const ms = Date.parse(iso); + if (!Number.isFinite(ms)) return false; + return (nowMs - ms) <= this.MAX_SNAPSHOT_AGE_MS; + } catch { + return false; + } + } + + /** + * ¿El usuario figura como miembro activo del grupo? + */ + public static isUserActiveInGroup(userId: string, groupId: string): boolean { + if (!userId || !groupId) return false; + const row = this.dbInstance.prepare(` + SELECT 1 + FROM group_members + WHERE group_id = ? AND user_id = ? AND is_active = 1 + LIMIT 1 + `).get(groupId, userId); + return !!row; + } + + /** + * Devuelve todos los group_ids activos donde el usuario figura activo. + * Filtra también por grupos activos en la tabla groups. + */ + public static getActiveGroupIdsForUser(userId: string): string[] { + if (!userId) return []; + const rows = this.dbInstance.prepare(` + SELECT gm.group_id AS id + FROM group_members gm + JOIN groups g ON g.id = gm.group_id + WHERE gm.user_id = ? AND gm.is_active = 1 AND g.active = 1 + `).all(userId) as any[]; + const set = new Set(); + for (const r of rows) { + if (r?.id) set.add(String(r.id)); + } + return Array.from(set); + } + + /** + * Devuelve los group_ids donde el usuario es miembro activo y cuya snapshot es fresca. + */ + public static getFreshMemberGroupsForUser(userId: string): string[] { + const gids = this.getActiveGroupIdsForUser(userId); + return gids.filter(gid => this.isSnapshotFresh(gid)); + } } diff --git a/src/services/reminders.ts b/src/services/reminders.ts index 03cf01c..ad86370 100644 --- a/src/services/reminders.ts +++ b/src/services/reminders.ts @@ -152,6 +152,32 @@ export class RemindersService { sections.push(`… y ${total - items.length} más`); } + // (Etapa 3) Sección opcional de "sin responsable" filtrada por membresía activa + snapshot fresca. + const includeUnassigned = String(process.env.REMINDERS_INCLUDE_UNASSIGNED_FROM_MEMBERSHIP || '').toLowerCase() === 'true'; + if (includeUnassigned) { + const memberGroups = GroupSyncService.getFreshMemberGroupsForUser(pref.user_id); + for (const gid of memberGroups) { + const unassigned = TaskService.listGroupUnassigned(gid, 10); + if (unassigned.length > 0) { + const groupName = + (gid && GroupSyncService.activeGroupsCache.get(gid)) || + gid; + sections.push(`${groupName} — Sin responsable`); + const renderedUnassigned = unassigned.map((t) => { + const isOverdue = t.due_date ? t.due_date < todayYMD : false; + const datePart = t.due_date ? ` — ${isOverdue ? `${ICONS.warn} ` : ''}${ICONS.date} ${formatDDMM(t.due_date)}` : ''; + return `- ${t.id}) _${t.description || '(sin descripción)'}_${datePart} — ${ICONS.unassigned} sin responsable`; + }); + sections.push(...renderedUnassigned); + + const totalUnassigned = TaskService.countGroupUnassigned(gid); + if (totalUnassigned > unassigned.length) { + sections.push(`… y ${totalUnassigned - unassigned.length} más`); + } + } + } + } + await ResponseQueue.add([{ recipient: pref.user_id, message: sections.join('\n') diff --git a/src/tasks/service.ts b/src/tasks/service.ts index 49fbe52..2e295a8 100644 --- a/src/tasks/service.ts +++ b/src/tasks/service.ts @@ -417,4 +417,63 @@ export class TaskService { 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; + completed: number; + completed_at: string | null; + } | null { + const row = this.dbInstance.prepare(` + SELECT + id, + description, + due_date, + group_id, + 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, + completed: Number(row.completed || 0), + completed_at: row.completed_at ? String(row.completed_at) : 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; + } }