|
|
/**
|
|
|
* Utilidades compartidas para handlers de comandos (Etapa 1).
|
|
|
* Aún no se usan desde CommandService; servirán en etapas siguientes.
|
|
|
*/
|
|
|
|
|
|
import { TaskService } from '../../tasks/service';
|
|
|
import { GroupSyncService } from '../group-sync';
|
|
|
import { ICONS } from '../../utils/icons';
|
|
|
import { codeId, formatDDMM } from '../../utils/formatting';
|
|
|
|
|
|
export const ACTION_ALIASES: Record<string, string> = {
|
|
|
'n': 'nueva',
|
|
|
'nueva': 'nueva',
|
|
|
'crear': 'nueva',
|
|
|
'+': 'nueva',
|
|
|
'ver': 'ver',
|
|
|
'mostrar': 'ver',
|
|
|
'listar': 'ver',
|
|
|
'ls': 'ver',
|
|
|
'mias': 'ver',
|
|
|
'mías': 'ver',
|
|
|
'todas': 'ver',
|
|
|
'todos': 'ver',
|
|
|
'x': 'completar',
|
|
|
'hecho': 'completar',
|
|
|
'completar': 'completar',
|
|
|
'done': 'completar',
|
|
|
'tomar': 'tomar',
|
|
|
'claim': 'tomar',
|
|
|
'asumir': 'tomar',
|
|
|
'asumo': 'tomar',
|
|
|
'soltar': 'soltar',
|
|
|
'unassign': 'soltar',
|
|
|
'dejar': 'soltar',
|
|
|
'liberar': 'soltar',
|
|
|
'renunciar': 'soltar',
|
|
|
'ayuda': 'ayuda',
|
|
|
'help': 'ayuda',
|
|
|
'info': 'ayuda',
|
|
|
'?': 'ayuda',
|
|
|
'config': 'configurar',
|
|
|
'configurar': 'configurar',
|
|
|
'web': 'web'
|
|
|
};
|
|
|
|
|
|
export const SCOPE_ALIASES: Record<string, 'mis' | 'todos'> = {
|
|
|
'todo': 'todos',
|
|
|
'todos': 'todos',
|
|
|
'todas': 'todos',
|
|
|
'mis': 'mis',
|
|
|
'mias': 'mis',
|
|
|
'mías': 'mis',
|
|
|
'yo': 'mis'
|
|
|
};
|
|
|
|
|
|
export const CTA_HELP = 'ℹ️ Tus tareas: `t mias` · Todas: `t todas` · Info: `t info` · Web: `t web`';
|
|
|
|
|
|
/**
|
|
|
* Formatea un Date a YYYY-MM-DD respetando TZ (por defecto Europe/Madrid).
|
|
|
*/
|
|
|
function ymdInTZ(d: Date, tz?: string): string {
|
|
|
const TZ = (tz && tz.trim()) || (process.env.TZ && process.env.TZ.trim()) || 'Europe/Madrid';
|
|
|
const parts = new Intl.DateTimeFormat('en-GB', {
|
|
|
timeZone: TZ,
|
|
|
year: 'numeric',
|
|
|
month: '2-digit',
|
|
|
day: '2-digit'
|
|
|
}).formatToParts(d);
|
|
|
const get = (t: string) => parts.find(p => p.type === t)?.value || '';
|
|
|
return `${get('year')}-${get('month')}-${get('day')}`;
|
|
|
}
|
|
|
|
|
|
export function todayYMD(tz?: string): string {
|
|
|
return ymdInTZ(new Date(), tz);
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* Parsea múltiples IDs desde tokens, deduplica, y aplica límite.
|
|
|
*/
|
|
|
export function parseMultipleIds(tokens: string[], max: number = 10): { ids: number[]; truncated: boolean } {
|
|
|
const raw = (tokens || []).join(' ').trim();
|
|
|
const all = raw
|
|
|
? raw
|
|
|
.split(/[,\s]+/)
|
|
|
.map(t => t.trim())
|
|
|
.filter(Boolean)
|
|
|
.map(t => parseInt(t, 10))
|
|
|
.filter(n => Number.isFinite(n) && n > 0)
|
|
|
: [];
|
|
|
const dedup: number[] = [];
|
|
|
const seen = new Set<number>();
|
|
|
for (const n of all) {
|
|
|
if (!seen.has(n)) {
|
|
|
seen.add(n);
|
|
|
dedup.push(n);
|
|
|
}
|
|
|
}
|
|
|
const truncated = dedup.length > max;
|
|
|
const ids = dedup.slice(0, max);
|
|
|
return { ids, truncated };
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* Resuelve un ID de entrada (display_code) a task.id si está activa.
|
|
|
*/
|
|
|
export function resolveTaskIdFromInput(n: number): number | null {
|
|
|
const byCode = TaskService.getActiveTaskByDisplayCode(n);
|
|
|
return byCode ? byCode.id : null;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* Aplica la política de membresía para acciones sobre una tarea.
|
|
|
* Devuelve true si el usuario está permitido según flags/env.
|
|
|
*/
|
|
|
export function enforceMembership(sender: string, task: { group_id?: string | null }, enforceFlag?: boolean): boolean {
|
|
|
const enforce =
|
|
|
typeof enforceFlag === 'boolean'
|
|
|
? enforceFlag
|
|
|
: String(process.env.GROUP_MEMBERS_ENFORCE || '').toLowerCase() === 'true';
|
|
|
|
|
|
const gid = task?.group_id || null;
|
|
|
if (!gid) return true; // tareas personales no requieren membresía
|
|
|
if (!enforce) return true;
|
|
|
if (!GroupSyncService.isSnapshotFresh(gid)) return true;
|
|
|
|
|
|
return GroupSyncService.isUserActiveInGroup(sender, gid);
|
|
|
}
|
|
|
|
|
|
/** Formatea el sufijo de fecha de vencimiento para una respuesta de tarea. */
|
|
|
export function formatDue(task: { due_date?: string | null } | null | undefined): string {
|
|
|
return task?.due_date ? ` — ${ICONS.date} ${formatDDMM(task.due_date)}` : '';
|
|
|
}
|
|
|
|
|
|
/** Construye el texto de resumen para procesamiento por lotes. */
|
|
|
export function buildSummary(counts: Record<string, number>, labels: Record<string, string>): string {
|
|
|
const parts: string[] = [];
|
|
|
for (const key of Object.keys(counts)) {
|
|
|
if (counts[key]) parts.push(`${labels[key]} ${counts[key]}`);
|
|
|
}
|
|
|
return parts.length ? `Resumen: ${parts.join(', ')}.` : '';
|
|
|
}
|
|
|
|
|
|
/** Construye el fragmento " — ⚠️ 📅 DD/MM" o vacío para una tarea según si está vencida. */
|
|
|
export function formatDatePart(due_date: string | null | undefined, todayYMD: string): string {
|
|
|
if (!due_date) return '';
|
|
|
const overdue = due_date < todayYMD;
|
|
|
return ` — ${overdue ? `${ICONS.warn} ` : ''}${ICONS.date} ${formatDDMM(due_date)}`;
|
|
|
}
|
|
|
|
|
|
/** Renderiza una línea de tarea con su código, descripción, fecha y dueño. */
|
|
|
export function formatTaskLine(
|
|
|
t: { id: number; description?: string | null; due_date?: string | null; display_code?: number | null },
|
|
|
owner: string,
|
|
|
todayYMD: string
|
|
|
): string {
|
|
|
const dc = (t as any).display_code as number | undefined;
|
|
|
const datePart = formatDatePart(t.due_date, todayYMD);
|
|
|
return `- ${codeId(t.id, dc)} ${t.description || '(sin descripción)'}${datePart} — ${owner}`;
|
|
|
}
|
|
|
|
|
|
/** Outcome of a single action in a multi-ID batch. */
|
|
|
export interface BatchOutcome {
|
|
|
status: string;
|
|
|
line: string;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* Generic multi-ID batch handler.
|
|
|
*
|
|
|
* Iterates over IDs, calls `action` for each, collects outcomes,
|
|
|
* counts statuses, builds a summary and returns a single Msg.
|
|
|
*/
|
|
|
export function handleBatch(
|
|
|
sender: string,
|
|
|
ids: number[],
|
|
|
truncated: boolean,
|
|
|
action: (idInput: number, sender: string) => BatchOutcome,
|
|
|
statusLabels: Record<string, string>,
|
|
|
usageMessage: string,
|
|
|
): { recipient: string; message: string }[] {
|
|
|
if (ids.length === 0) {
|
|
|
return [{ recipient: sender, message: usageMessage }];
|
|
|
}
|
|
|
|
|
|
const lines: string[] = [];
|
|
|
if (truncated) lines.push('⚠️ Se procesarán solo los primeros 10 IDs.');
|
|
|
|
|
|
const counts: Record<string, number> = {};
|
|
|
|
|
|
for (const idInput of ids) {
|
|
|
const outcome = action(idInput, sender);
|
|
|
lines.push(outcome.line);
|
|
|
counts[outcome.status] = (counts[outcome.status] || 0) + 1;
|
|
|
}
|
|
|
|
|
|
const summary = buildSummary(counts, statusLabels);
|
|
|
if (summary) { lines.push(''); lines.push(summary); }
|
|
|
|
|
|
return [{ recipient: sender, message: lines.join('\n') }];
|
|
|
}
|
|
|
|
|
|
/** Resuelve un ID de entrada, carga la tarea y aplica membresía. Retorna error o {resolvedId, task}. */
|
|
|
export function resolveAndValidate(
|
|
|
idInput: number,
|
|
|
sender: string
|
|
|
): { resolvedId: number; task: any } | { error: string } {
|
|
|
const resolvedId = resolveTaskIdFromInput(idInput);
|
|
|
if (!resolvedId) return { error: `⚠️ Tarea ${codeId(idInput)} no encontrada.` };
|
|
|
const task = TaskService.getTaskById(resolvedId);
|
|
|
if (!task) return { error: `⚠️ Tarea ${codeId(resolvedId)} no encontrada.` };
|
|
|
if (!enforceMembership(sender, task)) return { error: `🚫 ${codeId(resolvedId)} — no permitido (no eres miembro activo).` };
|
|
|
return { resolvedId, task };
|
|
|
}
|