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("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] }); }) );