feat: archivar grupos y notificar admins; ocultar grupos archivados

Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
webui
brobert 2 weeks ago
parent f87337d60b
commit 7ba2770422

@ -44,7 +44,7 @@ export const GET: RequestHandler = async (event) => {
`a.user_id = ?`, `a.user_id = ?`,
`(COALESCE(t.completed, 0) = 1 OR t.completed_at IS NOT NULL)`, `(COALESCE(t.completed, 0) = 1 OR t.completed_at IS NOT NULL)`,
`t.completed_at IS NOT NULL AND t.completed_at >= strftime('%Y-%m-%d %H:%M:%f','now','-24 hours')`, `t.completed_at IS NOT NULL AND t.completed_at >= strftime('%Y-%m-%d %H:%M:%f','now','-24 hours')`,
`(t.group_id IS NULL OR (EXISTS (SELECT 1 FROM allowed_groups ag WHERE ag.group_id = t.group_id AND ag.status = 'allowed') AND EXISTS (SELECT 1 FROM group_members gm WHERE gm.group_id = t.group_id AND gm.user_id = ? AND gm.is_active = 1)))` `(t.group_id IS NULL OR (EXISTS (SELECT 1 FROM allowed_groups ag WHERE ag.group_id = t.group_id AND ag.status = 'allowed') AND EXISTS (SELECT 1 FROM group_members gm WHERE gm.group_id = t.group_id AND gm.user_id = ? AND gm.is_active = 1) AND EXISTS (SELECT 1 FROM groups g WHERE g.id = t.group_id AND COALESCE(g.active,1)=1 AND COALESCE(g.archived,0)=0)))`
]; ];
const params: any[] = [userId, userId]; const params: any[] = [userId, userId];
@ -132,7 +132,7 @@ export const GET: RequestHandler = async (event) => {
`a.user_id = ?`, `a.user_id = ?`,
`COALESCE(t.completed, 0) = 0`, `COALESCE(t.completed, 0) = 0`,
`t.completed_at IS NULL`, `t.completed_at IS NULL`,
`(t.group_id IS NULL OR (EXISTS (SELECT 1 FROM allowed_groups ag WHERE ag.group_id = t.group_id AND ag.status = 'allowed') AND EXISTS (SELECT 1 FROM group_members gm WHERE gm.group_id = t.group_id AND gm.user_id = ? AND gm.is_active = 1)))` `(t.group_id IS NULL OR (EXISTS (SELECT 1 FROM allowed_groups ag WHERE ag.group_id = t.group_id AND ag.status = 'allowed') AND EXISTS (SELECT 1 FROM group_members gm WHERE gm.group_id = t.group_id AND gm.user_id = ? AND gm.is_active = 1) AND EXISTS (SELECT 1 FROM groups g WHERE g.id = t.group_id AND COALESCE(g.active,1)=1 AND COALESCE(g.archived,0)=0)))`
]; ];
const params: any[] = [userId]; const params: any[] = [userId];

