essalud-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +77 -0
- package/README.md +141 -0
- package/dist/essalud/api.d.ts +140 -0
- package/dist/essalud/api.js +136 -0
- package/dist/essalud/cmd-cancelar.d.ts +5 -0
- package/dist/essalud/cmd-cancelar.js +74 -0
- package/dist/essalud/cmd-citas.d.ts +1 -0
- package/dist/essalud/cmd-citas.js +47 -0
- package/dist/essalud/cmd-especialidades.d.ts +1 -0
- package/dist/essalud/cmd-especialidades.js +30 -0
- package/dist/essalud/cmd-fechas.d.ts +1 -0
- package/dist/essalud/cmd-fechas.js +47 -0
- package/dist/essalud/cmd-login.d.ts +7 -0
- package/dist/essalud/cmd-login.js +223 -0
- package/dist/essalud/cmd-perfil.d.ts +1 -0
- package/dist/essalud/cmd-perfil.js +38 -0
- package/dist/essalud/cmd-reservar.d.ts +14 -0
- package/dist/essalud/cmd-reservar.js +147 -0
- package/dist/essalud/cmd-token.d.ts +1 -0
- package/dist/essalud/cmd-token.js +62 -0
- package/dist/essalud/har.d.ts +7 -0
- package/dist/essalud/har.js +76 -0
- package/dist/essalud/index.d.ts +2 -0
- package/dist/essalud/index.js +87 -0
- package/dist/essalud/interactive.d.ts +5 -0
- package/dist/essalud/interactive.js +495 -0
- package/dist/essalud/jwt.d.ts +12 -0
- package/dist/essalud/jwt.js +29 -0
- package/dist/essalud-bin.d.ts +2 -0
- package/dist/essalud-bin.js +24 -0
- package/package.json +64 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { cmdCancelar } from "./cmd-cancelar.js";
|
|
3
|
+
import { cmdCitas } from "./cmd-citas.js";
|
|
4
|
+
import { cmdEspecialidades } from "./cmd-especialidades.js";
|
|
5
|
+
import { cmdFechas } from "./cmd-fechas.js";
|
|
6
|
+
import { cmdLogin } from "./cmd-login.js";
|
|
7
|
+
import { cmdPerfil } from "./cmd-perfil.js";
|
|
8
|
+
import { cmdReservar } from "./cmd-reservar.js";
|
|
9
|
+
import { cmdToken } from "./cmd-token.js";
|
|
10
|
+
import { runInteractiveMode } from "./interactive.js";
|
|
11
|
+
export function makeEssaludCommand() {
|
|
12
|
+
const essalud = new Command("essalud")
|
|
13
|
+
.description("Trámites de EsSalud (citas, perfil, etc.)")
|
|
14
|
+
// Si no se pasa ningún subcomando, lanzar el modo interactivo
|
|
15
|
+
.action(() => {
|
|
16
|
+
void runInteractiveMode();
|
|
17
|
+
});
|
|
18
|
+
essalud.command("perfil").description("Muestra los datos del asegurado").action(cmdPerfil);
|
|
19
|
+
essalud.command("citas").description("Lista las citas emitidas").action(cmdCitas);
|
|
20
|
+
essalud
|
|
21
|
+
.command("token")
|
|
22
|
+
.description("Verifica si hay token guardado y si está vigente")
|
|
23
|
+
.action(cmdToken);
|
|
24
|
+
essalud
|
|
25
|
+
.command("login")
|
|
26
|
+
.description("Login asistido: abre browser headed, captura el Bearer token de EsSalud y lo guarda.")
|
|
27
|
+
.option("--token <jwt>", "Pegar el JWT directamente (sin abrir browser)")
|
|
28
|
+
.option("--from-har <path>", "Importar token desde un HAR exportado de DevTools")
|
|
29
|
+
.action((opts) => {
|
|
30
|
+
const loginOpts = {
|
|
31
|
+
token: opts.token,
|
|
32
|
+
fromHar: opts.fromHar,
|
|
33
|
+
};
|
|
34
|
+
return cmdLogin(loginOpts);
|
|
35
|
+
});
|
|
36
|
+
essalud
|
|
37
|
+
.command("especialidades <codCentro>")
|
|
38
|
+
.description("Lista especialidades y actividades disponibles para un centro (POST /parametroSolicitud).")
|
|
39
|
+
.action((codCentro) => cmdEspecialidades(codCentro));
|
|
40
|
+
essalud
|
|
41
|
+
.command("fechas <codCentro> <codServicioHosp> <codActSubAct>")
|
|
42
|
+
.description("Muestra cupos y slots disponibles para una especialidad/actividad (POST /programacionDisponible).")
|
|
43
|
+
.action((codCentro, codServicioHosp, codActSubAct) => cmdFechas(codCentro, codServicioHosp, codActSubAct));
|
|
44
|
+
essalud
|
|
45
|
+
.command("reservar")
|
|
46
|
+
.description("Reserva una cita. Dry-run por defecto. Requiere --confirm para el POST real a /generarCita.")
|
|
47
|
+
.option("--cupo-json <json>", "JSON del cupo completo (de la salida de 'fechas')")
|
|
48
|
+
.option("--nro-cupo <nro>", "Número de cupo a reservar (de vCupoDisp[].nroCupo)")
|
|
49
|
+
.option("--hora-slot <hora>", "Hora del slot elegido (solo para mostrar en el resumen)")
|
|
50
|
+
.option("--cod-prog-asis <cod>", "codProgAsis del cupo (alternativa a --cupo-json)")
|
|
51
|
+
.option("--consultorio <cod>", "Consultorio (alternativa a --cupo-json)")
|
|
52
|
+
.option("--fecha <fecha>", "fechaCitaProg del cupo (alternativa a --cupo-json)")
|
|
53
|
+
.option("--turno-ini <hora>", "turnoIni (alternativa a --cupo-json)")
|
|
54
|
+
.option("--turno-fin <hora>", "turnoFin (alternativa a --cupo-json)")
|
|
55
|
+
.option("--celular <nro>", "Número de celular (por defecto del perfil)")
|
|
56
|
+
.option("--email <email>", "Email (por defecto del perfil)")
|
|
57
|
+
.option("--confirm", "Ejecutar la reserva real (requiere confirmación interactiva)")
|
|
58
|
+
.action((opts) => {
|
|
59
|
+
const reservarOpts = {
|
|
60
|
+
cupoJson: opts.cupoJson,
|
|
61
|
+
nroCupo: opts.nroCupo,
|
|
62
|
+
horaSlot: opts.horaSlot,
|
|
63
|
+
codProgAsis: opts.codProgAsis,
|
|
64
|
+
consultorio: opts.consultorio,
|
|
65
|
+
fecha: opts.fecha,
|
|
66
|
+
turnoIni: opts.turnoIni,
|
|
67
|
+
turnoFin: opts.turnoFin,
|
|
68
|
+
celular: opts.celular,
|
|
69
|
+
email: opts.email,
|
|
70
|
+
confirm: opts.confirm ?? false,
|
|
71
|
+
};
|
|
72
|
+
return cmdReservar(reservarOpts);
|
|
73
|
+
});
|
|
74
|
+
essalud
|
|
75
|
+
.command("cancelar <citActMedNum>")
|
|
76
|
+
.description("Cancela una cita (POST /eliminarCita). Dry-run por defecto; requiere --confirm.")
|
|
77
|
+
.option("--cod-centro <cod>", "Código del centro de la cita (citCenAsiCod), si no aparece en 'citas'")
|
|
78
|
+
.option("--confirm", "Ejecutar la cancelación real (requiere confirmación interactiva)")
|
|
79
|
+
.action((citActMedNum, opts) => {
|
|
80
|
+
const cancelarOpts = {
|
|
81
|
+
confirm: opts.confirm ?? false,
|
|
82
|
+
codCentro: opts.codCentro,
|
|
83
|
+
};
|
|
84
|
+
return cmdCancelar(citActMedNum, cancelarOpts);
|
|
85
|
+
});
|
|
86
|
+
return essalud;
|
|
87
|
+
}
|
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Modo interactivo de EsSalud — menú estilo command palette usando @clack/prompts.
|
|
3
|
+
* Se lanza cuando se corre `essalud` sin subcomando.
|
|
4
|
+
*/
|
|
5
|
+
import { readFile } from "node:fs/promises";
|
|
6
|
+
import * as p from "@clack/prompts";
|
|
7
|
+
import boxen from "boxen";
|
|
8
|
+
import figlet from "figlet";
|
|
9
|
+
import pc from "picocolors";
|
|
10
|
+
import { generarCita, getCitasEmitidas, getPaciente, getParametroSolicitud, getPerfil, getProgramacionDisponible, request, TOKEN_PATH, } from "./api.js";
|
|
11
|
+
import { cmdLogin } from "./cmd-login.js";
|
|
12
|
+
import { decodeJwtPayload } from "./jwt.js";
|
|
13
|
+
async function getTokenStatus() {
|
|
14
|
+
try {
|
|
15
|
+
const raw = await readFile(TOKEN_PATH, "utf-8");
|
|
16
|
+
const token = raw.trim();
|
|
17
|
+
if (!token.startsWith("ey")) {
|
|
18
|
+
return { valid: false, expired: false, expiresAt: null, token: null };
|
|
19
|
+
}
|
|
20
|
+
const payload = decodeJwtPayload(token);
|
|
21
|
+
if (payload && typeof payload.exp === "number") {
|
|
22
|
+
const expiresAt = new Date(payload.exp * 1000);
|
|
23
|
+
const expired = Date.now() > payload.exp * 1000;
|
|
24
|
+
return { valid: true, expired, expiresAt, token };
|
|
25
|
+
}
|
|
26
|
+
// JWT sin exp legible: lo tratamos como válido sin info de expiración.
|
|
27
|
+
return { valid: true, expired: false, expiresAt: null, token };
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return { valid: false, expired: false, expiresAt: null, token: null };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Banner de bienvenida
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
function printWordmark() {
|
|
37
|
+
try {
|
|
38
|
+
const art = figlet.textSync("essalud", { font: "ANSI Shadow" });
|
|
39
|
+
console.log(pc.cyan(art));
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
// Fallback si figlet falla por alguna razón
|
|
43
|
+
console.log(pc.cyan("\n essalud\n"));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
async function printBanner(ts) {
|
|
47
|
+
printWordmark();
|
|
48
|
+
const paciente = await getPaciente();
|
|
49
|
+
// Saludo
|
|
50
|
+
const nombreMostrado = paciente?.nombres?.trim() || paciente?.apePaterno?.trim() || null;
|
|
51
|
+
const saludoLine = nombreMostrado ? `Hola, ${nombreMostrado}` : "Hola";
|
|
52
|
+
// Estado del token
|
|
53
|
+
let estadoLine;
|
|
54
|
+
if (!ts.valid || !ts.token) {
|
|
55
|
+
estadoLine = pc.yellow("no logueada — corre login");
|
|
56
|
+
}
|
|
57
|
+
else if (ts.expired) {
|
|
58
|
+
estadoLine = pc.red("VENCIDO — corre login");
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
// Extraer DNI del JWT (sub) o mostrar genérico
|
|
62
|
+
const payload = decodeJwtPayload(ts.token ?? "");
|
|
63
|
+
const dni = (typeof payload?.sub === "string" && payload.sub) ||
|
|
64
|
+
(typeof payload?.username === "string" && payload.username) ||
|
|
65
|
+
(typeof payload?.preferred_username === "string" && payload.preferred_username) ||
|
|
66
|
+
"";
|
|
67
|
+
const dniStr = dni ? ` · DNI ${dni}` : "";
|
|
68
|
+
const venceStr = ts.expiresAt
|
|
69
|
+
? ` · vence ${ts.expiresAt.toLocaleString("es-PE", { timeZone: "America/Lima", dateStyle: "short", timeStyle: "short" })}`
|
|
70
|
+
: "";
|
|
71
|
+
estadoLine = pc.green(`logueada${dniStr}${venceStr}`);
|
|
72
|
+
}
|
|
73
|
+
// Centro
|
|
74
|
+
const centroLine = paciente?.desCentro
|
|
75
|
+
? `Centro: ${paciente.desCentro}`
|
|
76
|
+
: "Centro: (desconocido — corre login)";
|
|
77
|
+
const content = [
|
|
78
|
+
pc.bold("essalud · citas EsSalud"),
|
|
79
|
+
"",
|
|
80
|
+
saludoLine,
|
|
81
|
+
estadoLine,
|
|
82
|
+
centroLine,
|
|
83
|
+
"",
|
|
84
|
+
pc.dim("Elige una opción ↓"),
|
|
85
|
+
].join("\n");
|
|
86
|
+
console.log(boxen(content, {
|
|
87
|
+
padding: { top: 0, bottom: 0, left: 2, right: 2 },
|
|
88
|
+
margin: { top: 1, bottom: 0, left: 0, right: 0 },
|
|
89
|
+
borderStyle: "round",
|
|
90
|
+
borderColor: "#E8845A",
|
|
91
|
+
textAlignment: "center",
|
|
92
|
+
}));
|
|
93
|
+
}
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
// Guard: exige login activo
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
async function requireLogin(ts) {
|
|
98
|
+
if (ts.valid && !ts.expired)
|
|
99
|
+
return true;
|
|
100
|
+
p.log.warn(ts.expired
|
|
101
|
+
? "Tu token venció. Necesitas hacer login nuevamente."
|
|
102
|
+
: "No hay sesión activa. Primero haz login.");
|
|
103
|
+
const ir = await p.confirm({ message: "¿Quieres hacer login ahora?" });
|
|
104
|
+
if (p.isCancel(ir) || !ir)
|
|
105
|
+
return false;
|
|
106
|
+
await runLogin();
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// Subrutinas de cada opción del menú
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
async function runLogin() {
|
|
113
|
+
p.log.info("Iniciando login...");
|
|
114
|
+
try {
|
|
115
|
+
await cmdLogin({});
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
p.log.error(String(err));
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
async function runPerfil(ts) {
|
|
122
|
+
if (!(await requireLogin(ts)))
|
|
123
|
+
return;
|
|
124
|
+
const s = p.spinner();
|
|
125
|
+
s.start("Cargando perfil...");
|
|
126
|
+
try {
|
|
127
|
+
const [perfil, paciente] = await Promise.all([getPerfil(), getPaciente()]);
|
|
128
|
+
s.stop("Perfil cargado.");
|
|
129
|
+
printPerfil(perfil, paciente);
|
|
130
|
+
}
|
|
131
|
+
catch (err) {
|
|
132
|
+
s.stop("Error al cargar el perfil.");
|
|
133
|
+
p.log.error(String(err));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
function printPerfil(perfil, paciente) {
|
|
137
|
+
// /perfil trae nombre separado; enricher con paciente.json si hay
|
|
138
|
+
const nombreApi = [
|
|
139
|
+
perfil.apellidoPatAsegurado,
|
|
140
|
+
perfil.apellidoMatAsegurado,
|
|
141
|
+
perfil.nombreAsegurado,
|
|
142
|
+
]
|
|
143
|
+
.filter(Boolean)
|
|
144
|
+
.join(" ");
|
|
145
|
+
const nombrePaciente = paciente
|
|
146
|
+
? [paciente.apePaterno, paciente.apeMaterno, paciente.nombres].filter(Boolean).join(" ")
|
|
147
|
+
: "";
|
|
148
|
+
const nombre = nombrePaciente || nombreApi || "(sin datos)";
|
|
149
|
+
const centro = paciente?.desCentro ?? "—";
|
|
150
|
+
const contacto = perfil.contacto;
|
|
151
|
+
p.note([
|
|
152
|
+
`Nombre : ${nombre}`,
|
|
153
|
+
`Centro : ${centro}`,
|
|
154
|
+
`DNI : ${perfil.numeroDocIdent ?? "—"}`,
|
|
155
|
+
`Celular : ${contacto?.nroCelular ?? paciente?.celular ?? "—"}`,
|
|
156
|
+
`Email : ${contacto?.email ?? paciente?.email ?? "—"}`,
|
|
157
|
+
].join("\n"), "Mi perfil");
|
|
158
|
+
}
|
|
159
|
+
async function runCitas(ts) {
|
|
160
|
+
if (!(await requireLogin(ts)))
|
|
161
|
+
return;
|
|
162
|
+
const s = p.spinner();
|
|
163
|
+
s.start("Cargando citas...");
|
|
164
|
+
try {
|
|
165
|
+
const citas = await getCitasEmitidas();
|
|
166
|
+
s.stop(`${citas.length} cita(s) encontrada(s).`);
|
|
167
|
+
if (citas.length === 0) {
|
|
168
|
+
p.log.info("No tienes citas programadas.");
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
printCitas(citas);
|
|
172
|
+
}
|
|
173
|
+
catch (err) {
|
|
174
|
+
s.stop("Error al cargar las citas.");
|
|
175
|
+
p.log.error(String(err));
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
function printCitas(citas) {
|
|
179
|
+
const lines = citas.map((c, i) => {
|
|
180
|
+
const fecha = c.citFecha ?? "—";
|
|
181
|
+
const hora = c.citHora ?? "—";
|
|
182
|
+
const centro = c.citCenAsiDes ?? "—";
|
|
183
|
+
const estado = c.citEstCita ?? "—";
|
|
184
|
+
const id = c.citActMedNum ?? c.citAutoGenCod ?? "—";
|
|
185
|
+
return `${i + 1}. [${id}] ${fecha} ${hora} · ${centro} · ${estado}`;
|
|
186
|
+
});
|
|
187
|
+
p.note(lines.join("\n"), "Mis citas");
|
|
188
|
+
}
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
// Reservar cita — flujo guiado
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
async function runReservar(ts) {
|
|
193
|
+
if (!(await requireLogin(ts)))
|
|
194
|
+
return;
|
|
195
|
+
// --- Paso 1: centro — tomarlo de paciente.json sin preguntar ---
|
|
196
|
+
const paciente = await getPaciente();
|
|
197
|
+
let codCentro;
|
|
198
|
+
if (paciente?.codCentro) {
|
|
199
|
+
codCentro = paciente.codCentro;
|
|
200
|
+
p.log.info(`Centro: ${paciente.desCentro} (${codCentro})`);
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
// Fallback: preguntar solo si no hay paciente.json
|
|
204
|
+
const codCentroRaw = await p.text({
|
|
205
|
+
message: "Código de tu centro de adscripción (Enter para usar 021, tu centro default)",
|
|
206
|
+
placeholder: "021",
|
|
207
|
+
defaultValue: "021",
|
|
208
|
+
});
|
|
209
|
+
if (p.isCancel(codCentroRaw))
|
|
210
|
+
return;
|
|
211
|
+
codCentro = codCentroRaw.trim() || "021";
|
|
212
|
+
}
|
|
213
|
+
// --- Paso 2: especialidades ---
|
|
214
|
+
const s1 = p.spinner();
|
|
215
|
+
s1.start("Consultando especialidades...");
|
|
216
|
+
let servicios = [];
|
|
217
|
+
try {
|
|
218
|
+
const data = await getParametroSolicitud(codCentro);
|
|
219
|
+
servicios = data.dataParmServicioHosp ?? [];
|
|
220
|
+
s1.stop(`${servicios.length} especialidad(es) disponible(s).`);
|
|
221
|
+
}
|
|
222
|
+
catch (err) {
|
|
223
|
+
s1.stop("Error al consultar especialidades.");
|
|
224
|
+
p.log.error(String(err));
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
if (servicios.length === 0) {
|
|
228
|
+
p.log.warn("No se encontraron especialidades para ese centro.");
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
const servicioOptions = servicios.map((sv) => ({
|
|
232
|
+
value: sv.codServicioHosp,
|
|
233
|
+
label: sv.desServicioHosp,
|
|
234
|
+
hint: sv.codServicioHosp,
|
|
235
|
+
}));
|
|
236
|
+
const codServicioHosp = await p.select({
|
|
237
|
+
message: "Elige la especialidad",
|
|
238
|
+
options: servicioOptions,
|
|
239
|
+
});
|
|
240
|
+
if (p.isCancel(codServicioHosp))
|
|
241
|
+
return;
|
|
242
|
+
const servicioElegido = servicios.find((sv) => sv.codServicioHosp === codServicioHosp);
|
|
243
|
+
if (!servicioElegido)
|
|
244
|
+
return;
|
|
245
|
+
// --- Paso 3: actividad (si hay más de una) ---
|
|
246
|
+
let codActSubAct;
|
|
247
|
+
const actividades = servicioElegido.vdataActSubAct ?? [];
|
|
248
|
+
if (actividades.length === 0) {
|
|
249
|
+
p.log.warn("Esta especialidad no tiene actividades configuradas.");
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
else if (actividades.length === 1 && actividades[0]) {
|
|
253
|
+
codActSubAct = actividades[0].codActSubAct;
|
|
254
|
+
p.log.info(`Actividad: ${actividades[0].desSubActHosp}`);
|
|
255
|
+
}
|
|
256
|
+
else {
|
|
257
|
+
const actOpts = actividades.map((a) => ({
|
|
258
|
+
value: a.codActSubAct,
|
|
259
|
+
label: a.desSubActHosp,
|
|
260
|
+
hint: a.desActHosp ?? a.codActSubAct,
|
|
261
|
+
}));
|
|
262
|
+
const chosenAct = await p.select({
|
|
263
|
+
message: "Elige la actividad",
|
|
264
|
+
options: actOpts,
|
|
265
|
+
});
|
|
266
|
+
if (p.isCancel(chosenAct))
|
|
267
|
+
return;
|
|
268
|
+
codActSubAct = chosenAct;
|
|
269
|
+
}
|
|
270
|
+
// --- Paso 4: cupos disponibles ---
|
|
271
|
+
const s2 = p.spinner();
|
|
272
|
+
s2.start("Buscando cupos disponibles...");
|
|
273
|
+
let cupos = [];
|
|
274
|
+
try {
|
|
275
|
+
cupos = await getProgramacionDisponible({
|
|
276
|
+
codCentro,
|
|
277
|
+
codServicioHosp: codServicioHosp,
|
|
278
|
+
codActSubAct,
|
|
279
|
+
codTurnoDeseado: "0",
|
|
280
|
+
});
|
|
281
|
+
s2.stop(`${cupos.length} cupo(s) disponible(s).`);
|
|
282
|
+
}
|
|
283
|
+
catch (err) {
|
|
284
|
+
s2.stop("Error al consultar cupos.");
|
|
285
|
+
p.log.error(String(err));
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
if (cupos.length === 0) {
|
|
289
|
+
p.log.warn("Sin programación disponible para esta especialidad/actividad. Intenta otra.");
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
const opciones = [];
|
|
293
|
+
for (const cupo of cupos) {
|
|
294
|
+
for (const slot of cupo.vCupoDisp ?? []) {
|
|
295
|
+
opciones.push({ cupo, slot });
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
if (opciones.length === 0) {
|
|
299
|
+
p.log.warn("Los cupos no tienen slots disponibles.");
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
const slotOpts = opciones.map((o, i) => ({
|
|
303
|
+
value: String(i),
|
|
304
|
+
label: `${o.cupo.apeNomProf} — ${o.cupo.fechaCitaProg} ${o.slot.hora}`,
|
|
305
|
+
hint: `Cupo #${o.slot.nroCupo} · Consul. ${o.cupo.consultorio}`,
|
|
306
|
+
}));
|
|
307
|
+
const chosenSlotIdx = await p.select({
|
|
308
|
+
message: "Elige un cupo",
|
|
309
|
+
options: slotOpts,
|
|
310
|
+
});
|
|
311
|
+
if (p.isCancel(chosenSlotIdx))
|
|
312
|
+
return;
|
|
313
|
+
const elegido = opciones[Number(chosenSlotIdx)];
|
|
314
|
+
if (!elegido)
|
|
315
|
+
return;
|
|
316
|
+
// --- Paso 5: resumen + confirmación ---
|
|
317
|
+
p.note([
|
|
318
|
+
`Profesional : ${elegido.cupo.apeNomProf}`,
|
|
319
|
+
`Fecha : ${elegido.cupo.fechaCitaProg}`,
|
|
320
|
+
`Hora : ${elegido.slot.hora}`,
|
|
321
|
+
`Cupo # : ${elegido.slot.nroCupo}`,
|
|
322
|
+
`Consultorio : ${elegido.cupo.consultorio}`,
|
|
323
|
+
`Especialidad: ${servicioElegido.desServicioHosp}`,
|
|
324
|
+
].join("\n"), "Resumen de la cita");
|
|
325
|
+
const confirma = await p.confirm({
|
|
326
|
+
message: "¿Reservar esta cita REAL? (ocupa un cupo real en EsSalud)",
|
|
327
|
+
initialValue: false,
|
|
328
|
+
});
|
|
329
|
+
if (p.isCancel(confirma) || !confirma) {
|
|
330
|
+
p.log.info("Reserva cancelada. No se realizó ninguna acción.");
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
// --- Paso 6: obtener datos de contacto del perfil ---
|
|
334
|
+
const s3 = p.spinner();
|
|
335
|
+
s3.start("Obteniendo datos de contacto...");
|
|
336
|
+
let numCelular = "";
|
|
337
|
+
let email = "";
|
|
338
|
+
try {
|
|
339
|
+
const perfil = await getPerfil();
|
|
340
|
+
numCelular = perfil.contacto?.nroCelular ?? "";
|
|
341
|
+
email = perfil.contacto?.email ?? "";
|
|
342
|
+
s3.stop("Datos obtenidos.");
|
|
343
|
+
}
|
|
344
|
+
catch {
|
|
345
|
+
s3.stop("No se pudo obtener el perfil; continuando sin datos de contacto.");
|
|
346
|
+
}
|
|
347
|
+
// --- Paso 7: generarCita ---
|
|
348
|
+
const s4 = p.spinner();
|
|
349
|
+
s4.start("Reservando cita...");
|
|
350
|
+
try {
|
|
351
|
+
const result = await generarCita({
|
|
352
|
+
codProgAsis: elegido.cupo.codProgAsis,
|
|
353
|
+
consultorio: elegido.cupo.consultorio,
|
|
354
|
+
fechaCitaPro: elegido.cupo.fechaCitaProg,
|
|
355
|
+
nroCupo: elegido.slot.nroCupo,
|
|
356
|
+
turnoIni: elegido.cupo.turnoIni,
|
|
357
|
+
turnoFin: elegido.cupo.turnoFin,
|
|
358
|
+
numCelular,
|
|
359
|
+
email,
|
|
360
|
+
});
|
|
361
|
+
s4.stop("Cita reservada.");
|
|
362
|
+
const cita = result[0];
|
|
363
|
+
if (cita) {
|
|
364
|
+
p.note([
|
|
365
|
+
`N° de cita : ${cita.numCitaCreada ?? "—"}`,
|
|
366
|
+
`Fecha : ${cita.fechaCita ?? elegido.cupo.fechaCitaProg}`,
|
|
367
|
+
`Hora : ${cita.horaCita ?? elegido.slot.hora}`,
|
|
368
|
+
`Profesional : ${cita.apeNomProf ?? elegido.cupo.apeNomProf}`,
|
|
369
|
+
`Especialidad: ${cita.desServHosp ?? servicioElegido.desServicioHosp}`,
|
|
370
|
+
].join("\n"), "Cita confirmada");
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
catch (err) {
|
|
374
|
+
s4.stop("Error al reservar la cita.");
|
|
375
|
+
p.log.error(String(err));
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
// ---------------------------------------------------------------------------
|
|
379
|
+
// Cancelar cita — flujo guiado
|
|
380
|
+
// ---------------------------------------------------------------------------
|
|
381
|
+
async function runCancelar(ts) {
|
|
382
|
+
if (!(await requireLogin(ts)))
|
|
383
|
+
return;
|
|
384
|
+
const s = p.spinner();
|
|
385
|
+
s.start("Cargando citas...");
|
|
386
|
+
let citas = [];
|
|
387
|
+
try {
|
|
388
|
+
citas = await getCitasEmitidas();
|
|
389
|
+
}
|
|
390
|
+
catch (err) {
|
|
391
|
+
s.stop("Error al cargar las citas.");
|
|
392
|
+
p.log.error(String(err));
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
// Solo ofrecemos las que la API marca como cancelables (deja fuera las anuladas).
|
|
396
|
+
const cancelables = citas.filter((c) => c.puedeCancelar === true);
|
|
397
|
+
s.stop(`${citas.length} cita(s); ${cancelables.length} cancelable(s).`);
|
|
398
|
+
if (cancelables.length === 0) {
|
|
399
|
+
p.log.info("No hay citas que puedas cancelar (las anuladas no cuentan).");
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
const opts = cancelables.map((c) => {
|
|
403
|
+
const id = c.citActMedNum ?? c.citAutoGenCod ?? "?";
|
|
404
|
+
const fecha = c.citFecha ?? "—";
|
|
405
|
+
const hora = c.citHora ?? "—";
|
|
406
|
+
const centro = c.citCenAsiDes ?? "—";
|
|
407
|
+
return {
|
|
408
|
+
value: id,
|
|
409
|
+
label: `[${id}] ${fecha} ${hora}`,
|
|
410
|
+
hint: centro,
|
|
411
|
+
};
|
|
412
|
+
});
|
|
413
|
+
const citaId = await p.select({
|
|
414
|
+
message: "Elige la cita a cancelar",
|
|
415
|
+
options: opts,
|
|
416
|
+
});
|
|
417
|
+
if (p.isCancel(citaId))
|
|
418
|
+
return;
|
|
419
|
+
const confirma = await p.confirm({
|
|
420
|
+
message: `¿Cancelar la cita ${citaId}? Esta acción es irreversible.`,
|
|
421
|
+
initialValue: false,
|
|
422
|
+
});
|
|
423
|
+
if (p.isCancel(confirma) || !confirma) {
|
|
424
|
+
p.log.info("Cancelación abortada.");
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
// Necesitamos el centro de la cita para cancelarla. Si no viene, abortamos:
|
|
428
|
+
// adivinarlo podría cancelar contra el centro equivocado (operación irreversible).
|
|
429
|
+
const citaObj = citas.find((c) => c.citActMedNum === citaId || c.citAutoGenCod === citaId);
|
|
430
|
+
const codCentro = citaObj?.citCenAsiCod;
|
|
431
|
+
if (!codCentro) {
|
|
432
|
+
p.log.error("No pude determinar el centro de esa cita; no la cancelo para no afectar la equivocada.");
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
const s2 = p.spinner();
|
|
436
|
+
s2.start("Cancelando cita...");
|
|
437
|
+
try {
|
|
438
|
+
await request("POST", "eliminarCita", {
|
|
439
|
+
oriCenAsis: "1",
|
|
440
|
+
numCitaCreada: citaId,
|
|
441
|
+
codCentro,
|
|
442
|
+
});
|
|
443
|
+
s2.stop("Cita cancelada correctamente.");
|
|
444
|
+
p.log.success(`Cita ${citaId} cancelada.`);
|
|
445
|
+
}
|
|
446
|
+
catch (err) {
|
|
447
|
+
s2.stop("Error al cancelar la cita.");
|
|
448
|
+
p.log.error(String(err));
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
export async function runInteractiveMode() {
|
|
452
|
+
const ts = await getTokenStatus();
|
|
453
|
+
await printBanner(ts);
|
|
454
|
+
// eslint-disable-next-line no-constant-condition
|
|
455
|
+
while (true) {
|
|
456
|
+
// Re-fetch ts inside loop for menu state updates after login
|
|
457
|
+
const loopTs = await getTokenStatus();
|
|
458
|
+
const opcion = await p.select({
|
|
459
|
+
message: "¿Qué quieres hacer?",
|
|
460
|
+
options: [
|
|
461
|
+
{ value: "login", label: "Login", hint: "Iniciar sesión en EsSalud" },
|
|
462
|
+
{ value: "perfil", label: "Mi perfil", hint: "Ver datos del asegurado" },
|
|
463
|
+
{ value: "citas", label: "Mis citas", hint: "Ver citas programadas" },
|
|
464
|
+
{
|
|
465
|
+
value: "reservar",
|
|
466
|
+
label: "Reservar una cita",
|
|
467
|
+
hint: "Flujo guiado: especialidad → cupo → confirmar",
|
|
468
|
+
},
|
|
469
|
+
{ value: "cancelar", label: "Cancelar una cita", hint: "Seleccionar y cancelar una cita" },
|
|
470
|
+
{ value: "salir", label: "Salir", hint: "Cerrar el asistente" },
|
|
471
|
+
],
|
|
472
|
+
});
|
|
473
|
+
if (p.isCancel(opcion) || opcion === "salir") {
|
|
474
|
+
p.outro("Hasta luego.");
|
|
475
|
+
process.exit(0);
|
|
476
|
+
}
|
|
477
|
+
switch (opcion) {
|
|
478
|
+
case "login":
|
|
479
|
+
await runLogin();
|
|
480
|
+
break;
|
|
481
|
+
case "perfil":
|
|
482
|
+
await runPerfil(loopTs);
|
|
483
|
+
break;
|
|
484
|
+
case "citas":
|
|
485
|
+
await runCitas(loopTs);
|
|
486
|
+
break;
|
|
487
|
+
case "reservar":
|
|
488
|
+
await runReservar(loopTs);
|
|
489
|
+
break;
|
|
490
|
+
case "cancelar":
|
|
491
|
+
await runCancelar(loopTs);
|
|
492
|
+
break;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Matchea un JWT (header.payload.signature en base64url). */
|
|
2
|
+
export declare const JWT_RE: RegExp;
|
|
3
|
+
export interface JwtPayload {
|
|
4
|
+
sub?: string;
|
|
5
|
+
exp?: number;
|
|
6
|
+
iat?: number;
|
|
7
|
+
[key: string]: unknown;
|
|
8
|
+
}
|
|
9
|
+
/** Un JWT de EsSalud decodifica con exp o scope en el payload. */
|
|
10
|
+
export declare function looksLikeEsSaludJwt(jwt: string): boolean;
|
|
11
|
+
/** Decodifica el payload de un JWT (sin verificar la firma). Devuelve null si no es válido. */
|
|
12
|
+
export declare function decodeJwtPayload(jwt: string): JwtPayload | null;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Helpers puros para trabajar con JWTs de EsSalud (sin efectos secundarios).
|
|
2
|
+
/** Matchea un JWT (header.payload.signature en base64url). */
|
|
3
|
+
export const JWT_RE = /eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/;
|
|
4
|
+
/** Un JWT de EsSalud decodifica con exp o scope en el payload. */
|
|
5
|
+
export function looksLikeEsSaludJwt(jwt) {
|
|
6
|
+
try {
|
|
7
|
+
const part = jwt.split(".")[1] ?? "";
|
|
8
|
+
const json = Buffer.from(part.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf-8");
|
|
9
|
+
const payload = JSON.parse(json);
|
|
10
|
+
return typeof payload.exp === "number" || payload.scope != null;
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/** Decodifica el payload de un JWT (sin verificar la firma). Devuelve null si no es válido. */
|
|
17
|
+
export function decodeJwtPayload(jwt) {
|
|
18
|
+
try {
|
|
19
|
+
const parts = jwt.split(".");
|
|
20
|
+
if (parts.length !== 3)
|
|
21
|
+
return null;
|
|
22
|
+
const b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
|
|
23
|
+
const padded = b64 + "=".repeat((4 - (b64.length % 4)) % 4);
|
|
24
|
+
return JSON.parse(Buffer.from(padded, "base64").toString("utf-8"));
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Punto de entrada del comando global `essalud`.
|
|
4
|
+
* Sin argumentos → modo interactivo.
|
|
5
|
+
* Con argumentos → subcomandos one-shot (login, perfil, citas, etc.).
|
|
6
|
+
*/
|
|
7
|
+
import { Command } from "commander";
|
|
8
|
+
import { makeEssaludCommand } from "./essalud/index.js";
|
|
9
|
+
import { runInteractiveMode } from "./essalud/interactive.js";
|
|
10
|
+
// Si no hay argumentos más allá de node + script → modo interactivo directo
|
|
11
|
+
const args = process.argv.slice(2);
|
|
12
|
+
if (args.length === 0) {
|
|
13
|
+
void runInteractiveMode();
|
|
14
|
+
}
|
|
15
|
+
else {
|
|
16
|
+
// Reusar todos los subcomandos del comando essalud, pero en un programa raíz
|
|
17
|
+
const program = new Command();
|
|
18
|
+
program.name("essalud").description("Asistente de citas EsSalud").version("0.1.0");
|
|
19
|
+
const essaludCmd = makeEssaludCommand();
|
|
20
|
+
for (const sub of essaludCmd.commands) {
|
|
21
|
+
program.addCommand(sub);
|
|
22
|
+
}
|
|
23
|
+
program.parse(process.argv);
|
|
24
|
+
}
|