simcapture-sdk
v0.22.1
Published
Typed SimCapture API client with auth, token caching, and 401 retry.
Readme
simcapture-sdk
SimCapture API client. A typed wrapper over the SimCapture REST API that handles
authentication, token caching, and 401 retry so consumers don't hand-roll
axios + login code per service.
npm install simcapture-sdkUsage
import { SimCaptureClient, SimCaptureError } from "simcapture-sdk";
const sc = new SimCaptureClient({
apiUrl: process.env.SIMCAPTURE_API!, // https://api.simcapture.com
inventoryUrl: process.env.SIMCAPTURE_INVENTORY_API!,
credentials: {
username: process.env.SIMCAPTURE_USER!,
password: process.env.SIMCAPTURE_PASSWORD!,
clientSubdomain: process.env.SIMCAPTURE_SUBDOMAIN!,
},
});
const orgs = await sc.organizations.findAll();
const reservations = await sc.reservations.findAll({
start: "2025-07-01T05:00:00.000Z",
end: "2026-12-31T05:00:00.000Z",
});
try {
await sc.reservations.findOne("bad-id");
} catch (e) {
if (e instanceof SimCaptureError) {
console.error(e.status, e.code, e.body); // real upstream status, not a blanket 400
}
}Reservations
Create a reservation — clientId is taken from the session (injected into
the body and each location), and start / end map to startTs / endTs. Only
organizationId, title, start, end, and locations are required; the rest
default to the web app's values. Locations / simulators come from
locations.findAll / simulators.findAll:
const reservation = await sc.reservations.create({
organizationId: "b1a6f6af-52d8-437e-bf6d-b601b1f62d14",
title: "Reserva de prueba",
start: "2026-06-05T23:00:00.000Z",
end: "2026-06-05T23:10:00.000Z",
locations: [{ locationId: "dedf64b1-c016-4dde-b8cf-5be04eaafdde", name: "P3-H1", sort: 141 }],
scenarioId: "d8cfffa4-06ca-413b-8730-259551c26c5c",
participantIds: ["3044b924-5999-47eb-b481-20d6238bf04a"],
simulators: [{ simulatorId: "4c466411-fb73-4077-b336-b11645eed029", name: "Simulator 1" }],
// publicNotes / privateNotes / color / setupTimeMinutes … all optional
});
reservation.reservationId;Change only the setup time — updateSetupTime fetches the reservation first
to preserve reservationEndTs / expectedNumberOfParticipants, then PUTs the new
reservationSetupTimeMinutes (the web app's setup-time edit):
await sc.reservations.updateSetupTime("1a3b471c-49e4-4a41-8936-ae1cba44b6d3", 60);Need to change other setup/overview fields too? Use update(reservationId, body)
directly with a ReservationOverviewUpdate or ReservationSetupUpdate body.
Check availability — isAvailable returns true when no reservation overlaps
the start / end window. SimCapture only filters by the time window server-side,
so locations / courses / organizations are matched by name (case-insensitive)
client-side: a reservation counts as an overlap only when it matches every filter you
pass. With no filters it's a plain "is the window free?" check. Organization names are
resolved to ids via organizations.findAll (one extra request, only when you pass an
organizations filter).
// is the window free at all?
await sc.reservations.isAvailable({
start: "2026-06-05T23:00:00.000Z",
end: "2026-06-05T23:10:00.000Z",
});
// free for these rooms? (matches ReservationLocation.name)
await sc.reservations.isAvailable({
start: "2026-06-05T23:00:00.000Z",
end: "2026-06-05T23:10:00.000Z",
locations: ["P3-H1", "P3-H2"],
});
// free for this course? (matches the course public or private title)
await sc.reservations.isAvailable({
start: "2026-06-05T23:00:00.000Z",
end: "2026-06-05T23:10:00.000Z",
courses: ["Anatomía"],
});
// free for this organization? (matches Organization.name, resolved to id)
await sc.reservations.isAvailable({
start: "2026-06-05T23:00:00.000Z",
end: "2026-06-05T23:10:00.000Z",
organizations: ["Facultad de Medicina"],
});Names aren't unique like ids — two locations sharing a name both match. Use ids via
findAll if you need exact identity.
Courses
SDK inputs use simplified field names and sensible defaults; the resource maps
them to the SimCapture wire shape (start → startTs, title → privateTitle,
nested courseDetails/passingCutoff/examFlow, …) so callers don't hand-build
the raw payloads.
Create a course — courseId is generated when omitted:
const course = await sc.courses.create({
privateTitle: "Curso de prueba (privado)", // staff-facing
publicTitle: "Curso de prueba", // learner-facing; defaults to privateTitle
organizationId: "1c7a4713-123f-4e7c-b613-1fe4d48ed1bf",
overview: "Resumen del curso",
learningObjectives: ["Objetivo de aprendizaje"],
passingPercentage: 0, // → passingCutoff.absolutePercentage
});
course.courseId; // use for createScenario / assignEvaluationAdd a scenario to a course — scenarioId is generated when omitted; passing
examFlow (a cloned exam-flow template) flips canHaveExamFlow on:
const scenario = await sc.courses.createScenario(course.courseId, {
title: "ESC 1 de prueba", // → privateTitle
overview: "Descripción del escenario",
learningObjectives: ["Objetivo del escenario"],
simulatorIds: ["4c466411-fb73-4077-b336-b11645eed029"],
// examFlow optional — supply the recording-flow template structure
});
scenario.scenarioId;Add participants (students) to a course — pass user ids; they get the
Course_Participant role. Returns the updated course with its role assignments:
await sc.courses.addParticipants(course.courseId, [
"c28953cf-8eb8-4f12-8072-4cde0265effa",
]);Assign an evaluation to students with a scheduled date/time window
(allowLateCompletion defaults to true):
await sc.courses.assignEvaluation(
course.courseId,
"836a17dc-5e6a-4e9b-9126-ef7d89b954f6", // evaluationTemplateId (NOT scenarioId)
{
start: "2026-06-10T12:55:00.000Z", // → startTs
end: "2026-06-10T13:35:00.000Z", // → endTs
scenarioId: scenario.scenarioId,
students: ["01b8fcf0-6b47-4b29-a628-cdd535eca89b", "07a8203d-…"], // → userIds
},
);Users
List / search users — GET /users, paginated (zero-based page). count
and active default to true; pass search to filter by name / email /
username. Feed the ids into courses.addParticipants:
const { rows } = await sc.users.find({ search: "GEORGE" });
const { rows: page0, count } = await sc.users.find({ page: 0 });
await sc.courses.addParticipants(
course.courseId,
rows.map((u) => u.userId),
);List role groups — GET /userGroups. Use these to pick the userGroupId
when creating a user (e.g. System Admin, Administrator, Participant):
const groups = await sc.users.findGroups();
const participant = groups.find((g) => g.name === "Participant");Create a user — userId is generated when omitted. userGroupId is the
role group (from users.findGroups):
const user = await sc.users.create({
userName: "jdoe",
password: "s3cret",
email: "[email protected]",
firstName: "Jane",
lastName: "Doe",
middleName: "Q",
organizationIds: ["88e54068-a28e-4eff-8469-6d04cb6337c9"],
userGroupId: participant!.userGroupId,
});
user.userId; // → courses.addParticipants(courseId, [user.userId])Delete a user — DELETE /users/{userId}. Returns no body:
await sc.users.delete(user.userId);Scenarios
Toggle evaluation/rubric/EMR features — PUT /scenarios/{id}/eval-template-config.
Every flag is optional and defaults to false, so you only pass the ones you
want on. Returns no body:
await sc.scenarios.updateEvalTemplateConfig(scenario.scenarioId, {
hasDuringSessionEval: true, // "Evaluación del Administrador" in the UI
});Or a standalone scenario eval ("escenario independiente") with a scoring rubric — pair the two manual-scenario flags:
await sc.scenarios.updateEvalTemplateConfig(scenario.scenarioId, {
hasManualScenarioEval: true, // standalone scenario evaluation
canHaveManualScenarioScoringRubric: true, // + scoring rubric
});Attach an evaluation template — POST /scenarios/{id}/evaluation-templates.
evaluationTemplateId is generated when omitted; the returned id is what you pass
to courses.assignEvaluation:
const evalTemplate = await sc.scenarios.createEvaluationTemplate(scenario.scenarioId, {
title: "Evaluación de administrador (privada)",
timeRef: "DuringSession", // "Evaluación del Administrador"
});
evalTemplate.evaluationTemplateId; // → courses.assignEvaluation(courseId, this, …)Add questions to an evaluation template —
POST …/evaluation-templates/{id}/questions. The SDK builds the raw payload
(Lexical rich-text, answer-option ids, layout/type UUIDs) from a plain
{ text, answers } shape. A yes/no question is just two answers — point the
correct one:
await sc.scenarios.setEvaluationQuestions(
scenario.scenarioId,
evalTemplate.evaluationTemplateId,
[
{
text: "¿El alumno realizó el procedimiento correctamente?",
answers: [
{ text: "SI", points: 1 },
{ text: "NO", points: 0 },
],
// points: 1, required: true by default
},
],
);This replaces the template's question list, so pass every question you want kept. Defaults match the web builder: single-select (radio) layout,
required: true,points: 1.
Read students' scores for an evaluation —
GET …/evaluation-templates/{id}/evaluations. One result per graded student,
with totalScore / possibleScore and the per-question answers
(includeScores defaults to true):
const results = await sc.scenarios.getEvaluations(
scenario.scenarioId,
evalTemplate.evaluationTemplateId,
);
for (const r of results) {
const { firstName, lastName } = r.evaluatee;
console.log(`${firstName} ${lastName}: ${r.totalScore}/${r.possibleScore}`);
}Delete an evaluation template —
DELETE /scenarios/{id}/evaluation-templates/{id}. Returns no body:
await sc.scenarios.deleteEvaluationTemplate(
scenario.scenarioId,
evalTemplate.evaluationTemplateId,
);Add an attachment — uploading is two steps (PUT the raw bytes, then register
the asset). addAttachment does both: it generates the assetId, uploads under
fileName, and registers it (displayName / description default to fileName):
import { readFile } from "node:fs/promises";
const file = await readFile("invite.ics");
await sc.scenarios.addAttachment(scenario.scenarioId, {
fileName: "invite.ics",
data: file, // ArrayBuffer | Uint8Array | Blob
// displayName / description default to fileName
});Need the steps separately (e.g. to upload several files then register them in one
batch)? Use uploadAttachment(scenarioId, fileName, data) then
registerAttachments(scenarioId, [{ assetId, name, displayName, description }]).
Resources
| Resource | Methods |
|---|---|
| organizations | findAll, create, bulkReplace |
| locations | findAll |
| live | findAll |
| reservations | findAll, create, findOne, update, updateSetupTime, updateDetails, delete |
| courses | find, create, createScenario, updateDetails, addParticipants, getItems, assignEvaluation |
| users | find, findGroups, create, delete |
| scenarios | findOne, updateSetup, updateDetails, updateEvalTemplateConfig, createEvaluationTemplate, setEvaluationQuestions, getEvaluations, deleteEvaluationTemplate, addAttachment, uploadAttachment, registerAttachments, getAttachment |
| simulators | findAll, findOne, update, getConfigs, getConfig |
| notifications | send |
| inventory | findAll, findOne, edit (inventory server) |
Auth behaviour
- Tokens are cached and reused until a soft TTL (
tokenTtlMs, default 30 min) elapses. - A
401clears the cache, re-logins once, and retries the failed request once. - Concurrent callers share a single in-flight
/authrequest.
Architecture (DDD)
domain/ entities, value-objects, ports (HttpClient, TokenStore), models, errors
application/ use-cases: Authenticator + one resource class per domain
infrastructure/ axios transport adapter + in-memory token store
config/ SimCaptureConfig + resolution/validation
client.ts composition root wiring infra → applicationflowchart TB
Consumer["Consumer (MS / frontend)"] --> Client["SimCaptureClient<br/>(composition root)"]
subgraph application["application"]
Resources["Resources<br/>reservations · scenarios · …"]
Auth["Authenticator<br/>token cache · 401 retry"]
end
subgraph domain["domain (no deps)"]
Ports["Ports<br/>HttpClient · TokenStore"]
Models["Models · VOs · SimCaptureError"]
end
subgraph infrastructure["infrastructure"]
Axios["AxiosHttpClient"]
Store["InMemoryTokenStore"]
end
Client --> Resources
Client --> Auth
Resources --> Auth
Resources -.depends on.-> Ports
Auth -.depends on.-> Ports
Auth --> Models
Axios -.implements.-> Ports
Store -.implements.-> Ports
Axios --> SimCapture["SimCapture API<br/>api + inventory servers"]
Client -. injects .-> Axios
Client -. injects .-> StoreDependencies point inward — the domain/application layers depend only on the
HttpClient/TokenStore ports, so they are unit-tested with fakes (no network).
Advanced consumers can inject their own transport or a shared token store via the
second SimCaptureClient constructor argument.
Development (Bun)
bun install
bun run typecheck # tsc --noEmit (strict)
bun test # unit tests against the ports (no real network)
bun run build # tsup → ESM + CJS + .d.ts in dist/ (Node-compatible)The published artifact is built with tsup for Node ≥18 (ESM + CJS + types). Bun is
the dev/build/test toolchain only — there are no bun:* imports in shipped code.
