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

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,
};
}
}