You cannot select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
	
	
		
			421 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			TypeScript
		
	
			
		
		
	
	
			421 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			TypeScript
		
	
| 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<string>();
 | |
|         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,
 | |
|     };
 | |
|   }
 | |
| }
 |