diff --git a/apps/web/src/lib/ui/data/TaskItem.svelte b/apps/web/src/lib/ui/data/TaskItem.svelte index 536ba55..2f37666 100644 --- a/apps/web/src/lib/ui/data/TaskItem.svelte +++ b/apps/web/src/lib/ui/data/TaskItem.svelte @@ -1,15 +1,96 @@
  • @@ -26,18 +107,36 @@ {/if} {/if} - {#if assignees?.length} -
    + +
    + {#if assignees?.length} asignados: {assignees.join(', ')} -
    - {/if} + {/if} +
    + +
    + {#if !isAssigned} + + {:else} + + {/if} + + {#if !editing} + + {:else} + + + + + {/if} +
  • diff --git a/apps/web/src/routes/api/tasks/[id]/+server.ts b/apps/web/src/routes/api/tasks/[id]/+server.ts new file mode 100644 index 0000000..cfc329b --- /dev/null +++ b/apps/web/src/routes/api/tasks/[id]/+server.ts @@ -0,0 +1,112 @@ +import type { RequestHandler } from './$types'; +import { getDb } from '$lib/server/db'; + +function isValidYmd(input: string): boolean { + const m = /^\s*(\d{4})-(\d{2})-(\d{2})\s*$/.exec(input || ''); + if (!m) return false; + const y = Number(m[1]), mo = Number(m[2]), d = Number(m[3]); + if (!Number.isFinite(y) || !Number.isFinite(mo) || !Number.isFinite(d)) return false; + if (mo < 1 || mo > 12 || d < 1 || d > 31) return false; + const dt = new Date(`${String(y).padStart(4, '0')}-${String(mo).padStart(2, '0')}-${String(d).padStart(2, '0')}T00:00:00Z`); + // Comprobar que el Date resultante coincide (evita 2025-02-31) + return dt.getUTCFullYear() === y && (dt.getUTCMonth() + 1) === mo && dt.getUTCDate() === d; +} + +export const PATCH: 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 }); + } + + let payload: any = null; + try { + payload = await event.request.json(); + } catch { + return new Response('Bad Request', { status: 400 }); + } + + const due_date_raw = payload?.due_date; + if (due_date_raw !== null && due_date_raw !== undefined && typeof due_date_raw !== 'string') { + return new Response('Bad Request', { status: 400 }); + } + const due_date = + due_date_raw == null || String(due_date_raw).trim() === '' + ? null + : String(due_date_raw).trim(); + + if (due_date !== null && !isValidYmd(due_date)) { + return new Response(JSON.stringify({ error: 'invalid_due_date' }), { + status: 400, + headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' } + }); + } + + const db = await getDb(); + + // 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 + 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' } + }); + } + if (Number(task.completed) !== 0 || task.completed_at) { + return new Response(JSON.stringify({ status: 'completed' }), { + status: 400, + headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' } + }); + } + + // Gating: grupo permitido + usuario miembro activo (si tiene group_id) + 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 }); + } + } + + // Aplicar actualización + db.prepare(`UPDATE tasks SET due_date = ? WHERE id = ?`).run(due_date, taskId); + + const updated = db + .prepare(`SELECT id, description, due_date, display_code 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 + } + }; + + 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/tasks/[id]/claim/+server.ts b/apps/web/src/routes/api/tasks/[id]/claim/+server.ts new file mode 100644 index 0000000..06996e0 --- /dev/null +++ b/apps/web/src/routes/api/tasks/[id]/claim/+server.ts @@ -0,0 +1,99 @@ +import type { RequestHandler } from './$types'; +import { getDb } from '$lib/server/db'; + +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(); + + // 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 + 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' } + }); + } + if (Number(task.completed) !== 0 || task.completed_at) { + return new Response(JSON.stringify({ status: 'completed' }), { + status: 400, + headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' } + }); + } + + // Gating: grupo permitido + usuario miembro activo (si tiene group_id) + 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 }); + } + } + + // Asegurar existencia del usuario (best-effort) + try { + db.transaction(() => { + db.prepare( + `INSERT INTO users (id, first_seen, last_seen) + VALUES (?, strftime('%Y-%m-%d %H:%M:%f','now'), strftime('%Y-%m-%d %H:%M:%f','now')) + ON CONFLICT(id) DO NOTHING` + ).run(userId); + db.prepare( + `UPDATE users SET last_seen = strftime('%Y-%m-%d %H:%M:%f','now') WHERE id = ?` + ).run(userId); + })(); + } catch {} + + // Reclamar (idempotente) + const res = db + .prepare( + `INSERT OR IGNORE INTO task_assignments (task_id, user_id, assigned_by) + VALUES (?, ?, ?)` + ) + .run(taskId, userId, userId) as any; + + const status = Number(res?.changes || 0) > 0 ? 'claimed' : 'already'; + + const body = { + status, + 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 + } + }; + + 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/tasks/[id]/unassign/+server.ts b/apps/web/src/routes/api/tasks/[id]/unassign/+server.ts new file mode 100644 index 0000000..e4a5db0 --- /dev/null +++ b/apps/web/src/routes/api/tasks/[id]/unassign/+server.ts @@ -0,0 +1,84 @@ +import type { RequestHandler } from './$types'; +import { getDb } from '$lib/server/db'; + +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(); + + // 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 + 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' } + }); + } + if (Number(task.completed) !== 0 || task.completed_at) { + return new Response(JSON.stringify({ status: 'completed' }), { + status: 400, + headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' } + }); + } + + // Gating: grupo permitido + usuario miembro activo (si tiene group_id) + 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 }); + } + } + + // Eliminar asignación (idempotente) + const delRes = db + .prepare(`DELETE FROM task_assignments WHERE task_id = ? AND user_id = ?`) + .run(taskId, userId) as any; + + const cntRow = db + .prepare(`SELECT COUNT(*) AS cnt FROM task_assignments WHERE task_id = ?`) + .get(taskId) as any; + const remaining = Number(cntRow?.cnt || 0); + + const status = Number(delRes?.changes || 0) > 0 ? 'unassigned' : 'not_assigned'; + + const body = { + status, + now_unassigned: remaining === 0, + 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 + } + }; + + 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/app/+page.svelte b/apps/web/src/routes/app/+page.svelte index 4d584d6..28b1365 100644 --- a/apps/web/src/routes/app/+page.svelte +++ b/apps/web/src/routes/app/+page.svelte @@ -44,7 +44,7 @@