feat: aplicar gating por AllowedGroups en tareas y recordatorios

Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
pull/1/head
borja 1 month ago
parent c51cb3f124
commit db9f71abaa

@ -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) {

@ -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) {

@ -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');
});
});

@ -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<string, any> = {};
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);
});
});
Loading…
Cancel
Save