diff --git a/apps/web/src/routes/api/groups/[id]/tasks/+server.ts b/apps/web/src/routes/api/groups/[id]/tasks/+server.ts new file mode 100644 index 0000000..bb193c0 --- /dev/null +++ b/apps/web/src/routes/api/groups/[id]/tasks/+server.ts @@ -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(); + 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' } + }); +}; diff --git a/apps/web/src/routes/api/me/groups/+server.ts b/apps/web/src/routes/api/me/groups/+server.ts new file mode 100644 index 0000000..489c08a --- /dev/null +++ b/apps/web/src/routes/api/me/groups/+server.ts @@ -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' } + }); +}; diff --git a/apps/web/src/routes/api/me/preferences/+server.ts b/apps/web/src/routes/api/me/preferences/+server.ts new file mode 100644 index 0000000..1cff687 --- /dev/null +++ b/apps/web/src/routes/api/me/preferences/+server.ts @@ -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' } + }); +}; diff --git a/apps/web/src/routes/api/me/tasks/+server.ts b/apps/web/src/routes/api/me/tasks/+server.ts index 65f8a06..6cd763b 100644 --- a/apps/web/src/routes/api/me/tasks/+server.ts +++ b/apps/web/src/routes/api/me/tasks/+server.ts @@ -27,14 +27,18 @@ export const GET: RequestHandler = async (event) => { const db = await getDb(); - // Construir filtros dinámicos + // Construir filtros dinámicos (con gating por grupo permitido y membresía activa) const whereParts = [ `a.user_id = ?`, `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]; + // Añadir userId para el chequeo de membresía en el filtro de gating + params.push(userId); + if (search) { whereParts.push(`t.description LIKE ?`); params.push(`%${search.replace(/%/g, '\\%').replace(/_/g, '\\_')}%`); diff --git a/apps/web/src/routes/app/groups/+page.svelte b/apps/web/src/routes/app/groups/+page.svelte new file mode 100644 index 0000000..c06440a --- /dev/null +++ b/apps/web/src/routes/app/groups/+page.svelte @@ -0,0 +1,51 @@ + + + + Grupos + + + +{#if loading} +

Cargando…

+{:else if error} +

Error: {error}

+{:else if groups.length === 0} +

No perteneces a ningún grupo permitido.

+{:else} +

Grupos

+ +{/if}