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:
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user