diff --git a/apps/web/src/routes/api/me/tasks/+server.ts b/apps/web/src/routes/api/me/tasks/+server.ts index a3e59b3..3b5bbd4 100644 --- a/apps/web/src/routes/api/me/tasks/+server.ts +++ b/apps/web/src/routes/api/me/tasks/+server.ts @@ -44,7 +44,7 @@ export const GET: RequestHandler = async (event) => { `a.user_id = ?`, `(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.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]; @@ -132,7 +132,7 @@ export const GET: RequestHandler = async (event) => { `a.user_id = ?`, `COALESCE(t.completed, 0) = 0`, `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]; diff --git a/apps/web/src/routes/api/tasks/[id]/+server.ts b/apps/web/src/routes/api/tasks/[id]/+server.ts index f9a62f4..92900a7 100644 --- a/apps/web/src/routes/api/tasks/[id]/+server.ts +++ b/apps/web/src/routes/api/tasks/[id]/+server.ts @@ -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` ) .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 }); } } else { diff --git a/apps/web/src/routes/ics/aggregate/[token].ics/+server.ts b/apps/web/src/routes/ics/aggregate/[token].ics/+server.ts index 41b1bf9..04327b8 100644 --- a/apps/web/src/routes/ics/aggregate/[token].ics/+server.ts +++ b/apps/web/src/routes/ics/aggregate/[token].ics/+server.ts @@ -49,7 +49,7 @@ export const GET: RequestHandler = async ({ params, request }) => { .prepare( `SELECT t.id, t.description, t.due_date, g.name AS group_name 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 allowed_groups ag ON ag.group_id = t.group_id AND ag.status = 'allowed' WHERE COALESCE(t.completed, 0) = 0 diff --git a/apps/web/src/routes/ics/group/[token].ics/+server.ts b/apps/web/src/routes/ics/group/[token].ics/+server.ts index 3588439..b3c50f5 100644 --- a/apps/web/src/routes/ics/group/[token].ics/+server.ts +++ b/apps/web/src/routes/ics/group/[token].ics/+server.ts @@ -40,6 +40,15 @@ export const GET: RequestHandler = async ({ params, request }) => { 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 }); + // 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 startYmd = ymdUTC(today); const endYmd = ymdUTC(addMonthsUTC(today, icsHorizonMonths)); diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index c182899..99c4ed0 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -389,5 +389,19 @@ export const migrations: Migration[] = [ } } 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 {} + } } ]; diff --git a/src/services/admin.ts b/src/services/admin.ts index e8ce6aa..24c1cd8 100644 --- a/src/services/admin.ts +++ b/src/services/admin.ts @@ -104,6 +104,86 @@ export class AdminService { 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 + 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 + 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 if ( rest === 'allow all' || diff --git a/src/services/group-sync.ts b/src/services/group-sync.ts index d9ddde6..17690bd 100644 --- a/src/services/group-sync.ts +++ b/src/services/group-sync.ts @@ -4,6 +4,7 @@ import { normalizeWhatsAppId } from '../utils/whatsapp'; import { Metrics } from './metrics'; import { IdentityService } from './identity'; import { AllowedGroups } from './allowed-groups'; +import { ResponseQueue } from './response-queue'; // In-memory cache for active groups // const activeGroupsCache = new Map(); // groupId -> groupName @@ -86,14 +87,82 @@ export class GroupSyncService { console.log('ℹ️ Grupos crudos de la API:', JSON.stringify(groups, null, 2)); 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); 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); + // Detectar grupos que pasaron de activos a inactivos (y no están archivados) en este sync + try { + const beforeMap = new Map(); + 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(); + 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(); + 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 try { (AllowedGroups as any).dbInstance = this.dbInstance; this.fillMissingAllowedGroupLabels(groups); } catch {} diff --git a/src/tasks/service.ts b/src/tasks/service.ts index b41fb0f..81afe44 100644 --- a/src/tasks/service.ts +++ b/src/tasks/service.ts @@ -577,6 +577,12 @@ export class TaskService { 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 + )) ORDER BY CASE WHEN t.due_date IS NULL THEN 1 ELSE 0 END, t.due_date ASC,