feat: añadir endpoint uncomplete, ajustar PATCH y UI para deshacer

Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
webui
brobert 2 weeks ago
parent af159c8c8d
commit 83ae0939e5

@ -39,3 +39,8 @@ export const icsHorizonMonths = Math.max(1, Math.floor(ICS_HORIZON_MONTHS));
const ICS_RATE_LIMIT_PER_MIN = Number(env.ICS_RATE_LIMIT_PER_MIN || 0);
export const icsRateLimitPerMin = Math.max(0, Math.floor(ICS_RATE_LIMIT_PER_MIN));
// Uncomplete window (minutos; por defecto 1440 = 24h)
const UNCOMPLETE_WINDOW_MIN_RAW = Number(env.UNCOMPLETE_WINDOW_MIN || 1440);
export const UNCOMPLETE_WINDOW_MIN = Math.max(1, Math.floor(UNCOMPLETE_WINDOW_MIN_RAW));
export const uncompleteWindowMs = UNCOMPLETE_WINDOW_MIN * 60 * 1000;

@ -74,6 +74,25 @@
}
}
async function doUncomplete() {
if (busy || !completed) return;
busy = true;
try {
const res = await fetch(`/api/tasks/${id}/uncomplete`, { method: "POST" });
if (res.ok) {
success("Tarea reabierta");
location.reload();
} else {
const txt = await res.text();
toastError(txt || "No se pudo deshacer completar");
}
} catch {
toastError("Error de red");
} finally {
busy = false;
}
}
async function doUnassign() {
if (busy) return;
busy = true;
@ -239,6 +258,16 @@
📅 {dateDmy}{#if overdue}
{/if}
</span>
{:else}
<button
class="btn primary"
aria-label="Deshacer completar"
title="Deshacer completar"
on:click|preventDefault={doUncomplete}
disabled={busy}
>
↩️ Deshacer
</button>
{/if}
</div>
{#if assignees?.length}

@ -84,7 +84,7 @@ export const PATCH: RequestHandler = async (event) => {
// Cargar tarea y validar abierta
const task = db
.prepare(
`SELECT id, description, due_date, group_id, COALESCE(completed, 0) AS completed, completed_at, display_code
`SELECT id, description, due_date, group_id, created_by, COALESCE(completed, 0) AS completed, completed_at, display_code
FROM tasks
WHERE id = ?`
)
@ -118,6 +118,16 @@ export const PATCH: RequestHandler = async (event) => {
if (!allowed || !active) {
return new Response('Forbidden', { status: 403 });
}
} else {
// Tarea sin grupo: permitir si el usuario está asignado o es el creador
const isAssigned = db
.prepare(`SELECT 1 FROM task_assignments WHERE task_id = ? AND user_id = ? LIMIT 1`)
.get(taskId, userId);
const isCreator = String(task.created_by || '') === String(userId);
if (!isAssigned && !isCreator) {
return new Response('Forbidden', { status: 403 });
}
}
// Aplicar actualización

@ -0,0 +1,117 @@
import type { RequestHandler } from './$types';
import { getDb } from '$lib/server/db';
import { UNCOMPLETE_WINDOW_MIN } from '$lib/server/env';
function toIsoSql(d: Date): string {
return d.toISOString().replace('T', ' ').replace('Z', '');
}
export const POST: RequestHandler = async (event) => {
const userId = event.locals.userId ?? null;
if (!userId) {
return new Response('Unauthorized', { status: 401 });
}
const idStr = event.params.id || '';
const taskId = parseInt(idStr, 10);
if (!Number.isFinite(taskId) || taskId <= 0) {
return new Response('Bad Request', { status: 400 });
}
const db = await getDb();
const task = db.prepare(`
SELECT id, description, due_date, group_id, COALESCE(completed, 0) AS completed, completed_at, display_code
FROM tasks
WHERE id = ?
`).get(taskId) as any;
if (!task) {
return new Response(JSON.stringify({ status: 'not_found' }), {
status: 404,
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
});
}
// Si ya está sin completar, es idempotente
if (Number(task.completed) === 0) {
const body = {
status: 'already',
task: {
id: Number(task.id),
description: String(task.description || ''),
due_date: task.due_date ? String(task.due_date) : null,
display_code: task.display_code != null ? Number(task.display_code) : null,
completed: 0,
completed_at: null
}
};
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
});
}
// Gating:
// - Si tiene group_id: grupo allowed y miembro activo
// - Si NO tiene group_id: debe estar asignada al usuario
const groupId: string | null = task.group_id ? String(task.group_id) : null;
if (groupId) {
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 });
}
} else {
const isAssigned = db
.prepare(`SELECT 1 FROM task_assignments WHERE task_id = ? AND user_id = ? LIMIT 1`)
.get(taskId, userId);
if (!isAssigned) {
return new Response('Forbidden', { status: 403 });
}
}
// Validar ventana: completed_at dentro de UNCOMPLETE_WINDOW_MIN
if (!task.completed_at) {
return new Response('Forbidden', { status: 403 });
}
const cutoff = toIsoSql(new Date(Date.now() - UNCOMPLETE_WINDOW_MIN * 60 * 1000));
if (String(task.completed_at) < String(cutoff)) {
return new Response('Forbidden', { status: 403 });
}
// Deshacer completar (no tocamos completed_by)
db.prepare(`
UPDATE tasks
SET completed = 0,
completed_at = NULL
WHERE id = ?
`).run(taskId);
const updated = db.prepare(`
SELECT id, description, due_date, display_code, COALESCE(completed, 0) AS completed, completed_at
FROM tasks
WHERE id = ?
`).get(taskId) as any;
const body = {
status: 'updated',
task: {
id: Number(updated.id),
description: String(updated.description || ''),
due_date: updated.due_date ? String(updated.due_date) : null,
display_code: updated.display_code != null ? Number(updated.display_code) : null,
completed: Number(updated.completed || 0),
completed_at: updated.completed_at ? String(updated.completed_at) : null
}
};
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
});
};
Loading…
Cancel
Save