feat: añadir feeds de integraciones ICS (aggregate) y horizonte 12m
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>webui
parent
2438f736de
commit
9a69461b6c
@ -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' }
|
||||
});
|
||||
};
|
||||
@ -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' }
|
||||
});
|
||||
};
|
||||
@ -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<string> {
|
||||
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<typeof serverPromise> | 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);
|
||||
});
|
||||
});
|
||||
@ -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 };
|
||||
}
|
||||
Loading…
Reference in New Issue