From b5d91d518f3e87d440721ab7b382b3cc3b7e3715 Mon Sep 17 00:00:00 2001 From: brobert Date: Thu, 16 Oct 2025 00:49:03 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20a=C3=B1adir=20/api/me/tasks/overview=20?= =?UTF-8?q?y=20adaptar=20/app=20para=20consumirla?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: aider (openrouter/openai/gpt-5) --- .../routes/api/me/tasks/overview/+server.ts | 113 ++++++++++++++++++ apps/web/src/routes/app/+page.server.ts | 43 +++---- 2 files changed, 132 insertions(+), 24 deletions(-) create mode 100644 apps/web/src/routes/api/me/tasks/overview/+server.ts diff --git a/apps/web/src/routes/api/me/tasks/overview/+server.ts b/apps/web/src/routes/api/me/tasks/overview/+server.ts new file mode 100644 index 0000000..191d7f6 --- /dev/null +++ b/apps/web/src/routes/api/me/tasks/overview/+server.ts @@ -0,0 +1,113 @@ +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 url = new URL(event.request.url); + const orderParam = (url.searchParams.get('order') || 'due').trim().toLowerCase(); + const order = orderParam === 'group_then_due' ? 'group_then_due' : 'due'; + + const db = await getDb(); + + // Orden para "assigned" + const assignedOrder = + order === 'group_then_due' + ? `(t.group_id IS NULL) ASC, g.name COLLATE NOCASE ASC, CASE WHEN t.due_date IS NULL THEN 1 ELSE 0 END, t.due_date ASC, t.id ASC` + : `CASE WHEN t.due_date IS NULL THEN 1 ELSE 0 END, t.due_date ASC, t.id ASC`; + + // Tareas asignadas al usuario (abiertas) + const assignedRows = db + .prepare( + `SELECT t.id, t.description, t.due_date, t.group_id, t.display_code, g.name AS group_name + FROM tasks t + LEFT JOIN groups g ON g.id = t.group_id + INNER JOIN task_assignments a ON a.task_id = t.id + WHERE a.user_id = ? + AND COALESCE(t.completed, 0) = 0 + AND t.completed_at IS NULL + AND ( + 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) + ) + ) + ORDER BY ${assignedOrder}` + ) + .all(userId, userId) as any[]; + + const assigned = assignedRows.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, + group_name: r.group_name != null ? String(r.group_name) : null, // personales => null + display_code: r.display_code != null ? Number(r.display_code) : null, + assignees: [] as string[] + })); + + // Cargar asignados completos para "assigned" + if (assigned.length > 0) { + const ids = assigned.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(); + 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 assigned) { + it.assignees = map.get(it.id) || []; + } + } + + // Orden para "unassigned" + const unassignedOrder = + order === 'group_then_due' + ? `(t.group_id IS NULL) ASC, g.name COLLATE NOCASE ASC, CASE WHEN t.due_date IS NULL THEN 1 ELSE 0 END, t.due_date ASC, t.id ASC` + : `CASE WHEN t.due_date IS NULL THEN 1 ELSE 0 END, t.due_date ASC, t.id ASC`; + + // Tareas sin responsable (solo de grupos permitidos donde soy miembro activo) + const unassignedRows = db + .prepare( + `SELECT t.id, t.description, t.due_date, t.group_id, t.display_code, g.name AS group_name + FROM tasks t + LEFT JOIN groups g ON g.id = t.group_id + WHERE t.group_id IS NOT NULL + 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) + AND 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) + ORDER BY ${unassignedOrder}` + ) + .all(userId) as any[]; + + const unassigned = unassignedRows.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, + group_name: r.group_name != null ? String(r.group_name) : null, + display_code: r.display_code != null ? Number(r.display_code) : null, + assignees: [] as string[] // por definición, vacío + })); + + return new Response(JSON.stringify({ assigned, unassigned }), { + status: 200, + headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' } + }); +}; diff --git a/apps/web/src/routes/app/+page.server.ts b/apps/web/src/routes/app/+page.server.ts index c3b26f1..9e45481 100644 --- a/apps/web/src/routes/app/+page.server.ts +++ b/apps/web/src/routes/app/+page.server.ts @@ -67,6 +67,25 @@ export const load: PageServerLoad = async (event) => { recentTasks = Array.isArray(jsonRecent?.items) ? jsonRecent.items : []; } + // Overview: obtener "sin responsable" de todos los grupos en una sola llamada + const overviewOrder = order === 'group' ? 'group_then_due' : 'due'; + const resOverview = await event.fetch( + `/api/me/tasks/overview?order=${encodeURIComponent(overviewOrder)}`, + { headers: { 'cache-control': 'no-store' } } + ); + if (resOverview.ok) { + const jsonOv = await resOverview.json(); + const items: any[] = Array.isArray(jsonOv?.unassigned) ? jsonOv.unassigned : []; + unassignedOpen = items.map((it) => ({ + id: Number(it.id), + description: String(it.description || ''), + due_date: it.due_date ? String(it.due_date) : null, + group_id: it.group_id ? String(it.group_id) : null, + display_code: it.display_code != null ? Number(it.display_code) : null, + assignees: Array.isArray(it.assignees) ? it.assignees.map(String) : [] + })); + } + // Mis grupos (para nombres y para recolectar "sin responsable") const resGroups = await event.fetch('/api/me/groups', { headers: { 'cache-control': 'no-store' } @@ -78,30 +97,6 @@ export const load: PageServerLoad = async (event) => { const gid = String(g.id); const gname = g.name != null ? String(g.name) : null; if (gname) groupNames[gid] = gname; - - // Cargar solo "sin responsable" por grupo (sin límite) - try { - const r = await event.fetch( - `/api/groups/${encodeURIComponent(gid)}/tasks?onlyUnassigned=true&limit=0`, - { headers: { 'cache-control': 'no-store' } } - ); - if (r.ok) { - const j = await r.json(); - const items: any[] = Array.isArray(j?.items) ? j.items : []; - for (const it of items) { - unassignedOpen.push({ - id: Number(it.id), - description: String(it.description || ''), - due_date: it.due_date ? String(it.due_date) : null, - group_id: it.group_id ? String(it.group_id) : null, - display_code: it.display_code != null ? Number(it.display_code) : null, - assignees: Array.isArray(it.assignees) ? it.assignees.map(String) : [] - }); - } - } - } catch { - // ignorar fallos por grupo - } } }