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