Compare commits
87 Commits
038f06dc69
...
4c9f4d1439
@ -0,0 +1,35 @@
|
||||
import { toIsoSqlUTC as coreToIsoSqlUTC, normalizeTime as coreNormalizeTime } from '../../../../../src/utils/datetime';
|
||||
|
||||
/**
|
||||
* Serializa una fecha en UTC al formato SQL ISO "YYYY-MM-DD HH:MM:SS[.SSS]".
|
||||
* Mantiene exactamente la semántica previa basada en toISOString().replace().
|
||||
*/
|
||||
export function toIsoSqlUTC(d: Date = new Date()): string {
|
||||
return coreToIsoSqlUTC(d);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normaliza una hora 'HH:mm'. Devuelve null si no es válida.
|
||||
*/
|
||||
export function normalizeTime(input: string | null | undefined): string | null {
|
||||
return coreNormalizeTime(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Devuelve YYYY-MM-DD en UTC (útil para consultas por rango de fecha).
|
||||
*/
|
||||
export function ymdUTC(date: Date): string {
|
||||
const yyyy = String(date.getUTCFullYear()).padStart(4, '0');
|
||||
const mm = String(date.getUTCMonth() + 1).padStart(2, '0');
|
||||
const dd = String(date.getUTCDate()).padStart(2, '0');
|
||||
return `${yyyy}-${mm}-${dd}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Suma meses en UTC preservando día (ajustado por desbordes de fin de mes por el propio motor).
|
||||
*/
|
||||
export function addMonthsUTC(date: Date, months: number): Date {
|
||||
const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
|
||||
d.setUTCMonth(d.getUTCMonth() + months);
|
||||
return d;
|
||||
}
|
||||
@ -0,0 +1,103 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import ClaimIcon from "$lib/ui/icons/ClaimIcon.svelte";
|
||||
import UnassignIcon from "$lib/ui/icons/UnassignIcon.svelte";
|
||||
import EditIcon from "$lib/ui/icons/EditIcon.svelte";
|
||||
import CalendarEditIcon from "$lib/ui/icons/CalendarEditIcon.svelte";
|
||||
|
||||
export let isAssigned: boolean;
|
||||
export let canUnassign: boolean;
|
||||
export let busy: boolean;
|
||||
export let completed: boolean;
|
||||
|
||||
export let editingText: boolean;
|
||||
|
||||
export let editingDate: boolean;
|
||||
export let dateValue: string = "";
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
claim: void;
|
||||
unassign: void;
|
||||
toggleEditText: void;
|
||||
saveText: void;
|
||||
cancelText: void;
|
||||
toggleEditDate: void;
|
||||
saveDate: { value: string | null };
|
||||
clearDate: void;
|
||||
cancelDate: void;
|
||||
}>();
|
||||
|
||||
let localDate = dateValue;
|
||||
$: if (editingDate) {
|
||||
localDate = dateValue;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if !completed}
|
||||
{#if !isAssigned}
|
||||
<button
|
||||
class="icon-btn secondary-action"
|
||||
aria-label="Reclamar"
|
||||
on:click|preventDefault={() => dispatch("claim")}
|
||||
disabled={busy}
|
||||
>
|
||||
<ClaimIcon /> Reclamar
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
class="icon-btn secondary-action"
|
||||
aria-label="Soltar"
|
||||
title={canUnassign ? "Soltar" : "No puedes soltar una tarea personal. Márcala como completada para eliminarla"}
|
||||
on:click|preventDefault={() => dispatch("unassign")}
|
||||
disabled={busy || !canUnassign}
|
||||
>
|
||||
<UnassignIcon /> Soltar
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if !editingText}
|
||||
<button
|
||||
class="icon-btn secondary-action"
|
||||
aria-label="Editar texto"
|
||||
title="Editar texto"
|
||||
on:click|preventDefault={() => dispatch("toggleEditText")}
|
||||
disabled={busy}
|
||||
>
|
||||
<EditIcon /> Editar
|
||||
</button>
|
||||
{:else}
|
||||
<button class="btn primary secondary-action" on:click|preventDefault={() => dispatch("saveText")} disabled={busy}>
|
||||
Guardar
|
||||
</button>
|
||||
<button class="btn ghost secondary-action" on:click|preventDefault={() => dispatch("cancelText")} disabled={busy}>
|
||||
Cancelar
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if !editingDate}
|
||||
<button
|
||||
class="icon-btn secondary-action"
|
||||
aria-label="Editar fecha"
|
||||
title="Editar fecha"
|
||||
on:click|preventDefault={() => dispatch("toggleEditDate")}
|
||||
disabled={busy}
|
||||
>
|
||||
<CalendarEditIcon /> Fecha
|
||||
</button>
|
||||
{:else}
|
||||
<input class="date" type="date" bind:value={localDate} />
|
||||
<button
|
||||
class="btn primary secondary-action"
|
||||
on:click|preventDefault={() => dispatch("saveDate", { value: (localDate || "").trim() || null })}
|
||||
disabled={busy}
|
||||
>
|
||||
Guardar
|
||||
</button>
|
||||
<button class="btn danger secondary-action" on:click|preventDefault={() => dispatch("clearDate")} disabled={busy}>
|
||||
Quitar
|
||||
</button>
|
||||
<button class="btn ghost secondary-action" on:click|preventDefault={() => dispatch("cancelDate")} disabled={busy}>
|
||||
Cancelar
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
@ -0,0 +1,144 @@
|
||||
<script lang="ts">
|
||||
import { normalizeDigits, buildWaMeUrl } from "$lib/utils/phone";
|
||||
import Popover from "$lib/ui/feedback/Popover.svelte";
|
||||
import AssigneesIcon from "$lib/ui/icons/AssigneesIcon.svelte";
|
||||
import { onDestroy } from "svelte";
|
||||
|
||||
export let id: number;
|
||||
export let assignees: string[] = [];
|
||||
export let currentUserId: string | null | undefined = null;
|
||||
|
||||
let open = false;
|
||||
|
||||
$: assigneesCount = Array.isArray(assignees) ? assignees.length : 0;
|
||||
$: isAssigned =
|
||||
!!currentUserId &&
|
||||
(assignees || []).some((a) => normalizeDigits(a) === normalizeDigits(currentUserId!));
|
||||
$: assigneesAria =
|
||||
assigneesCount === 0
|
||||
? "Sin responsables"
|
||||
: `${assigneesCount} responsable${assigneesCount === 1 ? "" : "s"}${isAssigned ? "; tú incluido" : ""}`;
|
||||
|
||||
onDestroy(() => { open = false; });
|
||||
</script>
|
||||
|
||||
{#if assigneesCount === 0}
|
||||
<button
|
||||
class="assignees-badge empty"
|
||||
type="button"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded="false"
|
||||
aria-controls={"assignees-popover-" + id}
|
||||
title="Sin responsables"
|
||||
disabled
|
||||
>
|
||||
🙅
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
class="assignees-badge"
|
||||
class:mine={isAssigned}
|
||||
type="button"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
aria-controls={"assignees-popover-" + id}
|
||||
title={assigneesAria}
|
||||
aria-label={assigneesAria}
|
||||
on:click={() => (open = true)}
|
||||
>
|
||||
<span class="icon" aria-hidden="true">
|
||||
<AssigneesIcon />
|
||||
</span>
|
||||
<span class="count">{assigneesCount}</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<Popover bind:open={open} ariaLabel="Responsables" id={"assignees-popover-" + id}>
|
||||
<h3 class="popover-title">Responsables</h3>
|
||||
{#if assigneesCount === 0}
|
||||
<p class="muted">No hay responsables asignados.</p>
|
||||
{:else}
|
||||
<ul class="assignees-list">
|
||||
{#each assignees as a}
|
||||
<li>
|
||||
<a href={buildWaMeUrl(normalizeDigits(a))} target="_blank" rel="noopener noreferrer nofollow">
|
||||
{normalizeDigits(a)}
|
||||
</a>
|
||||
{#if currentUserId && normalizeDigits(a) === normalizeDigits(currentUserId)}
|
||||
<span class="you-pill">tú</span>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
<div class="popover-actions">
|
||||
<button class="btn ghost" on:click={() => (open = false)}>Cerrar</button>
|
||||
</div>
|
||||
</Popover>
|
||||
|
||||
<style>
|
||||
.muted { color: var(--color-text-muted); }
|
||||
|
||||
.assignees-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-self: start;
|
||||
gap: 8px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 0 4px 4px var(--color-border);
|
||||
}
|
||||
.assignees-badge .icon {
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
.assignees-badge .count {
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
}
|
||||
.assignees-badge.mine { border-color: var(--color-surface); }
|
||||
.assignees-badge.mine .icon { position: relative; }
|
||||
.assignees-badge.mine .icon::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: -6px;
|
||||
top: -6px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: var(--color-primary);
|
||||
border: 1px solid var(--color-surface);
|
||||
border-radius: 50%;
|
||||
}
|
||||
.assignees-badge[aria-expanded="true"] { border-color: var(--color-primary); }
|
||||
.assignees-badge:disabled { cursor: not-allowed; opacity: 0.6; }
|
||||
.assignees-badge.empty { padding: 2px 6px; gap: 0; }
|
||||
|
||||
.assignees-list { list-style: none; margin: 8px 0; padding: 0; }
|
||||
.assignees-list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.assignees-list a { color: var(--color-primary); text-decoration: none; }
|
||||
.assignees-list a:hover, .assignees-list a:focus-visible { text-decoration: underline; }
|
||||
|
||||
.popover-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.popover-title { margin: 0 0 4px 0; font-size: 0.95rem; }
|
||||
.you-pill {
|
||||
margin-left: 6px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
background: rgba(37, 99, 235, 0.12);
|
||||
color: var(--color-primary);
|
||||
font-size: 11px;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import CheckCircleSuccessIcon from "$lib/ui/icons/CheckCircleSuccessIcon.svelte";
|
||||
import { createEventDispatcher } from "svelte";
|
||||
|
||||
export let completed: boolean;
|
||||
export let busy: boolean;
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
complete: void;
|
||||
uncomplete: void;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
{#if completed}
|
||||
<button
|
||||
class="btn primary primary-action"
|
||||
aria-label="Deshacer completar"
|
||||
title="Deshacer completar"
|
||||
on:click|preventDefault={() => dispatch("uncomplete")}
|
||||
disabled={busy}
|
||||
>
|
||||
↩️ Deshacer
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
class="btn primary primary-action"
|
||||
aria-label="Completar"
|
||||
title="Completar"
|
||||
on:click|preventDefault={() => dispatch("complete")}
|
||||
disabled={busy}
|
||||
>
|
||||
<CheckCircleSuccessIcon />
|
||||
Completar
|
||||
</button>
|
||||
{/if}
|
||||
@ -0,0 +1,45 @@
|
||||
<script lang="ts">
|
||||
import { compareYmd, todayYmdUTC, ymdToDmy, isToday, isTomorrow } from "$lib/utils/date";
|
||||
import DueOkIcon from "$lib/ui/icons/DueOkIcon.svelte";
|
||||
import DueSoonIcon from "$lib/ui/icons/DueSoonIcon.svelte";
|
||||
import DueOverdueIcon from "$lib/ui/icons/DueOverdueIcon.svelte";
|
||||
|
||||
export let due_date: string | null;
|
||||
|
||||
$: today = todayYmdUTC();
|
||||
$: overdue = !!due_date && compareYmd(due_date, today) < 0;
|
||||
$: imminent = !!due_date && (isToday(due_date!) || isTomorrow(due_date!));
|
||||
$: dateDmy = due_date ? ymdToDmy(due_date) : "";
|
||||
$: title = overdue ? "Vencida" : imminent ? "Próxima" : "Fecha";
|
||||
</script>
|
||||
|
||||
{#if due_date}
|
||||
<span class="date-badge" class:overdue class:soon={imminent} {title}>
|
||||
{#if !overdue && !imminent}
|
||||
<DueOkIcon />
|
||||
{:else if imminent}
|
||||
<DueSoonIcon />
|
||||
{:else}
|
||||
<DueOverdueIcon />
|
||||
{/if}
|
||||
{dateDmy}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.date-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 6px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid transparent;
|
||||
font-size: 12px;
|
||||
}
|
||||
.date-badge.overdue {
|
||||
border-color: var(--color-danger);
|
||||
}
|
||||
.date-badge.soon {
|
||||
border-color: var(--color-warning);
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
import TaskDueBadge from "$lib/ui/data/task/TaskDueBadge.svelte";
|
||||
|
||||
export let groupLabel: string;
|
||||
export let gc: { border?: string; bg?: string; text?: string } | null = null;
|
||||
export let due_date: string | null = null;
|
||||
</script>
|
||||
|
||||
<span
|
||||
class="group-badge"
|
||||
title="Grupo"
|
||||
style={gc
|
||||
? `--gc-border: ${gc.border}; --gc-bg: ${gc.bg}; --gc-text: ${gc.text};`
|
||||
: undefined}
|
||||
>
|
||||
{groupLabel}
|
||||
</span>
|
||||
{#if due_date}
|
||||
<TaskDueBadge {due_date} />
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.group-badge {
|
||||
padding: 2px 6px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--gc-border, var(--color-border));
|
||||
background: var(--gc-bg, transparent);
|
||||
color: var(--gc-text, inherit);
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,99 @@
|
||||
<script lang="ts">
|
||||
import { tick, createEventDispatcher } from "svelte";
|
||||
|
||||
export let description: string;
|
||||
export let completed: boolean;
|
||||
export let editing: boolean;
|
||||
export let busy: boolean;
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
toggleEdit: void;
|
||||
saveText: { text: string };
|
||||
cancelText: void;
|
||||
}>();
|
||||
|
||||
let el: HTMLElement | null = null;
|
||||
|
||||
// Mantener el DOM sincronizado cuando se cierra la edición o cambia la descripción
|
||||
$: if (el && !editing) {
|
||||
el.textContent = description;
|
||||
}
|
||||
|
||||
// Enfocar al entrar en modo edición
|
||||
$: if (editing) {
|
||||
tick().then(() => {
|
||||
if (el) {
|
||||
el.focus();
|
||||
placeCaretAtEnd(el);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function placeCaretAtEnd(node: HTMLElement) {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(node);
|
||||
range.collapse(false);
|
||||
const sel = window.getSelection();
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(range);
|
||||
}
|
||||
|
||||
function normalizeText(s: string): string {
|
||||
return s.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
export function getCurrentText(): string {
|
||||
return normalizeText(el?.textContent || "");
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
tabindex="0"
|
||||
class="desc"
|
||||
class:editing={editing}
|
||||
class:completed
|
||||
contenteditable={editing && !completed}
|
||||
role="textbox"
|
||||
aria-label="Descripción de la tarea"
|
||||
spellcheck="true"
|
||||
bind:this={el}
|
||||
on:dblclick={() => !busy && !completed && dispatch('toggleEdit')}
|
||||
on:keydown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
dispatch('cancelText');
|
||||
} else if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
dispatch('saveText', { text: getCurrentText() });
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{description}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.desc {
|
||||
padding: 8px 4px;
|
||||
grid-column: 1/3;
|
||||
grid-row: 2/3;
|
||||
}
|
||||
|
||||
.desc.editing {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
background: var(--color-surface);
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
white-space: normal;
|
||||
text-overflow: clip;
|
||||
grid-column: 1/3;
|
||||
grid-row: 2/3;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.desc.completed {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
export let className: string = '';
|
||||
</script>
|
||||
|
||||
<svg
|
||||
viewBox="0 0 122.88 91.99"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
class={className}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<style>
|
||||
.icon-btn-svg { fill-rule: evenodd; clip-rule: evenodd; fill: var(--color-text); }
|
||||
</style>
|
||||
<g>
|
||||
<path
|
||||
class="icon-btn-svg"
|
||||
d="M45.13,35.29h-0.04c-7.01-0.79-16.42,0.01-20.78,0C17.04,35.6,9.47,41.91,5.02,51.3 c-2.61,5.51-3.3,9.66-3.73,15.55C0.42,72.79-0.03,78.67,0,84.47c1.43,9.03,12.88,6.35,13.85,0l1.39-18.2 c0.21-2.75,0.4-4.61,1.51-7.23c0.52-1.23,1.15-2.28,1.89-3.15l0.69,33.25l-0.39,2.78h31.49l-0.42-3.1l0.61-36.67 c3.2-1.29,5.96-1.89,8.39-1.99c-0.12,0.25-0.25,0.5-0.37,0.75c-2.61,5.51-3.3,9.66-3.73,15.55c-0.86,5.93-1.32,11.81-1.29,17.61 c1.43,9.03,12.88,6.35,13.85,0l1.39-18.2c0.21-2.75,0.4-4.61,1.51-7.23c0.52-1.23,1.15-2.28,1.89-3.15l0.69,33.25l-0.46,3.24h31.62 l-0.48-3.55l0.49-28.62v0.56l0.1-4.87c0.74,0.87,1.36,1.92,1.89,3.15c1.12,2.62,1.3,4.48,1.51,7.23l1.39,18.2 c1.34,8.68,13.85,8.85,13.85,0c0.03-5.81-0.42-11.68-1.29-17.61c-0.43-5.89-1.12-10.04-3.73-15.55 c-4.57-9.65-10.48-14.76-19.45-15.81c-5.53-0.45-14.82,0.06-20.36-0.1c-1.38,0.19-2.74,0.47-4.06,0.87 c-3.45-0.48-8.01-1.07-12.56-1.09C54.76,34.77,48.15,35.91,45.13,35.29L45.13,35.29z M88.3,0c9.01,0,16.32,7.31,16.32,16.32 c0,9.01-7.31,16.32-16.32,16.32c-9.01,0-16.32-7.31-16.32-16.32C71.98,7.31,79.29,0,88.3,0L88.3,0z M34.56,0 c9.01,0,16.32,7.31,16.32,16.32c0,9.01-7.31,16.32-16.32,16.32s-16.32-7.31-16.32-16.32C18.24,7.31,25.55,0,34.56,0L34.56,0z"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
export let className: string = '';
|
||||
</script>
|
||||
|
||||
<svg
|
||||
viewBox="0 0 110.01 122.88"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
class={className}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<style>
|
||||
.icon-btn-svg { fill-rule: evenodd; clip-rule: evenodd; fill: var(--color-text); }
|
||||
</style>
|
||||
<g>
|
||||
<path
|
||||
class="icon-btn-svg"
|
||||
d="M1.87,14.69h22.66L24.5,14.3V4.13C24.5,1.86,26.86,0,29.76,0c2.89,0,5.26,1.87,5.26,4.13V14.3l-0.03,0.39 h38.59l-0.03-0.39V4.13C73.55,1.86,75.91,0,78.8,0c2.89,0,5.26,1.87,5.26,4.13V14.3l-0.03,0.39h24.11c1.03,0,1.87,0.84,1.87,1.87 v19.46c0,1.03-0.84,1.87-1.87,1.87H1.87C0.84,37.88,0,37.04,0,36.01V16.55C0,15.52,0.84,14.69,1.87,14.69L1.87,14.69z M71.6,74.59 c2.68-0.02,4.85,2.14,4.85,4.82c-0.01,2.68-2.19,4.87-4.87,4.89l-11.76,0.08l-0.08,11.77c-0.02,2.66-2.21,4.81-4.89,4.81 c-2.68-0.01-4.84-2.17-4.81-4.83l0.08-11.69L38.4,84.54c-2.68,0.02-4.85-2.14-4.85-4.82c0.01-2.68,2.19-4.88,4.87-4.9l11.76-0.08 l0.08-11.77c0.02-2.66,2.21-4.82,4.89-4.81c2.68,0,4.83,2.16,4.81,4.82l-0.08,11.69L71.6,74.59L71.6,74.59L71.6,74.59z M0.47,42.19 h109.08c0.26,0,0.46,0.21,0.46,0.46l0,0v79.76c0,0.25-0.21,0.46-0.46,0.46l-109.08,0c-0.25,0-0.46-0.21-0.46-0.46V42.66 C0,42.4,0.21,42.19,0.47,42.19L0.47,42.19L0.47,42.19z M8.84,50.58h93.84c0.52,0,0.94,0.45,0.94,0.94v62.85 c0,0.49-0.45,0.94-0.94,0.94H8.39c-0.49,0-0.94-0.42-0.94-0.94v-62.4c0-1.03,0.84-1.86,1.86-1.86L8.84,50.58L8.84,50.58z M78.34,29.87c2.89,0,5.26-1.87,5.26-4.13V15.11l-0.03-0.41H73.11l-0.03,0.41v10.16c0,2.27,2.36,4.13,5.25,4.13L78.34,29.87 L78.34,29.87z M29.29,29.87c2.89,0,5.26-1.87,5.26-4.13V15.11l-0.03-0.41H24.06l-0.03,0.41v10.16c0,2.27,2.36,4.13,5.25,4.13V29.87 L29.29,29.87z"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
export let className: string = '';
|
||||
</script>
|
||||
|
||||
<svg
|
||||
viewBox="0 0 96 96"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
class={className}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
fill="#6BBE66"
|
||||
d="M48,0c26.51,0,48,21.49,48,48S74.51,96,48,96S0,74.51,0,48 S21.49,0,48,0L48,0z M26.764,49.277c0.644-3.734,4.906-5.813,8.269-3.79c0.305,0.182,0.596,0.398,0.867,0.646l0.026,0.025 c1.509,1.446,3.2,2.951,4.876,4.443l1.438,1.291l17.063-17.898c1.019-1.067,1.764-1.757,3.293-2.101 c5.235-1.155,8.916,5.244,5.206,9.155L46.536,63.366c-2.003,2.137-5.583,2.332-7.736,0.291c-1.234-1.146-2.576-2.312-3.933-3.489 c-2.35-2.042-4.747-4.125-6.701-6.187C26.993,52.809,26.487,50.89,26.764,49.277L26.764,49.277z"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
export let className: string = '';
|
||||
</script>
|
||||
|
||||
<svg
|
||||
viewBox="0 0 121.2 122.88"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
class={className}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<style>
|
||||
.icon-btn-svg { fill-rule: evenodd; clip-rule: evenodd; fill: var(--color-text); }
|
||||
</style>
|
||||
<g>
|
||||
<path
|
||||
class="icon-btn-svg"
|
||||
d="M66.17,24.52c9.78,12.13,14.55,26.46,13.93,39.58c-4.52-11.08-11.54-22.31-20.85-32.65l-3.12,3.12 c-0.35,0.35-0.93,0.35-1.28,0l-9.87-9.87c-0.35-0.35-0.35-0.93,0-1.28l3.03-3.03C37.4,11.2,25.96,4.37,14.75,0.13 c13.22-0.99,27.81,3.57,40.2,13.31l1.36-1.36c0.35-0.35,0.93-0.35,1.28,0l9.87,9.87c0.35,0.35,0.35,0.93,0,1.28L66.17,24.52 L66.17,24.52z M49.32,58.69v-4.05l19.11,2.04L49.32,58.69L49.32,58.69z M57.83,74.36l-2.87-2.87l14.96-12.07L57.83,74.36 L57.83,74.36z M111.77,35.18l2.32,5.02l-24.85,8.4L111.77,35.18L111.77,35.18z M92.26,20.63l5.19,1.91L85.82,46.05L92.26,20.63 L92.26,20.63z M102.7,57.6l18.5,11.47v53.81H25.73c19.44-19.44,46.04-25.61,61.42-52.24C92.99,60.52,91.01,49.7,102.7,57.6 L102.7,57.6z M44.6,27.81l7.99,7.99L9.64,78.76c-2.2,2.2-5.8,2.2-7.99,0l0,0c-2.2-2.2-2.2-5.8,0-7.99L44.6,27.81L44.6,27.81z"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
export let className: string = '';
|
||||
</script>
|
||||
|
||||
<svg
|
||||
viewBox="0 0 122.88 99.56"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
class={className}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<style>
|
||||
.st0 { fill: var(--color-text); }
|
||||
.st1 { fill-rule: evenodd; clip-rule: evenodd; fill: #38ae48; }
|
||||
</style>
|
||||
<g>
|
||||
<path
|
||||
class="st0"
|
||||
d="M73.1,0c6.73,0,13.16,1.34,19.03,3.78c6.09,2.52,11.57,6.22,16.16,10.81c4.59,4.58,8.28,10.06,10.81,16.17 c2.43,5.87,3.78,12.3,3.78,19.03c0,6.73-1.34,13.16-3.78,19.03c-2.52,6.09-6.22,11.58-10.81,16.16 c-4.58,4.59-10.06,8.28-16.17,10.81c-5.87,2.43-12.3,3.78-19.03,3.78c-6.73,0-13.16-1.34-19.03-3.77 c-6.09-2.52-11.57-6.22-16.16-10.81l-0.01-0.01c-4.59-4.59-8.29-10.07-10.81-16.16c-0.78-1.89-1.45-3.83-2-5.82 c1.04,0.1,2.1,0.15,3.17,0.15c2.03,0,4.01-0.18,5.94-0.53c0.32,0.96,0.67,1.91,1.05,2.84c2.07,5,5.11,9.51,8.9,13.29 c3.78,3.78,8.29,6.82,13.29,8.9c4.81,1.99,10.11,3.1,15.66,3.1c5.56,0,10.85-1.1,15.66-3.1c5-2.07,9.51-5.11,13.29-8.9 c3.78-3.78,6.82-8.29,8.9-13.29c1.99-4.81,3.1-10.11,3.1-15.66c0-5.56-1.1-10.85-3.1-15.66c-2.07-5-5.11-9.51-8.9-13.29 c-3.78-3.78-8.29-6.82-13.29-8.9c-4.81-1.99-10.11-3.1-15.66-3.1c-5.56,0-10.85,1.1-15.66,3.1c-0.43,0.18-0.86,0.37-1.28,0.56 c-1.64-2.58-3.62-4.92-5.89-6.95c1.24-0.64,2.51-1.23,3.8-1.77C59.94,1.34,66.37,0,73.1,0L73.1,0z M67.38,26.12 c0-1.22,0.5-2.33,1.3-3.13c0.8-0.8,1.9-1.3,3.12-1.3c1.22,0,2.33,0.5,3.13,1.3c0.8,0.8,1.3,1.91,1.3,3.13v23.22l17.35,10.29 c1.04,0.62,1.74,1.6,2.03,2.7c0.28,1.09,0.15,2.29-0.47,3.34c-0.62,1.04-1.6,1.74-2.7,2.03c-1.09,0.28-2.29,0.15-3.33-0.47 L69.65,55.71c-0.67-0.37-1.22-0.91-1.62-1.55c-0.41-0.67-0.65-1.46-0.65-2.3V26.12L67.38,26.12z"
|
||||
/>
|
||||
<path
|
||||
class="st1"
|
||||
d="M26.99,2.56c14.91,0,26.99,12.08,26.99,26.99c0,14.91-12.08,26.99-26.99,26.99C12.08,56.54,0,44.45,0,29.55 C0,14.64,12.08,2.56,26.99,2.56L26.99,2.56z M15.05,30.27c0.36-2.1,2.76-3.27,4.65-2.13c0.17,0.1,0.34,0.22,0.49,0.36l0.02,0.01 c0.85,0.81,1.8,1.66,2.74,2.5l0.81,0.73l9.59-10.06c0.57-0.6,0.99-0.99,1.85-1.18c2.94-0.65,5.01,2.95,2.93,5.15L26.17,38.19 c-1.13,1.2-3.14,1.31-4.35,0.16c-0.69-0.64-1.45-1.3-2.21-1.96c-1.32-1.15-2.67-2.32-3.77-3.48 C15.18,32.25,14.89,31.17,15.05,30.27L15.05,30.27z"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
export let className: string = '';
|
||||
</script>
|
||||
|
||||
<svg
|
||||
viewBox="0 0 122.88 100.6"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
class={className}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<style>
|
||||
.st0 { fill: var(--color-text); }
|
||||
.st2 { fill-rule: evenodd; clip-rule: evenodd; fill: #d8453e; }
|
||||
</style>
|
||||
<g>
|
||||
<path
|
||||
class="st0"
|
||||
d="M72.58,0c6.8,0,13.3,1.36,19.23,3.81c6.16,2.55,11.7,6.29,16.33,10.92l0,0c4.63,4.63,8.37,10.17,10.92,16.34 c2.46,5.93,3.81,12.43,3.81,19.23c0,6.8-1.36,13.3-3.81,19.23c-2.55,6.16-6.29,11.7-10.92,16.33l0,0 c-4.63,4.63-10.17,8.37-16.34,10.92c-5.93,2.46-12.43,3.81-19.23,3.81c-6.8,0-13.3-1.36-19.23-3.81 c-6.15-2.55-11.69-6.28-16.33-10.92l-0.01-0.01c-4.64-4.64-8.37-10.17-10.92-16.33c-0.79-1.91-1.47-3.87-2.02-5.89 c1.05,0.1,2.12,0.15,3.2,0.15c2.05,0,4.05-0.19,6-0.54c0.32,0.97,0.67,1.93,1.06,2.87c2.09,5.05,5.17,9.6,8.99,13.43 c3.82,3.82,8.38,6.9,13.43,8.99c4.87,2.02,10.21,3.13,15.83,3.13c5.62,0,10.96-1.11,15.83-3.13c5.05-2.09,9.6-5.17,13.43-8.99 c3.82-3.82,6.9-8.38,8.99-13.43c2.02-4.87,3.13-10.21,3.13-15.83c0-5.62-1.11-10.96-3.13-15.83c-2.09-5.05-5.17-9.6-8.99-13.43 c-3.82-3.82-8.38-6.9-13.43-8.99c-4.87-2.02-10.21-3.13-15.83-3.13c-5.62,0-10.96,1.11-15.83,3.13c-0.44,0.18-0.87,0.37-1.3,0.56 c-1.65-2.61-3.66-4.97-5.95-7.02c1.25-0.65,2.53-1.24,3.84-1.79C59.28,1.36,65.78,0,72.58,0L72.58,0z M66.8,26.39 c0-1.23,0.5-2.35,1.31-3.16c0.81-0.81,1.93-1.31,3.16-1.31c1.23,0,2.35,0.5,3.16,1.31c0.81,0.81,1.31,1.93,1.31,3.16v23.47 l17.54,10.4c1.05,0.62,1.76,1.62,2.05,2.73c0.28,1.1,0.15,2.31-0.47,3.37l0,0.01l0,0c-0.62,1.05-1.62,1.76-2.73,2.05 c-1.1,0.28-2.31,0.15-3.37-0.47l-0.01,0l0,0L69.1,56.29c-0.67-0.38-1.24-0.92-1.64-1.57c-0.42-0.68-0.66-1.48-0.66-2.32V26.39 L66.8,26.39z"
|
||||
/>
|
||||
<path
|
||||
class="st2"
|
||||
d="M27.27,3.18c15.06,0,27.27,12.21,27.27,27.27c0,15.06-12.21,27.27-27.27,27.27C12.21,57.73,0,45.52,0,30.45 C0,15.39,12.21,3.18,27.27,3.18L27.27,3.18z M24.35,41.34h5.82v5.16h-5.82V41.34L24.35,41.34L24.35,41.34z M30.17,37.77h-5.82 c-0.58-7.07-1.8-11.56-1.8-18.63c0-2.61,2.12-4.72,4.72-4.72c2.61,0,4.72,2.12,4.72,4.72C32,26.2,30.76,30.7,30.17,37.77 L30.17,37.77L30.17,37.77L30.17,37.77z"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
export let className: string = '';
|
||||
</script>
|
||||
|
||||
<svg
|
||||
viewBox="0 0 122.88 99.56"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
class={className}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<style>
|
||||
.st0 { fill: var(--color-text); }
|
||||
.st3 { fill-rule: evenodd; clip-rule: evenodd; fill: var(--color-warning); }
|
||||
</style>
|
||||
<g>
|
||||
<path
|
||||
class="st0"
|
||||
d="M73.1,0c6.73,0,13.16,1.34,19.03,3.78c6.09,2.52,11.57,6.22,16.16,10.81c4.59,4.58,8.28,10.06,10.81,16.17 c2.43,5.87,3.78,12.3,3.78,19.03c0,6.73-1.34,13.16-3.78,19.03c-2.52,6.09-6.22,11.58-10.81,16.16 c-4.58,4.59-10.06,8.28-16.17,10.81c-5.87,2.43-12.3,3.78-19.03,3.78c-6.73,0-13.16-1.34-19.03-3.77 c-6.09-2.52-11.57-6.22-16.16-10.81l-0.01-0.01c-4.59-4.59-8.29-10.07-10.81-16.16c-0.78-1.89-1.45-3.83-2-5.82 c1.04,0.1,2.1,0.15,3.17,0.15c2.03,0,4.01-0.18,5.94-0.53c0.32,0.96,0.67,1.91,1.05,2.84c2.07,5,5.11,9.51,8.9,13.29 c3.78,3.78,8.29,6.82,13.29,8.9c4.81,1.99,10.11,3.1,15.66,3.1c5.56,0,10.85-1.1,15.66-3.1c5-2.07,9.51-5.11,13.29-8.9 c3.78-3.78,6.82-8.29,8.9-13.29c1.99-4.81,3.1-10.11,3.1-15.66c0-5.56-1.1-10.85-3.1-15.66c-2.07-5-5.11-9.51-8.9-13.29 c-3.78-3.78-8.29-6.82-13.29-8.9c-4.81-1.99-10.11-3.1-15.66-3.1c-5.56,0-10.85,1.1-15.66,3.1c-0.43,0.18-0.86,0.37-1.28,0.56 c-1.64-2.58-3.62-4.92-5.89-6.95c1.24-0.64,2.51-1.23,3.8-1.77C59.94,1.34,66.37,0,73.1,0L73.1,0z M67.38,26.12 c0-1.22,0.5-2.33,1.3-3.13c0.8-0.8,1.9-1.3,3.12-1.3c1.22,0,2.33,0.5,3.13,1.3c0.8,0.8,1.3,1.91,1.3,3.13v23.22l17.35,10.29 c1.04,0.62,1.74,1.6,2.03,2.7c0.28,1.09,0.15,2.29-0.47,3.34c-0.62,1.04-1.6,1.74-2.7,2.03c-1.09,0.28-2.29,0.15-3.33-0.47 L69.65,55.71c-0.67-0.37-1.22-0.91-1.62-1.55c-0.41-0.67-0.65-1.46-0.65-2.3V26.12L67.38,26.12z"
|
||||
/>
|
||||
<path
|
||||
class="st3"
|
||||
d="M26.99,2.56c14.91,0,26.99,12.08,26.99,26.99c0,14.91-12.08,26.99-26.99,26.99C12.08,56.54,0,44.45,0,29.55 C0,14.64,12.08,2.56,26.99,2.56L26.99,2.56z M15.05,30.27c0.36-2.1,2.76-3.27,4.65-2.13c0.17,0.1,0.34,0.22,0.49,0.36l0.02,0.01 c0.85,0.81,1.8,1.66,2.74,2.5l0.81,0.73l9.59-10.06c0.57-0.6,0.99-0.99,1.85-1.18c2.94-0.65,5.01,2.95,2.93,5.15L26.17,38.19 c-1.13,1.2-3.14,1.31-4.35,0.16c-0.69-0.64-1.45-1.3-2.21-1.96c-1.32-1.15-2.67-2.32-3.77-3.48 C15.18,32.25,14.89,31.17,15.05,30.27L15.05,30.27z"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
export let className: string = '';
|
||||
</script>
|
||||
|
||||
<svg
|
||||
viewBox="0 0 121.48 122.88"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
class={className}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<style>
|
||||
.icon-btn-svg { fill-rule: evenodd; clip-rule: evenodd; fill: var(--color-text); }
|
||||
</style>
|
||||
<g>
|
||||
<path
|
||||
class="icon-btn-svg"
|
||||
d="M96.84,2.22l22.42,22.42c2.96,2.96,2.96,7.8,0,10.76l-12.4,12.4L73.68,14.62l12.4-12.4 C89.04-0.74,93.88-0.74,96.84,2.22L96.84,2.22z M70.18,52.19L70.18,52.19l0,0.01c0.92,0.92,1.38,2.14,1.38,3.34 c0,1.2-0.46,2.41-1.38,3.34v0.01l-0.01,0.01L40.09,88.99l0,0h-0.01c-0.26,0.26-0.55,0.48-0.84,0.67h-0.01 c-0.3,0.19-0.61,0.34-0.93,0.45c-1.66,0.58-3.59,0.2-4.91-1.12h-0.01l0,0v-0.01c-0.26-0.26-0.48-0.55-0.67-0.84v-0.01 c-0.19-0.3-0.34-0.61-0.45-0.93c-0.58-1.66-0.2-3.59,1.11-4.91v-0.01l30.09-30.09l0,0h0.01c0.92-0.92,2.14-1.38,3.34-1.38 c1.2,0,2.41,0.46,3.34,1.38L70.18,52.19L70.18,52.19L70.18,52.19z M45.48,109.11c-8.98,2.78-17.95,5.55-26.93,8.33 C-2.55,123.97-2.46,128.32,3.3,108l9.07-32v0l-0.03-0.03L67.4,20.9l33.18,33.18l-55.07,55.07L45.48,109.11L45.48,109.11z M18.03,81.66l21.79,21.79c-5.9,1.82-11.8,3.64-17.69,5.45c-13.86,4.27-13.8,7.13-10.03-6.22L18.03,81.66L18.03,81.66z"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
export let className: string = '';
|
||||
</script>
|
||||
|
||||
<svg
|
||||
viewBox="0 0 108.01 122.88"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
class={className}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<style>
|
||||
.icon-btn-svg { fill-rule: evenodd; clip-rule: evenodd; fill: var(--color-text); }
|
||||
</style>
|
||||
<path
|
||||
class="icon-btn-svg"
|
||||
d="M.5,0H15a.51.51,0,0,1,.5.5V83.38L35.16,82h.22l.24,0c2.07-.14,3.65-.26,4.73-1.23l1.86-2.17a1.12,1.12,0,0,1,1.49-.18l9.35,6.28a1.15,1.15,0,0,1,.49,1c0,.55-.19.7-.61,1.08A11.28,11.28,0,0,0,51.78,88a27.27,27.27,0,0,1-3,3.1,15.84,15.84,0,0,1-3.68,2.45c-2.8,1.36-5.45,1.54-8.59,1.76l-.24,0-.21,0L15.5,96.77v25.61a.52.52,0,0,1-.5.5H.5a.51.51,0,0,1-.5-.5V.5A.5.5,0,0,1,.5,0ZM46,59.91l9-19.12-.89-.25a12.43,12.43,0,0,0-4.77-.82c-1.9.28-3.68,1.42-5.67,2.7-.83.53-1.69,1.09-2.62,1.63-.7.33-1.51.86-2.19,1.25l-8.7,5a1.11,1.11,0,0,1-1.51-.42l-5.48-9.64a1.1,1.1,0,0,1,.42-1.51c3.43-2,7.42-4,10.75-6.14,4-2.49,7.27-4.48,11.06-5.42s8-.8,13.89,1c2.12.59,4.55,1.48,6.55,2.2,1,.35,1.8.66,2.44.87,9.86,3.29,13.19,9.66,15.78,14.6,1.12,2.13,2.09,4,3.34,5,.51.42,1.67.27,3,.09a21.62,21.62,0,0,1,2.64-.23c4.32-.41,8.66-.66,13-1a1.1,1.1,0,0,1,1.18,1L108,61.86A1.11,1.11,0,0,1,107,63L95,63.9c-5.33.38-9.19.66-15-2.47l-.12-.07a23.23,23.23,0,0,1-7.21-8.5l0,0L65.73,68.4a63.9,63.9,0,0,0,5.85,5.32c6,5,11,9.21,9.38,20.43a23.89,23.89,0,0,1-.65,2.93c-.27,1-.56,1.9-.87,2.84-2.29,6.54-4.22,13.5-6.29,20.13a1.1,1.1,0,0,1-1,.81l-11.66.78a1,1,0,0,1-.39,0,1.12,1.12,0,0,1-.75-1.38c2.45-8.12,5-16.25,7.39-24.38a29,29,0,0,0,.87-3,7,7,0,0,0,.08-2.65l0-.24a4.16,4.16,0,0,0-.73-2.22,53.23,53.23,0,0,0-8.76-5.57c-3.75-2.07-7.41-4.08-10.25-7a12.15,12.15,0,0,1-3.59-7.36A14.76,14.76,0,0,1,46,59.91ZM80.07,6.13a12.29,12.29,0,0,1,13.1,11.39v0a12.29,12.29,0,0,1-24.52,1.72v0A12.3,12.3,0,0,1,80,6.13ZM3.34,35H6.69V51.09H3.34V35Z"
|
||||
/>
|
||||
</svg>
|
||||
@ -0,0 +1,118 @@
|
||||
# Plan de Refactorización - Mejora de Mantenibilidad
|
||||
|
||||
**Fecha:** 2024
|
||||
**Estado:** Propuesto
|
||||
**Objetivo:** Mejorar la mantenibilidad del código eliminando duplicación y reduciendo la complejidad de archivos grandes.
|
||||
|
||||
## Resumen Ejecutivo
|
||||
|
||||
El proyecto ha crecido orgánicamente y presenta varios problemas de mantenibilidad:
|
||||
- **19 duplicaciones** de la función `toIsoSql`
|
||||
- **Múltiples duplicaciones** de `sha256Hex` en tests
|
||||
- **Archivos excesivamente grandes**: `group-sync.ts` (1307 líneas), `server.ts` (665 líneas)
|
||||
- **Código compartido duplicado** entre `src/` (bot) y `apps/web/`
|
||||
- **Setup repetitivo** en tests (60+ archivos)
|
||||
|
||||
Este plan aborda estos problemas en 4 fases priorizadas.
|
||||
|
||||
---
|
||||
|
||||
## 🔴 Fase 1: Eliminación de Duplicación de Utilidades (1-2 horas)
|
||||
|
||||
**Prioridad:** CRÍTICA
|
||||
**Impacto:** Alto (reduce ~150 líneas duplicadas)
|
||||
**Riesgo:** Bajo (cambio mecánico)
|
||||
|
||||
### Objetivos
|
||||
1. Centralizar función `toIsoSql` en un único lugar
|
||||
2. Centralizar función `sha256Hex` para tests
|
||||
3. Eliminar todas las duplicaciones mediante imports
|
||||
|
||||
### Archivos a Crear
|
||||
|
||||
#### `src/utils/date.ts`
|
||||
- Exportar función `toIsoSql(d?: Date): string`
|
||||
- Documentar formato de salida (ISO SQL: `YYYY-MM-DD HH:MM:SS.mmm`)
|
||||
|
||||
#### `tests/helpers/crypto.ts`
|
||||
- Exportar función `sha256Hex(input: string): Promise<string>`
|
||||
- Reutilizar en todos los tests web y unit
|
||||
|
||||
### Archivos a Modificar
|
||||
|
||||
**Archivos con `toIsoSql` duplicada (19 archivos):**
|
||||
- `apps/web/src/hooks.server.ts`
|
||||
- `apps/web/src/routes/api/tasks/[id]/claim/+server.ts`
|
||||
- `apps/web/src/routes/api/tasks/[id]/uncomplete/+server.ts`
|
||||
- `src/services/group-sync.ts`
|
||||
- `tests/unit/services/cleanup-inactive.test.ts`
|
||||
- `tests/unit/tasks/complete-reaction.test.ts`
|
||||
- `tests/unit/services/metrics-health.test.ts`
|
||||
- `tests/unit/services/response-queue.cleanup.test.ts`
|
||||
- `tests/web/api.integrations.feeds.test.ts`
|
||||
- `tests/web/api.me.preferences.test.ts`
|
||||
- `tests/web/api.me.tasks.test.ts`
|
||||
- `tests/web/api.tasks.complete.reaction.test.ts`
|
||||
- `tests/web/app.integrations.page.test.ts`
|
||||
- `tests/web/app.preferences.page.test.ts`
|
||||
- Y otros según `grep -rn "function toIsoSql"`
|
||||
|
||||
**Archivos con `sha256Hex` duplicada:**
|
||||
- `tests/web/api.integrations.feeds.test.ts`
|
||||
- `tests/web/api.me.preferences.test.ts`
|
||||
- `tests/web/api.me.tasks.test.ts`
|
||||
- `tests/web/app.integrations.page.test.ts`
|
||||
- `tests/web/app.preferences.page.test.ts`
|
||||
- Verificar que `src/utils/crypto.ts` y `apps/web/src/lib/server/crypto.ts` no estén duplicados
|
||||
|
||||
### Pasos de Ejecución
|
||||
1. Crear `src/utils/date.ts` con implementación de referencia
|
||||
2. Crear `tests/helpers/crypto.ts` con implementación de referencia
|
||||
3. Buscar y reemplazar imports en todos los archivos afectados
|
||||
4. Eliminar definiciones locales de las funciones
|
||||
5. Ejecutar suite completa de tests: `bun test`
|
||||
6. Verificar que no hay regresiones
|
||||
|
||||
### Criterios de Éxito
|
||||
- ✅ Todos los tests pasan
|
||||
- ✅ Solo existe 1 definición de `toIsoSql` en el código fuente
|
||||
- ✅ Solo existe 1 definición de `sha256Hex` en tests
|
||||
- ✅ Reducción de ~150 líneas de código
|
||||
|
||||
---
|
||||
|
||||
## 🔴 Fase 2: División de `group-sync.ts` (3-4 horas)
|
||||
|
||||
**Prioridad:** CRÍTICA
|
||||
**Impacto:** Alto (mejora legibilidad y testabilidad)
|
||||
**Riesgo:** Medio (requiere análisis cuidadoso)
|
||||
|
||||
### Objetivos
|
||||
1. Dividir archivo monolítico de 1307 líneas en módulos cohesivos
|
||||
2. Separar responsabilidades claramente
|
||||
3. Facilitar testing unitario de cada componente
|
||||
4. Mantener API pública compatible
|
||||
|
||||
### Análisis Previo Necesario
|
||||
|
||||
**Archivos a consultar para entender el alcance:**
|
||||
- `src/services/group-sync.ts` (archivo principal a dividir)
|
||||
- `tests/unit/services/group-sync.test.ts`
|
||||
- `tests/unit/services/group-sync.onboarding.test.ts`
|
||||
- `tests/unit/services/group-sync.coverage.test.ts`
|
||||
- `tests/unit/services/group-sync.gating.test.ts`
|
||||
- `tests/unit/services/group-sync.label-update.test.ts`
|
||||
- `tests/unit/services/group-sync.sync-members.test.ts`
|
||||
- `tests/unit/services/group-sync.members.test.ts`
|
||||
- `tests/unit/services/group-sync.fetch-members.test.ts`
|
||||
- `tests/unit/services/group-sync.scheduler.test.ts`
|
||||
- `tests/unit/services/group-sync.scheduler.gating.test.ts`
|
||||
|
||||
**Identificar en el código:**
|
||||
- Funciones públicas vs privadas
|
||||
- Responsabilidades principales (sincronización, scheduling, gating, eventos)
|
||||
- Dependencias entre funciones
|
||||
- Estado compartido (si existe)
|
||||
|
||||
### Estructura Propuesta
|
||||
|
||||
@ -0,0 +1,63 @@
|
||||
export type EvolutionResult = { ok: boolean; status?: number; error?: string };
|
||||
|
||||
export function buildHeaders(): HeadersInit {
|
||||
return {
|
||||
apikey: process.env.EVOLUTION_API_KEY || '',
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
}
|
||||
|
||||
export async function sendText(payload: { number: string; text: string; mentioned?: string[] }): Promise<EvolutionResult> {
|
||||
const baseUrl = process.env.EVOLUTION_API_URL;
|
||||
const instance = process.env.EVOLUTION_API_INSTANCE;
|
||||
if (!baseUrl || !instance) {
|
||||
const msg = 'Missing EVOLUTION_API_URL or EVOLUTION_API_INSTANCE';
|
||||
return { ok: false, error: msg };
|
||||
}
|
||||
const url = `${baseUrl}/message/sendText/${instance}`;
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: buildHeaders(),
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
const errTxt = body?.slice(0, 200) || `HTTP ${res.status}`;
|
||||
return { ok: false, status: res.status, error: errTxt };
|
||||
}
|
||||
return { ok: true, status: res.status };
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
return { ok: false, error: errMsg };
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendReaction(payload: {
|
||||
key: { remoteJid: string; id: string; fromMe: boolean; participant?: string };
|
||||
reaction: string;
|
||||
}): Promise<EvolutionResult> {
|
||||
const baseUrl = process.env.EVOLUTION_API_URL;
|
||||
const instance = process.env.EVOLUTION_API_INSTANCE;
|
||||
if (!baseUrl || !instance) {
|
||||
const msg = 'Missing EVOLUTION_API_URL or EVOLUTION_API_INSTANCE';
|
||||
return { ok: false, error: msg };
|
||||
}
|
||||
const url = `${baseUrl}/message/sendReaction/${instance}`;
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: buildHeaders(),
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
const errTxt = body?.slice(0, 200) || `HTTP ${res.status}`;
|
||||
return { ok: false, status: res.status, error: errTxt };
|
||||
}
|
||||
return { ok: true, status: res.status };
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
return { ok: false, error: errMsg };
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,51 @@
|
||||
import type { Database } from 'bun:sqlite';
|
||||
|
||||
/**
|
||||
* Error específico cuando se intenta acceder a la DB sin haberla configurado.
|
||||
*/
|
||||
export class DbNotConfiguredError extends Error {
|
||||
constructor(message: string = 'Database has not been configured. Call setDb(db) before using getDb().') {
|
||||
super(message);
|
||||
this.name = 'DbNotConfiguredError';
|
||||
}
|
||||
}
|
||||
|
||||
let currentDb: Database | null = null;
|
||||
|
||||
/**
|
||||
* Establece la instancia global de DB.
|
||||
* Se permite sobrescribir (útil en tests).
|
||||
*/
|
||||
export function setDb(db: Database): void {
|
||||
currentDb = db;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene la instancia global de DB o lanza si no está configurada.
|
||||
*/
|
||||
export function getDb(): Database {
|
||||
if (currentDb) return currentDb;
|
||||
throw new DbNotConfiguredError('Database has not been configured. Call setDb(db) before using getDb().');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resetea la instancia global de DB. Útil en tests para detectar fugas entre suites.
|
||||
*/
|
||||
export function resetDb(): void {
|
||||
currentDb = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias de resetDb() por ergonomía en tests.
|
||||
*/
|
||||
export function clearDb(): void {
|
||||
currentDb = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ejecuta una función con la DB actual (sync o async) y devuelve su resultado.
|
||||
*/
|
||||
export function withDb<T>(fn: (db: Database) => T | Promise<T>): T | Promise<T> {
|
||||
const db = getDb();
|
||||
return fn(db);
|
||||
}
|
||||
@ -0,0 +1,18 @@
|
||||
import { join, resolve } from 'path';
|
||||
|
||||
/**
|
||||
* Resuelve la ruta absoluta al archivo de la base de datos SQLite compartida.
|
||||
* Prioridad:
|
||||
* 1) DB_PATH (ruta completa al archivo)
|
||||
* 2) DATA_DIR + filename
|
||||
* - En producción (NODE_ENV=production) por defecto '/app/data'
|
||||
* - En no-producción por defecto './tmp'
|
||||
*/
|
||||
export function resolveDbAbsolutePath(filename: string = 'tasks.db'): string {
|
||||
const dbPathEnv = String(process.env.DB_PATH || '').trim();
|
||||
if (dbPathEnv) return resolve(dbPathEnv);
|
||||
|
||||
const isProdEnv = String(process.env.NODE_ENV || 'development').trim().toLowerCase() === 'production';
|
||||
const dataDir = process.env.DATA_DIR ? String(process.env.DATA_DIR) : (isProdEnv ? '/app/data' : 'tmp');
|
||||
return resolve(join(dataDir, filename));
|
||||
}
|
||||
@ -0,0 +1,93 @@
|
||||
import type { Database } from 'bun:sqlite';
|
||||
import { setDb } from '../db/locator';
|
||||
import { WebhookManager } from '../services/webhook-manager';
|
||||
import { GroupSyncService } from '../services/group-sync';
|
||||
import { ResponseQueue } from '../services/response-queue';
|
||||
import { RemindersService } from '../services/reminders';
|
||||
import { MaintenanceService } from '../services/maintenance';
|
||||
|
||||
export async function startServices(_db: Database): Promise<void> {
|
||||
// Exponer la DB globalmente vía locator para servicios que lo usen.
|
||||
try { setDb(_db); } catch {}
|
||||
await WebhookManager.registerWebhook();
|
||||
// Add small delay to allow webhook to propagate
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
const isActive = await WebhookManager.verifyWebhook();
|
||||
if (!isActive) {
|
||||
console.error('❌ Webhook verification failed - retrying in 2 seconds...');
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
const isActiveRetry = await WebhookManager.verifyWebhook();
|
||||
if (!isActiveRetry) {
|
||||
console.error('❌ Webhook verification failed after retry');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize groups - critical for operation
|
||||
await GroupSyncService.checkInitialGroups();
|
||||
|
||||
// Start groups scheduler (periodic sync of groups)
|
||||
try {
|
||||
GroupSyncService.startGroupsScheduler();
|
||||
console.log('✅ Group scheduler started');
|
||||
} catch (e) {
|
||||
console.error('⚠️ Failed to start Group scheduler:', e);
|
||||
}
|
||||
|
||||
// Initial members sync (non-blocking if fails)
|
||||
try {
|
||||
await GroupSyncService.syncMembersForActiveGroups();
|
||||
GroupSyncService.startMembersScheduler();
|
||||
console.log('✅ Group members scheduler started');
|
||||
} catch (e) {
|
||||
console.error('⚠️ Failed to run initial members sync or start scheduler:', e);
|
||||
}
|
||||
|
||||
// Start response queue worker (background)
|
||||
try {
|
||||
await ResponseQueue.process();
|
||||
console.log('✅ ResponseQueue worker started');
|
||||
// Start cleanup scheduler (daily retention)
|
||||
ResponseQueue.startCleanupScheduler();
|
||||
console.log('✅ ResponseQueue cleanup scheduler started');
|
||||
RemindersService.start();
|
||||
console.log('✅ RemindersService started');
|
||||
} catch (e) {
|
||||
console.error('❌ Failed to start ResponseQueue worker or cleanup scheduler:', e);
|
||||
}
|
||||
|
||||
// Mantenimiento (cleanup de miembros inactivos)
|
||||
try {
|
||||
MaintenanceService.start();
|
||||
console.log('✅ MaintenanceService started');
|
||||
// Ejecutar reconciliación de alias una vez al arranque (one-shot)
|
||||
try {
|
||||
await MaintenanceService.reconcileAliasUsersOnce();
|
||||
console.log('✅ MaintenanceService: reconciliación de alias ejecutada (one-shot)');
|
||||
} catch (e2) {
|
||||
console.error('⚠️ Failed to run alias reconciliation one-shot:', e2);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('⚠️ Failed to start MaintenanceService:', e);
|
||||
}
|
||||
}
|
||||
|
||||
export function stopServices(): void {
|
||||
try {
|
||||
ResponseQueue.stopCleanupScheduler();
|
||||
} catch {}
|
||||
try {
|
||||
// No existe un "stop" público de workers; paramos el lazo
|
||||
(ResponseQueue as any).stop?.();
|
||||
} catch {}
|
||||
try {
|
||||
RemindersService.stop();
|
||||
} catch {}
|
||||
try {
|
||||
GroupSyncService.stopGroupsScheduler();
|
||||
GroupSyncService.stopMembersScheduler();
|
||||
} catch {}
|
||||
try {
|
||||
MaintenanceService.stop();
|
||||
} catch {}
|
||||
}
|
||||
@ -0,0 +1,46 @@
|
||||
import type { Database } from 'bun:sqlite';
|
||||
import { Metrics } from '../services/metrics';
|
||||
|
||||
export async function handleHealthRequest(url: URL, db: Database): Promise<Response> {
|
||||
// /health?full=1 devuelve JSON con detalles
|
||||
if (url.searchParams.get('full') === '1') {
|
||||
try {
|
||||
const rowG = db.prepare(`SELECT COUNT(*) AS c, MAX(last_verified) AS lv FROM groups WHERE active = 1`).get() as { c?: number; lv?: string | null } | undefined;
|
||||
const rowM = db.prepare(`SELECT COUNT(*) AS c FROM group_members WHERE is_active = 1`).get() as { c?: number } | undefined;
|
||||
const active_groups = Number(rowG?.c || 0);
|
||||
const active_members = Number(rowM?.c || 0);
|
||||
const lv = rowG?.lv ? String(rowG.lv) : null;
|
||||
let last_sync_at: string | null = lv;
|
||||
let snapshot_age_ms: number | null = null;
|
||||
if (lv) {
|
||||
const iso = lv.includes('T') ? lv : (lv.replace(' ', 'T') + 'Z');
|
||||
const ms = Date.parse(iso);
|
||||
if (Number.isFinite(ms)) {
|
||||
snapshot_age_ms = Date.now() - ms;
|
||||
}
|
||||
}
|
||||
const lastSyncMetric = Metrics.get('last_sync_ok');
|
||||
const maxAgeRaw = Number(process.env.MAX_MEMBERS_SNAPSHOT_AGE_MS);
|
||||
const maxAgeMs = Number.isFinite(maxAgeRaw) && maxAgeRaw > 0 ? maxAgeRaw : 24 * 60 * 60 * 1000;
|
||||
const snapshot_fresh = typeof snapshot_age_ms === 'number' ? (snapshot_age_ms <= maxAgeMs) : false;
|
||||
let last_sync_ok: number;
|
||||
if (typeof lastSyncMetric === 'number') {
|
||||
last_sync_ok = (lastSyncMetric === 1 && snapshot_fresh) ? 1 : 0;
|
||||
} else {
|
||||
// Si no hay métrica explícita, nos basamos exclusivamente en la frescura de la snapshot
|
||||
last_sync_ok = snapshot_fresh ? 1 : 0;
|
||||
}
|
||||
const payload = { status: 'ok', active_groups, active_members, last_sync_at, snapshot_age_ms, snapshot_fresh, last_sync_ok };
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
} catch (e) {
|
||||
return new Response(JSON.stringify({ status: 'error' }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
}
|
||||
return new Response('OK', { status: 200 });
|
||||
}
|
||||
@ -0,0 +1,44 @@
|
||||
import type { Database } from 'bun:sqlite';
|
||||
import { Metrics } from '../services/metrics';
|
||||
import { GroupSyncService } from '../services/group-sync';
|
||||
|
||||
export async function handleMetricsRequest(request: Request, db: Database): Promise<Response> {
|
||||
if (request.method !== 'GET') {
|
||||
return new Response('🚫 Method not allowed', { status: 405 });
|
||||
}
|
||||
if (!Metrics.enabled()) {
|
||||
return new Response('Metrics disabled', { status: 404 });
|
||||
}
|
||||
|
||||
// Gauges de allowed_groups por estado (best-effort)
|
||||
try {
|
||||
const rows = db
|
||||
.prepare(`SELECT status, COUNT(*) AS c FROM allowed_groups GROUP BY status`)
|
||||
.all() as Array<{ status: string; c: number }>;
|
||||
let pending = 0, allowed = 0, blocked = 0;
|
||||
for (const r of rows) {
|
||||
const s = String(r?.status || '');
|
||||
const c = Number(r?.c || 0);
|
||||
if (s === 'pending') pending = c;
|
||||
else if (s === 'allowed') allowed = c;
|
||||
else if (s === 'blocked') blocked = c;
|
||||
}
|
||||
Metrics.set('allowed_groups_total_pending', pending);
|
||||
Metrics.set('allowed_groups_total_allowed', allowed);
|
||||
Metrics.set('allowed_groups_total_blocked', blocked);
|
||||
} catch {}
|
||||
|
||||
// Exponer métrica con el tiempo restante hasta el próximo group sync (o -1 si scheduler inactivo)
|
||||
try {
|
||||
const secs = GroupSyncService.getSecondsUntilNextGroupSync();
|
||||
const val = (secs == null || !Number.isFinite(secs)) ? -1 : secs;
|
||||
Metrics.set('group_sync_seconds_until_next', val);
|
||||
} catch {}
|
||||
|
||||
const format = (process.env.METRICS_FORMAT || 'prom').toLowerCase() === 'json' ? 'json' : 'prom';
|
||||
const body = Metrics.render(format as any);
|
||||
return new Response(body, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': format === 'json' ? 'application/json' : 'text/plain; version=0.0.4' }
|
||||
});
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
import type { Database } from 'bun:sqlite';
|
||||
|
||||
/**
|
||||
* Construye un mapa de grupos activos (no comunidad, no archivados) id -> nombre.
|
||||
*/
|
||||
export function cacheActiveGroups(db: Database): Map<string, string> {
|
||||
const map = new Map<string, string>();
|
||||
const rows = db
|
||||
.prepare('SELECT id, name FROM groups WHERE active = TRUE AND COALESCE(is_community,0) = 0 AND COALESCE(archived,0) = 0')
|
||||
.all() as Array<{ id: string; name: string | null }>;
|
||||
for (const g of rows) {
|
||||
map.set(String(g.id), String(g.name ?? ''));
|
||||
}
|
||||
return map;
|
||||
}
|
||||
@ -0,0 +1,79 @@
|
||||
import type { Database } from 'bun:sqlite';
|
||||
import { ensureUserExists } from '../../db';
|
||||
import { toIsoSqlUTC } from '../../utils/datetime';
|
||||
|
||||
/**
|
||||
* Reconciliación idempotente de membresías de un grupo.
|
||||
*/
|
||||
export function reconcileGroupMembers(
|
||||
db: Database,
|
||||
groupId: string,
|
||||
snapshot: Array<{ userId: string; isAdmin: boolean }>,
|
||||
nowIso?: string
|
||||
): { added: number; updated: number; deactivated: number } {
|
||||
if (!groupId || !Array.isArray(snapshot)) {
|
||||
throw new Error('Invalid arguments for reconcileGroupMembers');
|
||||
}
|
||||
const now = nowIso || toIsoSqlUTC(new Date());
|
||||
let added = 0, updated = 0, deactivated = 0;
|
||||
|
||||
const incoming = new Map<string, { isAdmin: boolean }>();
|
||||
for (const m of snapshot) {
|
||||
if (!m?.userId) continue;
|
||||
incoming.set(m.userId, { isAdmin: !!m.isAdmin });
|
||||
}
|
||||
|
||||
db.transaction(() => {
|
||||
const existingRows = db.prepare(`
|
||||
SELECT user_id, is_admin, is_active
|
||||
FROM group_members
|
||||
WHERE group_id = ?
|
||||
`).all(groupId) as Array<{ user_id: string; is_admin: number; is_active: number }>;
|
||||
const existing = new Map(existingRows.map(r => [r.user_id, { isAdmin: !!r.is_admin, isActive: !!r.is_active }]));
|
||||
|
||||
for (const [userId, { isAdmin }] of incoming.entries()) {
|
||||
ensureUserExists(userId, db);
|
||||
const row = existing.get(userId);
|
||||
if (!row) {
|
||||
db.prepare(`
|
||||
INSERT INTO group_members (group_id, user_id, is_admin, is_active, first_seen_at, last_seen_at)
|
||||
VALUES (?, ?, ?, 1, ?, ?)
|
||||
`).run(groupId, userId, isAdmin ? 1 : 0, now, now);
|
||||
added++;
|
||||
} else {
|
||||
const roleChanged = row.isAdmin !== isAdmin;
|
||||
if (!row.isActive || roleChanged) {
|
||||
db.prepare(`
|
||||
UPDATE group_members
|
||||
SET is_active = 1,
|
||||
is_admin = ?,
|
||||
last_seen_at = ?,
|
||||
last_role_change_at = CASE WHEN ? THEN ? ELSE last_role_change_at END
|
||||
WHERE group_id = ? AND user_id = ?
|
||||
`).run(isAdmin ? 1 : 0, now, roleChanged ? 1 : 0, roleChanged ? now : null, groupId, userId);
|
||||
updated++;
|
||||
} else {
|
||||
db.prepare(`
|
||||
UPDATE group_members
|
||||
SET last_seen_at = ?
|
||||
WHERE group_id = ? AND user_id = ?
|
||||
`).run(now, groupId, userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [userId, state] of existing.entries()) {
|
||||
if (!incoming.has(userId) && state.isActive) {
|
||||
db.prepare(`
|
||||
UPDATE group_members
|
||||
SET is_active = 0,
|
||||
last_seen_at = ?
|
||||
WHERE group_id = ? AND user_id = ?
|
||||
`).run(now, groupId, userId);
|
||||
deactivated++;
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return { added, updated, deactivated };
|
||||
}
|
||||
@ -0,0 +1,74 @@
|
||||
import type { Database } from 'bun:sqlite';
|
||||
import { toIsoSqlUTC } from '../../utils/datetime';
|
||||
|
||||
export type CleanupOptions = {
|
||||
retentionDaysSent: number;
|
||||
retentionDaysFailed: number;
|
||||
batchSize: number;
|
||||
optimize: boolean;
|
||||
vacuum: boolean;
|
||||
vacuumEveryNRuns: number;
|
||||
cleanupRunCount: number;
|
||||
};
|
||||
|
||||
export async function runCleanupOnce(
|
||||
db: Database,
|
||||
opts: CleanupOptions,
|
||||
now: Date = new Date()
|
||||
): Promise<{ deletedSent: number; deletedFailed: number; totalDeleted: number; nextCleanupRunCount: number }> {
|
||||
const msPerDay = 24 * 60 * 60 * 1000;
|
||||
const sentThresholdIso = toIsoSqlUTC(new Date(now.getTime() - opts.retentionDaysSent * msPerDay));
|
||||
const failedThresholdIso = toIsoSqlUTC(new Date(now.getTime() - opts.retentionDaysFailed * msPerDay));
|
||||
|
||||
const cleanStatus = (status: 'sent' | 'failed', thresholdIso: string, batch: number): number => {
|
||||
let deleted = 0;
|
||||
const selectStmt = db.prepare(`
|
||||
SELECT id
|
||||
FROM response_queue
|
||||
WHERE status = ? AND updated_at < ?
|
||||
ORDER BY updated_at
|
||||
LIMIT ?
|
||||
`);
|
||||
|
||||
while (true) {
|
||||
const rows = selectStmt.all(status, thresholdIso, batch) as Array<{ id: number }>;
|
||||
if (!rows || rows.length === 0) break;
|
||||
|
||||
const ids = rows.map((r) => r.id);
|
||||
const placeholders = ids.map(() => '?').join(',');
|
||||
db.prepare(`DELETE FROM response_queue WHERE id IN (${placeholders})`).run(...ids);
|
||||
deleted += ids.length;
|
||||
|
||||
if (rows.length < batch) break;
|
||||
}
|
||||
return deleted;
|
||||
};
|
||||
|
||||
const deletedSent = cleanStatus('sent', sentThresholdIso, opts.batchSize);
|
||||
const deletedFailed = cleanStatus('failed', failedThresholdIso, opts.batchSize);
|
||||
const totalDeleted = deletedSent + deletedFailed;
|
||||
|
||||
// Mantenimiento ligero tras limpieza
|
||||
if (opts.optimize && totalDeleted > 0) {
|
||||
try {
|
||||
db.exec('PRAGMA optimize;');
|
||||
} catch (e) {
|
||||
console.warn('PRAGMA optimize failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// VACUUM opcional
|
||||
let nextCleanupRunCount = opts.cleanupRunCount;
|
||||
if (opts.vacuum && totalDeleted > 0) {
|
||||
nextCleanupRunCount++;
|
||||
if (nextCleanupRunCount % Math.max(1, opts.vacuumEveryNRuns) === 0) {
|
||||
try {
|
||||
db.exec('VACUUM;');
|
||||
} catch (e) {
|
||||
console.warn('VACUUM failed:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { deletedSent, deletedFailed, totalDeleted, nextCleanupRunCount };
|
||||
}
|
||||
@ -0,0 +1,55 @@
|
||||
export type OnboardingMeta = {
|
||||
kind: 'onboarding';
|
||||
variant: 'initial' | 'reminder';
|
||||
part: 1 | 2;
|
||||
bundle_id: string;
|
||||
group_id?: string | null;
|
||||
task_id?: number | null;
|
||||
display_code?: number | null;
|
||||
};
|
||||
|
||||
export type ReactionMeta = {
|
||||
kind: 'reaction';
|
||||
emoji: string;
|
||||
chatId: string;
|
||||
messageId: string;
|
||||
participant?: string;
|
||||
fromMe?: boolean;
|
||||
};
|
||||
|
||||
export type QueueMetadata = OnboardingMeta | ReactionMeta | Record<string, any>;
|
||||
|
||||
export function parseQueueMetadata(raw: string | null | undefined): QueueMetadata | null {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const obj = JSON.parse(String(raw));
|
||||
if (!obj || typeof obj !== 'object') return null;
|
||||
const kind = String((obj as any).kind || '');
|
||||
if (kind === 'reaction') {
|
||||
// Validación mínima
|
||||
return {
|
||||
kind: 'reaction',
|
||||
emoji: String((obj as any).emoji || ''),
|
||||
chatId: String((obj as any).chatId || ''),
|
||||
messageId: String((obj as any).messageId || ''),
|
||||
participant: typeof (obj as any).participant === 'string' ? String((obj as any).participant) : undefined,
|
||||
fromMe: typeof (obj as any).fromMe === 'boolean' ? Boolean((obj as any).fromMe) : undefined
|
||||
} as ReactionMeta;
|
||||
}
|
||||
if (kind === 'onboarding') {
|
||||
return obj as OnboardingMeta;
|
||||
}
|
||||
return obj as Record<string, any>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isReactionMeta(m: QueueMetadata | null | undefined): m is ReactionMeta {
|
||||
if (!m || typeof m !== 'object') return false;
|
||||
const any = m as any;
|
||||
return any.kind === 'reaction'
|
||||
&& typeof any.chatId === 'string'
|
||||
&& typeof any.messageId === 'string'
|
||||
&& typeof any.emoji === 'string';
|
||||
}
|
||||
@ -0,0 +1,74 @@
|
||||
import type { Database } from 'bun:sqlite';
|
||||
import { isGroupId } from '../utils/whatsapp';
|
||||
import { AllowedGroups } from '../services/allowed-groups';
|
||||
import { ResponseQueue } from '../services/response-queue';
|
||||
|
||||
/**
|
||||
* Publica una reacción ✅ al mensaje origen de la tarea si:
|
||||
* - REACTIONS_ENABLED está activado,
|
||||
* - scope permite (all o solo grupos),
|
||||
* - está dentro del TTL configurado (por defecto 14 días),
|
||||
* - y pasa el gating de grupos en modo 'enforce'.
|
||||
*
|
||||
* No lanza errores (no debe bloquear el flujo de completado).
|
||||
*/
|
||||
export function enqueueCompletionReactionIfEligible(db: Database, taskId: number): void {
|
||||
try {
|
||||
const rxEnabled = String(process.env.REACTIONS_ENABLED || 'false').toLowerCase();
|
||||
const enabled = ['true', '1', 'yes', 'on'].includes(rxEnabled);
|
||||
if (!enabled) return;
|
||||
|
||||
let origin: any = null;
|
||||
try {
|
||||
origin = db.prepare(`
|
||||
SELECT chat_id, message_id, created_at, participant, from_me
|
||||
FROM task_origins
|
||||
WHERE task_id = ?
|
||||
`).get(taskId) as { chat_id?: string; message_id?: string; created_at?: string; participant?: string | null; from_me?: number | boolean | null } | undefined;
|
||||
} catch {
|
||||
origin = db.prepare(`
|
||||
SELECT chat_id, message_id, created_at
|
||||
FROM task_origins
|
||||
WHERE task_id = ?
|
||||
`).get(taskId) as { chat_id?: string; message_id?: string; created_at?: string } | undefined;
|
||||
}
|
||||
|
||||
if (!origin || !origin.chat_id || !origin.message_id) return;
|
||||
|
||||
const chatId = String(origin.chat_id);
|
||||
const scope = String(process.env.REACTIONS_SCOPE || 'groups').toLowerCase();
|
||||
if (!(scope === 'all' || isGroupId(chatId))) return;
|
||||
|
||||
// TTL desde REACTIONS_TTL_DAYS (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) return;
|
||||
|
||||
// Gating 'enforce' para grupos
|
||||
if (isGroupId(chatId)) {
|
||||
const mode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase();
|
||||
if (mode === 'enforce') {
|
||||
let allowed = true;
|
||||
try { allowed = AllowedGroups.isAllowed(chatId); } catch { allowed = true; }
|
||||
if (!allowed) return;
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
const rxOpts: { participant?: string; fromMe?: boolean } = {};
|
||||
if (participant !== undefined) rxOpts.participant = participant;
|
||||
if (typeof fromMe === 'boolean') rxOpts.fromMe = fromMe;
|
||||
|
||||
ResponseQueue.enqueueReaction(chatId, String(origin.message_id), '✅', rxOpts).catch(() => {});
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,40 @@
|
||||
import type { Database } from 'bun:sqlite';
|
||||
|
||||
/**
|
||||
* Calcula el siguiente display_code disponible entre tareas activas
|
||||
* (y tareas completadas en las últimas 24h), empezando en 1 hasta 9999,
|
||||
* respetando huecos.
|
||||
*/
|
||||
export function pickNextDisplayCode(db: Database): number {
|
||||
const MAX_DISPLAY_CODE = 9999;
|
||||
|
||||
const rows = db
|
||||
.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;
|
||||
}
|
||||
@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Mapeadores puros para normalizar filas SQLite a DTOs usados por TaskService.
|
||||
* Mantienen las mismas formas que consumen comandos, recordatorios y API web.
|
||||
*/
|
||||
|
||||
export function mapTaskListItem(
|
||||
row: { id: number; description: string; due_date: string | null; group_id: string | null; display_code: number | null },
|
||||
assignees: string[]
|
||||
): {
|
||||
id: number;
|
||||
description: string;
|
||||
due_date: string | null;
|
||||
group_id: string | null;
|
||||
display_code: number | null;
|
||||
assignees: string[];
|
||||
} {
|
||||
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,
|
||||
assignees: Array.isArray(assignees) ? assignees.map(a => String(a)) : []
|
||||
};
|
||||
}
|
||||
|
||||
export function mapTaskWithGroupNameRow(
|
||||
row: { id: number; description: string; due_date: string | null; group_id: string | null; display_code: number | null; group_name: string | null }
|
||||
): {
|
||||
id: number;
|
||||
description: string;
|
||||
due_date: string | null;
|
||||
group_id: string | null;
|
||||
group_name: string | null;
|
||||
display_code: number | 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,
|
||||
group_name: row.group_name ? String(row.group_name) : null,
|
||||
display_code: row.display_code != null ? Number(row.display_code) : null
|
||||
};
|
||||
}
|
||||
|
||||
export function mapTaskDetailsRow(
|
||||
row: { id?: number; description?: string; due_date?: string | null; group_id?: string | null; display_code?: number | null; completed?: number; completed_at?: string | null }
|
||||
): {
|
||||
id: number;
|
||||
description: string;
|
||||
due_date: string | null;
|
||||
group_id: string | null;
|
||||
display_code: number | null;
|
||||
completed: number;
|
||||
completed_at: string | 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
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
/* Lote 0 shims: ampliar tipos para Bun/Fetch sin tocar lógica de runtime */
|
||||
declare global {
|
||||
// Algunos módulos usan HeadersInit, no siempre presente sin lib DOM
|
||||
type HeadersInit = any;
|
||||
|
||||
// Ensanchar Headers de Bun para que sea asignable donde se espera DOM HeadersInit
|
||||
interface Headers {
|
||||
toJSON?: any;
|
||||
count?: any;
|
||||
getAll?: any;
|
||||
}
|
||||
|
||||
// Añadir timeout soportado por Bun.fetch en algunos usos y permitir httpVersion usado en algunos fetch
|
||||
interface BunFetchRequestInit {
|
||||
timeout?: number;
|
||||
httpVersion?: any;
|
||||
}
|
||||
|
||||
// Evitar 'unknown' en Response.json() en modo estricto
|
||||
interface Response {
|
||||
json(): Promise<any>;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@ -0,0 +1,16 @@
|
||||
export function toIsoSqlUTC(d: Date = new Date()): string {
|
||||
return d.toISOString().replace('T', ' ').replace('Z', '');
|
||||
}
|
||||
|
||||
export function normalizeTime(input: string | null | undefined): string | null {
|
||||
const s = (input ?? '').trim();
|
||||
const m = /^(\d{1,2}):(\d{1,2})$/.exec(s);
|
||||
if (!m) return null;
|
||||
const h = Number(m[1]);
|
||||
const min = Number(m[2]);
|
||||
if (!Number.isFinite(h) || !Number.isFinite(min)) return null;
|
||||
if (h < 0 || h > 23 || min < 0 || min > 59) return null;
|
||||
const hh = String(h).padStart(2, '0');
|
||||
const mm = String(min).padStart(2, '0');
|
||||
return `${hh}:${mm}`;
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
import { sha256Hex as coreSha256Hex } from '../../src/utils/crypto';
|
||||
|
||||
export const sha256Hex = coreSha256Hex;
|
||||
@ -0,0 +1,38 @@
|
||||
import { toIsoSqlUTC } from '../../src/utils/datetime';
|
||||
|
||||
export function toIsoSql(d: Date = new Date()): string {
|
||||
return toIsoSqlUTC(d);
|
||||
}
|
||||
|
||||
export { toIsoSqlUTC };
|
||||
|
||||
export function ymdInTZ(d: Date, tz: string = 'Europe/Madrid'): string {
|
||||
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 addDaysToYMD(ymd: string, days: number, tz: string = 'Europe/Madrid'): string {
|
||||
const [Y, M, D] = ymd.split('-').map(n => parseInt(n, 10));
|
||||
const base = new Date(Date.UTC(Y, (M || 1) - 1, D || 1));
|
||||
base.setUTCDate(base.getUTCDate() + days);
|
||||
return ymdInTZ(base, tz);
|
||||
}
|
||||
|
||||
export function ymdUTC(date: Date = new Date()): string {
|
||||
const yyyy = String(date.getUTCFullYear()).padStart(4, '0');
|
||||
const mm = String(date.getUTCMonth() + 1).padStart(2, '0');
|
||||
const dd = String(date.getUTCDate()).padStart(2, '0');
|
||||
return `${yyyy}-${mm}-${dd}`;
|
||||
}
|
||||
|
||||
export function addDays(date: Date, days: number): Date {
|
||||
const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
|
||||
d.setUTCDate(d.getUTCDate() + days);
|
||||
return d;
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
let simulatedQueue: any[] = [];
|
||||
|
||||
export const SimulatedResponseQueue = {
|
||||
async add(responses: any[]) {
|
||||
simulatedQueue.push(...responses);
|
||||
},
|
||||
clear() { simulatedQueue = []; },
|
||||
get() { return simulatedQueue; }
|
||||
};
|
||||
@ -0,0 +1,50 @@
|
||||
import Database from 'bun:sqlite';
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import { getDb, setDb, withDb, DbNotConfiguredError } from '../../../src/db/locator';
|
||||
|
||||
describe('db/locator', () => {
|
||||
it('getDb lanza si no está configurada', () => {
|
||||
expect(() => getDb()).toThrow(DbNotConfiguredError);
|
||||
});
|
||||
|
||||
it('setDb y getDb devuelven la misma instancia', () => {
|
||||
const db = new Database(':memory:');
|
||||
setDb(db);
|
||||
expect(getDb()).toBe(db);
|
||||
try { db.close(); } catch {}
|
||||
});
|
||||
|
||||
it('withDb soporta callback síncrono', () => {
|
||||
const db = new Database(':memory:');
|
||||
setDb(db);
|
||||
const result = withDb(d => {
|
||||
expect(d).toBe(db);
|
||||
return 42;
|
||||
});
|
||||
expect(result).toBe(42);
|
||||
try { db.close(); } catch {}
|
||||
});
|
||||
|
||||
it('withDb soporta callback asíncrono', async () => {
|
||||
const db = new Database(':memory:');
|
||||
setDb(db);
|
||||
const result = await withDb(async d => {
|
||||
expect(d).toBe(db);
|
||||
await Promise.resolve();
|
||||
return 'ok';
|
||||
});
|
||||
expect(result).toBe('ok');
|
||||
try { db.close(); } catch {}
|
||||
});
|
||||
|
||||
it('permitir sobrescritura de setDb', () => {
|
||||
const db1 = new Database(':memory:');
|
||||
const db2 = new Database(':memory:');
|
||||
setDb(db1);
|
||||
setDb(db2);
|
||||
const got = getDb();
|
||||
expect(got).toBe(db2);
|
||||
try { db1.close(); } catch {}
|
||||
try { db2.close(); } catch {}
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,75 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { initializeDatabase } from '../../src/db';
|
||||
import { setDb, getDb } from '../../src/db/locator';
|
||||
import { TaskService } from '../../src/tasks/service';
|
||||
import { ResponseQueue } from '../../src/services/response-queue';
|
||||
|
||||
describe('Locator fallback - servicios usan getDb() cuando no hay dbInstance', () => {
|
||||
let memdb: Database;
|
||||
let prevDb: Database | null = null;
|
||||
let hadPrev = false;
|
||||
let originalEnv: NodeJS.ProcessEnv;
|
||||
let originalTaskDb: any;
|
||||
let originalQueueDb: any;
|
||||
|
||||
beforeAll(() => {
|
||||
originalEnv = { ...process.env };
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.CHATBOT_PHONE_NUMBER = '999999';
|
||||
|
||||
// Capturar DB previa del locator (si la hubiera)
|
||||
try {
|
||||
prevDb = getDb();
|
||||
hadPrev = true;
|
||||
} catch {
|
||||
prevDb = null;
|
||||
hadPrev = false;
|
||||
}
|
||||
|
||||
// Crear DB de pruebas y configurarla en el locator global
|
||||
memdb = new Database(':memory:');
|
||||
initializeDatabase(memdb);
|
||||
setDb(memdb);
|
||||
|
||||
// Forzar fallback deshabilitando las instancias estáticas
|
||||
originalTaskDb = (TaskService as any).dbInstance;
|
||||
(TaskService as any).dbInstance = undefined;
|
||||
|
||||
originalQueueDb = (ResponseQueue as any).dbInstance;
|
||||
(ResponseQueue as any).dbInstance = undefined;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
// Restaurar instancias estáticas
|
||||
try { (TaskService as any).dbInstance = originalTaskDb; } catch {}
|
||||
try { (ResponseQueue as any).dbInstance = originalQueueDb; } catch {}
|
||||
|
||||
// Restaurar locator previo si existía; si no, dejamos memdb viva sin cerrar
|
||||
if (hadPrev && prevDb) {
|
||||
try { setDb(prevDb); } catch {}
|
||||
try { memdb.close(); } catch {}
|
||||
}
|
||||
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
it('TaskService.createTask y countAllActive funcionan vía locator', () => {
|
||||
const createdBy = '34600123456';
|
||||
const taskId = TaskService.createTask(
|
||||
{ description: 'Locator smoke', due_date: null, group_id: null, created_by: createdBy },
|
||||
[]
|
||||
);
|
||||
expect(typeof taskId).toBe('number');
|
||||
expect(taskId).toBeGreaterThan(0);
|
||||
|
||||
const cnt = TaskService.countAllActive();
|
||||
expect(cnt).toBe(1);
|
||||
});
|
||||
|
||||
it('ResponseQueue.add persiste en DB vía locator', async () => {
|
||||
await ResponseQueue.add([{ recipient: '321', message: 'hola locator' }]);
|
||||
const row = memdb.query(`SELECT COUNT(*) AS c FROM response_queue`).get() as any;
|
||||
expect(Number(row?.c || 0)).toBe(1);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue