fase 3.5: licenciamiento por plan (clave firmada, planes basico/profesional/enterprise/custom, modulos incluidos y limites de usuarios/sucursales/citas/almacenamiento, modo aviso o bloqueo, panel de licencia) + API keys gestionables con alcances y revocacion (arregla la API publica que estaba rota) + webhooks salientes firmados con outbox, reintentos y bitacora; herramientas de vendedor y docs

This commit is contained in:
2026-10-11 19:53:44 +00:00
parent ab9d84cb67
commit 3ed0d327d2
30 changed files with 3705 additions and 89 deletions

View File

@@ -1076,6 +1076,138 @@ export interface ClinicSettings {
recursos: RecursosSettings;
compras: ComprasSettings;
comisiones: ComisionesSettings;
licencia: LicenciaSettings;
}
// ---- Licencia por plan (bloque `licencia` de clinic-settings) ----
export interface LicenciaLimites {
usuarios_max: number;
sucursales_max: number;
citas_mes_max: number;
almacenamiento_mb: number;
}
export interface LicenciaSettings {
plan: '' | 'basico' | 'profesional' | 'enterprise' | 'custom';
modo: 'aviso' | 'bloqueo';
cliente: string;
vence: string;
clave: string;
modulos: string[];
limites: LicenciaLimites;
}
export const LICENCIA_LIMITES_DEFAULT: LicenciaLimites = {
usuarios_max: 0, sucursales_max: 0, citas_mes_max: 0, almacenamiento_mb: 0,
};
export const LICENCIA_DEFAULT: LicenciaSettings = {
plan: '', modo: 'aviso', cliente: '', vence: '', clave: '', modulos: [],
limites: { ...LICENCIA_LIMITES_DEFAULT },
};
/** True si la licencia está aplicada y en modo bloqueo. */
export const licenciaBloqueo = (s?: Partial<LicenciaSettings> | null): boolean =>
Boolean(s && s.clave && s.modo === 'bloqueo');
/** True si el módulo gateable está incluido (sin licencia/bloqueo = sí). */
export const licenciaModuloIncluido = (s: Partial<LicenciaSettings> | null | undefined, modulo: string): boolean => {
if (!licenciaBloqueo(s)) return true;
const modulos = s?.modulos || [];
if (modulos.length === 0) return true;
return modulos.includes(modulo);
};
export interface LicenciaEstado {
plan: string;
plan_label: string;
modo: 'aviso' | 'bloqueo';
cliente: string;
vence: string;
dias_restantes: number | null;
aplicada: boolean;
bloqueo_activo: boolean;
aviso_vence: boolean;
}
export interface LicenciaLimiteFila {
clave: string;
label: string;
uso: number;
limite: number;
porcentaje: number;
estado: 'ok' | 'cerca' | 'excedido' | 'sin_limite';
}
export interface LicenciaModuloFila {
clave: string;
label: string;
base: boolean;
incluido: boolean;
}
export interface LicenciaEstadoResponse {
status: string;
licencia: LicenciaEstado;
limites: LicenciaLimiteFila[];
modulos: LicenciaModuloFila[];
planes: { clave: string; label: string; modulos: string[] }[];
aviso: string | null;
message?: string;
}
export interface ApiKey {
id: number;
name: string;
prefijo: string;
activo: boolean;
scopes: string[];
scopes_label: string;
vence: string;
ultimo_uso: string;
usos: number;
notas: string;
creado_por_login: string;
revocada_at: string;
revocada_por: string;
estado: 'activa' | 'vencida' | 'revocada';
}
export interface Webhook {
id: number;
name: string;
url: string;
eventos: string[];
eventos_label: string;
activo: boolean;
tiene_secreto: boolean;
headers: Record<string, string>;
notas: string;
creado_por_login: string;
ultima_entrega_at: string;
fallos_consecutivos: number;
}
export interface WebhookEvento {
id: number;
webhook_id: number;
webhook: string;
evento: string;
evento_label: string;
estado: 'pendiente' | 'enviado' | 'fallido' | 'descartado';
estado_label: string;
intentos: number;
proximo_intento: string;
respuesta_codigo: number;
respuesta_texto: string;
enviado_at: string;
creado_at: string;
}
export interface WebhookEventoCatalogo {
clave: string;
label: string;
ejemplo: Record<string, unknown>;
}
// ---- Reservas en línea (portal público de auto-agendado) ----
@@ -2082,6 +2214,7 @@ export const CLINIC_SETTINGS_DEFAULT: ClinicSettings = {
recursos: RECURSOS_DEFAULT,
compras: COMPRAS_DEFAULT,
comisiones: COMISIONES_DEFAULT,
licencia: LICENCIA_DEFAULT,
};
export interface FrontendUser {
@@ -2776,6 +2909,79 @@ export const odooApi = {
return data;
},
// ---- Licencia por plan ----
async getLicencia(): Promise<LicenciaEstadoResponse> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/licencia`);
return data;
},
async putLicencia(payload: { clave?: string; modo?: 'aviso' | 'bloqueo' }): Promise<LicenciaEstadoResponse> {
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/licencia`, payload);
return data;
},
async validarLicencia(clave: string): Promise<{ status: string; valida: boolean; error: string | null; payload: Record<string, unknown> | null; dias_restantes: number | null; message?: string }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/licencia/validar`, { clave });
return data;
},
// ---- Claves de API de terceros ----
async listApiKeys(): Promise<{ status: string; api_keys: ApiKey[]; scopes: { clave: string; label: string }[] }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/api_keys`);
return data;
},
async createApiKey(payload: { name: string; scopes?: string[]; vence?: string; notas?: string }): Promise<{ status: string; clave: string; api_key: ApiKey; message?: string }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/api_keys`, payload);
return data;
},
async updateApiKey(id: number, payload: { name?: string; scopes?: string[]; activo?: boolean; vence?: string; notas?: string }): Promise<{ status: string; api_key: ApiKey }> {
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/api_keys/${id}`, payload);
return data;
},
async revocarApiKey(id: number): Promise<{ status: string; api_key: ApiKey; message?: string }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/api_keys/${id}/revocar`);
return data;
},
// ---- Webhooks salientes ----
async listWebhooks(): Promise<{ status: string; webhooks: Webhook[]; eventos: WebhookEventoCatalogo[]; estados: { clave: string; label: string }[]; formato_firma: string }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/webhooks`);
return data;
},
async createWebhook(payload: { name: string; url: string; eventos: string[]; activo?: boolean; secreto?: string; headers?: Record<string, string>; notas?: string }): Promise<{ status: string; webhook: Webhook; secreto: string; message?: string }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/webhooks`, payload);
return data;
},
async updateWebhook(id: number, payload: Partial<{ name: string; url: string; eventos: string[]; activo: boolean; secreto: string; notas: string }>): Promise<{ status: string; webhook: Webhook }> {
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/webhooks/${id}`, payload);
return data;
},
async deleteWebhook(id: number): Promise<{ status: string; message?: string }> {
const { data } = await axios.delete(`${ODOO_BASE}/skeen/frontend/v1/webhooks/${id}`);
return data;
},
async listWebhookEventos(id: number, params?: { estado?: string; evento?: string; limit?: number }): Promise<{ status: string; eventos: WebhookEvento[]; total: number; resumen: Record<string, number> }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/webhooks/${id}/eventos`, { params });
return data;
},
async probarWebhook(id: number): Promise<{ status: string; ok: boolean; message: string; evento: WebhookEvento | null }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/webhooks/${id}/probar`);
return data;
},
async reintentarWebhookEvento(id: number): Promise<{ status: string; estado: string; evento: WebhookEvento }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/webhook_eventos/${id}/reintentar`);
return data;
},
// ---- Compras y proveedores ----
// Con `compras.activo=false` (default) el backend responde 403 y estas
// llamadas nunca se hacen desde la UI (la sección no se monta).