@ -114,8 +114,13 @@ export const PATCH: RequestHandler = async (event) => {
`SELECT 1 FROM group_members WHERE group_id = ? AND user_id = ? AND is_active = 1 LIMIT 1` `SELECT 1 FROM group_members WHERE group_id = ? AND user_id = ? AND is_active = 1 LIMIT 1`
) )
.get(groupId, userId); .get(groupId, userId);
const gstatus = db
.prepare(
`SELECT 1 FROM groups WHERE id = ? AND COALESCE(active,1)=1 AND COALESCE(archived,0)=0 LIMIT 1`
)
.get(groupId);
if (!allowed || !active) { if (!allowed || !active || !gstatus) {
return new Response('Forbidden', { status: 403 }); return new Response('Forbidden', { status: 403 });
} }
} else { } else {

@ -49,7 +49,7 @@ export const GET: RequestHandler = async ({ params, request }) => {
.prepare( .prepare(
`SELECT t.id, t.description, t.due_date, g.name AS group_name `SELECT t.id, t.description, t.due_date, g.name AS group_name
FROM tasks t FROM tasks t
INNER JOIN groups g ON g.id = t.group_id AND COALESCE(g.active,1)=1 INNER JOIN groups g ON g.id = t.group_id AND COALESCE(g.active,1)=1 AND COALESCE(g.archived,0)=0
INNER JOIN group_members gm ON gm.group_id = t.group_id AND gm.user_id = ? AND gm.is_active = 1 INNER JOIN group_members gm ON gm.group_id = t.group_id AND gm.user_id = ? AND gm.is_active = 1
INNER JOIN allowed_groups ag ON ag.group_id = t.group_id AND ag.status = 'allowed' INNER JOIN allowed_groups ag ON ag.group_id = t.group_id AND ag.status = 'allowed'
WHERE COALESCE(t.completed, 0) = 0 WHERE COALESCE(t.completed, 0) = 0

@ -40,6 +40,15 @@ export const GET: RequestHandler = async ({ params, request }) => {
if (row.revoked_at) return new Response('Gone', { status: 410 }); if (row.revoked_at) return new Response('Gone', { status: 410 });
if (String(row.type) !== 'group' || !row.group_id) return new Response('Not Found', { status: 404 }); if (String(row.type) !== 'group' || !row.group_id) return new Response('Not Found', { status: 404 });
// Validar estado del grupo (activo y no archivado); en caso contrario, tratar como feed caducado
const gRow = db
.prepare(`SELECT COALESCE(active,1) as active, COALESCE(archived,0) as archived FROM groups WHERE id = ?`)
.get(row.group_id) as any;
if (!gRow || Number(gRow.active || 0) !== 1 || Number(gRow.archived || 0) === 1) {
db.prepare(`UPDATE calendar_tokens SET last_used_at = ? WHERE id = ?`).run(toIsoSql(), row.id);
return new Response('Gone', { status: 410 });
}
const today = new Date(); const today = new Date();
const startYmd = ymdUTC(today); const startYmd = ymdUTC(today);
const endYmd = ymdUTC(addMonthsUTC(today, icsHorizonMonths)); const endYmd = ymdUTC(addMonthsUTC(today, icsHorizonMonths));

@ -389,5 +389,19 @@ export const migrations: Migration[] = [
} }
} catch {} } catch {}
} }
},
{
version: 14,
name: 'groups-archived-flag',
checksum: 'v14-groups-archived-2025-10-19',
up: (db: Database) => {
try {
const cols = db.query(`PRAGMA table_info(groups)`).all() as any[];
const hasArchived = Array.isArray(cols) && cols.some((c: any) => String(c.name) === 'archived');
if (!hasArchived) {
db.exec(`ALTER TABLE groups ADD COLUMN archived BOOLEAN NOT NULL DEFAULT 0;`);
}
} catch {}
}
} }
]; ];

