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.

706 lines
23 KiB
TypeScript

import type { Database } from 'bun:sqlite';
import { db, ensureUserExists } from '../db';
import { AllowedGroups } from '../services/allowed-groups';
import { isGroupId } from '../utils/whatsapp';
import { ResponseQueue } from '../services/response-queue';
import { Metrics } from '../services/metrics';
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 MAX_DISPLAY_CODE = 9999;
const runTx = this.dbInstance.transaction(() => {
const pickNextDisplayCode = (): number => {
const rows = this.dbInstance
.prepare(`
SELECT display_code
FROM tasks
WHERE display_code IS NOT NULL
AND (
COALESCE(completed, 0) = 0
OR (completed_at IS NOT NULL AND completed_at >= strftime('%Y-%m-%d %H:%M:%f','now','-24 hours'))
)
ORDER BY display_code ASC
`)
.all() as Array<{ display_code: number }>;
let expect = 1;
for (const r of rows) {
const dc = Number(r.display_code || 0);
if (dc < expect) continue;
if (dc === expect) {
expect++;
if (expect > MAX_DISPLAY_CODE) break;
continue;
}
// encontrado hueco
break;
}
if (expect > MAX_DISPLAY_CODE) {
throw new Error('No hay códigos disponibles (límite alcanzado)');
}
return expect;
};
const insertTask = this.dbInstance.prepare(`
INSERT INTO tasks (description, due_date, group_id, created_by, display_code)
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;
// Etapa 5: en modo 'enforce', si es un grupo no permitido, forzar a NULL (compatibilidad)
try {
const mode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase();
if (groupIdToInsert && isGroupId(groupIdToInsert) && mode === 'enforce') {
try { (AllowedGroups as any).dbInstance = this.dbInstance; } catch {}
if (!AllowedGroups.isAllowed(groupIdToInsert)) {
groupIdToInsert = null;
}
}
} catch {}
if (groupIdToInsert) {
const exists = this.dbInstance.prepare(`SELECT 1 FROM groups WHERE id = ? AND COALESCE(is_community,0) = 0`).get(groupIdToInsert);
if (!exists) {
groupIdToInsert = null;
}
}
// Elegir display_code global reutilizable entre tareas activas
const displayCode = pickNextDisplayCode();
const runResult = insertTask.run(
task.description,
task.due_date ?? null,
groupIdToInsert,
ensuredCreator,
displayCode
);
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;
display_code: number | null;
assignees: string[];
}> {
const rows = this.dbInstance
.prepare(`
SELECT id, description, due_date, group_id, display_code
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,
display_code: r.display_code != null ? Number(r.display_code) : 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;
display_code: number | null;
assignees: string[];
}> {
const rows = this.dbInstance
.prepare(`
SELECT t.id, t.description, t.due_date, t.group_id, t.display_code
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,
display_code: r.display_code != null ? Number(r.display_code) : 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; display_code: number | null };
} {
const ensured = ensureUserExists(completedBy, this.dbInstance);
const existing = this.dbInstance
.prepare(`
SELECT id, description, due_date, completed, completed_at, display_code, group_id
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,
display_code: existing.display_code != null ? Number(existing.display_code) : 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);
// Fase 2: reacción ✅ al completar dentro del TTL y con gating
try {
const rxEnabled = String(process.env.REACTIONS_ENABLED || 'false').toLowerCase();
const enabled = ['true','1','yes','on'].includes(rxEnabled);
if (enabled) {
let origin: any = null;
try {
origin = this.dbInstance.prepare(`
SELECT chat_id, message_id, created_at, participant, from_me
FROM task_origins
WHERE task_id = ?
`).get(taskId) as any;
} catch {
origin = this.dbInstance.prepare(`
SELECT chat_id, message_id, created_at
FROM task_origins
WHERE task_id = ?
`).get(taskId) as any;
}
if (origin && origin.chat_id && origin.message_id) {
const chatId = String(origin.chat_id);
const scope = String(process.env.REACTIONS_SCOPE || 'groups').toLowerCase();
if (scope === 'all' || isGroupId(chatId)) {
// TTL desde REACTIONS_TTL_DAYS (usar tal cual; default 14 si inválido)
const ttlDaysEnv = Number(process.env.REACTIONS_TTL_DAYS);
const ttlDays = Number.isFinite(ttlDaysEnv) && ttlDaysEnv > 0 ? ttlDaysEnv : 14;
const maxAgeMs = ttlDays * 24 * 60 * 60 * 1000;
const createdRaw = String(origin.created_at || '');
const createdIso = createdRaw.includes('T') ? createdRaw : (createdRaw.replace(' ', 'T') + 'Z');
const createdMs = Date.parse(createdIso);
const withinTtl = Number.isFinite(createdMs) ? (Date.now() - createdMs <= maxAgeMs) : false;
if (withinTtl) {
// Gating 'enforce' para grupos
let allowed = true;
if (isGroupId(chatId)) {
try { (AllowedGroups as any).dbInstance = this.dbInstance; } catch {}
const mode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase();
if (mode === 'enforce') {
try { allowed = AllowedGroups.isAllowed(chatId); } catch { allowed = true; }
}
}
if (allowed) {
// Encolar reacción ✅ con idempotencia; no bloquear si falla
const participant = origin && origin.participant ? String(origin.participant) : undefined;
const fromMe = (origin && (origin.from_me === 1 || origin.from_me === true)) ? true : undefined;
ResponseQueue.enqueueReaction(chatId, String(origin.message_id), '✅', { participant, fromMe })
.catch(() => {});
}
}
}
}
}
} catch {}
return {
status: 'updated',
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,
},
};
}
// 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;
display_code: number | null;
assignees: string[];
}> {
const rows = this.dbInstance
.prepare(`
SELECT id, description, due_date, group_id, display_code
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,
display_code: r.display_code != null ? Number(r.display_code) : 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; display_code: number | 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, display_code
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,
display_code: existing.display_code != null ? Number(existing.display_code) : 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,
display_code: existing.display_code != null ? Number(existing.display_code) : 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,
display_code: existing.display_code != null ? Number(existing.display_code) : null,
},
};
}
// Soltar tarea (unassign): idempotente
static unassignTask(taskId: number, userId: string): {
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
} {
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, display_code
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,
display_code: existing.display_code != null ? Number(existing.display_code) : null,
},
};
}
// 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 = ?
`);
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,
display_code: existing.display_code != null ? Number(existing.display_code) : 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,
display_code: existing.display_code != null ? Number(existing.display_code) : null,
},
now_unassigned: remaining === 0,
};
}
// ===== Helpers adicionales para consumidores (Etapa 3) =====
// Devuelve datos básicos de una tarea, o null si no existe
static getTaskById(taskId: number): {
id: number;
description: string;
due_date: string | null;
group_id: string | null;
display_code: number | null;
completed: number;
completed_at: string | null;
} | null {
const row = this.dbInstance.prepare(`
SELECT
id,
description,
due_date,
group_id,
display_code,
COALESCE(completed, 0) as completed,
completed_at
FROM tasks
WHERE id = ?
`).get(taskId) as any;
if (!row) return null;
return {
id: Number(row.id),
description: String(row.description || ''),
due_date: row.due_date ? String(row.due_date) : null,
group_id: row.group_id ? String(row.group_id) : null,
display_code: row.display_code != null ? Number(row.display_code) : null,
completed: Number(row.completed || 0),
completed_at: row.completed_at ? String(row.completed_at) : null,
};
}
// Buscar tarea activa por display_code global
static getActiveTaskByDisplayCode(displayCode: number): { id: number; description: string; due_date: string | null; display_code: number | null } | null {
const row = this.dbInstance.prepare(`
SELECT id, description, due_date, display_code
FROM tasks
WHERE display_code = ? AND COALESCE(completed, 0) = 0 AND completed_at IS NULL
LIMIT 1
`).get(displayCode) as any;
if (!row) return null;
return {
id: Number(row.id),
description: String(row.description || ''),
due_date: row.due_date ? String(row.due_date) : null,
display_code: row.display_code != null ? Number(row.display_code) : null,
};
}
// Lista tareas sin responsable para múltiples grupos.
// Implementación simple: reutiliza el método existente por grupo.
static listUnassignedByGroups(groupIds: string[], limitPerGroup: number = 10): Map<string, Array<{
id: number;
description: string;
due_date: string | null;
group_id: string | null;
assignees: string[];
}>> {
const out = new Map<string, Array<{
id: number;
description: string;
due_date: string | null;
group_id: string | null;
assignees: string[];
}>>();
if (!Array.isArray(groupIds) || groupIds.length === 0) return out;
for (const gid of groupIds) {
const rows = this.listGroupUnassigned(gid, limitPerGroup);
if (rows.length > 0) {
out.set(gid, rows);
}
}
return out;
}
// Listar todas las tareas activas en todos los grupos (ordenadas por due_date ASC, NULL al final)
static listAllActive(limit: number = 50): Array<{
id: number;
description: string;
due_date: string | null;
group_id: string | null;
group_name: string | null;
display_code: number | null;
}> {
const rows = this.dbInstance
.prepare(`
SELECT t.id, t.description, t.due_date, t.group_id, t.display_code, g.name AS group_name
FROM tasks t
LEFT JOIN groups g ON g.id = t.group_id
WHERE COALESCE(t.completed, 0) = 0 AND t.completed_at IS NULL
AND (t.group_id IS NULL OR EXISTS (
SELECT 1 FROM groups g2
WHERE g2.id = t.group_id
AND COALESCE(g2.active,1)=1
AND COALESCE(g2.archived,0)=0
AND COALESCE(g2.is_community,0)=0
))
ORDER BY
CASE WHEN t.due_date IS NULL THEN 1 ELSE 0 END,
t.due_date ASC,
t.id ASC
LIMIT ?
`)
.all(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,
group_name: r.group_name ? String(r.group_name) : null,
display_code: r.display_code != null ? Number(r.display_code) : null,
}));
}
static countAllActive(): number {
const row = this.dbInstance
.prepare(`
SELECT COUNT(*) AS cnt
FROM tasks t
WHERE COALESCE(t.completed, 0) = 0 AND t.completed_at IS NULL
AND (t.group_id IS NULL OR EXISTS (
SELECT 1 FROM groups g2
WHERE g2.id = t.group_id
AND COALESCE(g2.active,1)=1
AND COALESCE(g2.archived,0)=0
AND COALESCE(g2.is_community,0)=0
))
`)
.get() as any;
return Number(row?.cnt || 0);
}
}