feat: añadir endpoints para grupos y tareas con gating y UI
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>webui
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' }
|
||||
});
|
||||
};
|
||||
@ -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…
Reference in New Issue