@ -104,6 +104,86 @@ export class AdminService {
return [{ recipient: sender, message: `✅ Grupo deshabilitado: ${ctx.groupId}` }]; return [{ recipient: sender, message: `✅ Grupo deshabilitado: ${ctx.groupId}` }];
} }
// /admin archivar-aquí
if (rest === 'archivar-aquí' || rest === 'archivar-aqui' || rest === 'archive here' || rest === 'archive-aqui' || rest === 'archive-aquí') {
if (!isGroupId(ctx.groupId)) {
return [{ recipient: sender, message: ' Este comando se debe usar dentro de un grupo.' }];
}
this.dbInstance.transaction(() => {
this.dbInstance.prepare(`
UPDATE groups
SET active = 0, archived = 1, last_verified = strftime('%Y-%m-%d %H:%M:%f','now')
WHERE id = ?
`).run(ctx.groupId);
this.dbInstance.prepare(`
UPDATE calendar_tokens
SET revoked_at = strftime('%Y-%m-%d %H:%M:%f','now')
WHERE group_id = ? AND revoked_at IS NULL
`).run(ctx.groupId);
this.dbInstance.prepare(`
UPDATE group_members
SET is_active = 0
WHERE group_id = ? AND is_active = 1
`).run(ctx.groupId);
})();
try { AllowedGroups.setStatus(ctx.groupId, 'blocked'); } catch {}
return [{ recipient: sender, message: `📦 Grupo archivado: ${ctx.groupId}` }];
}
// /admin archivar-grupo <jid>
if (rest.startsWith('archivar-grupo ') || rest.startsWith('archive-group ')) {
const arg = rest.startsWith('archivar-grupo ') ? rest.slice('archivar-grupo '.length).trim() : rest.slice('archive-group '.length).trim();
if (!isGroupId(arg)) {
return [{ recipient: sender, message: '⚠️ Debes indicar un group_id válido terminado en @g.us' }];
}
this.dbInstance.transaction(() => {
this.dbInstance.prepare(`
UPDATE groups
SET active = 0, archived = 1, last_verified = strftime('%Y-%m-%d %H:%M:%f','now')
WHERE id = ?
`).run(arg);
this.dbInstance.prepare(`
UPDATE calendar_tokens
SET revoked_at = strftime('%Y-%m-%d %H:%M:%f','now')
WHERE group_id = ? AND revoked_at IS NULL
`).run(arg);
this.dbInstance.prepare(`
UPDATE group_members
SET is_active = 0
WHERE group_id = ? AND is_active = 1
`).run(arg);
})();
try { AllowedGroups.setStatus(arg, 'blocked'); } catch {}
return [{ recipient: sender, message: `📦 Grupo archivado: ${arg}` }];
}
// /admin borrar-aquí
if (rest === 'borrar-aquí' || rest === 'borrar-aqui' || rest === 'delete here' || rest === 'delete-here') {
if (!isGroupId(ctx.groupId)) {
return [{ recipient: sender, message: ' Este comando se debe usar dentro de un grupo.' }];
}
this.dbInstance.transaction(() => {
this.dbInstance.prepare(`DELETE FROM tasks WHERE group_id = ?`).run(ctx.groupId);
this.dbInstance.prepare(`DELETE FROM groups WHERE id = ?`).run(ctx.groupId);
try { this.dbInstance.prepare(`DELETE FROM allowed_groups WHERE group_id = ?`).run(ctx.groupId); } catch {}
})();
return [{ recipient: sender, message: `🗑️ Grupo borrado y datos asociados eliminados: ${ctx.groupId}` }];
}
// /admin borrar-grupo <jid>
if (rest.startsWith('borrar-grupo ') || rest.startsWith('delete-group ')) {
const arg = rest.startsWith('borrar-grupo ') ? rest.slice('borrar-grupo '.length).trim() : rest.slice('delete-group '.length).trim();
if (!isGroupId(arg)) {
return [{ recipient: sender, message: '⚠️ Debes indicar un group_id válido terminado en @g.us' }];
}
this.dbInstance.transaction(() => {
this.dbInstance.prepare(`DELETE FROM tasks WHERE group_id = ?`).run(arg);
this.dbInstance.prepare(`DELETE FROM groups WHERE id = ?`).run(arg);
try { this.dbInstance.prepare(`DELETE FROM allowed_groups WHERE group_id = ?`).run(arg); } catch {}
})();
return [{ recipient: sender, message: `🗑️ Grupo borrado y datos asociados eliminados: ${arg}` }];
}
// /admin allow all // /admin allow all
if ( if (
rest === 'allow all' || rest === 'allow all' ||

@ -4,6 +4,7 @@ import { normalizeWhatsAppId } from '../utils/whatsapp';
import { Metrics } from './metrics'; import { Metrics } from './metrics';
import { IdentityService } from './identity'; import { IdentityService } from './identity';
import { AllowedGroups } from './allowed-groups'; import { AllowedGroups } from './allowed-groups';
import { ResponseQueue } from './response-queue';
// In-memory cache for active groups // In-memory cache for active groups
// const activeGroupsCache = new Map<string, string>(); // groupId -> groupName // const activeGroupsCache = new Map<string, string>(); // groupId -> groupName
@ -86,14 +87,82 @@ export class GroupSyncService {
console.log(' Grupos crudos de la API:', JSON.stringify(groups, null, 2)); console.log(' Grupos crudos de la API:', JSON.stringify(groups, null, 2));
console.log(' Sin filtrar por comunidad (modo multicomunidad). Total grupos:', groups.length); console.log(' Sin filtrar por comunidad (modo multicomunidad). Total grupos:', groups.length);
const dbGroupsBefore = this.dbInstance.prepare('SELECT id, active FROM groups').all(); const dbGroupsBefore = this.dbInstance.prepare('SELECT id, active, COALESCE(archived,0) AS archived, name FROM groups').all();
console.log(' Grupos en DB antes de upsert:', dbGroupsBefore); console.log(' Grupos en DB antes de upsert:', dbGroupsBefore);
const result = await this.upsertGroups(groups); const result = await this.upsertGroups(groups);
const dbGroupsAfter = this.dbInstance.prepare('SELECT id, active FROM groups').all(); const dbGroupsAfter = this.dbInstance.prepare('SELECT id, active, COALESCE(archived,0) AS archived, name FROM groups').all();
console.log(' Grupos en DB después de upsert:', dbGroupsAfter); console.log(' Grupos en DB después de upsert:', dbGroupsAfter);
// Detectar grupos que pasaron de activos a inactivos (y no están archivados) en este sync
try {
const beforeMap = new Map<string, { active: number; archived: number; name?: string | null }>();
for (const r of dbGroupsBefore as any[]) {
beforeMap.set(String(r.id), { active: Number(r.active || 0), archived: Number(r.archived || 0), name: r.name ? String(r.name) : null });
}
const afterMap = new Map<string, { active: number; archived: number; name?: string | null }>();
for (const r of dbGroupsAfter as any[]) {
afterMap.set(String(r.id), { active: Number(r.active || 0), archived: Number(r.archived || 0), name: r.name ? String(r.name) : null });
}
const newlyDeactivated: Array<{ id: string; name: string | null }> = [];
for (const [id, b] of beforeMap.entries()) {
const a = afterMap.get(id);
if (!a) continue;
if (Number(b.active) === 1 && Number(a.active) === 0 && Number(a.archived) === 0) {
newlyDeactivated.push({ id, name: a.name ?? b.name ?? null });
}
}
if (newlyDeactivated.length > 0) {
// Revocar tokens y desactivar membresía para estos grupos
this.dbInstance.transaction(() => {
for (const g of newlyDeactivated) {
this.dbInstance.prepare(`
UPDATE calendar_tokens
SET revoked_at = strftime('%Y-%m-%d %H:%M:%f','now')
WHERE group_id = ? AND revoked_at IS NULL
`).run(g.id);
this.dbInstance.prepare(`
UPDATE group_members
SET is_active = 0
WHERE group_id = ? AND is_active = 1
`).run(g.id);
}
})();
// Notificar a admins (omitir en tests)
if (String(process.env.NODE_ENV || '').toLowerCase() !== 'test') {
const adminSet = new Set<string>();
const rawAdmins = String(process.env.ADMIN_USERS || '');
for (const token of rawAdmins.split(',').map(s => s.trim()).filter(Boolean)) {
const n = normalizeWhatsAppId(token);
if (n) adminSet.add(n);
}
const admins = Array.from(adminSet);
if (admins.length > 0) {
const messages = [];
const makeMsg = (g: { id: string; name: string | null }) => {
const label = g.name ? `${g.name} (${g.id})` : g.id;
return `⚠️ El grupo ${label} parece haber dejado de existir o no está disponible.\n\nAcciones disponibles:\n- Archivar (recomendado): /admin archivar-grupo ${g.id}\n- Borrar definitivamente: /admin borrar-grupo ${g.id}`;
};
for (const g of newlyDeactivated) {
const msg = makeMsg(g);
for (const admin of admins) {
messages.push({ recipient: admin, message: msg });
}
}
if (messages.length > 0) {
try { await ResponseQueue.add(messages as any); } catch (e) { console.warn('No se pudo encolar notificación a admins:', e); }
}
}
}
}
} catch (e) {
console.warn('⚠️ Error al procesar grupos desactivados para notificación/limpieza:', e);
}
// Completar labels faltantes en allowed_groups usando todos los grupos devueltos por la API // Completar labels faltantes en allowed_groups usando todos los grupos devueltos por la API
try { (AllowedGroups as any).dbInstance = this.dbInstance; this.fillMissingAllowedGroupLabels(groups); } catch {} try { (AllowedGroups as any).dbInstance = this.dbInstance; this.fillMissingAllowedGroupLabels(groups); } catch {}

@ -577,6 +577,12 @@ export class TaskService {
FROM tasks t FROM tasks t
LEFT JOIN groups g ON g.id = t.group_id LEFT JOIN groups g ON g.id = t.group_id
WHERE COALESCE(t.completed, 0) = 0 AND t.completed_at IS NULL 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
))
ORDER BY ORDER BY
CASE WHEN t.due_date IS NULL THEN 1 ELSE 0 END, CASE WHEN t.due_date IS NULL THEN 1 ELSE 0 END,
t.due_date ASC, t.due_date ASC,

Loading…
Cancel
Save