feat(platform): multi-tenancy con credenciales por negocio y sincronización por id
El backend Postgres de `platform/` asumía un solo negocio con un solo token del
CRM. Este cambio lo convierte en una plataforma multi-cuenta y añade la
sincronización selectiva de las cinco entidades del encargo.
## Multi-tenancy
El `locationId` ya era por negocio, pero el token vivía en la variable de entorno
`CRM_TOKEN`, una sola para todo el proceso. Con dos negocios eso usaba el token
del primero contra la subcuenta del segundo: 401 en el mejor caso, escritura en
la subcuenta equivocada en el peor.
- `lib/crypto.ts` — AES-256-GCM para los tokens. Autenticado a propósito: una
fila manipulada hace que el descifrado FALLE, en vez de devolver basura que
acabaríamos mandando como credencial al CRM. La clave maestra vive en
`CRM_MASTER_KEY`, fuera de la base.
- `crm/ctx.ts` — `CrmCtx { businessId, locationId, token }` sustituye al
`locationId: string` suelto que viajaba por once firmas. Es un objeto y no dos
parámetros porque dos `string` seguidos se cruzan sin que el compilador diga
nada, y cruzarlos aquí manda el token de un cliente a la subcuenta de otro. Es
el único sitio donde el token existe descifrado, y solo en memoria.
- `crm/client.ts` — `CrmOptions.token` pasa a ser OBLIGATORIO, sin valor por
defecto: olvidarlo es ahora un error de compilación. El estrangulador pasa a
ser por token y aprende la cuota de las cabeceras `x-ratelimit-*`, que declaran
100 peticiones por 10 s — el cliente iba 6,5x por debajo con una estimación.
- Migración 003: credencial cifrada, calendario y la red de seguridad de mensajes
POR NEGOCIO. Como variable global decidía por todas las cuentas a la vez.
Lo único de la credencial que sale del servidor es la huella de 6 caracteres.
## Consola de superadministración
`/api/admin`, solo para el rol `admin`: alta de cuentas con su dueña en una
transacción, vínculo, desvínculo y suspensión. Las credenciales se COMPRUEBAN
contra el CRM antes de guardarse — un token sin validar traslada el fallo al
primer intento de sincronizar, lejos de donde se cometió. El error distingue
«token inválido» de «subcuenta inexistente» de «token de otra subcuenta».
Pantalla en `/admin/cuentas`, verificada en navegador: el campo del token es de
contraseña y viene vacío, porque no hay valor que traer.
## Sincronización por identificador
`POST /api/crm/sync/:entidad/:id` para contacto, conversación, mensaje, cita y
servicio. La dirección la decide la entidad: las tres primeras se TRAEN porque el
CRM es su dueño; las dos últimas se EMPUJAN, porque el calendario del CRM tiene
una sola cita en dos años y su catálogo de servicios está vacío.
- `crm/conversations.ts` — lectura por id de conversaciones y mensajes sueltos.
- `crm/syncConversations.ts` — el espejo persistido. Las tablas existían desde
002_crm.sql y nadie escribía en ellas: la bandeja consultaba el CRM en vivo.
- `crm/calendars.ts` — escritura de citas al calendario. `isoConDesplazamiento`
escribe la hora de pared del negocio con su desplazamiento; `toISOString()`
habría movido la hora que el CRM enseña en su interfaz.
- `crm/services.ts` — publicación de servicios al catálogo.
## Verificado contra la subcuenta real, no deducido
Las cinco entidades se ejercieron contra el CRM del cliente. Las escrituras van
en un ciclo crear → releer → borrar → confirmar borrado, con la limpieza en un
`finally`, y antes se comprobó que el borrado existe: preguntar si se puede
deshacer ANTES de escribir en el CRM de un cliente, no después. La subcuenta
quedó como estaba.
47 hallazgos medidos en `crm/HALLAZGOS.md`, y la referencia de endpoints en
`crm/API.md`, con la lista explícita de dónde la documentación oficial falla.
110 pruebas de plataforma en verde, typecheck limpio, build correcto. El backend
de demo de `server/` no se ha tocado y sigue con sus 43 pruebas.
## Deuda conocida, dicha sin rodeos
- La bandeja de mensajes todavía lee en vivo del CRM, no del espejo.
- La autenticación sigue siendo el id del usuario en texto plano, también para el
rol admin. Esta consola crea cuentas y guarda credenciales de clientes encima
de esa base: no debe quedar expuesta a internet hasta endurecerla.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
dcbf750c09
commit
6d67b23e55
@@ -0,0 +1,245 @@
|
||||
import { Router } from "express";
|
||||
import { pool, withTx } from "../db/pool.ts";
|
||||
import { adminOnly, err, h, type AuthedRequest } from "../lib/auth.ts";
|
||||
import { writeAudit } from "../lib/audit.ts";
|
||||
import { guardarCredencial, olvidarCredencial } from "../crm/ctx.ts";
|
||||
import { crmRequest, CrmError } from "../crm/client.ts";
|
||||
import { DEFAULT_WORKING_HOURS, uniqueSlugPg } from "../lib/businessDefaults.ts";
|
||||
|
||||
export const adminRouter = Router();
|
||||
|
||||
// Todo el router exige rol de plataforma. Se aplica una vez aquí y no ruta por
|
||||
// ruta: olvidarlo en una sola ruta abriría el alta de cuentas a cualquier dueña.
|
||||
adminRouter.use(adminOnly);
|
||||
|
||||
/**
|
||||
* Las cuentas de la plataforma, con el estado de su vínculo con Bucéfalo CRM.
|
||||
*
|
||||
* `token_cipher` NO se selecciona siquiera: lo único de la credencial que sale
|
||||
* del servidor es la huella de 6 caracteres.
|
||||
*/
|
||||
adminRouter.get(
|
||||
"/businesses",
|
||||
h(async (_req: AuthedRequest, res) => {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT b.id, b.name, b.slug, b.timezone, b.status, b.created_at,
|
||||
c.location_id, c.label AS crm_label, c.token_fingerprint,
|
||||
c.token_updated_at, c.pipeline_id, c.calendar_id,
|
||||
c.allow_real_sends, c.test_email,
|
||||
c.last_sync_at, c.last_sync_status,
|
||||
(SELECT count(*) FROM clients cl
|
||||
WHERE cl.business_id = b.id AND cl.deleted_at IS NULL)::int AS clientes,
|
||||
(SELECT count(*) FROM users u WHERE u.business_id = b.id)::int AS usuarios
|
||||
FROM businesses b
|
||||
LEFT JOIN crm_connections c ON c.business_id = b.id
|
||||
ORDER BY b.created_at DESC`
|
||||
);
|
||||
res.json({ businesses: rows });
|
||||
})
|
||||
);
|
||||
|
||||
/** Alta de cuenta: el negocio y su dueña, en la misma transacción. */
|
||||
adminRouter.post(
|
||||
"/businesses",
|
||||
h(async (req: AuthedRequest, res) => {
|
||||
const { name, timezone, owner_email, owner_name, owner_password, industry } = req.body ?? {};
|
||||
if (!name || !owner_email || !owner_name || !owner_password) {
|
||||
err(res, 400, "Faltan el nombre del negocio y los datos de la dueña");
|
||||
return;
|
||||
}
|
||||
|
||||
const email = String(owner_email).trim().toLowerCase();
|
||||
const { rows: ya } = await pool.query(`SELECT 1 FROM users WHERE email = $1`, [email]);
|
||||
if (ya.length) {
|
||||
err(res, 409, `Ya existe una persona con el correo ${email}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const creado = await withTx(async (tx) => {
|
||||
const slug = await uniqueSlugPg(tx, String(name));
|
||||
const { rows: bs } = await tx.query(
|
||||
`INSERT INTO businesses (name, industry, timezone, slug, working_hours)
|
||||
VALUES ($1, $2, $3, $4, $5::jsonb)
|
||||
RETURNING id, name, slug, timezone, status, created_at`,
|
||||
[
|
||||
String(name).trim(),
|
||||
industry || "Estética y Spa",
|
||||
timezone || "America/Mexico_City",
|
||||
slug,
|
||||
DEFAULT_WORKING_HOURS,
|
||||
]
|
||||
);
|
||||
const business = bs[0];
|
||||
|
||||
const { rows: us } = await tx.query(
|
||||
`INSERT INTO users (business_id, email, password, name, role)
|
||||
VALUES ($1, $2, $3, $4, 'owner')
|
||||
RETURNING id, email, name, role`,
|
||||
[business.id, email, owner_password, String(owner_name).trim()]
|
||||
);
|
||||
|
||||
await writeAudit(tx, {
|
||||
businessId: business.id,
|
||||
actorUserId: req.user!.id,
|
||||
entity: "businesses",
|
||||
entityId: business.id,
|
||||
action: "create",
|
||||
after: { name: business.name, slug: business.slug, owner_email: email },
|
||||
ip: req.ip ?? null,
|
||||
});
|
||||
|
||||
return { business, owner: us[0] };
|
||||
});
|
||||
|
||||
res.status(201).json(creado);
|
||||
} catch (e: any) {
|
||||
if (e?.code === "23505") {
|
||||
err(res, 409, "Ya existe una cuenta con ese nombre o ese correo");
|
||||
return;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Vincula la cuenta con su subcuenta de Bucéfalo CRM.
|
||||
*
|
||||
* Las credenciales se COMPRUEBAN antes de guardarlas. Un token que no se valida
|
||||
* traslada el fallo al primer intento de sincronizar, lejos de donde se cometió,
|
||||
* y con un mensaje que no dice cuál de las dos cosas está mal. La prueba correcta
|
||||
* es leer la propia subcuenta con ese token y comparar identidad contra identidad,
|
||||
* no dar por bueno un 200 genérico.
|
||||
*/
|
||||
adminRouter.put(
|
||||
"/businesses/:id/crm",
|
||||
h(async (req: AuthedRequest, res) => {
|
||||
const businessId = Number(req.params.id);
|
||||
const { location_id, token, label } = req.body ?? {};
|
||||
if (!Number.isFinite(businessId)) {
|
||||
err(res, 400, "Identificador de cuenta inválido");
|
||||
return;
|
||||
}
|
||||
if (!location_id || !token) {
|
||||
err(res, 400, "Hacen falta el identificador de la subcuenta y el token privado");
|
||||
return;
|
||||
}
|
||||
|
||||
const { rows } = await pool.query(`SELECT id, name FROM businesses WHERE id = $1`, [
|
||||
businessId,
|
||||
]);
|
||||
if (!rows[0]) {
|
||||
err(res, 404, "La cuenta no existe");
|
||||
return;
|
||||
}
|
||||
|
||||
let nombreSubcuenta: string | null = null;
|
||||
try {
|
||||
const loc = await crmRequest<any>("GET", `/locations/${location_id}`, {
|
||||
token: String(token),
|
||||
});
|
||||
const devuelto = loc?.location?.id;
|
||||
if (devuelto && devuelto !== location_id) {
|
||||
err(
|
||||
res,
|
||||
400,
|
||||
"El token pertenece a otra subcuenta distinta de la que indicaste"
|
||||
);
|
||||
return;
|
||||
}
|
||||
nombreSubcuenta = loc?.location?.name ?? null;
|
||||
} catch (e: any) {
|
||||
if (e instanceof CrmError && e.status === 401) {
|
||||
// MEDIDO: en esta API el 401 es ambiguo — token caducado, sin permiso, o
|
||||
// de otra subcuenta. El mensaje lo dice en vez de afirmar una sola causa.
|
||||
err(
|
||||
res,
|
||||
400,
|
||||
"Bucéfalo CRM rechazó el token: puede estar caducado, no tener permiso de lectura de la subcuenta, o pertenecer a otra"
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (e instanceof CrmError && e.status === 404) {
|
||||
err(res, 400, "Ese identificador de subcuenta no existe, o el token no da acceso a ella");
|
||||
return;
|
||||
}
|
||||
err(res, 502, `Bucéfalo CRM no respondió: ${e?.message ?? e}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await guardarCredencial(
|
||||
businessId,
|
||||
String(location_id),
|
||||
String(token),
|
||||
label || nombreSubcuenta || undefined
|
||||
);
|
||||
|
||||
await withTx((tx) =>
|
||||
writeAudit(tx, {
|
||||
businessId,
|
||||
actorUserId: req.user!.id,
|
||||
entity: "crm_connections",
|
||||
entityId: businessId,
|
||||
action: "link",
|
||||
// El token NO se audita, ni cifrado: el registro de auditoría se lee, se
|
||||
// exporta y se copia, y una credencial ahí dentro acaba donde no debe.
|
||||
after: { location_id, label: label || nombreSubcuenta },
|
||||
ip: req.ip ?? null,
|
||||
})
|
||||
);
|
||||
|
||||
res.json({ ok: true, location_id, label: label || nombreSubcuenta });
|
||||
})
|
||||
);
|
||||
|
||||
/** Desvincula. Borra la credencial y conserva todo lo ya sincronizado. */
|
||||
adminRouter.delete(
|
||||
"/businesses/:id/crm",
|
||||
h(async (req: AuthedRequest, res) => {
|
||||
const businessId = Number(req.params.id);
|
||||
if (!Number.isFinite(businessId)) {
|
||||
err(res, 400, "Identificador de cuenta inválido");
|
||||
return;
|
||||
}
|
||||
await olvidarCredencial(businessId);
|
||||
await withTx((tx) =>
|
||||
writeAudit(tx, {
|
||||
businessId,
|
||||
actorUserId: req.user!.id,
|
||||
entity: "crm_connections",
|
||||
entityId: businessId,
|
||||
action: "unlink",
|
||||
ip: req.ip ?? null,
|
||||
})
|
||||
);
|
||||
res.json({ ok: true });
|
||||
})
|
||||
);
|
||||
|
||||
/** Ajustes de la cuenta que pertenecen a la plataforma, no al negocio. */
|
||||
adminRouter.patch(
|
||||
"/businesses/:id",
|
||||
h(async (req: AuthedRequest, res) => {
|
||||
const businessId = Number(req.params.id);
|
||||
const { status, name, timezone } = req.body ?? {};
|
||||
if (status && !["active", "suspended"].includes(status)) {
|
||||
err(res, 400, "El estado solo puede ser «active» o «suspended»");
|
||||
return;
|
||||
}
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE businesses
|
||||
SET status = COALESCE($2, status),
|
||||
name = COALESCE($3, name),
|
||||
timezone = COALESCE($4, timezone)
|
||||
WHERE id = $1
|
||||
RETURNING id, name, slug, timezone, status`,
|
||||
[businessId, status ?? null, name ?? null, timezone ?? null]
|
||||
);
|
||||
if (!rows[0]) {
|
||||
err(res, 404, "La cuenta no existe");
|
||||
return;
|
||||
}
|
||||
res.json({ business: rows[0] });
|
||||
})
|
||||
);
|
||||
@@ -0,0 +1,290 @@
|
||||
import { Router } from "express";
|
||||
import { pool, withTx } from "../db/pool.ts";
|
||||
import { writeAudit } from "../lib/audit.ts";
|
||||
import { err, h, type AuthedRequest } from "../lib/auth.ts";
|
||||
import { encolar } from "../crm/outbox.ts";
|
||||
|
||||
export const appointmentsRouter = Router();
|
||||
|
||||
// `start_at` y `end_at` se serializan a ISO-Z sin milisegundos, que es el
|
||||
// formato que el frontend ya parsea. `to_char` sobre el valor convertido a UTC
|
||||
// evita depender de la zona del proceso de Node.
|
||||
const COLS = `id, business_id, client_id, employee_id, service_id,
|
||||
to_char(start_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS start_at,
|
||||
to_char(end_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS end_at,
|
||||
status, cancelled_by, cancel_reason, price, notes, source_channel, created_at`;
|
||||
|
||||
/** Traduce la violación de exclusión de Postgres a un 409 en español. */
|
||||
function isOverlap(e: any) {
|
||||
return e?.code === "23P01" && String(e?.constraint) === "appointments_no_overlap";
|
||||
}
|
||||
|
||||
const OCUPADO = "Ese horario ya está ocupado para esta especialista";
|
||||
|
||||
appointmentsRouter.get(
|
||||
"/",
|
||||
h(async (req: AuthedRequest, res) => {
|
||||
const { from, to, employee_id, client_id, status, limit } = req.query as Record<
|
||||
string,
|
||||
string | undefined
|
||||
>;
|
||||
const params: unknown[] = [req.user!.business_id];
|
||||
let sql = `SELECT ${COLS} FROM appointments WHERE business_id = $1`;
|
||||
if (from) {
|
||||
params.push(from);
|
||||
sql += ` AND start_at >= $${params.length}::timestamptz`;
|
||||
}
|
||||
if (to) {
|
||||
params.push(to);
|
||||
sql += ` AND start_at < $${params.length}::timestamptz`;
|
||||
}
|
||||
if (employee_id) {
|
||||
params.push(Number(employee_id));
|
||||
sql += ` AND employee_id = $${params.length}`;
|
||||
}
|
||||
// Sin este filtro, la ficha de una clienta enseñaba las citas de todas: el
|
||||
// cliente lo mandaba y el servidor lo ignoraba en silencio.
|
||||
if (client_id) {
|
||||
params.push(Number(client_id));
|
||||
sql += ` AND client_id = $${params.length}`;
|
||||
}
|
||||
if (status) {
|
||||
params.push(status);
|
||||
sql += ` AND status = $${params.length}`;
|
||||
}
|
||||
params.push(Math.min(Number(limit) || 500, 1000));
|
||||
sql += ` ORDER BY start_at DESC LIMIT $${params.length}`;
|
||||
|
||||
const { rows } = await pool.query(sql, params);
|
||||
res.json({ appointments: rows });
|
||||
})
|
||||
);
|
||||
|
||||
appointmentsRouter.post(
|
||||
"/",
|
||||
h(async (req: AuthedRequest, res) => {
|
||||
const { client_id, employee_id, service_id, start_at, notes, source_channel } =
|
||||
req.body ?? {};
|
||||
if (!client_id || !employee_id || !service_id || !start_at) {
|
||||
err(res, 400, "Faltan datos de la cita");
|
||||
return;
|
||||
}
|
||||
const bid = req.user!.business_id;
|
||||
|
||||
const svc = await pool.query(
|
||||
`SELECT duration_min, price FROM services
|
||||
WHERE id = $1 AND business_id = $2 AND active`,
|
||||
[service_id, bid]
|
||||
);
|
||||
if (!svc.rows[0]) {
|
||||
err(res, 404, "Servicio no encontrado");
|
||||
return;
|
||||
}
|
||||
|
||||
const emp = await pool.query(
|
||||
`SELECT 1 FROM employees WHERE id = $1 AND business_id = $2 AND active`,
|
||||
[employee_id, bid]
|
||||
);
|
||||
if (!emp.rows[0]) {
|
||||
err(res, 404, "Especialista no encontrada");
|
||||
return;
|
||||
}
|
||||
|
||||
const cli = await pool.query(
|
||||
`SELECT 1 FROM clients WHERE id = $1 AND business_id = $2 AND deleted_at IS NULL`,
|
||||
[client_id, bid]
|
||||
);
|
||||
if (!cli.rows[0]) {
|
||||
err(res, 404, "Clienta no encontrada");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const appointment = await withTx(async (c) => {
|
||||
const { rows } = await c.query(
|
||||
`INSERT INTO appointments
|
||||
(business_id, client_id, employee_id, service_id, start_at, end_at,
|
||||
price, notes, source_channel, created_by_user_id)
|
||||
VALUES ($1,$2,$3,$4,$5::timestamptz,
|
||||
$5::timestamptz + make_interval(mins => $6::int),
|
||||
$7,$8,$9,$10)
|
||||
RETURNING ${COLS}`,
|
||||
[
|
||||
bid,
|
||||
client_id,
|
||||
employee_id,
|
||||
service_id,
|
||||
start_at,
|
||||
svc.rows[0].duration_min,
|
||||
svc.rows[0].price,
|
||||
notes || null,
|
||||
source_channel || null,
|
||||
req.user!.id,
|
||||
]
|
||||
);
|
||||
await c.query(
|
||||
`INSERT INTO appointment_events (appointment_id, actor_user_id, action, to_status)
|
||||
VALUES ($1,$2,'created','scheduled')`,
|
||||
[rows[0].id, req.user!.id]
|
||||
);
|
||||
await writeAudit(c, {
|
||||
businessId: bid,
|
||||
actorUserId: req.user!.id,
|
||||
entity: "appointments",
|
||||
entityId: rows[0].id,
|
||||
action: "create",
|
||||
after: rows[0],
|
||||
ip: req.ip ?? null,
|
||||
});
|
||||
// Se encola en la MISMA transacción: si el proceso muere aquí, la cita
|
||||
// y su intención de sincronizar caen juntas o sobreviven juntas.
|
||||
await encolar(c, {
|
||||
businessId: bid!, entidad: "appointment", entidadId: rows[0].id,
|
||||
operacion: "create", payload: { status: "scheduled" }, secuencia: "create",
|
||||
});
|
||||
return rows[0];
|
||||
});
|
||||
res.status(201).json({ appointment });
|
||||
} catch (e) {
|
||||
if (isOverlap(e)) {
|
||||
err(res, 409, OCUPADO);
|
||||
return;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
appointmentsRouter.patch(
|
||||
"/:id",
|
||||
h(async (req: AuthedRequest, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const bid = req.user!.business_id;
|
||||
const { start_at, employee_id, notes } = req.body ?? {};
|
||||
|
||||
const cur = await pool.query(
|
||||
`SELECT ${COLS} FROM appointments WHERE id = $1 AND business_id = $2`,
|
||||
[id, bid]
|
||||
);
|
||||
if (!cur.rows[0]) {
|
||||
err(res, 404, "Cita no encontrada");
|
||||
return;
|
||||
}
|
||||
if (cur.rows[0].status === "cancelled") {
|
||||
err(res, 409, "Una cita cancelada no se puede modificar");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const appointment = await withTx(async (c) => {
|
||||
const { rows } = await c.query(
|
||||
`UPDATE appointments SET
|
||||
start_at = COALESCE($3::timestamptz, start_at),
|
||||
end_at = CASE WHEN $3::timestamptz IS NULL THEN end_at
|
||||
ELSE $3::timestamptz + (end_at - start_at) END,
|
||||
employee_id = COALESCE($4::bigint, employee_id),
|
||||
notes = COALESCE($5::text, notes),
|
||||
updated_at = now()
|
||||
WHERE id = $1 AND business_id = $2
|
||||
RETURNING ${COLS}`,
|
||||
[id, bid, start_at ?? null, employee_id ?? null, notes ?? null]
|
||||
);
|
||||
if (start_at || employee_id) {
|
||||
await c.query(
|
||||
`INSERT INTO appointment_events (appointment_id, actor_user_id, action, detail)
|
||||
VALUES ($1,$2,'rescheduled',$3::jsonb)`,
|
||||
[
|
||||
id,
|
||||
req.user!.id,
|
||||
JSON.stringify({
|
||||
from: {
|
||||
start_at: cur.rows[0].start_at,
|
||||
employee_id: cur.rows[0].employee_id,
|
||||
},
|
||||
to: { start_at: rows[0].start_at, employee_id: rows[0].employee_id },
|
||||
}),
|
||||
]
|
||||
);
|
||||
}
|
||||
await writeAudit(c, {
|
||||
businessId: bid,
|
||||
actorUserId: req.user!.id,
|
||||
entity: "appointments",
|
||||
entityId: id,
|
||||
action: "update",
|
||||
before: cur.rows[0],
|
||||
after: rows[0],
|
||||
ip: req.ip ?? null,
|
||||
});
|
||||
return rows[0];
|
||||
});
|
||||
res.json({ appointment });
|
||||
} catch (e) {
|
||||
if (isOverlap(e)) {
|
||||
err(res, 409, OCUPADO);
|
||||
return;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
appointmentsRouter.post(
|
||||
"/:id/cancel",
|
||||
h(async (req: AuthedRequest, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const bid = req.user!.business_id;
|
||||
const { cancelled_by, reason } = req.body ?? {};
|
||||
if (cancelled_by !== "client" && cancelled_by !== "business") {
|
||||
err(res, 400, "Indica quién canceló: la clienta o el spa");
|
||||
return;
|
||||
}
|
||||
|
||||
const cur = await pool.query(
|
||||
`SELECT ${COLS} FROM appointments WHERE id = $1 AND business_id = $2`,
|
||||
[id, bid]
|
||||
);
|
||||
if (!cur.rows[0]) {
|
||||
err(res, 404, "Cita no encontrada");
|
||||
return;
|
||||
}
|
||||
|
||||
const appointment = await withTx(async (c) => {
|
||||
const { rows } = await c.query(
|
||||
`UPDATE appointments
|
||||
SET status = 'cancelled', cancelled_by = $3, cancel_reason = $4,
|
||||
updated_at = now()
|
||||
WHERE id = $1 AND business_id = $2 RETURNING ${COLS}`,
|
||||
[id, bid, cancelled_by, reason || null]
|
||||
);
|
||||
await c.query(
|
||||
`INSERT INTO appointment_events
|
||||
(appointment_id, actor_user_id, action, from_status, to_status, detail)
|
||||
VALUES ($1,$2,'cancelled',$3,'cancelled',$4::jsonb)`,
|
||||
[
|
||||
id,
|
||||
req.user!.id,
|
||||
cur.rows[0].status,
|
||||
JSON.stringify({ cancelled_by, reason: reason || null }),
|
||||
]
|
||||
);
|
||||
await writeAudit(c, {
|
||||
businessId: bid,
|
||||
actorUserId: req.user!.id,
|
||||
entity: "appointments",
|
||||
entityId: id,
|
||||
action: "cancel",
|
||||
before: cur.rows[0],
|
||||
after: rows[0],
|
||||
ip: req.ip ?? null,
|
||||
});
|
||||
await encolar(c, {
|
||||
businessId: bid!, entidad: "appointment", entidadId: id,
|
||||
operacion: "status", payload: { status: "cancelled", cancelled_by },
|
||||
secuencia: "cancelled",
|
||||
});
|
||||
return rows[0];
|
||||
});
|
||||
res.json({ appointment });
|
||||
})
|
||||
);
|
||||
@@ -0,0 +1,123 @@
|
||||
import { Router } from "express";
|
||||
import { withTx, pool } from "../db/pool.ts";
|
||||
import { writeAudit } from "../lib/audit.ts";
|
||||
import { err, h, type AuthedRequest } from "../lib/auth.ts";
|
||||
import { encolar } from "../crm/outbox.ts";
|
||||
|
||||
export const attendanceRouter = Router({ mergeParams: true });
|
||||
|
||||
const APPT_COLS = `id, business_id, client_id, employee_id, service_id,
|
||||
to_char(start_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS start_at,
|
||||
to_char(end_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS end_at,
|
||||
status, cancelled_by, price, notes, created_at`;
|
||||
|
||||
const VALID_PAYMENT = new Set(["cash", "card", "transfer", "other"]);
|
||||
|
||||
/**
|
||||
* El toque de asistencia: un solo POST resuelve la cita.
|
||||
*
|
||||
* La visita se crea **solo** si la clienta vino. Una visita es un hecho con
|
||||
* dinero; el "no vino" es un estado de la cita. Fusionar los dos conceptos es
|
||||
* lo que produce registros que sirven para planear y para cerrar, y que
|
||||
* terminan sin cerrarse nunca.
|
||||
*/
|
||||
attendanceRouter.post(
|
||||
"/",
|
||||
h(async (req: AuthedRequest, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const bid = req.user!.business_id;
|
||||
const { attended, total_charged, payment_method } = req.body ?? {};
|
||||
|
||||
if (typeof attended !== "boolean") {
|
||||
err(res, 400, "Indica si la clienta vino o no vino");
|
||||
return;
|
||||
}
|
||||
if (payment_method != null && !VALID_PAYMENT.has(payment_method)) {
|
||||
err(res, 400, "Método de pago no válido");
|
||||
return;
|
||||
}
|
||||
|
||||
const cur = await pool.query(
|
||||
`SELECT ${APPT_COLS} FROM appointments WHERE id = $1 AND business_id = $2`,
|
||||
[id, bid]
|
||||
);
|
||||
const appt = cur.rows[0];
|
||||
if (!appt) {
|
||||
err(res, 404, "Cita no encontrada");
|
||||
return;
|
||||
}
|
||||
if (appt.status === "cancelled") {
|
||||
err(res, 409, "Esta cita está cancelada: no se le puede marcar asistencia");
|
||||
return;
|
||||
}
|
||||
if (appt.status === "completed" || appt.status === "no_show") {
|
||||
err(res, 409, "Esta cita ya se resolvió");
|
||||
return;
|
||||
}
|
||||
|
||||
// Una empleada solo resuelve sus propias citas; la administradora, cualquiera.
|
||||
if (req.user!.role === "employee" && req.user!.employee_id !== appt.employee_id) {
|
||||
err(res, 403, "Solo puedes marcar asistencia en tus propias citas");
|
||||
return;
|
||||
}
|
||||
|
||||
const out = await withTx(async (c) => {
|
||||
const nextStatus = attended ? "completed" : "no_show";
|
||||
const { rows } = await c.query(
|
||||
`UPDATE appointments SET status = $3, updated_at = now()
|
||||
WHERE id = $1 AND business_id = $2 RETURNING ${APPT_COLS}`,
|
||||
[id, bid, nextStatus]
|
||||
);
|
||||
|
||||
let visit = null;
|
||||
if (attended) {
|
||||
const v = await c.query(
|
||||
`INSERT INTO visits
|
||||
(business_id, appointment_id, client_id, employee_id, occurred_at,
|
||||
total_charged, payment_method, recorded_by_user_id)
|
||||
SELECT business_id, id, client_id, employee_id, start_at, $2, $3, $4
|
||||
FROM appointments WHERE id = $1
|
||||
RETURNING id, business_id, appointment_id, client_id, employee_id,
|
||||
to_char(occurred_at AT TIME ZONE 'UTC',
|
||||
'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS occurred_at,
|
||||
total_charged, payment_method, recorded_by_user_id, recorded_at`,
|
||||
[id, total_charged ?? null, payment_method ?? null, req.user!.id]
|
||||
);
|
||||
visit = v.rows[0];
|
||||
}
|
||||
|
||||
await c.query(
|
||||
`INSERT INTO appointment_events
|
||||
(appointment_id, actor_user_id, action, from_status, to_status)
|
||||
VALUES ($1,$2,$3,$4,$5)`,
|
||||
[id, req.user!.id, attended ? "attended" : "no_show", appt.status, nextStatus]
|
||||
);
|
||||
await writeAudit(c, {
|
||||
businessId: bid,
|
||||
actorUserId: req.user!.id,
|
||||
entity: "appointments",
|
||||
entityId: id,
|
||||
action: "attendance",
|
||||
before: { status: appt.status },
|
||||
after: { status: nextStatus, visit_id: visit?.id ?? null },
|
||||
ip: req.ip ?? null,
|
||||
});
|
||||
|
||||
// La proyección al CRM se ENCOLA en esta misma transacción. Si se hiciera
|
||||
// aquí una llamada de red, un CRM caído impediría marcar la asistencia —
|
||||
// justo el registro que el negocio no puede permitirse perder.
|
||||
await encolar(c, {
|
||||
businessId: bid!,
|
||||
entidad: "appointment",
|
||||
entidadId: id,
|
||||
operacion: "status",
|
||||
payload: { status: nextStatus },
|
||||
secuencia: nextStatus,
|
||||
});
|
||||
|
||||
return { appointment: rows[0], visit };
|
||||
});
|
||||
|
||||
res.json(out);
|
||||
})
|
||||
);
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Router } from "express";
|
||||
import { pool } from "../db/pool.ts";
|
||||
import { authRequired, err, h, type AuthedRequest } from "../lib/auth.ts";
|
||||
|
||||
export const authRouter = Router();
|
||||
|
||||
/**
|
||||
* Portado tal cual del backend de demo: el token es el id del usuario y la
|
||||
* contraseña se compara en claro. Ver la nota de deuda en lib/auth.ts — se
|
||||
* endurece entero (hash + sesión real + cliente + pruebas) o no se toca.
|
||||
*/
|
||||
authRouter.post(
|
||||
"/login",
|
||||
h(async (req, res) => {
|
||||
const { email, password } = req.body ?? {};
|
||||
if (!email || !password) {
|
||||
err(res, 400, "Faltan credenciales");
|
||||
return;
|
||||
}
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, business_id, email, name, role, employee_id, avatar_color, password
|
||||
FROM users WHERE email = $1`,
|
||||
[String(email).toLowerCase().trim()]
|
||||
);
|
||||
const user = rows[0];
|
||||
if (!user || user.password !== password) {
|
||||
err(res, 401, "Correo o contraseña incorrectos");
|
||||
return;
|
||||
}
|
||||
const { password: _pw, ...safe } = user;
|
||||
res.json({ token: String(user.id), user: safe });
|
||||
})
|
||||
);
|
||||
|
||||
authRouter.get("/me", authRequired, (req: AuthedRequest, res) => {
|
||||
res.json({ user: req.user });
|
||||
});
|
||||
|
||||
authRouter.get(
|
||||
"/demo-users",
|
||||
h(async (_req, res) => {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT email, name, role, avatar_color FROM users
|
||||
ORDER BY CASE role WHEN 'admin' THEN 0 WHEN 'owner' THEN 1 ELSE 2 END, name`
|
||||
);
|
||||
res.json({ users: rows });
|
||||
})
|
||||
);
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Router } from "express";
|
||||
import { pool } from "../db/pool.ts";
|
||||
import { err, h, type AuthedRequest } from "../lib/auth.ts";
|
||||
|
||||
export const businessRouter = Router();
|
||||
|
||||
businessRouter.get(
|
||||
"/",
|
||||
h(async (req: AuthedRequest, res) => {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, name, industry, currency, currency_symbol, phone, address,
|
||||
slug, timezone, working_hours, status
|
||||
FROM businesses WHERE id = $1`,
|
||||
[req.user!.business_id]
|
||||
);
|
||||
if (!rows[0]) {
|
||||
err(res, 404, "Negocio no encontrado");
|
||||
return;
|
||||
}
|
||||
res.json({ business: rows[0] });
|
||||
})
|
||||
);
|
||||
@@ -0,0 +1,170 @@
|
||||
import { Router } from "express";
|
||||
import { pool, withTx } from "../db/pool.ts";
|
||||
import { normalizePhone } from "../lib/phone.ts";
|
||||
import { writeAudit } from "../lib/audit.ts";
|
||||
import { err, h, type AuthedRequest } from "../lib/auth.ts";
|
||||
|
||||
export const clientsRouter = Router();
|
||||
|
||||
const COLS = `id, business_id, name, email, phone, phone_e164, contactable,
|
||||
birth_date, notes, tags, source_channel, created_at,
|
||||
crm_contact_id, crm_synced_at, crm_source, crm_tags,
|
||||
attr_session_source, attr_medium, attr_campaign, attr_campaign_id,
|
||||
attr_utm_source, attr_utm_medium, attr_utm_content, attr_ad_id,
|
||||
attr_referrer`;
|
||||
|
||||
clientsRouter.get(
|
||||
"/",
|
||||
h(async (req: AuthedRequest, res) => {
|
||||
const q = (req.query.q as string | undefined)?.trim();
|
||||
const bid = req.user!.business_id;
|
||||
|
||||
if (!q) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT ${COLS} FROM clients
|
||||
WHERE business_id = $1 AND deleted_at IS NULL
|
||||
ORDER BY name LIMIT 50`,
|
||||
[bid]
|
||||
);
|
||||
res.json({ clients: rows });
|
||||
return;
|
||||
}
|
||||
|
||||
// Se busca por tres vías a la vez: el nombre, el teléfono tal cual se guardó,
|
||||
// y el normalizado. La tercera es la que hace que teclear "5588887777"
|
||||
// encuentre a quien está guardada como "+52 55 8888 7777".
|
||||
const like = `%${q}%`;
|
||||
const e164 = normalizePhone(q);
|
||||
const { rows } = await pool.query(
|
||||
`SELECT ${COLS} FROM clients
|
||||
WHERE business_id = $1 AND deleted_at IS NULL
|
||||
AND (name ILIKE $2 OR phone ILIKE $2 OR email ILIKE $2
|
||||
OR ($3::text IS NOT NULL AND phone_e164 = $3))
|
||||
ORDER BY name LIMIT 50`,
|
||||
[bid, like, e164]
|
||||
);
|
||||
res.json({ clients: rows });
|
||||
})
|
||||
);
|
||||
|
||||
clientsRouter.get(
|
||||
"/:id",
|
||||
h(async (req: AuthedRequest, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const bid = req.user!.business_id;
|
||||
const { rows } = await pool.query(
|
||||
`SELECT ${COLS} FROM clients
|
||||
WHERE id = $1 AND business_id = $2 AND deleted_at IS NULL`,
|
||||
[id, bid]
|
||||
);
|
||||
if (!rows[0]) {
|
||||
err(res, 404, "Clienta no encontrada");
|
||||
return;
|
||||
}
|
||||
|
||||
// Las visitas salen de `visits`, no de las citas: una cita es una
|
||||
// intención y una visita es el hecho consumado. Contar citas como visitas
|
||||
// infla el historial con gente que no vino.
|
||||
const v = await pool.query(
|
||||
`SELECT count(*)::int AS visits,
|
||||
COALESCE(sum(total_charged),0)::float AS total_spent,
|
||||
max(occurred_at) AS last_visit
|
||||
FROM visits WHERE business_id = $1 AND client_id = $2`,
|
||||
[bid, id]
|
||||
);
|
||||
const ns = await pool.query(
|
||||
`SELECT count(*)::int AS c FROM appointments
|
||||
WHERE business_id = $1 AND client_id = $2 AND status = 'no_show'`,
|
||||
[bid, id]
|
||||
);
|
||||
const visits = v.rows[0].visits as number;
|
||||
const total = v.rows[0].total_spent as number;
|
||||
|
||||
res.json({
|
||||
client: {
|
||||
...rows[0],
|
||||
stats: {
|
||||
visits,
|
||||
total_spent: Math.round(total * 100) / 100,
|
||||
last_visit: v.rows[0].last_visit,
|
||||
avg_ticket: visits ? Math.round((total / visits) * 100) / 100 : 0,
|
||||
no_show_count: ns.rows[0].c,
|
||||
},
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
clientsRouter.post(
|
||||
"/",
|
||||
h(async (req: AuthedRequest, res) => {
|
||||
const { name, phone, email, notes, source_channel } = req.body ?? {};
|
||||
if (!name || typeof name !== "string" || !name.trim()) {
|
||||
err(res, 400, "El nombre es obligatorio");
|
||||
return;
|
||||
}
|
||||
const bid = req.user!.business_id;
|
||||
const e164 = normalizePhone(phone);
|
||||
|
||||
// Se pregunta antes de insertar para poder devolver la ficha existente. El
|
||||
// índice único sigue siendo la garantía real: entre esta consulta y el
|
||||
// INSERT cabe otra alta, y por eso abajo también se atrapa el 23505.
|
||||
if (e164) {
|
||||
const dup = await pool.query(
|
||||
`SELECT ${COLS} FROM clients
|
||||
WHERE business_id = $1 AND phone_e164 = $2 AND deleted_at IS NULL`,
|
||||
[bid, e164]
|
||||
);
|
||||
if (dup.rows[0]) {
|
||||
res.status(409).json({
|
||||
error: "Ya existe una clienta con ese teléfono",
|
||||
existing: dup.rows[0],
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const client = await withTx(async (c) => {
|
||||
const { rows } = await c.query(
|
||||
`INSERT INTO clients
|
||||
(business_id, name, email, phone, phone_e164, notes, source_channel)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING ${COLS}`,
|
||||
[
|
||||
bid,
|
||||
name.trim(),
|
||||
email || null,
|
||||
phone || null,
|
||||
e164,
|
||||
notes || null,
|
||||
source_channel || null,
|
||||
]
|
||||
);
|
||||
await writeAudit(c, {
|
||||
businessId: bid,
|
||||
actorUserId: req.user!.id,
|
||||
entity: "clients",
|
||||
entityId: rows[0].id,
|
||||
action: "create",
|
||||
after: rows[0],
|
||||
ip: req.ip ?? null,
|
||||
});
|
||||
return rows[0];
|
||||
});
|
||||
res.status(201).json({ client });
|
||||
} catch (e: any) {
|
||||
if (e.code === "23505") {
|
||||
const dup = await pool.query(
|
||||
`SELECT ${COLS} FROM clients WHERE business_id = $1 AND phone_e164 = $2`,
|
||||
[bid, e164]
|
||||
);
|
||||
res.status(409).json({
|
||||
error: "Ya existe una clienta con ese teléfono",
|
||||
existing: dup.rows[0] ?? null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
})
|
||||
);
|
||||
@@ -0,0 +1,213 @@
|
||||
import { Router } from "express";
|
||||
import { pool } from "../db/pool.ts";
|
||||
import { err, h, ownerOnly, type AuthedRequest } from "../lib/auth.ts";
|
||||
import { writeAudit } from "../lib/audit.ts";
|
||||
import { withTx } from "../db/pool.ts";
|
||||
import { obtenerConexion, autoconfigurar } from "../crm/connection.ts";
|
||||
import { ctxDe } from "../crm/ctx.ts";
|
||||
import { sincronizarContactos } from "../crm/syncContacts.ts";
|
||||
import { proyectarCita } from "../crm/syncAppointments.ts";
|
||||
import { despachar, estadoOutbox } from "../crm/outbox.ts";
|
||||
import { sincronizarPorId, esEntidad, ENTIDADES } from "../crm/syncOne.ts";
|
||||
import { sincronizarConversaciones } from "../crm/syncConversations.ts";
|
||||
|
||||
export const crmRouter = Router();
|
||||
|
||||
/** Estado de la conexión: lo que la pantalla de clientes necesita para el botón. */
|
||||
crmRouter.get(
|
||||
"/status",
|
||||
h(async (req: AuthedRequest, res) => {
|
||||
const bid = req.user!.business_id!;
|
||||
const conexion = await obtenerConexion(bid);
|
||||
if (!conexion) {
|
||||
res.json({ connected: false });
|
||||
return;
|
||||
}
|
||||
|
||||
const stats = await pool.query(
|
||||
`SELECT count(*)::int AS clientes,
|
||||
count(*) FILTER (WHERE crm_contact_id IS NOT NULL)::int AS sincronizados,
|
||||
count(*) FILTER (WHERE contactable)::int AS contactables,
|
||||
count(*) FILTER (WHERE attr_campaign IS NOT NULL)::int AS con_campana
|
||||
FROM clients WHERE business_id = $1 AND deleted_at IS NULL`,
|
||||
[bid]
|
||||
);
|
||||
const ultima = await pool.query(
|
||||
`SELECT id, kind, status, fetched, created, updated, started_at, finished_at, error
|
||||
FROM crm_sync_runs WHERE business_id = $1 ORDER BY id DESC LIMIT 1`,
|
||||
[bid]
|
||||
);
|
||||
|
||||
res.json({
|
||||
connected: true,
|
||||
location_id: conexion.location_id,
|
||||
pipeline_id: conexion.pipeline_id,
|
||||
allow_duplicate_opp: conexion.allow_duplicate_opp,
|
||||
last_sync_at: conexion.last_sync_at,
|
||||
last_sync_status: conexion.last_sync_status,
|
||||
stats: stats.rows[0],
|
||||
last_run: ultima.rows[0] ?? null,
|
||||
outbox: await estadoOutbox(bid),
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
/** Conecta o reconfigura la subcuenta. El token vive en el entorno, no en el body. */
|
||||
crmRouter.post(
|
||||
"/connect",
|
||||
ownerOnly,
|
||||
h(async (req: AuthedRequest, res) => {
|
||||
const bid = req.user!.business_id!;
|
||||
// Las credenciales las pone la administración de la plataforma, no el
|
||||
// negocio: son de la subcuenta del cliente y no deben viajar por aquí.
|
||||
// Esta ruta solo redetecta pipeline y etapas de la subcuenta ya vinculada.
|
||||
const ctx = await ctxDe(bid); // lanza 409 si no está vinculado
|
||||
const c = await autoconfigurar(ctx);
|
||||
await withTx((tx) =>
|
||||
writeAudit(tx, {
|
||||
businessId: bid,
|
||||
actorUserId: req.user!.id,
|
||||
entity: "crm_connections",
|
||||
entityId: c.id,
|
||||
action: "connect",
|
||||
after: { location_id: c.location_id, pipeline_id: c.pipeline_id },
|
||||
ip: req.ip ?? null,
|
||||
})
|
||||
);
|
||||
res.json({ connection: c });
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* El botón de sincronizar contactos.
|
||||
*
|
||||
* Es una corrida en primer plano y no una tarea de fondo a propósito: 3 200
|
||||
* contactos tardan ~22 s y quien pulsa el botón quiere ver el resultado. Si el
|
||||
* volumen crece hasta molestar, se mueve a la bandeja; hoy sería complejidad
|
||||
* sin problema que resolver.
|
||||
*/
|
||||
crmRouter.post(
|
||||
"/sync/contacts",
|
||||
h(async (req: AuthedRequest, res) => {
|
||||
const bid = req.user!.business_id!;
|
||||
try {
|
||||
const r = await sincronizarContactos(bid, {
|
||||
userId: req.user!.id,
|
||||
maxPaginas: Number(req.body?.max_paginas) || 60,
|
||||
});
|
||||
res.json(r);
|
||||
} catch (e: any) {
|
||||
if (e?.status) {
|
||||
err(res, e.status, e.message);
|
||||
return;
|
||||
}
|
||||
err(res, 502, `El CRM no respondió como se esperaba: ${e.message}`);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
/** Empuja una cita concreta al CRM como oportunidad. */
|
||||
crmRouter.post(
|
||||
"/sync/appointment/:id",
|
||||
h(async (req: AuthedRequest, res) => {
|
||||
const bid = req.user!.business_id!;
|
||||
try {
|
||||
const r = await proyectarCita(bid, Number(req.params.id));
|
||||
res.json(r);
|
||||
} catch (e: any) {
|
||||
if (e?.status) {
|
||||
err(res, e.status, e.message);
|
||||
return;
|
||||
}
|
||||
err(res, 502, `El CRM no respondió como se esperaba: ${e.message}`);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
/** Vacía la bandeja de salida. */
|
||||
crmRouter.post(
|
||||
"/outbox/flush",
|
||||
h(async (req: AuthedRequest, res) => {
|
||||
const bid = req.user!.business_id!;
|
||||
const r = await despachar(bid, Number(req.body?.limite) || 25);
|
||||
res.json(r);
|
||||
})
|
||||
);
|
||||
|
||||
/** Lo que no se pudo sincronizar, para que alguien pueda mirarlo. */
|
||||
crmRouter.get(
|
||||
"/outbox",
|
||||
h(async (req: AuthedRequest, res) => {
|
||||
const bid = req.user!.business_id!;
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, entity, entity_id, operation, status, attempts, last_error,
|
||||
crm_id, created_at, sent_at
|
||||
FROM crm_outbox
|
||||
WHERE business_id = $1
|
||||
ORDER BY CASE status WHEN 'indeterminado' THEN 0 WHEN 'fallido' THEN 1
|
||||
WHEN 'pendiente' THEN 2 ELSE 3 END, id DESC
|
||||
LIMIT 100`,
|
||||
[bid]
|
||||
);
|
||||
res.json({ items: rows, resumen: await estadoOutbox(bid) });
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Espejo de las conversaciones recientes con sus mensajes.
|
||||
*
|
||||
* Va declarada ANTES que `/sync/:entidad/:id` no por ambigüedad —tienen distinto
|
||||
* número de segmentos— sino para que el orden del archivo diga cuál es la ruta
|
||||
* concreta y cuál la genérica.
|
||||
*/
|
||||
crmRouter.post(
|
||||
"/sync/conversations",
|
||||
h(async (req: AuthedRequest, res) => {
|
||||
const bid = req.user!.business_id!;
|
||||
try {
|
||||
res.json(
|
||||
await sincronizarConversaciones(bid, {
|
||||
limit: Math.min(Number(req.body?.limite) || 50, 200),
|
||||
userId: req.user!.id,
|
||||
})
|
||||
);
|
||||
} catch (e: any) {
|
||||
if (e?.status) {
|
||||
err(res, e.status, e.error ?? e.message);
|
||||
return;
|
||||
}
|
||||
err(res, 502, `Bucéfalo CRM no respondió como se esperaba: ${e.message}`);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Sincroniza UNA entidad por su identificador.
|
||||
*
|
||||
* Es el punto de entrada único que pedía el encargo. La dirección la decide la
|
||||
* entidad, no quien llama: contactos, conversaciones y mensajes se traen del
|
||||
* CRM; citas y servicios se empujan hacia él. Ver `crm/syncOne.ts`.
|
||||
*/
|
||||
crmRouter.post(
|
||||
"/sync/:entidad/:id",
|
||||
h(async (req: AuthedRequest, res) => {
|
||||
const { entidad, id } = req.params;
|
||||
if (!esEntidad(entidad)) {
|
||||
err(res, 400, `Entidad no reconocida. Las válidas son: ${ENTIDADES.join(", ")}`);
|
||||
return;
|
||||
}
|
||||
if (!id || id.length > 64) {
|
||||
err(res, 400, "Identificador ausente o demasiado largo");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
res.json(await sincronizarPorId(req.user!.business_id!, entidad, id));
|
||||
} catch (e: any) {
|
||||
if (e?.status) {
|
||||
err(res, e.status, e.error ?? e.message);
|
||||
return;
|
||||
}
|
||||
err(res, 502, `Bucéfalo CRM no respondió como se esperaba: ${e.message}`);
|
||||
}
|
||||
})
|
||||
);
|
||||
@@ -0,0 +1,140 @@
|
||||
import { Router } from "express";
|
||||
import { pool, withTx } from "../db/pool.ts";
|
||||
import { writeAudit } from "../lib/audit.ts";
|
||||
import { err, h, type AuthedRequest } from "../lib/auth.ts";
|
||||
import { bizDayBoundsIsoFor, bizTodayISO, DEFAULT_TZ } from "../../server/lib/time.ts";
|
||||
|
||||
export const dayCloseRouter = Router();
|
||||
|
||||
const APPT_COLS = `a.id, a.client_id, a.employee_id, a.service_id,
|
||||
to_char(a.start_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS start_at,
|
||||
to_char(a.end_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS end_at,
|
||||
a.status, a.price, c.name AS client_name, e.name AS employee_name, s.name AS service_name`;
|
||||
|
||||
// `bizDayBoundsIsoFor` devuelve un fin INCLUSIVO (23:59:59 hora local), así que
|
||||
// la comparación es `<=`. Con `<` se perdería la última cita del día.
|
||||
const IN_DAY = `a.start_at >= $2::timestamptz AND a.start_at <= $3::timestamptz`;
|
||||
|
||||
const UNRESOLVED_SQL = `
|
||||
SELECT ${APPT_COLS} FROM appointments a
|
||||
JOIN clients c ON c.id = a.client_id
|
||||
JOIN employees e ON e.id = a.employee_id
|
||||
JOIN services s ON s.id = a.service_id
|
||||
WHERE a.business_id = $1 AND ${IN_DAY} AND a.status = 'scheduled'
|
||||
ORDER BY a.start_at`;
|
||||
|
||||
const COUNTS_SQL = `
|
||||
SELECT
|
||||
count(*) FILTER (WHERE status = 'completed')::int AS attended,
|
||||
count(*) FILTER (WHERE status = 'no_show')::int AS no_show,
|
||||
count(*) FILTER (WHERE status = 'cancelled')::int AS cancelled
|
||||
FROM appointments
|
||||
WHERE business_id = $1
|
||||
AND start_at >= $2::timestamptz AND start_at <= $3::timestamptz`;
|
||||
|
||||
async function bizTz(businessId: number): Promise<string> {
|
||||
const { rows } = await pool.query(`SELECT timezone FROM businesses WHERE id = $1`, [
|
||||
businessId,
|
||||
]);
|
||||
return rows[0]?.timezone || DEFAULT_TZ;
|
||||
}
|
||||
|
||||
dayCloseRouter.get(
|
||||
"/",
|
||||
h(async (req: AuthedRequest, res) => {
|
||||
const bid = req.user!.business_id!;
|
||||
const tz = await bizTz(bid);
|
||||
const date = (req.query.date as string | undefined) || bizTodayISO(tz);
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||
err(res, 400, "Fecha no válida");
|
||||
return;
|
||||
}
|
||||
const { start, end } = bizDayBoundsIsoFor(tz, date);
|
||||
|
||||
const unresolved = await pool.query(UNRESOLVED_SQL, [bid, start, end]);
|
||||
const counts = await pool.query(COUNTS_SQL, [bid, start, end]);
|
||||
const closure = await pool.query(
|
||||
`SELECT closed_at FROM day_closures WHERE business_id = $1 AND business_date = $2::date`,
|
||||
[bid, date]
|
||||
);
|
||||
|
||||
res.json({
|
||||
date,
|
||||
closed_at: closure.rows[0]?.closed_at ?? null,
|
||||
unresolved: unresolved.rows,
|
||||
attended: counts.rows[0].attended,
|
||||
no_show: counts.rows[0].no_show,
|
||||
cancelled: counts.rows[0].cancelled,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
dayCloseRouter.post(
|
||||
"/",
|
||||
h(async (req: AuthedRequest, res) => {
|
||||
const bid = req.user!.business_id!;
|
||||
const tz = await bizTz(bid);
|
||||
const date = (req.body?.date as string | undefined) || bizTodayISO(tz);
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||
err(res, 400, "Fecha no válida");
|
||||
return;
|
||||
}
|
||||
|
||||
const { start, end } = bizDayBoundsIsoFor(tz, date);
|
||||
|
||||
const ya = await pool.query(
|
||||
`SELECT 1 FROM day_closures WHERE business_id = $1 AND business_date = $2::date`,
|
||||
[bid, date]
|
||||
);
|
||||
if (ya.rows[0]) {
|
||||
err(res, 409, "Ese día ya está cerrado");
|
||||
return;
|
||||
}
|
||||
|
||||
// La regla que sostiene todo el proyecto: no se puede cerrar el día dejando
|
||||
// citas sin desenlace. Es lo que convierte el registro en el camino más
|
||||
// corto para trabajar, en vez de una tarea añadida al final.
|
||||
const pend = await pool.query(UNRESOLVED_SQL, [bid, start, end]);
|
||||
if (pend.rows.length) {
|
||||
res.status(409).json({
|
||||
error: `Quedan ${pend.rows.length} cita(s) sin resolver: marca si vinieron o no antes de cerrar`,
|
||||
unresolved: pend.rows,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const closure = await withTx(async (c) => {
|
||||
const counts = await c.query(COUNTS_SQL, [bid, start, end]);
|
||||
const { rows } = await c.query(
|
||||
`INSERT INTO day_closures
|
||||
(business_id, business_date, closed_by_user_id,
|
||||
attended_count, no_show_count, cancelled_count)
|
||||
VALUES ($1,$2::date,$3,$4,$5,$6)
|
||||
RETURNING id, business_id,
|
||||
to_char(business_date, 'YYYY-MM-DD') AS business_date,
|
||||
closed_by_user_id, closed_at,
|
||||
attended_count, no_show_count, cancelled_count`,
|
||||
[
|
||||
bid,
|
||||
date,
|
||||
req.user!.id,
|
||||
counts.rows[0].attended,
|
||||
counts.rows[0].no_show,
|
||||
counts.rows[0].cancelled,
|
||||
]
|
||||
);
|
||||
await writeAudit(c, {
|
||||
businessId: bid,
|
||||
actorUserId: req.user!.id,
|
||||
entity: "day_closures",
|
||||
entityId: rows[0].id,
|
||||
action: "close",
|
||||
after: rows[0],
|
||||
ip: req.ip ?? null,
|
||||
});
|
||||
return rows[0];
|
||||
});
|
||||
|
||||
res.json({ closure });
|
||||
})
|
||||
);
|
||||
@@ -0,0 +1,203 @@
|
||||
import { Router } from "express";
|
||||
import { pool, withTx } from "../db/pool.ts";
|
||||
import { err, h, type AuthedRequest } from "../lib/auth.ts";
|
||||
import { writeAudit } from "../lib/audit.ts";
|
||||
import { obtenerConexion } from "../crm/connection.ts";
|
||||
import { ctxDe } from "../crm/ctx.ts";
|
||||
import { buscarConversaciones, mensajesDeConversacion } from "../crm/conversations.ts";
|
||||
import { enviarCorreo } from "../crm/messages.ts";
|
||||
import { loadEnv } from "../lib/env.ts";
|
||||
|
||||
export const messagesRouter = Router();
|
||||
|
||||
/**
|
||||
* MODO PRUEBA — restricción deliberada del MVP.
|
||||
*
|
||||
* Mientras `CRM_TEST_EMAIL` esté definido, **el servidor solo envía a esa
|
||||
* dirección**, sin importar a quién apunte la interfaz. La subcuenta es la de un
|
||||
* cliente real con 3 200 contactos: un bucle mal escrito o un clic de más
|
||||
* escribiría a personas de verdad, y eso no se arregla pidiendo perdón.
|
||||
*
|
||||
* Se quita definiendo `CRM_ALLOW_REAL_SENDS=1`, y esa es una decisión del dueño
|
||||
* del proyecto, no un descuido de configuración.
|
||||
*/
|
||||
function destinoPermitido(deseado: string): { to: string; forzado: boolean } {
|
||||
loadEnv();
|
||||
const prueba = process.env.CRM_TEST_EMAIL;
|
||||
const libre = process.env.CRM_ALLOW_REAL_SENDS === "1";
|
||||
if (prueba && !libre) {
|
||||
return { to: prueba, forzado: prueba.toLowerCase() !== deseado.toLowerCase() };
|
||||
}
|
||||
return { to: deseado, forzado: false };
|
||||
}
|
||||
|
||||
/** Bandeja: conversaciones del CRM, con la clienta local enlazada si se conoce. */
|
||||
messagesRouter.get(
|
||||
"/",
|
||||
h(async (req: AuthedRequest, res) => {
|
||||
const bid = req.user!.business_id!;
|
||||
const conexion = await obtenerConexion(bid);
|
||||
if (!conexion) {
|
||||
err(res, 409, "Este negocio no tiene conexión con Bucéfalo CRM");
|
||||
return;
|
||||
}
|
||||
|
||||
const ctx = await ctxDe(bid);
|
||||
const { conversations, total } = await buscarConversaciones(ctx, {
|
||||
limit: Number(req.query.limit) || 20,
|
||||
});
|
||||
|
||||
// Se enlazan con las clientas locales por el id del CRM para poder abrir su
|
||||
// ficha desde la bandeja: es el "al lado" que hace útil esta pantalla.
|
||||
const ids = conversations.map((c) => c.contactId).filter(Boolean) as string[];
|
||||
const locales = ids.length
|
||||
? await pool.query(
|
||||
`SELECT id, name, crm_contact_id, phone_e164, contactable
|
||||
FROM clients WHERE business_id = $1 AND crm_contact_id = ANY($2::text[])`,
|
||||
[bid, ids]
|
||||
)
|
||||
: { rows: [] as any[] };
|
||||
const porCrmId = new Map(locales.rows.map((r: any) => [r.crm_contact_id, r]));
|
||||
|
||||
res.json({
|
||||
total,
|
||||
conversations: conversations.map((c) => ({
|
||||
crm_conversation_id: c.id,
|
||||
crm_contact_id: c.contactId ?? null,
|
||||
contact_name: c.fullName || c.contactName || "Sin nombre",
|
||||
last_message_body: c.lastMessageBody ?? null,
|
||||
last_message_type: c.lastMessageType ?? null,
|
||||
last_message_at: c.lastMessageDate ?? null,
|
||||
unread_count: c.unreadCount ?? 0,
|
||||
client: porCrmId.get(c.contactId ?? "") ?? null,
|
||||
})),
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
/** Los mensajes de un hilo. Solo lectura: el CRM es el dueño del histórico. */
|
||||
messagesRouter.get(
|
||||
"/:conversationId",
|
||||
h(async (req: AuthedRequest, res) => {
|
||||
const conexion = await obtenerConexion(req.user!.business_id!);
|
||||
if (!conexion) {
|
||||
err(res, 409, "Este negocio no tiene conexión con Bucéfalo CRM");
|
||||
return;
|
||||
}
|
||||
const ctx = await ctxDe(req.user!.business_id!);
|
||||
const { mensajes, hayMas } = await mensajesDeConversacion(ctx, req.params.conversationId, {
|
||||
limit: 50,
|
||||
});
|
||||
res.json({
|
||||
hay_mas: hayMas,
|
||||
messages: mensajes.map((m) => ({
|
||||
id: m.id,
|
||||
body: m.body ?? null,
|
||||
direction: m.direction ?? null,
|
||||
channel: m.messageType ?? null,
|
||||
status: m.status ?? null,
|
||||
sent_at: m.dateAdded ?? null,
|
||||
})),
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Responder por correo.
|
||||
*
|
||||
* WhatsApp y SMS no están conectados en esta subcuenta: el correo es el único
|
||||
* canal ejercible hoy, y la respuesta lo dice explícitamente para que la
|
||||
* interfaz no prometa lo que no puede cumplir.
|
||||
*/
|
||||
messagesRouter.post(
|
||||
"/send",
|
||||
h(async (req: AuthedRequest, res) => {
|
||||
const bid = req.user!.business_id!;
|
||||
const { client_id, crm_contact_id, subject, body } = req.body ?? {};
|
||||
if (!subject || !body) {
|
||||
err(res, 400, "El asunto y el mensaje son obligatorios");
|
||||
return;
|
||||
}
|
||||
|
||||
const conexion = await obtenerConexion(bid);
|
||||
if (!conexion) {
|
||||
err(res, 409, "Este negocio no tiene conexión con Bucéfalo CRM");
|
||||
return;
|
||||
}
|
||||
|
||||
let contactId: string | null = crm_contact_id ?? null;
|
||||
let correoDestino: string | null = null;
|
||||
let clienteLocal: any = null;
|
||||
|
||||
if (client_id) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, name, email, crm_contact_id FROM clients
|
||||
WHERE id = $1 AND business_id = $2 AND deleted_at IS NULL`,
|
||||
[Number(client_id), bid]
|
||||
);
|
||||
clienteLocal = rows[0] ?? null;
|
||||
if (!clienteLocal) {
|
||||
err(res, 404, "Clienta no encontrada");
|
||||
return;
|
||||
}
|
||||
contactId = contactId ?? clienteLocal.crm_contact_id;
|
||||
correoDestino = clienteLocal.email;
|
||||
}
|
||||
|
||||
if (!contactId) {
|
||||
err(res, 409, "Esta clienta todavía no está sincronizada con el CRM");
|
||||
return;
|
||||
}
|
||||
|
||||
const { to, forzado } = destinoPermitido(correoDestino || "");
|
||||
if (!to) {
|
||||
err(res, 409, "No hay dirección de correo a la que escribir");
|
||||
return;
|
||||
}
|
||||
|
||||
const ctx = await ctxDe(bid);
|
||||
const r = await enviarCorreo(ctx, {
|
||||
contactId,
|
||||
emailTo: to,
|
||||
subject: String(subject).slice(0, 200),
|
||||
html: `<div style="font-family:system-ui,sans-serif;font-size:15px;line-height:1.6">${String(
|
||||
body
|
||||
)
|
||||
.split("\n")
|
||||
.map((l) => `<p>${escaparHtml(l)}</p>`)
|
||||
.join("")}</div>`,
|
||||
});
|
||||
|
||||
await withTx((tx) =>
|
||||
writeAudit(tx, {
|
||||
businessId: bid,
|
||||
actorUserId: req.user!.id,
|
||||
entity: "messages",
|
||||
entityId: clienteLocal?.id ?? null,
|
||||
action: "send_email",
|
||||
after: { to, forzado, crm: r },
|
||||
ip: req.ip ?? null,
|
||||
})
|
||||
);
|
||||
|
||||
res.json({
|
||||
// El CRM responde "Email queued successfully": es acuse de ENCOLADO, no
|
||||
// de entrega. La interfaz debe decir "en camino", nunca "entregado".
|
||||
queued: true,
|
||||
crm: r,
|
||||
sent_to: to,
|
||||
redirigido: forzado,
|
||||
aviso: forzado
|
||||
? `Modo prueba: el mensaje se envió a ${to}, no a la clienta.`
|
||||
: null,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
function escaparHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
Reference in New Issue
Block a user