diff --git a/src/services/reminders.ts b/src/services/reminders.ts index d6e71ba..175f213 100644 --- a/src/services/reminders.ts +++ b/src/services/reminders.ts @@ -6,6 +6,7 @@ import { ContactsService } from './contacts'; import { GroupSyncService } from './group-sync'; import { ICONS } from '../utils/icons'; import { codeId, formatDDMM, bold, italic } from '../utils/formatting'; +import { AllowedGroups } from './allowed-groups'; type UserPreference = { user_id: string; @@ -88,6 +89,12 @@ export class RemindersService { WHERE reminder_freq IN ('daily', 'weekly', 'weekdays') `).all() as UserPreference[]; + // Determinar si aplicar gating por grupos + const enforce = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase() === 'enforce'; + if (enforce) { + try { (AllowedGroups as any).dbInstance = this.dbInstance; } catch {} + } + for (const pref of rows) { // Evitar duplicado el mismo día if (pref.last_reminded_on === todayYMD) continue; @@ -102,9 +109,9 @@ export class RemindersService { if (pref.reminder_freq === 'weekly' && weekday !== 'Mon') continue; try { - const items = TaskService.listUserPending(pref.user_id, 10); - const total = TaskService.countUserPending(pref.user_id); - if (!items || items.length === 0 || total === 0) { + const allItems = TaskService.listUserPending(pref.user_id, 10); + const items = enforce ? allItems.filter(t => !t.group_id || AllowedGroups.isAllowed(t.group_id)) : allItems; + if (!items || items.length === 0) { // No enviar si no hay tareas; no marcamos last_reminded_on para permitir enviar si aparecen más tarde hoy continue; } @@ -152,14 +159,15 @@ export class RemindersService { sections.push(...rendered); } - if (total > items.length) { - sections.push(italic(`… y ${total - items.length} más`)); - } + // No contamos "total" global para evitar inconsistencias de grupos bloqueados; dejamos el resumen por ítems visibles. // (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); + let memberGroups = GroupSyncService.getFreshMemberGroupsForUser(pref.user_id); + if (enforce) { + memberGroups = memberGroups.filter(gid => AllowedGroups.isAllowed(gid)); + } for (const gid of memberGroups) { const unassigned = TaskService.listGroupUnassigned(gid, 10); if (unassigned.length > 0) { diff --git a/src/tasks/service.ts b/src/tasks/service.ts index 7558643..18f34a7 100644 --- a/src/tasks/service.ts +++ b/src/tasks/service.ts @@ -1,5 +1,7 @@ import type { Database } from 'bun:sqlite'; import { db, ensureUserExists } from '../db'; +import { AllowedGroups } from '../services/allowed-groups'; +import { isGroupId } from '../utils/whatsapp'; type CreateTaskInput = { description: string; @@ -59,6 +61,18 @@ export class TaskService { // 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 = ?`).get(groupIdToInsert); if (!exists) { diff --git a/tests/unit/services/reminders.gating.test.ts b/tests/unit/services/reminders.gating.test.ts new file mode 100644 index 0000000..5bf18fd --- /dev/null +++ b/tests/unit/services/reminders.gating.test.ts @@ -0,0 +1,75 @@ +import { describe, it, beforeEach, afterEach, expect } from 'bun:test'; +import Database from 'bun:sqlite'; +import { initializeDatabase } from '../../../src/db'; +import { TaskService } from '../../../src/tasks/service'; +import { RemindersService } from '../../../src/services/reminders'; +import { AllowedGroups } from '../../../src/services/allowed-groups'; +import { ResponseQueue } from '../../../src/services/response-queue'; + +describe('RemindersService - gating por grupos en modo enforce', () => { + const envBackup = process.env; + let memdb: Database; + let originalAdd: any; + let sent: any[] = []; + + beforeEach(() => { + process.env = { ...envBackup, NODE_ENV: 'test', GROUP_GATING_MODE: 'enforce', TZ: 'Europe/Madrid' }; + memdb = new Database(':memory:'); + initializeDatabase(memdb); + (TaskService as any).dbInstance = memdb; + (RemindersService as any).dbInstance = memdb; + (AllowedGroups as any).dbInstance = memdb; + + // Stub de ResponseQueue + originalAdd = (ResponseQueue as any).add; + (ResponseQueue as any).add = async (msgs: any[]) => { sent.push(...msgs); }; + sent = []; + + // Preferencias del usuario receptor + memdb.exec(` + INSERT INTO user_preferences (user_id, reminder_freq, reminder_time, last_reminded_on, updated_at) + VALUES ('34600123456', 'daily', '00:00', NULL, strftime('%Y-%m-%d %H:%M:%f','now')) + ON CONFLICT(user_id) DO UPDATE SET + reminder_freq = excluded.reminder_freq, + reminder_time = excluded.reminder_time, + last_reminded_on = NULL, + updated_at = excluded.updated_at + `); + + // Sembrar grupos y estados + memdb.exec(`INSERT OR IGNORE INTO groups (id) VALUES ('ok@g.us')`); + memdb.exec(`INSERT OR IGNORE INTO groups (id) VALUES ('na@g.us')`); + AllowedGroups.setStatus('ok@g.us', 'allowed', 'OK'); + AllowedGroups.setStatus('na@g.us', 'allowed', 'NA'); // inicialmente allowed para que las tareas se creen con group_id + + // Crear dos tareas, una en cada grupo, asignadas al usuario + TaskService.createTask( + { description: 'Tarea OK', created_by: '34600123456', group_id: 'ok@g.us', due_date: null }, + [{ user_id: '34600123456', assigned_by: '34600123456' }] + ); + TaskService.createTask( + { description: 'Tarea NA', created_by: '34600123456', group_id: 'na@g.us', due_date: null }, + [{ user_id: '34600123456', assigned_by: '34600123456' }] + ); + + // Cambiar a bloqueado uno de los grupos antes de correr los recordatorios + AllowedGroups.setStatus('na@g.us', 'blocked', 'NA'); + }); + + afterEach(() => { + (ResponseQueue as any).add = originalAdd; + memdb.close(); + process.env = envBackup; + }); + + it('omite tareas de grupos no allowed en los recordatorios', async () => { + await RemindersService.runOnce(new Date()); + + expect(sent.length).toBe(1); + const msg = String(sent[0].message); + + // Debe mencionar el grupo allowed y omitir el bloqueado + expect(msg).toContain('ok@g.us'); + expect(msg).not.toContain('na@g.us'); + }); +}); diff --git a/tests/unit/tasks/service.gating.test.ts b/tests/unit/tasks/service.gating.test.ts new file mode 100644 index 0000000..6b36194 --- /dev/null +++ b/tests/unit/tasks/service.gating.test.ts @@ -0,0 +1,84 @@ +import { describe, it, beforeEach, afterEach, expect } from 'bun:test'; +import Database from 'bun:sqlite'; +import { initializeDatabase } from '../../../src/db'; +import { TaskService } from '../../../src/tasks/service'; +import { AllowedGroups } from '../../../src/services/allowed-groups'; + +function seedGroup(db: Database, groupId: string) { + // Intento genérico de seed para la tabla groups con columnas comunes + const cols = db.query(`PRAGMA table_info(groups)`).all() as any[]; + const colNames = cols.map(c => String(c.name)); + const values: Record = {}; + for (const c of colNames) { + if (c === 'id') values[c] = groupId; + else if (c === 'name' || c === 'title' || c === 'subject') values[c] = 'Test Group'; + else if (c === 'is_active' || c === 'active') values[c] = 1; + else if (c.endsWith('_at')) values[c] = new Date().toISOString().replace('T', ' ').replace('Z', ''); + else if (c === 'created_by') values[c] = 'tester'; + // Para otras columnas dejaremos NULL si lo permite + } + const colsList = Object.keys(values); + const placeholders = colsList.map(() => '?').join(', '); + const sql = `INSERT OR IGNORE INTO groups (${colsList.join(', ')}) VALUES (${placeholders})`; + db.prepare(sql).run(...colsList.map(k => values[k])); +} + +describe('TaskService - gating en creación con group_id (enforce)', () => { + const envBackup = process.env; + let memdb: Database; + + beforeEach(() => { + process.env = { ...envBackup, NODE_ENV: 'test', GROUP_GATING_MODE: 'enforce' }; + memdb = new Database(':memory:'); + initializeDatabase(memdb); + (TaskService as any).dbInstance = memdb; + (AllowedGroups as any).dbInstance = memdb; + }); + + afterEach(() => { + process.env = envBackup; + memdb.close(); + }); + + it('fuerza group_id=null cuando el grupo no está allowed', () => { + const gid = 'na@g.us'; + seedGroup(memdb, gid); + AllowedGroups.setStatus(gid, 'blocked'); + + const taskId = TaskService.createTask( + { + description: 'Probar gating', + due_date: null, + group_id: gid, + created_by: '34600123456', + }, + [{ user_id: '34600123456', assigned_by: '34600123456' }] + ); + + const row = memdb + .query(`SELECT group_id FROM tasks WHERE id = ?`) + .get(taskId) as any; + expect(row?.group_id).toBeNull(); + }); + + it('conserva group_id cuando el grupo está allowed', () => { + const gid = 'ok@g.us'; + seedGroup(memdb, gid); + AllowedGroups.setStatus(gid, 'allowed'); + + const taskId = TaskService.createTask( + { + description: 'Tarea en grupo allowed', + due_date: null, + group_id: gid, + created_by: '34600123456', + }, + [{ user_id: '34600123456', assigned_by: '34600123456' }] + ); + + const row = memdb + .query(`SELECT group_id FROM tasks WHERE id = ?`) + .get(taskId) as any; + expect(String(row?.group_id)).toBe(gid); + }); +});