import type { Database } from 'bun:sqlite'; import { db, ensureUserExists } from '../db'; type CreateTaskInput = { description: string; due_date?: string | null; // Expect 'YYYY-MM-DD' or null group_id?: string | null; // Full JID (e.g., 'xxx@g.us') or null created_by: string; // Normalized user ID }; type CreateAssignmentInput = { user_id: string; // Normalized user ID assigned_by: string; // Normalized user ID (typically created_by) }; export class TaskService { static dbInstance: Database = db; static createTask(task: CreateTaskInput, assignments: CreateAssignmentInput[] = []): number { const runTx = this.dbInstance.transaction(() => { const insertTask = this.dbInstance.prepare(` INSERT INTO tasks (description, due_date, group_id, created_by) VALUES (?, ?, ?, ?) `); const ensuredCreator = ensureUserExists(task.created_by, this.dbInstance); if (!ensuredCreator) { throw new Error('No se pudo asegurar created_by'); } // Si el group_id no existe en la tabla groups, usar NULL para no violar la FK let groupIdToInsert = task.group_id ?? null; if (groupIdToInsert) { const exists = this.dbInstance.prepare(`SELECT 1 FROM groups WHERE id = ?`).get(groupIdToInsert); if (!exists) { groupIdToInsert = null; } } const runResult = insertTask.run( task.description, task.due_date ?? null, groupIdToInsert, ensuredCreator ); const taskId = Number((runResult as any).lastInsertRowid); if (assignments.length > 0) { const insertAssignment = this.dbInstance.prepare(` INSERT INTO task_assignments (task_id, user_id, assigned_by) VALUES (?, ?, ?) `); // Evitar duplicados por (task_id, user_id) tras asegurar usuarios const seen = new Set(); for (const a of assignments) { const ensuredUser = ensureUserExists(a.user_id, this.dbInstance); if (!ensuredUser) continue; if (seen.has(ensuredUser)) continue; seen.add(ensuredUser); const ensuredAssigner = ensureUserExists(a.assigned_by || ensuredCreator, this.dbInstance) || ensuredCreator; insertAssignment.run(taskId, ensuredUser, ensuredAssigner); } } return taskId; }); return runTx(); } // Listar pendientes del grupo (limite por defecto 10) static listGroupPending(groupId: string, limit: number = 10): Array<{ id: number; description: string; due_date: string | null; group_id: string | null; assignees: string[]; }> { const rows = this.dbInstance .prepare(` SELECT id, description, due_date, group_id FROM tasks WHERE group_id = ? AND COALESCE(completed, 0) = 0 AND completed_at IS NULL ORDER BY CASE WHEN due_date IS NULL THEN 1 ELSE 0 END, due_date ASC, id ASC LIMIT ? `) .all(groupId, limit) as any[]; const getAssignees = this.dbInstance.prepare(` SELECT user_id FROM task_assignments WHERE task_id = ? ORDER BY assigned_at ASC `); return rows.map((r) => { const assigneesRows = getAssignees.all(r.id) as any[]; const assignees = assigneesRows.map((a) => String(a.user_id)); return { 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, assignees, }; }); } // Listar pendientes asignadas al usuario (limite por defecto 10) static listUserPending(userId: string, limit: number = 10): Array<{ id: number; description: string; due_date: string | null; group_id: string | null; assignees: string[]; }> { const rows = this.dbInstance .prepare(` SELECT t.id, t.description, t.due_date, t.group_id FROM tasks t INNER JOIN task_assignments a ON a.task_id = t.id WHERE a.user_id = ? AND COALESCE(t.completed, 0) = 0 AND t.completed_at IS NULL ORDER BY CASE WHEN t.due_date IS NULL THEN 1 ELSE 0 END, t.due_date ASC, t.id ASC LIMIT ? `) .all(userId, limit) as any[]; const getAssignees = this.dbInstance.prepare(` SELECT user_id FROM task_assignments WHERE task_id = ? ORDER BY assigned_at ASC `); return rows.map((r) => { const assigneesRows = getAssignees.all(r.id) as any[]; const assignees = assigneesRows.map((a) => String(a.user_id)); return { 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, assignees, }; }); } // Contar pendientes del grupo (sin límite) static countGroupPending(groupId: string): number { const row = this.dbInstance .prepare(` SELECT COUNT(*) as cnt FROM tasks WHERE group_id = ? AND COALESCE(completed, 0) = 0 AND completed_at IS NULL `) .get(groupId) as any; return Number(row?.cnt || 0); } // Contar pendientes asignadas al usuario (sin límite) static countUserPending(userId: string): number { const row = this.dbInstance .prepare(` SELECT COUNT(*) as cnt FROM tasks t INNER JOIN task_assignments a ON a.task_id = t.id WHERE a.user_id = ? AND COALESCE(t.completed, 0) = 0 AND t.completed_at IS NULL `) .get(userId) as any; return Number(row?.cnt || 0); } // Completar tarea: registra quién completó e idempotente static completeTask(taskId: number, completedBy: string): { status: 'updated' | 'already' | 'not_found'; task?: { id: number; description: string; due_date: string | null }; } { const ensured = ensureUserExists(completedBy, this.dbInstance); const existing = this.dbInstance .prepare(` SELECT id, description, due_date, completed, completed_at FROM tasks WHERE id = ? `) .get(taskId) as any; if (!existing) { return { status: 'not_found' }; } if (existing.completed || existing.completed_at) { return { status: 'already', task: { id: Number(existing.id), description: String(existing.description || ''), due_date: existing.due_date ? String(existing.due_date) : null, }, }; } this.dbInstance .prepare(` UPDATE tasks SET completed = 1, completed_at = strftime('%Y-%m-%d %H:%M:%f', 'now'), completed_by = ? WHERE id = ? `) .run(ensured, taskId); return { status: 'updated', task: { id: Number(existing.id), description: String(existing.description || ''), due_date: existing.due_date ? String(existing.due_date) : null, }, }; } // Listar pendientes sin dueño del grupo (limite por defecto 10) static listGroupUnassigned(groupId: string, limit: number = 10): Array<{ id: number; description: string; due_date: string | null; group_id: string | null; assignees: string[]; }> { const rows = this.dbInstance .prepare(` SELECT id, description, due_date, group_id FROM tasks WHERE group_id = ? AND COALESCE(completed, 0) = 0 AND completed_at IS NULL AND NOT EXISTS ( SELECT 1 FROM task_assignments ta WHERE ta.task_id = tasks.id ) ORDER BY CASE WHEN due_date IS NULL THEN 1 ELSE 0 END, due_date ASC, id ASC LIMIT ? `) .all(groupId, limit) as any[]; return 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, assignees: [], })); } // Contar pendientes sin dueño del grupo (sin límite) static countGroupUnassigned(groupId: string): number { const row = this.dbInstance .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 ) `) .get(groupId) as any; return Number(row?.cnt || 0); } // Tomar tarea (claim): idempotente static claimTask(taskId: number, userId: string): { status: 'claimed' | 'already' | 'not_found' | 'completed'; task?: { id: number; description: string; due_date: string | null }; } { const ensuredUser = ensureUserExists(userId, this.dbInstance); if (!ensuredUser) { throw new Error('No se pudo asegurar el usuario'); } const existing = this.dbInstance .prepare(` SELECT id, description, due_date, completed, completed_at FROM tasks WHERE id = ? `) .get(taskId) as any; if (!existing) { return { status: 'not_found' }; } if (existing.completed || existing.completed_at) { return { status: 'completed', task: { id: Number(existing.id), description: String(existing.description || ''), due_date: existing.due_date ? String(existing.due_date) : null, }, }; } const already = this.dbInstance .prepare(`SELECT 1 FROM task_assignments WHERE task_id = ? AND user_id = ?`) .get(taskId, ensuredUser); if (already) { return { status: 'already', task: { id: Number(existing.id), description: String(existing.description || ''), due_date: existing.due_date ? String(existing.due_date) : null, }, }; } const insertAssignment = this.dbInstance.prepare(` INSERT OR IGNORE INTO task_assignments (task_id, user_id, assigned_by) VALUES (?, ?, ?) `); this.dbInstance.transaction(() => { insertAssignment.run(taskId, ensuredUser, ensuredUser); })(); return { status: 'claimed', task: { id: Number(existing.id), description: String(existing.description || ''), due_date: existing.due_date ? String(existing.due_date) : null, }, }; } // Soltar tarea (unassign): idempotente static unassignTask(taskId: number, userId: string): { status: 'unassigned' | 'not_assigned' | 'not_found' | 'completed'; task?: { id: number; description: string; due_date: string | null }; now_unassigned?: boolean; // true si tras soltar no quedan asignados } { const ensuredUser = ensureUserExists(userId, this.dbInstance); if (!ensuredUser) { throw new Error('No se pudo asegurar el usuario'); } const existing = this.dbInstance .prepare(` SELECT id, description, due_date, completed, completed_at FROM tasks WHERE id = ? `) .get(taskId) as any; if (!existing) { return { status: 'not_found' }; } if (existing.completed || existing.completed_at) { return { status: 'completed', task: { id: Number(existing.id), description: String(existing.description || ''), due_date: existing.due_date ? String(existing.due_date) : null, }, }; } const deleteStmt = this.dbInstance.prepare(` DELETE FROM task_assignments WHERE task_id = ? AND user_id = ? `); const result = deleteStmt.run(taskId, ensuredUser) as any; const cntRow = this.dbInstance .prepare(`SELECT COUNT(*) as cnt FROM task_assignments WHERE task_id = ?`) .get(taskId) as any; const remaining = Number(cntRow?.cnt || 0); if (result.changes && result.changes > 0) { return { status: 'unassigned', task: { id: Number(existing.id), description: String(existing.description || ''), due_date: existing.due_date ? String(existing.due_date) : null, }, now_unassigned: remaining === 0, }; } return { status: 'not_assigned', task: { id: Number(existing.id), description: String(existing.description || ''), due_date: existing.due_date ? String(existing.due_date) : null, }, now_unassigned: remaining === 0, }; } }