feat: impedir soltar tarea personal sin asignatarios; backend+UI

Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
webui
brobert 2 weeks ago
parent 80b375de3e
commit 542e1f03a4

@ -55,6 +55,7 @@
let showAssignees = false;
let assigneesButtonEl: HTMLButtonElement | null = null;
$: assigneesCount = Array.isArray(assignees) ? assignees.length : 0;
$: canUnassign = !(groupId == null && assigneesCount === 1 && isAssigned);
$: assigneesAria =
assigneesCount === 0
? "Sin responsables"
@ -509,9 +510,9 @@
<button
class="icon-btn secondary-action"
aria-label="Soltar"
title="Soltar"
title={canUnassign ? "Soltar" : "No puedes soltar una tarea personal. Márcala como completada para eliminarla"}
on:click|preventDefault={doUnassign}
disabled={busy}
disabled={busy || !canUnassign}
><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 108.01 122.88">
<path
class="icon-btn-svg"

@ -109,6 +109,10 @@ export const GET: RequestHandler = async (event) => {
}
for (const it of items) {
it.assignees = map.get(it.id) || [];
const personal = it.group_id == null;
const cnt = Array.isArray(it.assignees) ? it.assignees.length : 0;
const mine = (it.assignees || []).some((uid) => uid === userId);
(it as any).can_unassign = !(personal && cnt === 1 && mine);
}
}
@ -209,6 +213,10 @@ export const GET: RequestHandler = async (event) => {
}
for (const it of items) {
it.assignees = map.get(it.id) || [];
const personal = it.group_id == null;
const cnt = Array.isArray(it.assignees) ? it.assignees.length : 0;
const mine = (it.assignees || []).some((uid) => uid === userId);
(it as any).can_unassign = !(personal && cnt === 1 && mine);
}
}

@ -54,6 +54,25 @@ export const POST: RequestHandler = async (event) => {
}
}
// Bloqueo de tareas personales: si es la última asignación del propio usuario, denegar
const stats = db
.prepare(`
SELECT COUNT(*) AS cnt,
SUM(CASE WHEN user_id = ? THEN 1 ELSE 0 END) AS mine
FROM task_assignments
WHERE task_id = ?
`)
.get(userId, taskId) as any;
const cnt = Number(stats?.cnt || 0);
const mine = Number(stats?.mine || 0) > 0;
if (!groupId && cnt === 1 && mine) {
return new Response('No puedes soltar una tarea personal. Márcala como completada para eliminarla', {
status: 409,
headers: { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' }
});
}
// Eliminar asignación (idempotente)
const delRes = db
.prepare(`DELETE FROM task_assignments WHERE task_id = ? AND user_id = ?`)

@ -404,4 +404,37 @@ export const migrations: Migration[] = [
} catch {}
}
}
,
{
version: 15,
name: 'tasks-personal-unassign-guard',
checksum: 'v15-personal-unassign-2025-10-19',
up: (db: Database) => {
db.exec(`PRAGMA foreign_keys = ON;`);
// Reparar: reasignar tareas personales abiertas sin asignatarios a created_by
db.exec(`
INSERT INTO task_assignments (task_id, user_id, assigned_by, assigned_at)
SELECT t.id, t.created_by, t.created_by, strftime('%Y-%m-%d %H:%M:%f','now')
FROM tasks t
WHERE t.group_id IS NULL
AND COALESCE(t.completed,0) = 0
AND NOT EXISTS (SELECT 1 FROM task_assignments a WHERE a.task_id = t.id);
`);
// Trigger: impedir borrar el último asignatario en tareas personales
db.exec(`
CREATE TRIGGER IF NOT EXISTS trg_block_unassign_last_personal
BEFORE DELETE ON task_assignments
FOR EACH ROW
WHEN EXISTS (SELECT 1 FROM tasks WHERE id = OLD.task_id)
AND EXISTS (SELECT 1 FROM users WHERE id = OLD.user_id)
AND (SELECT group_id FROM tasks WHERE id = OLD.task_id) IS NULL
AND (SELECT COUNT(*) FROM task_assignments WHERE task_id = OLD.task_id) = 1
BEGIN
SELECT RAISE(ABORT, 'PERSONAL_UNASSIGN_FORBIDDEN');
END;
`);
}
}
];

