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