diff --git a/apps/web/src/lib/server/env.ts b/apps/web/src/lib/server/env.ts index 03d661c..dd99706 100644 --- a/apps/web/src/lib/server/env.ts +++ b/apps/web/src/lib/server/env.ts @@ -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; diff --git a/apps/web/src/lib/ui/data/TaskItem.svelte b/apps/web/src/lib/ui/data/TaskItem.svelte index cf39f5e..6af264c 100644 --- a/apps/web/src/lib/ui/data/TaskItem.svelte +++ b/apps/web/src/lib/ui/data/TaskItem.svelte @@ -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} + {:else} + {/if} {#if assignees?.length} diff --git a/apps/web/src/routes/api/tasks/[id]/+server.ts b/apps/web/src/routes/api/tasks/[id]/+server.ts index 38efdcc..f9a62f4 100644 --- a/apps/web/src/routes/api/tasks/[id]/+server.ts +++ b/apps/web/src/routes/api/tasks/[id]/+server.ts @@ -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 diff --git a/apps/web/src/routes/api/tasks/[id]/uncomplete/+server.ts b/apps/web/src/routes/api/tasks/[id]/uncomplete/+server.ts new file mode 100644 index 0000000..7c4fec5 --- /dev/null +++ b/apps/web/src/routes/api/tasks/[id]/uncomplete/+server.ts @@ -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' } + }); +};