@ -866,6 +866,13 @@ export class CommandService {
const res = TaskService.unassignTask(resolvedId, context.sender);
const due = res.task?.due_date ? `${ICONS.date} ${formatDDMM(res.task?.due_date)}` : '';
if (res.status === 'forbidden_personal') {
return [{
recipient: context.sender,
message: 'No puedes soltar una tarea personal. Márcala como completada para eliminarla'
}];
}
if (res.status === 'not_found') {
return [{
recipient: context.sender,

@ -246,7 +246,7 @@ export class TaskService {
const existing = this.dbInstance
.prepare(`
SELECT id, description, due_date, completed, completed_at, display_code
SELECT id, description, due_date, completed, completed_at, display_code, group_id
FROM tasks
WHERE id = ?
`)
@ -413,7 +413,7 @@ export class TaskService {
// Soltar tarea (unassign): idempotente
static unassignTask(taskId: number, userId: string): {
status: 'unassigned' | 'not_assigned' | 'not_found' | 'completed';
status: 'unassigned' | 'not_assigned' | 'not_found' | 'completed' | 'forbidden_personal';
task?: { id: number; description: string; due_date: string | null; display_code: number | null };
now_unassigned?: boolean; // true si tras soltar no quedan asignados
} {
@ -446,6 +446,30 @@ export class TaskService {
};
}
// Regla: no permitir soltar si es tarea personal y el usuario es el único asignatario
try {
const stats = this.dbInstance.prepare(`
SELECT COUNT(*) AS cnt,
SUM(CASE WHEN user_id = ? THEN 1 ELSE 0 END) AS mine
FROM task_assignments
WHERE task_id = ?
`).get(ensuredUser, taskId) as any;
const cnt = Number(stats?.cnt || 0);
const mine = Number(stats?.mine || 0) > 0;
if ((existing.group_id == null || existing.group_id === null) && cnt === 1 && mine) {
return {
status: 'forbidden_personal',
task: {
id: Number(existing.id),
description: String(existing.description || ''),
due_date: existing.due_date ? String(existing.due_date) : null,
display_code: existing.display_code != null ? Number(existing.display_code) : null,
},
now_unassigned: false,
};
}
} catch {}
const deleteStmt = this.dbInstance.prepare(`
DELETE FROM task_assignments
WHERE task_id = ? AND user_id = ?

@ -86,11 +86,10 @@ describe('CommandService - /t tomar y /t soltar', () => {
expect(res[0].message).toContain('no encontrada');
});
it('soltar: si queda sin dueño, mensaje adecuado', async () => {
it('soltar: personal única asignación → denegado', async () => {
const taskId = createTask('Desc soltar', '999', '2025-09-12', ['111']);
const res = await CommandService.handle(ctx('111', `/t soltar ${taskId}`));
expect(res[0].message).toContain('queda sin responsable');
expect(res[0].message).toContain(String(taskId));
expect(res[0].message).toContain('No puedes soltar una tarea personal. Márcala como completada para eliminarla');
});
it('soltar: not_assigned muestra mensaje informativo', async () => {

@ -61,18 +61,22 @@ describe('TaskService - claim/unassign', () => {
expect(res.task?.id).toBe(taskId);
});
it('unassign: happy path; luego not_assigned; now_unassigned=true', () => {
const taskId = createTask('Soltar luego de asignar', '111', '2025-09-20', ['222']);
// soltar por el mismo usuario
it('unassign: happy path con múltiples asignados; luego not_assigned; now_unassigned=false', () => {
const taskId = createTask('Soltar luego de asignar', '111', '2025-09-20', ['222', '333']);
const res1 = TaskService.unassignTask(taskId, '222');
expect(res1.status).toBe('unassigned');
expect(res1.task?.id).toBe(taskId);
expect(res1.now_unassigned).toBe(true);
expect(res1.now_unassigned).toBe(false);
// idempotente si no estaba asignado
const res2 = TaskService.unassignTask(taskId, '222');
expect(res2.status).toBe('not_assigned');
expect(res2.now_unassigned).toBe(true);
expect(res2.now_unassigned).toBe(false);
});
it('unassign: personal + único asignado → forbidden_personal', () => {
const taskId = createTask('Personal única asignación', '111', '2025-09-21', ['222']);
const res = TaskService.unassignTask(taskId, '222');
expect(res.status).toBe('forbidden_personal');
});
it('unassign: not_found', () => {

Loading…
Cancel
Save