feat: añadir endpoints para grupos y tareas con gating y UI

Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
webui
borja 2 weeks ago
parent 4ceb64877f
commit 770e688c96

@ -0,0 +1,97 @@
import type { RequestHandler } from './$types';
import { getDb } from '$lib/server/db';
export const GET: RequestHandler = async (event) => {
// Requiere sesión
const userId = event.locals.userId ?? null;
if (!userId) {
return new Response('Unauthorized', { status: 401 });
}
const groupId = event.params.id;
if (!groupId) {
return new Response('Bad Request', { status: 400 });
}
const url = new URL(event.request.url);
const unassignedFirst =
(url.searchParams.get('unassignedFirst') || '').trim().toLowerCase() === 'true';
const db = await getDb();
// Gating: grupo permitido + usuario es miembro activo
const allowed = db
.prepare(`SELECT 1 FROM allowed_groups WHERE group_id = ? AND status = 'allowed' LIMIT 1`)
.get(groupId);
const active = db
.prepare(
`SELECT 1 FROM group_members WHERE group_id = ? AND user_id = ? AND is_active = 1 LIMIT 1`
)
.get(groupId, userId);
if (!allowed || !active) {
return new Response('Forbidden', { status: 403 });
}
const orderParts: string[] = [];
if (unassignedFirst) {
orderParts.push(
`CASE WHEN EXISTS (SELECT 1 FROM task_assignments a WHERE a.task_id = t.id) THEN 1 ELSE 0 END ASC`
);
}
orderParts.push(
`CASE WHEN t.due_date IS NULL THEN 1 ELSE 0 END`,
`t.due_date ASC`,
`t.id ASC`
);
const rows = db
.prepare(
`SELECT t.id, t.description, t.due_date, t.group_id, t.display_code
FROM tasks t
WHERE t.group_id = ?
AND COALESCE(t.completed, 0) = 0
AND t.completed_at IS NULL
ORDER BY ${orderParts.join(', ')}`
)
.all(groupId) as any[];
let items = rows.map((r) => ({
id: Number(r.id),
description: String(r.description || ''),
due_date: r.due_date ? String(r.due_date) : null,
group_id: r.group_id ? String(r.group_id) : null,
display_code: r.display_code != null ? Number(r.display_code) : null,
assignees: [] as string[]
}));
// Cargar asignados
if (items.length > 0) {
const ids = items.map((it) => it.id);
const placeholders = ids.map(() => '?').join(',');
const assignRows = db
.prepare(
`SELECT task_id, user_id
FROM task_assignments
WHERE task_id IN (${placeholders})
ORDER BY assigned_at ASC`
)
.all(...ids) as any[];
const map = new Map<number, string[]>();
for (const row of assignRows) {
const tid = Number(row.task_id);
const uid = String(row.user_id);
if (!map.has(tid)) map.set(tid, []);
map.get(tid)!.push(uid);
}
for (const it of items) {
it.assignees = map.get(it.id) || [];
}
}
return new Response(JSON.stringify({ items }), {
status: 200,
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
});
};

@ -0,0 +1,61 @@
import type { RequestHandler } from './$types';
import { getDb } from '$lib/server/db';
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
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 any[];
// Preparar statements para contadores
const countOpenStmt = db.prepare(
`SELECT COUNT(*) AS cnt
FROM tasks t
WHERE t.group_id = ?
AND COALESCE(t.completed, 0) = 0
AND t.completed_at IS NULL`
);
const countUnassignedStmt = db.prepare(
`SELECT COUNT(*) AS cnt
FROM tasks t
WHERE t.group_id = ?
AND COALESCE(t.completed, 0) = 0
AND t.completed_at IS NULL
AND NOT EXISTS (SELECT 1 FROM task_assignments a WHERE a.task_id = t.id)`
);
const items = groups.map((g) => {
const open = countOpenStmt.get(g.id) as any;
const unassigned = countUnassignedStmt.get(g.id) as any;
return {
id: String(g.id),
name: g.name != null ? String(g.name) : null,
counts: {
open: Number(open?.cnt || 0),
unassigned: Number(unassigned?.cnt || 0)
}
};
});
return new Response(JSON.stringify({ items }), {
status: 200,
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
});
};

@ -0,0 +1,30 @@
import type { RequestHandler } from './$types';
import { getDb } from '$lib/server/db';
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();
const row = db
.prepare(
`SELECT reminder_freq AS freq, reminder_time AS time
FROM user_preferences
WHERE user_id = ?
LIMIT 1`
)
.get(userId) as any;
const body =
row && row.freq
? { freq: String(row.freq), time: row.time ? String(row.time) : null }
: { freq: 'off', time: '08:30' };
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
});
};

@ -27,14 +27,18 @@ export const GET: RequestHandler = async (event) => {
const db = await getDb(); const db = await getDb();
// Construir filtros dinámicos // Construir filtros dinámicos (con gating por grupo permitido y membresía activa)
const whereParts = [ const whereParts = [
`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)))`
]; ];
const params: any[] = [userId]; const params: any[] = [userId];
// Añadir userId para el chequeo de membresía en el filtro de gating
params.push(userId);
if (search) { if (search) {
whereParts.push(`t.description LIKE ?`); whereParts.push(`t.description LIKE ?`);
params.push(`%${search.replace(/%/g, '\\%').replace(/_/g, '\\_')}%`); params.push(`%${search.replace(/%/g, '\\%').replace(/_/g, '\\_')}%`);

@ -0,0 +1,51 @@
<script lang="ts">
import { onMount } from 'svelte';
type GroupItem = {
id: string;
name: string | null;
counts: { open: number; unassigned: number };
};
let loading = true;
let error: string | null = null;
let groups: GroupItem[] = [];
async function loadData() {
try {
const res = await fetch('/api/me/groups', { headers: { 'cache-control': 'no-store' } });
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
const data = await res.json();
groups = Array.isArray(data?.items) ? data.items : [];
} catch (e: any) {
error = e?.message || 'Error al cargar grupos';
} finally {
loading = false;
}
}
onMount(loadData);
</script>
<svelte:head>
<title>Grupos</title>
<meta name="robots" content="noindex,nofollow" />
</svelte:head>
{#if loading}
<p>Cargando…</p>
{:else if error}
<p style="color:#c00">Error: {error}</p>
{:else if groups.length === 0}
<p>No perteneces a ningún grupo permitido.</p>
{:else}
<h1>Grupos</h1>
<ul>
{#each groups as g}
<li>
<strong>{g.name ?? g.id}</strong>
<small style="margin-left:8px;color:#555">(abiertas: {g.counts.open}, sin responsable: {g.counts.unassigned})</small>
</li>
{/each}
</ul>
{/if}
Loading…
Cancel
Save