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.

214 lines
6.7 KiB
TypeScript

This file contains invisible Unicode characters!

This file contains invisible Unicode characters that may be processed differently from what appears below. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to reveal hidden characters.

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

/**
* 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 };
}