From 9a69461b6ce370936ca499d89cbdfa45e74a83c4 Mon Sep 17 00:00:00 2001 From: borja Date: Tue, 14 Oct 2025 11:15:37 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20a=C3=B1adir=20feeds=20de=20integracione?= =?UTF-8?q?s=20ICS=20(aggregate)=20y=20horizonte=2012m?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: aider (openrouter/openai/gpt-5) --- apps/web/src/lib/server/env.ts | 7 ++ .../routes/api/integrations/feeds/+server.ts | 70 ++++++++++++++ .../api/integrations/feeds/rotate/+server.ts | 63 ++++++++++++ tests/web/api.integrations.feeds.test.ts | 95 +++++++++++++++++++ tests/web/helpers/db.ts | 17 ++++ 5 files changed, 252 insertions(+) create mode 100644 apps/web/src/routes/api/integrations/feeds/+server.ts create mode 100644 apps/web/src/routes/api/integrations/feeds/rotate/+server.ts create mode 100644 tests/web/api.integrations.feeds.test.ts create mode 100644 tests/web/helpers/db.ts diff --git a/apps/web/src/lib/server/env.ts b/apps/web/src/lib/server/env.ts index 68f090c..4957996 100644 --- a/apps/web/src/lib/server/env.ts +++ b/apps/web/src/lib/server/env.ts @@ -25,3 +25,10 @@ export const sessionIdleTtlMs = Math.max(1, Math.floor(SESSION_IDLE_TTL_MIN)) * export const NODE_ENV = (env.NODE_ENV || 'development').trim().toLowerCase(); export const isProd = () => NODE_ENV === 'production'; + +// ICS: horizonte en meses y rate limit (por minuto, 0 = desactivado) +const ICS_HORIZON_MONTHS = Number(env.ICS_HORIZON_MONTHS || 12); +export const icsHorizonMonths = Math.max(1, Math.floor(ICS_HORIZON_MONTHS)); + +const ICS_RATE_LIMIT_PER_MIN = Number(env.ICS_RATE_LIMIT_PER_MIN || 0); +export const icsRateLimitPerMin = Math.max(0, Math.floor(ICS_RATE_LIMIT_PER_MIN)); diff --git a/apps/web/src/routes/api/integrations/feeds/+server.ts b/apps/web/src/routes/api/integrations/feeds/+server.ts new file mode 100644 index 0000000..5e14580 --- /dev/null +++ b/apps/web/src/routes/api/integrations/feeds/+server.ts @@ -0,0 +1,70 @@ +import type { RequestHandler } from './$types'; +import { getDb } from '$lib/server/db'; +import { findActiveToken, createCalendarTokenUrl } from '$lib/server/calendar-tokens'; + +export const GET: RequestHandler = async (event) => { + // Requiere sesión + const userId = event.locals.userId ?? null; + if (!userId) { + return new Response('Unauthorized', { status: 401 }); + } + + const db = await getDb(); + + // Listar solo grupos permitidos donde el usuario está activo (mismo gating que /api/me/groups) + const groups = db + .prepare( + `SELECT g.id, g.name + FROM groups g + INNER JOIN group_members gm + ON gm.group_id = g.id AND gm.user_id = ? AND gm.is_active = 1 + INNER JOIN allowed_groups ag + ON ag.group_id = g.id AND ag.status = 'allowed' + WHERE COALESCE(g.active, 1) = 1 + ORDER BY (g.name IS NULL) ASC, g.name ASC, g.id ASC` + ) + .all(userId) as Array<{ id: string; name: string | null }>; + + // Personal + const personalExisting = await findActiveToken('personal', userId, null); + const personal = + personalExisting + ? { url: null } + : await (async () => { + const created = await createCalendarTokenUrl('personal', userId, null); + return { url: created.url }; + })(); + + // Aggregate (multigrupo) + const aggregateExisting = await findActiveToken('aggregate', userId, null); + const aggregate = + aggregateExisting + ? { url: null } + : await (async () => { + const created = await createCalendarTokenUrl('aggregate', userId, null); + return { url: created.url }; + })(); + + // Por grupo (B): autogenerar si falta + const groupFeeds: Array<{ groupId: string; url: string | null }> = []; + for (const g of groups) { + const ex = await findActiveToken('group', userId, g.id); + if (ex) { + groupFeeds.push({ groupId: g.id, url: null }); + } else { + const created = await createCalendarTokenUrl('group', userId, g.id); + groupFeeds.push({ groupId: g.id, url: created.url }); + } + } + + const body = { + personal, + groups: groupFeeds, + aggregate + }; + + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' } + }); +}; diff --git a/apps/web/src/routes/api/integrations/feeds/rotate/+server.ts b/apps/web/src/routes/api/integrations/feeds/rotate/+server.ts new file mode 100644 index 0000000..267ea0e --- /dev/null +++ b/apps/web/src/routes/api/integrations/feeds/rotate/+server.ts @@ -0,0 +1,63 @@ +import type { RequestHandler } from './$types'; +import { getDb } from '$lib/server/db'; +import { rotateCalendarTokenUrl } from '$lib/server/calendar-tokens'; + +export const POST: RequestHandler = async (event) => { + // Requiere sesión + const userId = event.locals.userId ?? null; + if (!userId) { + return new Response('Unauthorized', { status: 401 }); + } + + let payload: any = null; + try { + payload = await event.request.json(); + } catch { + return new Response('Bad Request', { status: 400 }); + } + + const type = String(payload?.type || '').trim().toLowerCase(); + const groupId = payload?.groupId ? String(payload.groupId).trim() : null; + + if (!['personal', 'group', 'aggregate'].includes(type)) { + return new Response(JSON.stringify({ error: 'type inválido' }), { + status: 400, + headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' } + }); + } + + // Validación de gating/membresía si es group + if (type === 'group') { + if (!groupId) { + return new Response(JSON.stringify({ error: 'groupId requerido para type=group' }), { + status: 400, + headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' } + }); + } + const db = await getDb(); + const row = db + .prepare( + `SELECT 1 + FROM groups g + INNER JOIN group_members gm + ON gm.group_id = g.id AND gm.user_id = ? AND gm.is_active = 1 + INNER JOIN allowed_groups ag + ON ag.group_id = g.id AND ag.status = 'allowed' + WHERE g.id = ? AND COALESCE(g.active, 1) = 1 + LIMIT 1` + ) + .get(userId, groupId) as any; + if (!row) { + return new Response(JSON.stringify({ error: 'forbidden' }), { + status: 403, + headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' } + }); + } + } + + const rotated = await rotateCalendarTokenUrl(type as any, userId, groupId); + return new Response(JSON.stringify({ url: rotated.url }), { + status: 200, + headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' } + }); +}; diff --git a/tests/web/api.integrations.feeds.test.ts b/tests/web/api.integrations.feeds.test.ts new file mode 100644 index 0000000..0d48fb4 --- /dev/null +++ b/tests/web/api.integrations.feeds.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect, afterAll } from 'bun:test'; +import Database from 'bun:sqlite'; +import { startWebServer } from './helpers/server'; +import { createTempDb } from './helpers/db'; + +async function sha256Hex(input: string): Promise { + const enc = new TextEncoder().encode(input); + const buf = await crypto.subtle.digest('SHA-256', enc); + const bytes = new Uint8Array(buf); + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); +} + +function toIsoSql(d = new Date()): string { + return d.toISOString().replace('T', ' ').replace('Z', ''); +} + +describe('API - /api/integrations/feeds', () => { + const PORT = 19123; + const BASE = `http://127.0.0.1:${PORT}`; + const USER = '34600123456'; + const GROUP = '123@g.us'; + const SID = 'sid-test-123'; + + const tmp = createTempDb(); + const db: any = tmp.db as Database; + + // Sembrar datos mínimos + db.exec(`INSERT OR IGNORE INTO users (id) VALUES ('${USER}')`); + db.exec(`INSERT OR IGNORE INTO groups (id, community_id, name, active) VALUES ('${GROUP}', 'comm1', 'Group 1', 1)`); + db.exec(`INSERT OR IGNORE INTO allowed_groups (group_id, status, discovered_at, updated_at) VALUES ('${GROUP}', 'allowed', '${toIsoSql()}', '${toIsoSql()}')`); + db.exec(`INSERT OR IGNORE INTO group_members (group_id, user_id, is_admin, is_active, first_seen_at, last_seen_at) VALUES ('${GROUP}', '${USER}', 0, 1, '${toIsoSql()}', '${toIsoSql()}')`); + + // Crear sesión web válida (cookie sid) + const sidHashPromise = sha256Hex(SID); + const serverPromise = startWebServer({ + port: PORT, + env: { + DB_PATH: tmp.path, + WEB_BASE_URL: BASE + } + }); + + let server: Awaited | null = null; + + afterAll(async () => { + try { await server?.stop(); } catch {} + try { tmp.cleanup(); } catch {} + }); + + it('GET: autogenera y devuelve URLs para personal, grupo y aggregate; POST rotate rota el de grupo', async () => { + server = await serverPromise; + + const sidHash = await sidHashPromise; + // Insertar sesión después de lanzar el server (mismo archivo) + db.exec(` + INSERT OR REPLACE INTO web_sessions (id, user_id, session_hash, created_at, last_seen_at, expires_at) + VALUES ('sess-1', '${USER}', '${sidHash}', '${toIsoSql()}', '${toIsoSql()}', '${toIsoSql(new Date(Date.now() + 2 * 60 * 60 * 1000))}') + `); + + // GET feeds + const res = await fetch(`${BASE}/api/integrations/feeds`, { + headers: { cookie: `sid=${SID}` } + }); + expect(res.status).toBe(200); + const body = await res.json(); + // Personal URL presente (recién creada) + expect(typeof body.personal).toBe('object'); + expect(typeof body.personal.url === 'string' && body.personal.url.endsWith('.ics')).toBe(true); + // Aggregate URL presente (recién creada) + expect(typeof body.aggregate).toBe('object'); + expect(typeof body.aggregate.url === 'string' && body.aggregate.url.endsWith('.ics')).toBe(true); + // Grupo autogenerado con URL presente + const groupFeed = (body.groups || []).find((g: any) => g.groupId === GROUP); + expect(groupFeed).toBeDefined(); + expect(typeof groupFeed.url === 'string' && groupFeed.url.endsWith('.ics')).toBe(true); + + const previousGroupUrl = groupFeed.url; + + // POST rotate para el grupo + const resRotate = await fetch(`${BASE}/api/integrations/feeds/rotate`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + cookie: `sid=${SID}` + }, + body: JSON.stringify({ type: 'group', groupId: GROUP }) + }); + expect(resRotate.status).toBe(200); + const bodyRotate = await resRotate.json(); + expect(typeof bodyRotate.url === 'string' && bodyRotate.url.endsWith('.ics')).toBe(true); + expect(bodyRotate.url).not.toBe(previousGroupUrl); + }); +}); diff --git a/tests/web/helpers/db.ts b/tests/web/helpers/db.ts new file mode 100644 index 0000000..8a5dcd7 --- /dev/null +++ b/tests/web/helpers/db.ts @@ -0,0 +1,17 @@ +import { mkdirSync, rmSync } from 'fs'; +import { join } from 'path'; +import Database from 'bun:sqlite'; +import { initializeDatabase } from '../../src/db'; + +export function createTempDb(): { path: string; db: any; cleanup: () => void } { + const dir = join('tmp', 'web-tests'); + try { mkdirSync(dir, { recursive: true }); } catch {} + const path = join(dir, `db-${Date.now()}-${Math.random().toString(16).slice(2)}.sqlite`); + const db = new Database(path); + initializeDatabase(db); + const cleanup = () => { + try { db.close(); } catch {} + try { rmSync(path); } catch {} + }; + return { path, db, cleanup }; +}