refactor: webhook handler, group sync, command handlers, tests
- Refactor webhook handler with improved error handling and auto-ensure - Break group-sync into modular services (changes, deactivation, membership, scheduler) - Add startup.ts bootstrap with health checks and metrics - Refactor command handlers (nueva, completar, tomar, soltar, ver) for gating/resilience - Remove unused Svelte UI components (Badge, Skeleton, GroupCard, etc.) - Add ICS helpers, task helpers, preferences helpers to web lib - Remove legacy help.ts message service - Restructure tests: split monolithic server.test.ts into focused files - Add server test harness and coverage/conformance tests - Update docs (commands inventory, user guide, operational docs) - Command trigger simplified to 't' and task name (no slash) - Add .gitignore entries for fallow, sift, sq artifactsmain
parent
82d633124a
commit
b7ed1ad013
@ -1 +0,0 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
@ -0,0 +1,92 @@
|
||||
import { sha256Hex } from '$lib/server/crypto';
|
||||
import { icsHorizonMonths } from '$lib/server/env';
|
||||
import { buildIcsCalendar, checkIcsRateLimit } from '$lib/server/ics';
|
||||
import { toIsoSqlUTC, ymdUTC, addMonthsUTC } from '$lib/server/datetime';
|
||||
|
||||
type TaskRow = { id: number; description: string; due_date: string; group_name: string | null };
|
||||
|
||||
/**
|
||||
* Validate the ICS token, returning the DB row or an error Response.
|
||||
*/
|
||||
export async function validateIcsToken(
|
||||
db: any,
|
||||
token: string,
|
||||
expectedType: string
|
||||
): Promise<{ row: any } | Response> {
|
||||
if (!token) return new Response('Not Found', { status: 404 });
|
||||
|
||||
const tokenHash = await sha256Hex(token);
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT id, type, user_id, group_id, revoked_at
|
||||
FROM calendar_tokens
|
||||
WHERE token_hash = ?
|
||||
LIMIT 1`
|
||||
)
|
||||
.get(tokenHash) as any;
|
||||
|
||||
if (!row) return new Response('Not Found', { status: 404 });
|
||||
if (row.revoked_at) return new Response('Gone', { status: 410 });
|
||||
if (String(row.type) !== expectedType) return new Response('Not Found', { status: 404 });
|
||||
|
||||
return { row };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the ICS feed response: rate limit, query tasks, map events, build calendar,
|
||||
* handle ETag / 304, update last_used_at, return 200 with ETag.
|
||||
*/
|
||||
export async function buildIcsFeed(
|
||||
db: any,
|
||||
tokenHash: string,
|
||||
row: any,
|
||||
request: Request,
|
||||
title: string,
|
||||
tasks: TaskRow[]
|
||||
): Promise<Response> {
|
||||
// Rate limit
|
||||
const rl = checkIcsRateLimit(tokenHash);
|
||||
if (!rl.ok) {
|
||||
return new Response('Too Many Requests', {
|
||||
status: 429,
|
||||
headers: { 'Retry-After': String(rl.retryAfterSec || 60) }
|
||||
});
|
||||
}
|
||||
|
||||
// Map to events
|
||||
const events = tasks.map((t) => ({
|
||||
id: t.id,
|
||||
description: t.description,
|
||||
due_date: t.due_date,
|
||||
group_name: t.group_name || null,
|
||||
prefix: 'T' as const
|
||||
}));
|
||||
|
||||
const { body, etag } = await buildIcsCalendar(title, events);
|
||||
|
||||
// 304 if ETag matches
|
||||
const inm = request.headers.get('if-none-match');
|
||||
if (inm && inm === etag) {
|
||||
return new Response(null, { status: 304, headers: { ETag: etag, 'Cache-Control': 'public, max-age=300' } });
|
||||
}
|
||||
|
||||
db.prepare(`UPDATE calendar_tokens SET last_used_at = ? WHERE id = ?`).run(toIsoSqlUTC(), row.id);
|
||||
|
||||
return new Response(body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-type': 'text/calendar; charset=utf-8',
|
||||
'cache-control': 'public, max-age=300',
|
||||
ETag: etag
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Compute the YMD date range for ICS queries. */
|
||||
export function icsDateRange(): { startYmd: string; endYmd: string } {
|
||||
const today = new Date();
|
||||
return {
|
||||
startYmd: ymdUTC(today),
|
||||
endYmd: ymdUTC(addMonthsUTC(today, icsHorizonMonths))
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,56 @@
|
||||
import { normalizeTime } from '$lib/server/datetime';
|
||||
|
||||
/**
|
||||
* Resolves the reminder time to save based on frequency and raw input.
|
||||
* Returns { timeToSave } on success or { error } on validation failure.
|
||||
* Callers must handle the error case with their own Response/fail mechanism.
|
||||
*/
|
||||
export function resolveReminderTime(
|
||||
db: any,
|
||||
userId: string,
|
||||
freqRaw: string,
|
||||
timeRaw: string | null
|
||||
): { timeToSave: string } | { error: string } {
|
||||
if (freqRaw === 'off') {
|
||||
// Hora opcional: si viene, validar/normalizar; si no, conservar la actual o usar '08:30'
|
||||
if (timeRaw && timeRaw.length > 0) {
|
||||
const norm = normalizeTime(timeRaw);
|
||||
if (!norm) return { error: 'hora inválida' };
|
||||
return { timeToSave: norm };
|
||||
}
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT reminder_time AS time
|
||||
FROM user_preferences
|
||||
WHERE user_id = ?
|
||||
LIMIT 1`
|
||||
)
|
||||
.get(userId) as any;
|
||||
return { timeToSave: row?.time ? String(row.time) : '08:30' };
|
||||
}
|
||||
|
||||
// daily/weekly/weekdays: si no se especifica hora, usar '08:30'
|
||||
if (!timeRaw || timeRaw.length === 0) {
|
||||
return { timeToSave: '08:30' };
|
||||
}
|
||||
const norm = normalizeTime(timeRaw);
|
||||
if (!norm) return { error: 'hora inválida' };
|
||||
return { timeToSave: norm };
|
||||
}
|
||||
|
||||
/** SQL for upserting user preferences (preserves last_reminded_on). */
|
||||
export function upsertPreference(
|
||||
db: any,
|
||||
userId: string,
|
||||
freqRaw: string,
|
||||
timeToSave: string
|
||||
): void {
|
||||
db.prepare(
|
||||
`INSERT INTO user_preferences (user_id, reminder_freq, reminder_time, last_reminded_on, updated_at)
|
||||
VALUES (?, ?, ?, (SELECT last_reminded_on FROM user_preferences WHERE user_id = ?), strftime('%Y-%m-%d %H:%M:%f','now'))
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
reminder_freq = excluded.reminder_freq,
|
||||
reminder_time = excluded.reminder_time,
|
||||
updated_at = excluded.updated_at`
|
||||
).run(userId, freqRaw, timeToSave, userId);
|
||||
}
|
||||
@ -0,0 +1,264 @@
|
||||
import { getDb } from '$lib/server/db';
|
||||
|
||||
/**
|
||||
* Validate session and parse JSON body for POST endpoints.
|
||||
* Returns { userId, payload } on success, or a Response on failure.
|
||||
* Callers should check `instanceof Response` before destructuring.
|
||||
*/
|
||||
export async function requireAuthAndJson(event: {
|
||||
locals: { userId?: string | null };
|
||||
request: { json(): Promise<any> };
|
||||
}): Promise<{ userId: string; payload: any } | Response> {
|
||||
const userId = event.locals.userId ?? null;
|
||||
if (!userId) return new Response('Unauthorized', { status: 401 });
|
||||
|
||||
let payload: any = null;
|
||||
try {
|
||||
payload = await event.request.json();
|
||||
} catch {
|
||||
return new Response('Bad Request', { status: 400 });
|
||||
}
|
||||
|
||||
return { userId, payload };
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared auth + task loading logic used by task detail, claim, and unassign routes.
|
||||
*
|
||||
* Validates the user, parses the task ID from params, opens the DB, loads the task,
|
||||
* and checks that it exists and is not completed. Returns the context on success
|
||||
* or a Response on failure — callers should check `instanceof Response` first.
|
||||
*/
|
||||
export async function loadAndCheckTask(event: {
|
||||
locals: { userId?: string | null };
|
||||
params: { id?: string };
|
||||
}): Promise<{ db: any; task: any; userId: string } | Response> {
|
||||
const ctx = await _loadTask(event);
|
||||
if (ctx instanceof Response) return ctx;
|
||||
|
||||
// Additional check: reject completed tasks
|
||||
const { task } = ctx;
|
||||
if (Number(task.completed) !== 0 || task.completed_at) {
|
||||
return new Response(JSON.stringify({ status: 'completed' }), {
|
||||
status: 400,
|
||||
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
|
||||
});
|
||||
}
|
||||
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared group gating check: verifies the group is allowed and the user
|
||||
* is an active member. Returns a 403 Response on failure, or true to
|
||||
* continue. Callers should `if (res instanceof Response) return res;`.
|
||||
*/
|
||||
export function checkGroupAccess(
|
||||
db: any,
|
||||
groupId: string | null,
|
||||
userId: string
|
||||
): Response | true {
|
||||
if (!groupId) return true;
|
||||
|
||||
const allowed = db
|
||||
.prepare(`SELECT 1 FROM allowed_groups WHERE group_id = ? AND status = 'allowed' LIMIT 1`)
|
||||
.get(groupId);
|
||||
const active = db
|
||||
.prepare(
|
||||
`SELECT 1 FROM group_members WHERE group_id = ? AND user_id = ? AND is_active = 1 LIMIT 1`
|
||||
)
|
||||
.get(groupId, userId);
|
||||
|
||||
if (!allowed || !active) {
|
||||
return new Response('Forbidden', { status: 403 });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auth + task load + full gating (group + personal assignment).
|
||||
* Returns context or a Response on failure. Does NOT check completed status —
|
||||
* callers must handle that themselves (complete vs uncomplete have opposite
|
||||
* semantics).
|
||||
*/
|
||||
/**
|
||||
* Load a task, check auth, and verify group access.
|
||||
* Returns { db, task, userId } or a Response on failure.
|
||||
* Does NOT check personal assignment (suitable for claim/unassign routes).
|
||||
*/
|
||||
export async function loadTaskAndCheckGroup(event: {
|
||||
locals: { userId?: string | null };
|
||||
params: { id?: string };
|
||||
}): Promise<{ db: any; task: any; userId: string } | Response> {
|
||||
const ctx = await loadAndCheckTask(event);
|
||||
if (ctx instanceof Response) return ctx;
|
||||
const { db, task, userId } = ctx;
|
||||
|
||||
// Gating: grupo permitido + usuario miembro activo
|
||||
const groupId: string | null = task.group_id ? String(task.group_id) : null;
|
||||
const gating = checkGroupAccess(db, groupId, userId);
|
||||
if (gating instanceof Response) return gating;
|
||||
|
||||
return { db, task, userId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch allowed groups for a user where the user is an active member.
|
||||
*
|
||||
* @param excludeCommunityArchived - when true, also filters out
|
||||
* community groups (is_community=0) and archived groups (archived=0).
|
||||
* Defaults to false (includes all active allowed groups).
|
||||
*/
|
||||
export function fetchAllowedUserGroups(
|
||||
db: any,
|
||||
userId: string,
|
||||
opts?: { excludeCommunityArchived?: boolean }
|
||||
): Array<{ id: string; name: string | null }> {
|
||||
const extraWhere = opts?.excludeCommunityArchived
|
||||
? ' AND COALESCE(g.is_community, 0) = 0 AND COALESCE(g.archived, 0) = 0'
|
||||
: '';
|
||||
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT g.id, g.name
|
||||
FROM groups g
|
||||
INNER JOIN group_members gm
|
||||
ON gm.group_id = g.id AND gm.user_id = ? AND gm.is_active = 1
|
||||
INNER JOIN allowed_groups ag
|
||||
ON ag.group_id = g.id AND ag.status = 'allowed'
|
||||
WHERE COALESCE(g.active, 1) = 1${extraWhere}
|
||||
ORDER BY (g.name IS NULL) ASC, g.name ASC, g.id ASC`
|
||||
)
|
||||
.all(userId) as Array<{ id: string; name: string | null }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Low-level: auth + taskId parsing + DB + task load + not-found check.
|
||||
* Does NOT reject completed tasks — that's up to the caller.
|
||||
*/
|
||||
async function _loadTask(event: {
|
||||
locals: { userId?: string | null };
|
||||
params: { id?: string };
|
||||
}): Promise<{ db: any; task: any; userId: string } | Response> {
|
||||
// Auth
|
||||
const userId = event.locals.userId ?? null;
|
||||
if (!userId) return new Response('Unauthorized', { status: 401 });
|
||||
|
||||
// Parse task ID
|
||||
const idStr = event.params.id || '';
|
||||
const taskId = parseInt(idStr, 10);
|
||||
if (!Number.isFinite(taskId) || taskId <= 0) return new Response('Bad Request', { status: 400 });
|
||||
|
||||
// DB
|
||||
const db = await getDb();
|
||||
|
||||
// Load
|
||||
const task = db
|
||||
.prepare(
|
||||
`SELECT id, description, due_date, group_id, created_by,
|
||||
COALESCE(completed, 0) AS completed, completed_at, display_code
|
||||
FROM tasks
|
||||
WHERE id = ?`
|
||||
)
|
||||
.get(taskId) as any;
|
||||
|
||||
if (!task) {
|
||||
return new Response(JSON.stringify({ status: 'not_found' }), {
|
||||
status: 404,
|
||||
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
|
||||
});
|
||||
}
|
||||
|
||||
return { db, task, userId };
|
||||
}
|
||||
|
||||
export async function loadTaskAndGating(event: {
|
||||
locals: { userId?: string | null };
|
||||
params: { id?: string };
|
||||
}): Promise<{ db: any; task: any; userId: string; groupId: string | null } | Response> {
|
||||
const ctx = await _loadTask(event);
|
||||
if (ctx instanceof Response) return ctx;
|
||||
const { db, task, userId } = ctx;
|
||||
|
||||
// Gating: grupo allowed + miembro activo; si no tiene grupo, debe estar asignado
|
||||
const groupId: string | null = task.group_id ? String(task.group_id) : null;
|
||||
const gating = checkGroupAccess(db, groupId, userId);
|
||||
if (gating instanceof Response) return gating;
|
||||
if (!groupId) {
|
||||
const isAssigned = db
|
||||
.prepare(`SELECT 1 FROM task_assignments WHERE task_id = ? AND user_id = ? LIMIT 1`)
|
||||
.get(task.id, userId);
|
||||
if (!isAssigned) {
|
||||
return new Response('Forbidden', { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
return { db, task, userId, groupId };
|
||||
}
|
||||
|
||||
/** Convert a DB row to the standard API task shape. */
|
||||
export function formatTask(row: any): Record<string, any> {
|
||||
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,
|
||||
completed: 'completed' in (row || {}) ? Number(row.completed || 0) : undefined,
|
||||
completed_at: 'completed_at' in (row || {}) ? (row.completed_at ? String(row.completed_at) : null) : undefined
|
||||
};
|
||||
}
|
||||
|
||||
/** Map a DB row to a task list item (id, desc, date, group, code, assignees). */
|
||||
export function mapTaskRow(r: any): Record<string, any> {
|
||||
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: [] as string[]
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate item.assignees by batch-loading task_assignments.
|
||||
* Optionally computes can_unassign for the given userId (pass null to skip).
|
||||
*/
|
||||
export function loadAssignees(db: any, items: any[], userId: string | null): void {
|
||||
if (items.length === 0) return;
|
||||
const ids = items.map((it) => it.id);
|
||||
const placeholders = ids.map(() => '?').join(',');
|
||||
const assignRows = db
|
||||
.prepare(
|
||||
`SELECT task_id, user_id
|
||||
FROM task_assignments
|
||||
WHERE task_id IN (${placeholders})
|
||||
ORDER BY assigned_at ASC`
|
||||
)
|
||||
.all(...ids) as any[];
|
||||
|
||||
const map = new Map<number, string[]>();
|
||||
for (const row of assignRows) {
|
||||
const tid = Number(row.task_id);
|
||||
const uid = String(row.user_id);
|
||||
if (!map.has(tid)) map.set(tid, []);
|
||||
map.get(tid)!.push(uid);
|
||||
}
|
||||
for (const it of items) {
|
||||
it.assignees = map.get(it.id) || [];
|
||||
if (userId != null) {
|
||||
const personal = it.group_id == null;
|
||||
const cnt = Array.isArray(it.assignees) ? it.assignees.length : 0;
|
||||
const mine = (it.assignees || []).some((uid: string) => uid === userId);
|
||||
(it as any).can_unassign = !(personal && cnt === 1 && mine);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a 200 JSON response { status, task }. */
|
||||
export function respondTask(status: string, task: Record<string, any>): Response {
|
||||
return new Response(JSON.stringify({ status, task }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
|
||||
});
|
||||
}
|
||||
@ -1,35 +0,0 @@
|
||||
<script lang="ts">
|
||||
export let tone: 'default' | 'warning' | 'danger' | 'success' = 'default';
|
||||
</script>
|
||||
|
||||
<span class={`badge ${tone}`}><slot /></span>
|
||||
|
||||
<style>
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
line-height: 1;
|
||||
font-size: 12px;
|
||||
padding: 4px 6px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
gap: 6px;
|
||||
}
|
||||
.badge.warning {
|
||||
background: rgba(217, 119, 6, 0.12);
|
||||
border-color: rgba(217, 119, 6, 0.35);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
.badge.danger {
|
||||
background: rgba(220, 38, 38, 0.12);
|
||||
border-color: rgba(220, 38, 38, 0.35);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
.badge.success {
|
||||
background: rgba(22, 163, 74, 0.12);
|
||||
border-color: rgba(22, 163, 74, 0.35);
|
||||
color: var(--color-success);
|
||||
}
|
||||
</style>
|
||||
@ -1,24 +0,0 @@
|
||||
<script lang="ts">
|
||||
export let width: string = '100%';
|
||||
export let height: string = '12px';
|
||||
export let radius: string = '6px';
|
||||
</script>
|
||||
|
||||
<div class="skeleton" style={`width:${width};height:${height};border-radius:${radius};`} />
|
||||
|
||||
<style>
|
||||
.skeleton {
|
||||
background: linear-gradient(90deg, rgba(0,0,0,0.06), rgba(0,0,0,0.12), rgba(0,0,0,0.06));
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.2s infinite;
|
||||
}
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.skeleton {
|
||||
background: linear-gradient(90deg, rgba(255,255,255,0.08), rgba(255,255,255,0.16), rgba(255,255,255,0.08));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1,11 +0,0 @@
|
||||
<span class="sr-only"><slot /></span>
|
||||
|
||||
<style>
|
||||
.sr-only {
|
||||
position: absolute !important;
|
||||
width: 1px; height: 1px;
|
||||
padding: 0; margin: -1px;
|
||||
overflow: hidden; clip: rect(0,0,0,0);
|
||||
white-space: nowrap; border: 0;
|
||||
}
|
||||
</style>
|
||||
@ -1,108 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Card from '$lib/ui/layout/Card.svelte';
|
||||
import Badge from '$lib/ui/atoms/Badge.svelte';
|
||||
import { success, error as toastError } from '$lib/stores/toasts';
|
||||
|
||||
export type Counts = { open: number; unassigned: number };
|
||||
export type TaskPreview = { id: number; description: string; due_date: string | null; display_code: number | null };
|
||||
|
||||
export let id: string;
|
||||
export let name: string | null = null;
|
||||
export let counts: Counts = { open: 0, unassigned: 0 };
|
||||
export let previews: TaskPreview[] = [];
|
||||
|
||||
let busyTaskId: number | null = null;
|
||||
|
||||
async function claim(taskId: number) {
|
||||
if (busyTaskId) return;
|
||||
busyTaskId = taskId;
|
||||
try {
|
||||
const res = await fetch(`/api/tasks/${taskId}/claim`, { method: 'POST' });
|
||||
if (res.ok) {
|
||||
success('Tarea reclamada');
|
||||
// Actualizar estado local sin recargar
|
||||
previews = previews.filter((t) => t.id !== taskId);
|
||||
counts = { ...counts, unassigned: Math.max(0, (counts?.unassigned ?? 0) - 1) };
|
||||
} else {
|
||||
const txt = await res.text();
|
||||
toastError(txt || 'No se pudo reclamar');
|
||||
}
|
||||
} catch {
|
||||
toastError('Error de red');
|
||||
} finally {
|
||||
busyTaskId = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Card>
|
||||
<div class="header">
|
||||
<strong class="name">{name ?? id}</strong>
|
||||
<div class="badges">
|
||||
<Badge>abiertas: {counts.open}</Badge>
|
||||
<Badge tone="warning">sin responsable: {counts.unassigned}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if previews?.length}
|
||||
<div class="previews">
|
||||
<em class="title">Sin responsable:</em>
|
||||
<ul class="list">
|
||||
{#each previews as t}
|
||||
<li class="row">
|
||||
<div class="info">
|
||||
<span>#{t.display_code ?? t.id} — {t.description}</span>
|
||||
{#if t.due_date}<small class="muted"> (vence: {t.due_date})</small>{/if}
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn" on:click|preventDefault={() => claim(t.id)} disabled={busyTaskId === t.id}>Reclamar</button>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
</Card>
|
||||
|
||||
<style>
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
justify-content: space-between;
|
||||
}
|
||||
.name { font-size: 1rem; }
|
||||
.badges { display: inline-flex; gap: var(--space-2); flex-wrap: wrap; }
|
||||
.previews { margin-top: var(--space-3); }
|
||||
.title { color: var(--color-text); }
|
||||
.list { margin: 6px 0 0 18px; padding: 0; }
|
||||
.list li { margin: 4px 0; }
|
||||
.muted { color: var(--color-text-muted); }
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.info {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.actions {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.btn {
|
||||
padding: 3px 8px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn[disabled] { opacity: .6; cursor: not-allowed; }
|
||||
</style>
|
||||
@ -1,16 +0,0 @@
|
||||
<div class="error-banner" role="alert">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.error-banner {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--color-danger);
|
||||
background: rgba(220,38,38,0.08);
|
||||
color: var(--color-text);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.error-banner { background: rgba(248,113,113,0.12); }
|
||||
}
|
||||
</style>
|
||||
@ -1,27 +0,0 @@
|
||||
<script lang="ts">
|
||||
export let size: number = 16;
|
||||
export let className: string = '';
|
||||
export let ariaLabel: string | undefined;
|
||||
export let title: string | undefined;
|
||||
</script>
|
||||
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class={className}
|
||||
role={ariaLabel ? 'img' : undefined}
|
||||
aria-label={ariaLabel}
|
||||
aria-hidden={ariaLabel ? undefined : 'true'}
|
||||
>
|
||||
{#if title}<title>{title}</title>{/if}
|
||||
<path d="M6 2h12" />
|
||||
<path d="M6 22h12" />
|
||||
<path d="M8 4l8 8" />
|
||||
<path d="M8 20l8-8" />
|
||||
</svg>
|
||||
@ -1,32 +0,0 @@
|
||||
<script lang="ts">
|
||||
export let type: string = 'text';
|
||||
export let name: string | undefined;
|
||||
export let value: string | number | undefined = undefined;
|
||||
export let placeholder: string = '';
|
||||
export let disabled: boolean = false;
|
||||
</script>
|
||||
|
||||
<input
|
||||
class="textfield"
|
||||
{type}
|
||||
{name}
|
||||
bind:value
|
||||
{placeholder}
|
||||
{disabled}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.textfield {
|
||||
width: 100%;
|
||||
min-height: 36px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.textfield:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,7 @@
|
||||
export const REQUIRED_ENV = [
|
||||
'EVOLUTION_API_URL',
|
||||
'EVOLUTION_API_KEY',
|
||||
'EVOLUTION_API_INSTANCE',
|
||||
'CHATBOT_PHONE_NUMBER',
|
||||
'WEBHOOK_URL'
|
||||
];
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,60 @@
|
||||
import type { Database } from 'bun:sqlite';
|
||||
|
||||
/** Row shape used by the change detector (subset of groups columns). */
|
||||
export interface GroupRow {
|
||||
id: string;
|
||||
active: number;
|
||||
archived: number;
|
||||
is_community: number;
|
||||
name?: string | null;
|
||||
}
|
||||
|
||||
export interface GroupChanges {
|
||||
newlyActivated: string[];
|
||||
newlyDeactivated: Array<{ id: string; name: string | null }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare before/after snapshots of the groups table and detect:
|
||||
* - Groups that became active (new or reactivated, excluding communities/archived)
|
||||
* - Groups that went from active → inactive (excluding communities/archived)
|
||||
*/
|
||||
export function detectGroupChanges(
|
||||
before: GroupRow[],
|
||||
after: GroupRow[]
|
||||
): GroupChanges {
|
||||
const beforeMap = new Map<string, GroupRow>();
|
||||
for (const r of before) beforeMap.set(String(r.id), r);
|
||||
|
||||
const afterMap = new Map<string, GroupRow>();
|
||||
for (const r of after) afterMap.set(String(r.id), r);
|
||||
|
||||
const newlyActivated: string[] = [];
|
||||
for (const [id, a] of afterMap) {
|
||||
const b = beforeMap.get(id);
|
||||
const active =
|
||||
Number(a.active) === 1 &&
|
||||
Number(a.archived) === 0 &&
|
||||
Number(a.is_community) === 0;
|
||||
if (active && (!b || Number(b.active) !== 1)) {
|
||||
newlyActivated.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
const newlyDeactivated: Array<{ id: string; name: string | null }> = [];
|
||||
for (const [id, b] of beforeMap) {
|
||||
const a = afterMap.get(id);
|
||||
if (!a) continue;
|
||||
if (
|
||||
Number(b.active) === 1 &&
|
||||
Number(a.active) === 0 &&
|
||||
Number(a.archived) === 0 &&
|
||||
Number(a.is_community) === 0 &&
|
||||
Number(b.is_community) === 0
|
||||
) {
|
||||
newlyDeactivated.push({ id, name: a.name ?? b.name ?? null });
|
||||
}
|
||||
}
|
||||
|
||||
return { newlyActivated, newlyDeactivated };
|
||||
}
|
||||
@ -0,0 +1,85 @@
|
||||
import type { Database } from 'bun:sqlite';
|
||||
import { normalizeWhatsAppId } from '../../utils/whatsapp';
|
||||
import { ResponseQueue } from '../response-queue';
|
||||
import { Metrics } from '../metrics';
|
||||
|
||||
/**
|
||||
* Revoke calendar tokens and deactivate memberships for groups that have
|
||||
* been deactivated (no longer present in the API snapshot).
|
||||
*/
|
||||
export function revokeTokensAndDeactivateMembers(
|
||||
db: Database,
|
||||
deactivatedIds: string[],
|
||||
txn: (fn: () => void) => void = fn => fn()
|
||||
): void {
|
||||
if (deactivatedIds.length === 0) return;
|
||||
txn(() => {
|
||||
for (const groupId of deactivatedIds) {
|
||||
db.prepare(`
|
||||
UPDATE calendar_tokens
|
||||
SET revoked_at = strftime('%Y-%m-%d %H:%M:%f','now')
|
||||
WHERE group_id = ? AND revoked_at IS NULL
|
||||
`).run(groupId);
|
||||
db.prepare(`
|
||||
UPDATE group_members
|
||||
SET is_active = 0
|
||||
WHERE group_id = ? AND is_active = 1
|
||||
`).run(groupId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue admin notifications about deactivated groups.
|
||||
* Skips in test mode.
|
||||
*/
|
||||
export async function notifyAdminsAboutDeactivated(
|
||||
deactivated: Array<{ id: string; name: string | null }>
|
||||
): Promise<void> {
|
||||
if (deactivated.length === 0) return;
|
||||
if (String(process.env.NODE_ENV || '').toLowerCase() === 'test') return;
|
||||
|
||||
const adminSet = new Set<string>();
|
||||
const rawAdmins = String(process.env.ADMIN_USERS || '');
|
||||
for (const token of rawAdmins.split(',').map(s => s.trim()).filter(Boolean)) {
|
||||
const n = normalizeWhatsAppId(token);
|
||||
if (n) adminSet.add(n);
|
||||
}
|
||||
const admins = Array.from(adminSet);
|
||||
if (admins.length === 0) return;
|
||||
|
||||
const messages: Array<{ recipient: string; message: string }> = [];
|
||||
for (const g of deactivated) {
|
||||
const label = g.name ? `${g.name} (${g.id})` : g.id;
|
||||
const msg =
|
||||
`⚠️ El grupo ${label} parece haber dejado de existir o no está disponible.\n\n` +
|
||||
`Acciones disponibles:\n- Archivar (recomendado): /admin archivar-grupo ${g.id}\n` +
|
||||
`- Borrar definitivamente: /admin borrar-grupo ${g.id}`;
|
||||
for (const admin of admins) {
|
||||
messages.push({ recipient: admin, message: msg });
|
||||
}
|
||||
}
|
||||
|
||||
if (messages.length > 0) {
|
||||
try {
|
||||
await ResponseQueue.add(messages as any);
|
||||
Metrics.inc('admin_deactivation_notifications', messages.length);
|
||||
} catch (e) {
|
||||
console.warn('No se pudo encolar notificación a admins:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Full deactivation pipeline: revoke tokens, deactivate members, notify admins.
|
||||
*/
|
||||
export async function handleDeactivatedGroups(
|
||||
db: Database,
|
||||
deactivated: Array<{ id: string; name: string | null }>
|
||||
): Promise<void> {
|
||||
if (deactivated.length === 0) return;
|
||||
|
||||
const ids = deactivated.map(g => g.id);
|
||||
revokeTokensAndDeactivateMembers(db, ids, fn => db.transaction(fn)());
|
||||
await notifyAdminsAboutDeactivated(deactivated);
|
||||
}
|
||||
@ -0,0 +1,75 @@
|
||||
import type { Database } from 'bun:sqlite';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Snapshot freshness
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function maxSnapshotAgeMs(): number {
|
||||
const raw = Number(process.env.MAX_MEMBERS_SNAPSHOT_AGE_MS);
|
||||
return Number.isFinite(raw) && raw > 0 ? raw : 24 * 60 * 60 * 1000; // 24h default
|
||||
}
|
||||
|
||||
export function isSnapshotFresh(
|
||||
db: Database,
|
||||
groupId: string,
|
||||
nowMs: number = Date.now()
|
||||
): boolean {
|
||||
try {
|
||||
const row = db
|
||||
.prepare(`SELECT last_verified FROM groups WHERE id = ?`)
|
||||
.get(groupId) as { last_verified?: string | null } | undefined;
|
||||
const lv = row?.last_verified ? String(row.last_verified) : null;
|
||||
if (!lv) return false;
|
||||
const iso = lv.includes('T') ? lv : lv.replace(' ', 'T') + 'Z';
|
||||
const ms = Date.parse(iso);
|
||||
if (!Number.isFinite(ms)) return false;
|
||||
return nowMs - ms <= maxSnapshotAgeMs();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Membership queries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function isUserActiveInGroup(
|
||||
db: Database,
|
||||
userId: string,
|
||||
groupId: string
|
||||
): boolean {
|
||||
if (!userId || !groupId) return false;
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT 1 FROM group_members WHERE group_id = ? AND user_id = ? AND is_active = 1 LIMIT 1`
|
||||
)
|
||||
.get(groupId, userId);
|
||||
return !!row;
|
||||
}
|
||||
|
||||
export function getActiveGroupIdsForUser(
|
||||
db: Database,
|
||||
userId: string
|
||||
): string[] {
|
||||
if (!userId) return [];
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT gm.group_id AS id
|
||||
FROM group_members gm
|
||||
JOIN groups g ON g.id = gm.group_id
|
||||
WHERE gm.user_id = ? AND gm.is_active = 1 AND g.active = 1
|
||||
AND COALESCE(g.is_community,0) = 0
|
||||
AND COALESCE(g.archived,0) = 0`
|
||||
)
|
||||
.all(userId) as Array<{ id: string }>;
|
||||
return [...new Set(rows.map(r => String(r.id)))];
|
||||
}
|
||||
|
||||
export function getFreshMemberGroupsForUser(
|
||||
db: Database,
|
||||
userId: string
|
||||
): string[] {
|
||||
return getActiveGroupIdsForUser(db, userId).filter(gid =>
|
||||
isSnapshotFresh(db, gid)
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Resolve a schedule interval in milliseconds.
|
||||
*
|
||||
* Priority:
|
||||
* 1. env var if set and valid
|
||||
* 2. fallbackMs default
|
||||
*
|
||||
* In development mode, enforces a minimum of 10s to avoid accidental API spam.
|
||||
*/
|
||||
export function resolveInterval(
|
||||
envVar: string,
|
||||
fallbackMs: number
|
||||
): number {
|
||||
const raw = Number(process.env[envVar]);
|
||||
let interval = Number.isFinite(raw) && raw > 0 ? raw : fallbackMs;
|
||||
if (process.env.NODE_ENV === 'development' && interval < 10_000) {
|
||||
console.warn(
|
||||
`Sync interval from ${envVar} too low (${interval}ms), using 10s minimum`
|
||||
);
|
||||
interval = 10_000;
|
||||
}
|
||||
return interval;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scheduler state holders (mutable, per-scheduler)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface SchedulerState {
|
||||
running: boolean;
|
||||
timer: ReturnType<typeof setInterval> | null;
|
||||
intervalMs: number | null;
|
||||
nextTickAt: number | null;
|
||||
}
|
||||
|
||||
export function createSchedulerState(): SchedulerState {
|
||||
return { running: false, timer: null, intervalMs: null, nextTickAt: null };
|
||||
}
|
||||
|
||||
export function startScheduler(
|
||||
state: SchedulerState,
|
||||
intervalMs: number,
|
||||
task: () => Promise<void>,
|
||||
label: string
|
||||
): void {
|
||||
if (process.env.NODE_ENV === 'test') return;
|
||||
if (state.running) return;
|
||||
|
||||
state.running = true;
|
||||
state.intervalMs = intervalMs;
|
||||
state.nextTickAt = Date.now() + intervalMs;
|
||||
|
||||
state.timer = setInterval(() => {
|
||||
state.nextTickAt = Date.now() + (state.intervalMs ?? intervalMs);
|
||||
task().catch(err =>
|
||||
console.error(`❌ ${label} scheduler run error:`, err)
|
||||
);
|
||||
}, intervalMs);
|
||||
}
|
||||
|
||||
export function stopScheduler(state: SchedulerState): void {
|
||||
state.running = false;
|
||||
if (state.timer) {
|
||||
clearInterval(state.timer);
|
||||
state.timer = null;
|
||||
}
|
||||
state.intervalMs = null;
|
||||
state.nextTickAt = null;
|
||||
}
|
||||
|
||||
export function secondsUntilNextTick(
|
||||
state: SchedulerState,
|
||||
nowMs: number = Date.now()
|
||||
): number | null {
|
||||
const next = state.nextTickAt;
|
||||
if (next == null) return null;
|
||||
const secs = (next - nowMs) / 1000;
|
||||
return secs > 0 ? secs : 0;
|
||||
}
|
||||
@ -1,100 +0,0 @@
|
||||
/**
|
||||
* Centralización de contenidos de ayuda (Help v2)
|
||||
* Nota: Solo copy; no depende de flags ni del runtime. Integración en command.ts llega en Fase 4.
|
||||
*/
|
||||
import { section, bullets, code, italic } from '../../utils/formatting';
|
||||
|
||||
export function getQuickHelp(baseUrl?: string): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
parts.push(section('Comandos básicos'));
|
||||
parts.push(
|
||||
bullets([
|
||||
`Crear: ${code('/t n Descripción 27-11-14 @Ana')}`,
|
||||
`Ver mis: ${code('/t mias')} _por privado_`,
|
||||
`Ver todas: ${code('/t todas')} _por privado_`,
|
||||
`Más info: ${code('/t info')}`,
|
||||
`Completar: ${code('/t x 26')} _(máx. 10 a la vez)_`,
|
||||
`Tomar: ${code('/t tomar 12')} _(máx. 10 a la vez)_`,
|
||||
`Soltar: ${code('/t soltar 26')} _(máx. 10 a la vez)_`,
|
||||
`Recordatorios: ${code('/t configurar diario|l-v|semanal|off [HH:MM]')} _por privado_`,
|
||||
`Versión web: ${code('/t web')}`,
|
||||
])
|
||||
);
|
||||
|
||||
parts.push(
|
||||
italic('El bot responde por privado, incluso si escribes desde un grupo.')
|
||||
);
|
||||
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
export function getFullHelp(baseUrl?: string): string {
|
||||
const out: string[] = [];
|
||||
|
||||
// Crear
|
||||
out.push(section('Crear'));
|
||||
out.push(
|
||||
bullets([
|
||||
`${code('/t n Descripción [YYYY-MM-DD|YY-MM-DD|hoy|mañana] [@menciones...]')}`,
|
||||
'En privado: sin menciones → asignada a quien la crea.',
|
||||
'En grupo: sin menciones → queda “sin responsable”.',
|
||||
'Fechas: usa la última válida encontrada; no acepta pasadas.',
|
||||
])
|
||||
);
|
||||
|
||||
// Listados
|
||||
out.push('');
|
||||
out.push(section('Listados'));
|
||||
out.push(
|
||||
bullets([
|
||||
`${code('/t mias')} tus pendientes (por privado).`,
|
||||
`${code('/t todas')} tus pendientes + “sin responsable”.`,
|
||||
'Nota: no respondo en grupos; usa estos comandos por privado.',
|
||||
'Máx. 10 elementos por sección; se añade “… y N más” si hay más.',
|
||||
'Fechas en DD/MM y ⚠️ si están vencidas.',
|
||||
])
|
||||
);
|
||||
|
||||
// Fechas
|
||||
out.push('');
|
||||
out.push(section('Fechas'));
|
||||
out.push(
|
||||
bullets([
|
||||
'Puedes escribir fechas en formato `2027-09-04` o `27-09-04`',
|
||||
'`hoy` y `mañana` también son expresiones válidas',
|
||||
])
|
||||
);
|
||||
|
||||
// Recordatorios
|
||||
out.push('');
|
||||
out.push(section('Recordatorios'));
|
||||
out.push(
|
||||
bullets([
|
||||
`${code('/t configurar diario|l-v|semanal|off [HH:MM]')}`,
|
||||
'Alias: diario/diaria, laborables (l-v/lv), semanal, off/apagar.',
|
||||
'Si omites hora, se conserva la anterior o se usa 08:30 por defecto (semanal asume lunes).',
|
||||
])
|
||||
);
|
||||
|
||||
// Acceso web
|
||||
out.push('');
|
||||
out.push(section('Acceso web'));
|
||||
out.push(
|
||||
bullets([
|
||||
`${code('/t web')} genera un enlace de acceso de un solo uso (dura 10 min, una vez entras dura 2 horas).`,
|
||||
])
|
||||
);
|
||||
|
||||
// Otros
|
||||
out.push('');
|
||||
out.push(section('Otros'));
|
||||
out.push(
|
||||
bullets([
|
||||
'IDs visibles con 4 dígitos, pero puedes escribirlos sin ceros (ej.: 26).',
|
||||
'Máx. 10 IDs en completar/tomar; separa por espacios o comas.',
|
||||
])
|
||||
);
|
||||
|
||||
return out.join('\n');
|
||||
}
|
||||
@ -1,18 +0,0 @@
|
||||
export interface Task {
|
||||
id: number;
|
||||
description: string;
|
||||
created_at: Date;
|
||||
due_date: Date | null;
|
||||
completed: boolean;
|
||||
completed_at: Date | null;
|
||||
completed_by: string | null;
|
||||
group_id: string; // WhatsApp group ID where task was created
|
||||
created_by: string; // WhatsApp user ID of task creator
|
||||
}
|
||||
|
||||
export interface TaskAssignment {
|
||||
task_id: number;
|
||||
user_id: string; // Normalized phone number
|
||||
assigned_by: string; // Who assigned this
|
||||
assigned_at: Date;
|
||||
}
|
||||
@ -0,0 +1,292 @@
|
||||
/**
|
||||
* Coverage gap tests for src/server.ts functions flagged by fallow.
|
||||
*
|
||||
* These fill genuinely untested paths that fallow can't trace through
|
||||
* the existing integration-level tests.
|
||||
*/
|
||||
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
||||
import { Database } from "bun:sqlite";
|
||||
import { WebhookServer } from "../../src/server";
|
||||
import { getMessageText } from "../../src/http/webhook-handler";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Access private static methods for direct testing. */
|
||||
const server = WebhookServer as any;
|
||||
|
||||
function createTestRequest(body: unknown): Request {
|
||||
return new Request("http://localhost:3007", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
function createTestRequestRaw(body: string): Request {
|
||||
return new Request("http://localhost:3007", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getMessageText — 5 branches, tested only indirectly
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("getMessageText — direct edge cases", () => {
|
||||
test("returns empty for null / undefined", () => {
|
||||
expect(getMessageText(null)).toBe("");
|
||||
expect(getMessageText(undefined)).toBe("");
|
||||
});
|
||||
|
||||
test("returns empty for non-object input", () => {
|
||||
expect(getMessageText("a string")).toBe("");
|
||||
expect(getMessageText(42)).toBe("");
|
||||
});
|
||||
|
||||
test("extracts conversation text", () => {
|
||||
expect(getMessageText({ conversation: " hello world " })).toBe(
|
||||
"hello world",
|
||||
);
|
||||
});
|
||||
|
||||
test("falls back when conversation is empty string", () => {
|
||||
expect(
|
||||
getMessageText({
|
||||
conversation: "",
|
||||
extendedTextMessage: { text: "fallback" },
|
||||
}),
|
||||
).toBe("fallback");
|
||||
});
|
||||
|
||||
test("extracts extendedTextMessage.text", () => {
|
||||
expect(
|
||||
getMessageText({
|
||||
extendedTextMessage: { text: " extended text " },
|
||||
}),
|
||||
).toBe("extended text");
|
||||
});
|
||||
|
||||
test("extracts imageMessage.caption", () => {
|
||||
expect(
|
||||
getMessageText({
|
||||
imageMessage: { caption: " image caption " },
|
||||
}),
|
||||
).toBe("image caption");
|
||||
});
|
||||
|
||||
test("extracts videoMessage.caption", () => {
|
||||
expect(
|
||||
getMessageText({
|
||||
videoMessage: { caption: " video caption " },
|
||||
}),
|
||||
).toBe("video caption");
|
||||
});
|
||||
|
||||
test("returns empty when all fields are empty strings", () => {
|
||||
expect(
|
||||
getMessageText({
|
||||
conversation: "",
|
||||
extendedTextMessage: { text: "" },
|
||||
imageMessage: { caption: "" },
|
||||
}),
|
||||
).toBe("");
|
||||
});
|
||||
|
||||
test("returns empty when text is a non-string (edge case)", () => {
|
||||
expect(
|
||||
getMessageText({ conversation: 12345 }),
|
||||
).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// validateEnv — error paths never exercised
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("validateEnv — error paths", () => {
|
||||
let exitCode: number | null = null;
|
||||
let stderr: string[] = [];
|
||||
const realExit = process.exit;
|
||||
const realConsoleError = console.error;
|
||||
|
||||
beforeEach(() => {
|
||||
exitCode = null;
|
||||
stderr = [];
|
||||
process.exit = ((code?: number) => {
|
||||
exitCode = code ?? 1;
|
||||
throw new Error(`process.exit(${code})`);
|
||||
}) as any;
|
||||
console.error = (...args: any[]) => {
|
||||
stderr.push(args.map(String).join(" "));
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.exit = realExit;
|
||||
console.error = realConsoleError;
|
||||
});
|
||||
|
||||
test("exits when required env vars are missing", () => {
|
||||
// Remove a required var
|
||||
const saved = process.env.EVOLUTION_API_URL;
|
||||
delete (process.env as any).EVOLUTION_API_URL;
|
||||
|
||||
try {
|
||||
server.validateEnv();
|
||||
} catch {
|
||||
// Expected — process.exit throws
|
||||
}
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stderr.some((l) => l.includes("EVOLUTION_API_URL"))).toBe(true);
|
||||
|
||||
process.env.EVOLUTION_API_URL = saved;
|
||||
});
|
||||
|
||||
test("exits when CHATBOT_PHONE_NUMBER contains non-digits", () => {
|
||||
// Set all required vars so we reach the phone check
|
||||
const saved: Record<string, string | undefined> = {};
|
||||
for (const k of ["EVOLUTION_API_URL", "EVOLUTION_API_KEY", "EVOLUTION_API_INSTANCE", "WEBHOOK_URL"]) {
|
||||
saved[k] = process.env[k];
|
||||
process.env[k] = "set";
|
||||
}
|
||||
process.env.CHATBOT_PHONE_NUMBER = "55-1234-5678";
|
||||
|
||||
try {
|
||||
server.validateEnv();
|
||||
} catch {
|
||||
// Expected
|
||||
}
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stderr.some((l) => l.includes("digits"))).toBe(true);
|
||||
|
||||
for (const k of Object.keys(saved)) {
|
||||
process.env[k] = saved[k];
|
||||
}
|
||||
});
|
||||
|
||||
test("succeeds when all vars are valid", () => {
|
||||
// Ensure required vars are set
|
||||
const prev = {
|
||||
EVOLUTION_API_URL: process.env.EVOLUTION_API_URL,
|
||||
EVOLUTION_API_KEY: process.env.EVOLUTION_API_KEY,
|
||||
EVOLUTION_API_INSTANCE: process.env.EVOLUTION_API_INSTANCE,
|
||||
CHATBOT_PHONE_NUMBER: process.env.CHATBOT_PHONE_NUMBER,
|
||||
WEBHOOK_URL: process.env.WEBHOOK_URL,
|
||||
};
|
||||
process.env.EVOLUTION_API_URL = "http://localhost:8080";
|
||||
process.env.EVOLUTION_API_KEY = "k";
|
||||
process.env.EVOLUTION_API_INSTANCE = "i";
|
||||
process.env.CHATBOT_PHONE_NUMBER = "1234567890";
|
||||
process.env.WEBHOOK_URL = "http://localhost:3000";
|
||||
|
||||
try {
|
||||
server.validateEnv();
|
||||
// Should not throw
|
||||
expect(exitCode).toBeNull();
|
||||
} finally {
|
||||
Object.assign(process.env, prev);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// contacts.update / chats.update events — never tested
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("routeWebhookEvent — contacts.update / chats.update", () => {
|
||||
test("handles contacts.update event without crash", async () => {
|
||||
const res = await WebhookServer.handleRequest(
|
||||
createTestRequest({
|
||||
event: "contacts.update",
|
||||
instance: "test-instance",
|
||||
data: {
|
||||
contacts: [
|
||||
{ id: "1234567890@s.whatsapp.net", name: "Test User" },
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
test("handles chats.update event without crash", async () => {
|
||||
const res = await WebhookServer.handleRequest(
|
||||
createTestRequest({
|
||||
event: "chats.update",
|
||||
instance: "test-instance",
|
||||
data: {
|
||||
chats: [
|
||||
{ id: "group-id@g.us", name: "Updated Chat" },
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
test("handles contacts.update with empty data gracefully", async () => {
|
||||
const res = await WebhookServer.handleRequest(
|
||||
createTestRequest({
|
||||
event: "contacts.update",
|
||||
instance: "test-instance",
|
||||
data: {},
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error path: malformed JSON body
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("handleRequest — error paths", () => {
|
||||
test("returns 400 for malformed JSON body", async () => {
|
||||
const req = createTestRequestRaw("{not valid json");
|
||||
const res = await WebhookServer.handleRequest(req);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test("returns 400 for empty body", async () => {
|
||||
const req = new Request("http://localhost:3007", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
const res = await WebhookServer.handleRequest(req);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getBaseUrl — never tested
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("getBaseUrl", () => {
|
||||
test("uses x-forwarded-proto and x-forwarded-host when present", () => {
|
||||
const req = new Request("http://localhost:3007", {
|
||||
headers: {
|
||||
"x-forwarded-proto": "https",
|
||||
"x-forwarded-host": "example.com",
|
||||
},
|
||||
});
|
||||
expect(server.getBaseUrl(req)).toBe("https://example.com");
|
||||
});
|
||||
|
||||
test("falls back to host header when no forwarded headers", () => {
|
||||
const req = new Request("http://myhost.local:3007", {
|
||||
headers: { host: "myhost.local:3007" },
|
||||
});
|
||||
expect(server.getBaseUrl(req)).toBe("http://myhost.local:3007");
|
||||
});
|
||||
|
||||
test("uses http when no proto and no forwarded proto", () => {
|
||||
const req = new Request("http://10.0.0.1:3007", {
|
||||
headers: { host: "10.0.0.1:3007" },
|
||||
});
|
||||
expect(server.getBaseUrl(req)).toBe("http://10.0.0.1:3007");
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,149 @@
|
||||
/**
|
||||
* User validation integration tests.
|
||||
*
|
||||
* Verifies that WebhookServer.handleMessageUpsert correctly validates
|
||||
* and persists user records, handles DB errors gracefully, and
|
||||
* normalizes sender IDs before passing them to command services.
|
||||
*/
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { WebhookServer } from '../../src/server';
|
||||
import { initializeDatabase } from '../../src/db';
|
||||
import { SimulatedResponseQueue } from '../helpers/queue';
|
||||
import { createTestRequest, registerServerTestLifecycle } from '../helpers/server-test-harness';
|
||||
|
||||
const testDb = registerServerTestLifecycle();
|
||||
|
||||
// ── Tests ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('User validation in handleMessageUpsert', () => {
|
||||
test('should proceed with valid user', async () => {
|
||||
const payload = {
|
||||
event: 'messages.upsert',
|
||||
instance: 'test-instance',
|
||||
data: {
|
||||
key: {
|
||||
remoteJid: 'group-id@g.us',
|
||||
participant: '1234567890@s.whatsapp.net',
|
||||
},
|
||||
message: { conversation: 'tarea nueva Test' },
|
||||
},
|
||||
};
|
||||
const request = createTestRequest(payload);
|
||||
const response = await WebhookServer.handleRequest(request);
|
||||
expect(response.status).toBe(200);
|
||||
expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0);
|
||||
|
||||
const user = testDb.query('SELECT * FROM users WHERE id = ?').get('1234567890');
|
||||
expect(user).toBeDefined();
|
||||
expect((user as any).id).toBe('1234567890');
|
||||
});
|
||||
|
||||
test('should ignore message if user validation fails', async () => {
|
||||
const payload = {
|
||||
event: 'messages.upsert',
|
||||
instance: 'test-instance',
|
||||
data: {
|
||||
key: {
|
||||
remoteJid: 'group-id@g.us',
|
||||
participant: 'invalid!user@s.whatsapp.net',
|
||||
},
|
||||
message: { conversation: 'tarea nueva Test' },
|
||||
},
|
||||
};
|
||||
const request = createTestRequest(payload);
|
||||
const response = await WebhookServer.handleRequest(request);
|
||||
expect(response.status).toBe(200);
|
||||
expect(SimulatedResponseQueue.get().length).toBe(0);
|
||||
|
||||
const userCount = testDb.query('SELECT COUNT(*) as count FROM users').get();
|
||||
expect((userCount as any).count).toBe(0);
|
||||
});
|
||||
|
||||
test('should handle database errors during user validation', async () => {
|
||||
testDb.exec('DROP TABLE users');
|
||||
|
||||
const payload = {
|
||||
event: 'messages.upsert',
|
||||
instance: 'test-instance',
|
||||
data: {
|
||||
key: {
|
||||
remoteJid: 'group-id@g.us',
|
||||
participant: '1234567890@s.whatsapp.net',
|
||||
},
|
||||
message: { conversation: 'tarea nueva Test' },
|
||||
},
|
||||
};
|
||||
const request = createTestRequest(payload);
|
||||
const response = await WebhookServer.handleRequest(request);
|
||||
expect(response.status).toBe(200);
|
||||
expect(SimulatedResponseQueue.get().length).toBe(0);
|
||||
|
||||
// Reinitialize database for subsequent tests
|
||||
testDb.exec('DROP TABLE IF EXISTS schema_migrations');
|
||||
initializeDatabase(testDb);
|
||||
});
|
||||
|
||||
test('should integrate user validation completely in handleMessageUpsert with valid user', async () => {
|
||||
const payload = {
|
||||
event: 'messages.upsert',
|
||||
instance: 'test-instance',
|
||||
data: {
|
||||
key: {
|
||||
remoteJid: 'group-id@g.us',
|
||||
participant: '1234567890@s.whatsapp.net',
|
||||
},
|
||||
message: { conversation: 'tarea nueva Test' },
|
||||
},
|
||||
};
|
||||
const request = createTestRequest(payload);
|
||||
const response = await WebhookServer.handleRequest(request);
|
||||
expect(response.status).toBe(200);
|
||||
expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0);
|
||||
|
||||
const user = testDb.query('SELECT * FROM users WHERE id = ?').get('1234567890');
|
||||
expect(user).toBeDefined();
|
||||
expect((user as any).id).toBe('1234567890');
|
||||
expect((user as any).first_seen).toBeDefined();
|
||||
expect((user as any).last_seen).toBeDefined();
|
||||
});
|
||||
|
||||
test('should use normalized ID in command service', async () => {
|
||||
const payload = {
|
||||
event: 'messages.upsert',
|
||||
instance: 'test-instance',
|
||||
data: {
|
||||
key: {
|
||||
remoteJid: 'group-id@g.us',
|
||||
participant: '1234567890:12@s.whatsapp.net',
|
||||
},
|
||||
message: { conversation: 'tarea nueva Test' },
|
||||
},
|
||||
};
|
||||
|
||||
const request = createTestRequest(payload);
|
||||
await WebhookServer.handleRequest(request);
|
||||
expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('should handle end-to-end flow with valid user and command processing', async () => {
|
||||
const payload = {
|
||||
event: 'messages.upsert',
|
||||
instance: 'test-instance',
|
||||
data: {
|
||||
key: {
|
||||
remoteJid: 'group-id@g.us',
|
||||
participant: '1234567890@s.whatsapp.net',
|
||||
},
|
||||
message: { conversation: 'tarea nueva Test task' },
|
||||
},
|
||||
};
|
||||
|
||||
const request = createTestRequest(payload);
|
||||
const response = await WebhookServer.handleRequest(request);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const user = testDb.query('SELECT * FROM users WHERE id = ?').get('1234567890');
|
||||
expect(user).toBeDefined();
|
||||
expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue