Tipos compartidos
El paquete shared/ no tiene lógica: son solo tipos de TypeScript, y son el contrato entre las dos
mitades del sistema. Se importan con el alias @xenpia/shared/*, que apunta a ../shared/src/* en
los tsconfig de backend y frontend.
Los que hay que conocer
shared/src/**/*.ts al construir el sitio. No se edita a mano: se actualiza con yarn gen en docs/.BotConfig
export interface BotConfig {
id: string;
name: string;
description?: string;
prompt: string;
llm: LlmConfig;
/** Present when the bot uses the visual flow builder (advanced mode) */
flowConfig?: Record<string, unknown>;
/** Tenant id — propagated so node graphs can scope I/O to a tenant */
tenantId?: string;
/** Compliance: when true, the first outbound message of every new conversation is prepended with `aiDisclosureMessage`. */
aiDisclosureEnabled?: boolean;
aiDisclosureMessage?: string;
/** Case-insensitive substrings that pause the bot on inbound match (escalation to human). */
handoverKeywords?: string[];
/** Case-insensitive substrings that close the conversation and revoke messaging consent on inbound match. */
stopKeywords?: string[];
/** Resultado del guardián de seguridad (`bots.safety_status`). Ausente en despliegues sin la migración. */
safetyStatus?: BotSafetyStatus;
}
LlmConfig
export interface LlmConfig {
provider: ProviderType;
model: string;
apiKey: string;
params?: Record<string, any>;
}
MessageEnvelope
export interface MessageEnvelope {
provider: ProviderCode;
channelCode: ChannelCode;
externalChannelId: string;
externalUserId: string;
providerMessageId: string;
type: 'text' | 'media' | 'event' | 'template' | 'system' | 'interactive';
text?: string;
attachments?: Attachment[];
subject?: string;
html?: string;
rawPayload: any;
meta?: Record<string, any>;
}
TenantConfig
export interface TenantConfig {
id: string;
name?: string;
bots: BotConfig[];
}
Todo lo demás
La lista completa está organizada por archivo:
shared/src/types.ts— el grueso: mensajería, bots, CRM, pagos.shared/src/modules/scheduling.ts— agenda.shared/src/modules/health.tsyhealth-l10n.ts— historia clínica y su localización por país.shared/src/modules/documents.ts— plantillas y marca de documentos.shared/src/modules/catalog.ts— catálogos.
npm run build en el backend crea node_modules/@xenpia/shared como enlace a dist/shared/src.
Está en .gitignore a propósito. Si al arrancar te sale que no encuentra @xenpia/shared, casi
siempre es que falta compilar el backend una vez.
Agenda
shared/src/**/*.ts al construir el sitio. No se edita a mano: se actualiza con yarn gen en docs/.AppointmentTransitions
Forma de una tabla de transiciones de estado: para cada estado, a cuáles se puede pasar. Aquí vive solo el TIPO, no la tabla. Este paquete es solo tipos (ver la nota al final de `types.ts`), así que cada lado declara su propia constante con este `satisfies`. Eso no impide que difieran en contenido, pero sí garantiza que ninguna de las dos se olvide de un estado nuevo: la compilación rompe. La AUTORIDAD es el backend, que rechaza con `invalid_transition`. La tabla de la interfaz decide qué botones ofrecer, nada más. backend/src/modules/scheduling/appointment-transitions.ts frontend/src/sections/scheduling/appointment-transitions.ts
export type AppointmentTransitions = Record<
SchedulingAppointmentStatus,
readonly SchedulingAppointmentStatus[]
>;
CancelAppointmentInput
export type CancelAppointmentInput = {
appointment_id: string;
reason?: string;
/** Si se pasa, la mutación se acota al tenant (obligatorio desde el copiloto). */
tenant_id?: string;
};
ChannelMessageTemplate
Plantilla del proveedor asociada a un aviso de Xenpia.
export type ChannelMessageTemplate = {
id: string;
channel_code: string;
channel_account_id: string;
purpose: string;
origin: 'xenpia_standard' | 'existing';
name: string;
language: string;
category?: string | null;
status: 'pending' | 'approved' | 'rejected' | 'paused' | 'disabled' | 'error';
rejection_reason?: string | null;
parameter_map: Array<{ position?: number; parameterName?: string; key: string }>;
button_map: Array<{ index: number; intent: string }>;
last_synced_at?: string | null;
};
ChannelReachPolicyDto
Qué permite un canal para escribirle a alguien sin conversación viva. La tabla vive en el backend (`CHANNEL_REACH_POLICY`) y se sirve al panel tal cual, para que la ayuda nunca contradiga lo que hace el envío.
export type ChannelReachPolicyDto = {
/** Horas de ventana tras el último mensaje de la persona; null = sin ventana. */
sessionWindowHours: number | null;
/** template = solo con plantilla aprobada; free = libre; none = no puede. */
coldStart: 'template' | 'free' | 'none';
/** Dato de la ficha que sirve de destinatario. */
contactField?: 'phone' | 'email';
};
CreateAppointmentInput
export type CreateAppointmentInput = {
tenant_id: string;
location_id: string;
resource_id: string;
service_id: string;
starts_at: string;
/** Si se omite, se calcula con la duración del servicio. */
ends_at?: string;
/** Titular: a nombre de quién queda la cita. */
contact_id?: string;
/**
* Quién la pidió, cuando no es el titular — un hijo que agenda para su padre.
* Es lo que después le permite consultarla y cancelarla.
*/
requested_by_contact_id?: string;
conversation_id?: string;
notes?: string;
booked_via?: string;
created_by?: string;
metadata?: Record<string, unknown>;
};
FindSlotsQuery
export type FindSlotsQuery = {
tenant_id: string;
/** Servicio concreto, si ya se conoce. */
service_id?: string;
service_code?: string;
/** Lo que dijo el cliente en lenguaje natural: "cardiología", "cena para 4". */
specialty?: string;
resource_type_code?: string;
resource_id?: string;
/** Opcional a propósito: sin sede, busca en todas. */
location_id?: string;
from: string;
to: string;
/** Tope de huecos devueltos. */
limit?: number;
/**
* Override de duración (minutos). Si se omite, se usa la del servicio.
* Acotado 5–240 en el motor SQL.
*/
duration_minutes?: number;
/**
* Override de margen posterior. Si se omite y hay duration_minutes, el motor
* usa 0; si ambos se omiten, usa buffer_minutes del servicio.
*/
buffer_minutes?: number;
};
HealthResourceMetadata
Metadata típica de un recurso médico. La define la vertical, no el motor.
export type HealthResourceMetadata = {
specialty?: string;
license_number?: string;
languages?: string[];
};
HospitalityResourceMetadata
Metadata típica de una suite.
export type HospitalityResourceMetadata = {
capacity?: number;
beds?: number;
amenities?: string[];
};
OutboundDeliveryStatus
Estado de entrega de un aviso, tal como lo reporta el canal.
export type OutboundDeliveryStatus = 'sent' | 'delivered' | 'read' | 'failed';
ReminderLocalTime
Un aviso a una hora de reloj, en la zona horaria de la SEDE.
export type ReminderLocalTime = {
/** 0 = el día de la cita, 1 = la víspera, 3 = tres días antes. */
days_before: number;
/** Formato "HH:MM". */
at: string;
};
ReminderOutreachOverview
Lo que muestra Ajustes de recordatorios sobre el aviso a citas sin chat.
export type ReminderOutreachOverview = {
cold_outreach: { enabled: boolean; channel_account_id?: string };
accounts: Array<{
channel_account_id: string;
name: string | null;
display_phone: string | null;
template: ChannelMessageTemplate | null;
}>;
channel_policies: Record<string, ChannelReachPolicyDto>;
};
ReminderSchedule
export type ReminderSchedule = {
/** Avisos relativos a la hora de la cita, en minutos antes. */
offsets_minutes?: number[];
/** Avisos a una hora de reloj concreta. */
local_times?: ReminderLocalTime[];
/** Cuánto sigue abierta la respuesta a un aviso ya enviado, en horas. */
reply_window_hours?: number;
/** Canales por los que reintentar si el de la reserva no acepta el aviso. */
fallback_channels?: string[];
/**
* Avisar también a quien nunca escribió (citas cargadas a mano).
*
* Apagado por defecto y por decisión del negocio: WhatsApp exige una
* plantilla aprobada, cada envío tiene costo y Meta pide que la persona haya
* aceptado recibir mensajes. Encenderlo es afirmar que ese consentimiento se
* recoge al tomar el teléfono.
*/
cold_outreach?: {
enabled: boolean;
/** Cuenta de WhatsApp que envía. Sin esto, la única activa del tenant. */
channel_account_id?: string;
};
};
ReminderTemplateActionResult
Resultado de crear, adoptar o refrescar la plantilla del aviso.
export type ReminderTemplateActionResult =
| { ok: true; data: ChannelMessageTemplate }
| {
ok: false;
error_code:
| 'not_found'
| 'sandbox'
| 'no_credentials'
| 'graph_error'
| 'not_approved'
| 'invalid_mapping'
| 'unknown_purpose';
detail?: string;
};
ReminderTemplateCandidate
Plantilla aprobada de la cuenta de WhatsApp que podría usarse para el aviso.
export type ReminderTemplateCandidate = {
id?: string;
name: string;
language: string;
status: string;
category: string;
parameterFormat?: 'NAMED' | 'POSITIONAL';
bodyText?: string;
bodyParameters: Array<{ kind: 'positional' | 'named'; index?: number; name?: string; example?: string }>;
buttons: Array<{ type: string; text?: string }>;
};
RescheduleAppointmentInput
export type RescheduleAppointmentInput = {
appointment_id: string;
starts_at: string;
ends_at?: string;
resource_id?: string;
location_id?: string;
/** Si se cambia el servicio, se recalcula ends_at con su duración. */
service_id?: string;
/**
* Origen de la reserva. Al editar desde el panel se manda 'admin' para que
* deje de figurar como 'bot' cuando un usuario la modifica.
*/
booked_via?: string;
/** Si se pasa, la mutación se acota al tenant (obligatorio desde el copiloto). */
tenant_id?: string;
};
RestaurantResourceMetadata
Metadata típica de una mesa de restaurante.
export type RestaurantResourceMetadata = {
capacity?: number;
zone?: string;
outdoor?: boolean;
};
SchedulingAppointment
export type SchedulingAppointment = {
id: string;
tenant_id: string;
location_id: string;
resource_id: string;
service_id: string;
contact_id: string | null;
/** Qué conversación originó la cita. */
conversation_id: string | null;
starts_at: string;
ends_at: string;
status: SchedulingAppointmentStatus;
/**
* Sub-estado informativo (clave de `SchedulingSubstatus`). No cambia la
* lógica: la cita sigue en `status` para recordatorios, bot y turnero.
*/
substatus?: string | null;
/** Por dónde entró: 'bot', 'admin', 'import'… */
booked_via: string | null;
notes: string | null;
metadata: Record<string, unknown>;
created_by: string | null;
cancelled_at: string | null;
cancel_reason: string | null;
created_at: string;
updated_at: string;
deleted_at: string | null;
};
SchedulingAppointmentDetail
Cita con los nombres ya resueltos, para listados y calendario. Incluye los datos de contacto del paciente: quien mira una agenda necesita poder llamarle sin salir de la pantalla.
export type SchedulingAppointmentDetail = SchedulingAppointment & {
location_name: string;
location_timezone: string;
location_address: string | null;
resource_name: string;
service_name: string;
service_duration_minutes: number;
/** Titular: a nombre de quién está la cita. */
contact_name: string | null;
contact_email: string | null;
contact_phone: string | null;
/** Número de identificación del titular (cédula, RUC, pasaporte). */
contact_document_number: string | null;
/**
* Quien la pidió, SOLO cuando no es el titular.
*
* Es a quien recepción puede llamar: el contacto de un titular creado por el
* bot no tiene teléfono propio —a esa persona no se le escribe por ningún
* canal—, así que sin esto la cita se queda sin número al que marcar.
*/
requested_by_name: string | null;
requested_by_phone: string | null;
};
SchedulingAppointmentStatus
export type SchedulingAppointmentStatus =
| 'scheduled'
| 'confirmed'
/** Llegó y está en la sala de espera: lo marca el turnero o el mostrador. */
| 'arrived'
/** Ya entró a consulta. */
| 'in_progress'
| 'completed'
| 'cancelled'
| 'no_show';
SchedulingAvailability
export type SchedulingAvailability = {
id: string;
tenant_id: string;
resource_id: string;
/** Aquí vive la multi-sede: el recurso es único, su horario cambia por sede. */
location_id: string;
weekday: SchedulingWeekday;
/** Hora local a la sede, formato HH:MM:SS. */
start_time: string;
end_time: string;
valid_from: string | null;
valid_until: string | null;
created_at: string;
updated_at: string;
};
SchedulingAvailabilityException
export type SchedulingAvailabilityException = {
id: string;
tenant_id: string;
resource_id: string | null;
location_id: string | null;
kind: SchedulingExceptionKind;
starts_at: string;
ends_at: string;
reason: string | null;
created_at: string;
};
SchedulingCalendarView
Eje horario visible del calendario de agenda. Se guarda en `tenant_modules.config.calendarView`. Las horas fuera de este rango se ocultan, salvo el día (o la semana) que tenga citas fuera: entonces se ensancha la vista para no perderlas.
export type SchedulingCalendarView = {
/** Hora local de inicio del eje, "HH:MM". */
min: string;
/** Hora local de fin del eje, "HH:MM". */
max: string;
};
SchedulingErrorCode
Por qué se rechazó una operación. La interfaz traduce el código.
export type SchedulingErrorCode =
| 'slot_taken'
| 'outside_availability'
| 'unknown_service'
| 'unknown_resource'
| 'unknown_location'
| 'service_not_at_location'
| 'resource_cannot_serve'
| 'past_datetime'
/** El estado de partida no permite llegar al pedido (ver appointment-transitions). */
| 'invalid_transition'
/** El sub-estado no existe, está desactivado o no aplica al estado de la cita. */
| 'invalid_substatus'
// Envío manual de recordatorio: por qué no se pudo poner en cola.
| 'not_found'
| 'no_contact'
| 'no_channel'
| 'appointment_closed'
| 'appointment_past'
| 'write_failed'
/** El backend rechazó la petición (400): falta un campo o viene mal. `detail` dice cuál. */
| 'invalid_input'
/** Sin sesión (401) o sin permiso sobre esa agenda (403). `detail` trae el motivo del guard. */
| 'forbidden'
// Aviso a un contacto sin chat previo: por qué no se puede alcanzar.
/** El contacto no tiene teléfono en la ficha. */
| 'no_phone'
/** El tenant no activó el aviso a citas sin chat previo. */
| 'cold_outreach_disabled'
/** Hace falta una plantilla aprobada y no hay ninguna lista. */
| 'template_not_ready'
/** Ese teléfono ya está registrado en otro contacto: hay que fusionarlos. */
| 'phone_belongs_to_other_contact'
/** Pasó la ventana del canal y no hay plantilla con la que salir. */
| 'outside_window_no_template';
SchedulingExceptionKind
block = cierra el tramo · extra = turno añadido · allow = ese día solo ese tramo.
export type SchedulingExceptionKind = 'block' | 'extra' | 'allow';
SchedulingLocation
Módulo de Agendamiento — contrato entre backend y frontend. El motor es genérico: se reservan RECURSOS para prestar SERVICIOS en UBICACIONES. La vertical solo aporta vocabulario y contenido de `metadata`. hospital médico · consulta 30 min · Sede Norte restaurante mesa · cena 90 min · Local Centro hotelería suite · noche · Torre A SOLO TIPOS: este paquete no puede exportar valores en ejecución (ver la nota al final de ./types).
export type SchedulingLocation = {
id: string;
tenant_id: string;
workspace_id: string | null;
name: string;
/** Zona IANA. Los horarios de disponibilidad son locales a ella. */
timezone: string;
address: string | null;
phone: string | null;
is_active: boolean;
metadata: Record<string, unknown>;
created_at: string;
updated_at: string;
deleted_at: string | null;
};
SchedulingResource
export type SchedulingResource = {
id: string;
tenant_id: string;
resource_type_id: string;
/**
* NULL = se mueve entre sedes (personas). Con valor = fijo en esa sede
* (mesas, suites). Es lo que hace que el modelo sirva a las tres verticales.
*/
location_id: string | null;
/** Une el recurso con un usuario: es lo que permite ver "sus" citas. */
linked_user_id: string | null;
/**
* Ficha de persona (contacts): cédula, teléfono, email — como partner en Odoo.
* Distinto de linked_user_id (cuenta de login).
*/
contact_id: string | null;
name: string;
description: string | null;
avatar_url: string | null;
color: string | null;
metadata: Record<string, unknown>;
is_active: boolean;
created_at: string;
updated_at: string;
deleted_at: string | null;
};
SchedulingResourceCategory
professional = persona · space = lugar físico · equipment = aparato.
export type SchedulingResourceCategory = 'professional' | 'space' | 'equipment';
SchedulingResourceType
export type SchedulingResourceType = {
id: string;
tenant_id: string;
code: string;
name: string;
category: SchedulingResourceCategory;
icon: string | null;
/** Qué campos pide la interfaz para el `metadata` de sus recursos. */
metadata_schema: Record<string, unknown>;
is_active: boolean;
sort: number;
created_at: string;
updated_at: string;
deleted_at: string | null;
};
SchedulingResult
export type SchedulingResult<T> =
| { ok: true; data: T }
| { ok: false; error_code: SchedulingErrorCode; detail?: string };
SchedulingService
export type SchedulingService = {
id: string;
tenant_id: string;
resource_type_id: string;
code: string;
name: string;
description: string | null;
duration_minutes: number;
/** Margen posterior: limpieza, traslado entre sedes, respiro. */
buffer_minutes: number;
price: number | null;
currency: string | null;
metadata: Record<string, unknown>;
is_active: boolean;
created_at: string;
updated_at: string;
deleted_at: string | null;
};
SchedulingSlot
Un hueco reservable. La SEDE viaja en el resultado, no en la consulta: el bot busca en todo el tenant y resuelve la sede al elegir horario, en vez de preguntarla antes y recortar las opciones.
export type SchedulingSlot = {
starts_at: string;
ends_at: string;
resource_id: string;
resource_name: string;
location_id: string;
location_name: string;
location_timezone: string;
location_address: string | null;
service_id: string;
service_name: string;
};
SchedulingStatusColors
Color de cada estado de cita elegido por el tenant, como `#rrggbb`. Se guarda en `tenant_modules.config.statusColors` y solo lleva los estados que el tenant cambió: un estado ausente se pinta con el tono del tema. Lo leen el calendario, la cola de hoy, el listado de citas y el widget del chat.
export type SchedulingStatusColors = Partial<Record<SchedulingAppointmentStatus, string>>;
SchedulingSubstatus
Sub-estado de cita definido por el tenant ("Pagado", "Esperando exámenes"). Es una etiqueta encima del estado base, no un estado: no toca transiciones, recordatorios, bot ni turnero. Vive en `tenant_modules.config.substatuses`.
export type SchedulingSubstatus = {
/** Identificador estable (minúsculas, guiones). Es lo que guarda la cita. */
key: string;
name: string;
/** `#rrggbb`. */
color: string;
/** Estados en los que se puede elegir. Al salir de ellos, la cita lo pierde. */
base_statuses: SchedulingAppointmentStatus[];
/** Desactivado: deja de ofrecerse; las citas que ya lo tienen lo conservan. */
active: boolean;
};
SchedulingSubstatusUsage
Cuántas citas llevan cada sub-estado, por estado (para avisar antes de cambiarlo).
export type SchedulingSubstatusUsage = Record<string, Partial<Record<SchedulingAppointmentStatus, number>>>;
SchedulingWeekday
0 = domingo … 6 = sábado, siguiendo la convención de EXTRACT(DOW).
export type SchedulingWeekday = 0 | 1 | 2 | 3 | 4 | 5 | 6;