feat: exponer feeds ICS en la UI y añadir /app/integrations

Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
webui
borja 2 weeks ago
parent b738d8008d
commit 2412b7fa66

@ -29,7 +29,11 @@
<div class="titles">
<div class="title">{title}</div>
{#if description}<div class="desc">{description}</div>{/if}
{#if url}<div class="url">{url}</div>{/if}
{#if url}
<div class="url">{url}</div>
{:else}
<div class="hint">Por seguridad, la URL no se muestra. Pulsa “Rotar” para generar una nueva.</div>
{/if}
</div>
<div class="actions">
<Button variant="secondary" size="sm" on:click={handleCopy} disabled={!url}>Copiar</Button>
@ -70,4 +74,9 @@
gap: 8px;
flex-shrink: 0;
}
.hint {
margin-top: 6px;
color: var(--color-text-muted);
font-size: 12px;
}
</style>

@ -9,6 +9,7 @@
<nav class="nav">
<a href="/app" class:active={$page.url.pathname === '/app'}>Mis tareas</a>
<a href="/app/groups" class:active={$page.url.pathname.startsWith('/app/groups')}>Grupos</a>
<a href="/app/integrations" class:active={$page.url.pathname.startsWith('/app/integrations')}>Integraciones</a>
<a href="/app/preferences" class:active={$page.url.pathname.startsWith('/app/preferences')}>Preferencias</a>
</nav>
<form method="POST" action="/api/logout">

@ -46,14 +46,14 @@ export const GET: RequestHandler = async (event) => {
})();
// Por grupo (B): autogenerar si falta
const groupFeeds: Array<{ groupId: string; url: string | null }> = [];
const groupFeeds: Array<{ groupId: string; groupName: string | null; 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 });
groupFeeds.push({ groupId: g.id, groupName: g.name ?? null, url: null });
} else {
const created = await createCalendarTokenUrl('group', userId, g.id);
groupFeeds.push({ groupId: g.id, url: created.url });
groupFeeds.push({ groupId: g.id, groupName: g.name ?? null, url: created.url });
}
}

@ -0,0 +1,14 @@
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ fetch }) => {
const res = await fetch('/api/integrations/feeds', { method: 'GET', headers: { 'cache-control': 'no-store' } });
if (!res.ok) {
return {
personal: { url: null },
aggregate: { url: null },
groups: []
};
}
const data = await res.json();
return data;
};

@ -1,10 +1,55 @@
<script lang="ts">
import FeedCard from '$lib/ui/data/FeedCard.svelte';
import EmptyState from '$lib/ui/feedback/EmptyState.svelte';
import { toasts } from '$lib/stores/toasts';
import { onMount } from 'svelte';
// Placeholder inicial: en siguientes iteraciones se conectará a /api/integrations/feeds
const personalUrl = 'https://app.example.com/ics/personal/xxxxxxxxxxxxxxxx.ics';
const groupFeeds: Array<{ name: string; url: string | null }> = [];
export let data: {
personal: { url: string | null };
aggregate: { url: string | null };
groups: Array<{ groupId: string; groupName: string | null; url: string | null }>;
};
let personalUrl: string | null = data.personal?.url ?? null;
let aggregateUrl: string | null = data.aggregate?.url ?? null;
let groups = data.groups?.map(g => ({ ...g })) || [];
const rotating: Record<string, boolean> = {};
async function rotate(type: 'personal' | 'aggregate' | 'group', groupId?: string) {
try {
if (type === 'group' && groupId) rotating[groupId] = true;
if (type === 'personal') rotating['personal'] = true;
if (type === 'aggregate') rotating['aggregate'] = true;
const res = await fetch('/api/integrations/feeds/rotate', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ type, groupId: groupId ?? null })
});
if (!res.ok) {
const msg = await res.text();
toasts.error(`No se pudo rotar: ${msg || res.status}`);
return;
}
const body = await res.json();
if (type === 'personal') {
personalUrl = body.url || null;
} else if (type === 'aggregate') {
aggregateUrl = body.url || null;
} else if (type === 'group' && groupId) {
const idx = groups.findIndex(g => g.groupId === groupId);
if (idx >= 0) groups[idx].url = body.url || null;
}
toasts.success('Token rotado correctamente');
} catch (e) {
toasts.error('Error al rotar el token');
} finally {
if (type === 'group' && groupId) rotating[groupId] = false;
if (type === 'personal') rotating['personal'] = false;
if (type === 'aggregate') rotating['aggregate'] = false;
}
}
</script>
<svelte:head>
@ -20,16 +65,32 @@
title="Mis tareas (con fecha)"
description="Suscríbete a este feed en tu calendario para ver tus tareas con fecha de vencimiento."
url={personalUrl}
on:rotate={() => {/* se conectará a POST /api/integrations/feeds/rotate */}}
rotating={!!rotating['personal']}
on:rotate={() => rotate('personal')}
/>
<h2 style="font-size: 1.1rem; font-weight: 600; margin: 1rem 0 .5rem;">Feed multigrupo</h2>
<FeedCard
title="Mis grupos (sin responsable)"
description="Tareas sin responsable agregadas de tus grupos permitidos."
url={aggregateUrl}
rotating={!!rotating['aggregate']}
on:rotate={() => rotate('aggregate')}
/>
<h2 style="font-size: 1.1rem; font-weight: 600; margin: 1rem 0 .5rem;">Feeds por grupo (sin responsable)</h2>
{#if groupFeeds.length === 0}
{#if groups.length === 0}
<EmptyState>No hay grupos disponibles todavía. Cuando los haya, verás aquí sus feeds ICS.</EmptyState>
{:else}
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: var(--space-3);">
{#each groupFeeds as g}
<FeedCard title={g.name} description="Tareas sin responsable" url={g.url} />
{#each groups as g (g.groupId)}
<FeedCard
title={g.groupName || g.groupId}
description="Tareas sin responsable"
url={g.url}
rotating={!!rotating[g.groupId]}
on:rotate={() => rotate('group', g.groupId)}
/>
{/each}
</div>
{/if}

@ -0,0 +1,76 @@
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('Web page - /app/integrations', () => {
const PORT = 19124;
const BASE = `http://127.0.0.1:${PORT}`;
const USER = '34600123456';
const GROUP = '123@g.us';
const SID = 'sid-test-456';
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 Test', 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()}')`);
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 /app/integrations: muestra URLs ICS (personal, grupo y multigrupo) cuando se autogeneran', async () => {
server = await serverPromise;
const sidHash = await sidHashPromise;
// Insertar sesión
db.exec(`
INSERT OR REPLACE INTO web_sessions (id, user_id, session_hash, created_at, last_seen_at, expires_at)
VALUES ('sess-2', '${USER}', '${sidHash}', '${toIsoSql()}', '${toIsoSql()}', '${toIsoSql(new Date(Date.now() + 2 * 60 * 60 * 1000))}')
`);
const res = await fetch(`${BASE}/app/integrations`, {
headers: { cookie: `sid=${SID}` }
});
expect(res.status).toBe(200);
const html = await res.text();
// Debe incluir títulos y al menos una URL .ics
expect(html.includes('Integraciones')).toBe(true);
expect(html.includes('Mis tareas (con fecha)')).toBe(true);
expect(html.includes('Mis grupos (sin responsable)')).toBe(true);
expect(html.includes('/ics/personal/')).toBe(true);
expect(html.includes('/ics/group/')).toBe(true);
expect(html.includes('/ics/aggregate/')).toBe(true);
expect(html.includes('.ics')).toBe(true);
});
});
Loading…
Cancel
Save