openmagicline
Version:
Magicline API for everybody.
716 lines (702 loc) • 20.8 kB
JavaScript
"use strict";
var __create = Object.create;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
// If the importer is in node compatibility mode or this is not an ESM
// file that has been converted to a CommonJS file using a Babel-
// compatible transform (i.e. "__esModule" has not been set), then set
// "default" to the CommonJS "module.exports" for node compatibility.
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
mod
));
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
// src/index.ts
var src_exports = {};
__export(src_exports, {
Openmagicline: () => Openmagicline
});
module.exports = __toCommonJS(src_exports);
var import_once = __toESM(require("lodash/once"));
var import_debug = __toESM(require("debug"));
// src/util.ts
var import_form_data = __toESM(require("form-data"));
// src/constants.ts
var DEFAULT_UNIT_ID = 1;
// src/util.ts
var Util = class {
constructor(fetch, mgl) {
this.fetch = fetch;
this.mgl = mgl;
}
async getDefaultUnitID() {
const data = await this.mgl.organization.permitted();
const [firstChild] = data.listChildren;
if (!firstChild)
throw new Error("no children found");
return firstChild.databaseId ?? DEFAULT_UNIT_ID;
}
/**
* check if the login token works.
*/
async testLogin() {
try {
await this.mgl.locale.currentLocale();
return true;
} catch {
return false;
}
}
};
var headers = (mgl) => {
const u = new URL(mgl.baseUrl);
const returnValue = {
"accept-language": "en-US,en;q=0.6",
accept: "application/json, text/javascript, */*; q=0.01",
authority: u.hostname,
origin: u.origin,
referer: u.origin,
priority: "u=1, i",
"sec-ch-ua": '"Brave";v="135", "Not-A.Brand";v="8", "Chromium";v="135"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"macOS"',
"sec-fetch-dest": "empty",
"sec-fetch-mode": "cors",
"sec-fetch-site": "same-origin",
"sec-gpc": "1",
"x-ml-wc-version": "3.425.25",
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36"
};
if (mgl.cookies)
returnValue.cookie = mgl.cookies;
return returnValue;
};
var websocketHeaders = (mgl) => {
const returnValue = {
host: `${mgl.config.gym}.web.magicline.com`,
connection: "Upgrade",
pragma: "no-cache",
"cache-control": "no-cache",
upgrade: "websocket",
origin: `https://${mgl.config.gym}.web.magicline.com`,
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
"accept-encoding": "gzip, deflate, br, zstd",
"accept-language": "en-US,en;q=0.9,de;q=0.8"
};
if (mgl.cookies)
returnValue.cookie = mgl.cookies;
return returnValue;
};
// src/locale.ts
var Locale = class {
constructor(fetch) {
this.fetch = fetch;
}
async currentLocale() {
return await this.fetch("/currentLocale");
}
async supportedLocales() {
return await this.fetch("/supportedLocales");
}
};
// src/organization.ts
var Organization = class {
constructor(fetch, mgl) {
this.fetch = fetch;
this.mgl = mgl;
}
async permitted() {
return await this.fetch("/organizationunit/permitted");
}
async accountInfo() {
return await this.fetch("/me/info");
}
async apps(organizationUnitId) {
const unitID = organizationUnitId ?? await this.mgl.unitID;
return await this.fetch("/app", {
query: { organizationUnitId: unitID }
});
}
};
// src/customer.ts
var Customer = class {
constructor(fetch, mgl) {
this.fetch = fetch;
this.mgl = mgl;
}
defaultSearchOptions = {
facility: 0,
searchInName: true,
searchInCustomerNumber: true,
searchInAddress: false,
searchInBankAccount: false,
searchInCardNumber: false,
searchInLockerKey: false,
searchInPurchasedContingentCode: false,
showAllFacilities: true,
showCheckedIn: false,
showOnlyMembers: false
};
/**
* Search for customers.
*
* You will probably want to set `facility` to the unitID of the gym.
*/
search = async (searchString, options) => {
return await this.fetch(
"/customersearch",
{
method: "POST",
body: {
...this.defaultSearchOptions,
...options,
searchString
}
}
);
};
/**
* Get the cards of a customer.
*/
getCards = async (customerID) => {
return await this.fetch(
`/customer/${customerID}/accessidentification`
);
};
/**
* get contracts of a customer
* @param customerId customer id
* @param isActive get only active contracts (default: `true`)
*/
getContracts = async (customerId, isActive = true) => {
return await this.fetch("/contract", {
query: { customerId, isActive }
});
};
checkinConditions = async (customerId, organizationUnitId) => {
const unitID = organizationUnitId ?? await this.mgl.unitID;
return await this.fetch(
`/customer/${customerId}/conditions/checkin`,
{
query: { organizationUnitId: unitID }
}
);
};
benefits = async (customerId, active = "both") => {
const returnList = [];
if (active === "both" || active === true) {
const data = await this.fetch(
"/benefitaccount",
{
query: { customerId, active: true }
}
);
returnList.push(...data);
}
if (active === "both" || active === false) {
const data = await this.fetch(
"/benefitaccount",
{
query: { customerId, active: false }
}
);
returnList.push(...data);
}
return returnList;
};
detailedBalance = async (customerId) => {
return await this.fetch(
`/customer/${customerId}/balance/detailed`
);
};
getAssignableTagIDs = async (facilityId) => {
return this.fetch(
`/customer-tag/assignable/simple?facilityIds=${facilityId}`
);
};
setTagIDs = async (customerId, IDs) => {
return this.fetch(
`/customer/${customerId}/customer-tag/ids`,
{
body: IDs,
method: "PUT"
}
);
};
};
// src/checkin.ts
var Checkin = class {
constructor(fetch, mgl) {
this.fetch = fetch;
this.mgl = mgl;
}
/** map of `customerID` -> `checkinID` */
#checkinMemberMap = /* @__PURE__ */ new Map();
/** map of `customerID` -> `checkinID` */
get checkinMemberMap() {
return this.#checkinMemberMap;
}
defaultListParams = {
organizationUnitId: DEFAULT_UNIT_ID,
checkouts: false,
offset: 0,
maxResults: 25,
search: "",
filter: "",
sortedby: "checkinTime",
direction: "DESCENDING"
};
/**
* list all checked-in customers
* @param options filter, sort, etc.
*/
list = async (options) => {
let organizationUnitId = options?.organizationUnitId;
if (typeof organizationUnitId !== "number") {
organizationUnitId = await this.mgl.unitID;
}
const result = await this.fetch("/checkin", {
query: {
...this.defaultListParams,
organizationUnitId,
...options
}
});
for (const checkin of result.checkins) {
this.#checkinMemberMap.set(checkin.customerId, checkin.databaseId);
}
return result;
};
defaultCheckinParams = {
customerCardNumber: void 0,
customerUUID: "",
fkCustomer: 0,
fkDevice: void 0,
fkOrganizationUnit: DEFAULT_UNIT_ID,
lockerKey: "",
purchasedContingentCode: void 0,
databaseId: void 0,
optlock: 0,
requiredOrganizationUnitId: DEFAULT_UNIT_ID
};
/**
* check-in a customer
*/
checkin = async (options) => {
let unitID = options.requiredOrganizationUnitId ?? options.fkOrganizationUnit;
if (typeof unitID !== "number") {
unitID = await this.mgl.unitID;
}
return await this.fetch("/checkin", {
method: "POST",
body: {
...this.defaultCheckinParams,
fkOrganizationUnit: unitID,
requiredOrganizationUnitId: unitID,
...options
}
});
};
/**
* check-out a customer
* @param checkinId the ID of the checkin, **not** the customer ID
* @param options optional object containing optLockRemote, not sure what it does
*/
checkout = async (checkinId, options) => {
return await this.fetch(
`/checkin/${checkinId}`,
{
method: "DELETE",
query: options
}
);
};
checkoutByCustomerID = async (customerID) => {
let checkinID = this.#checkinMemberMap.get(customerID);
if (!checkinID) {
await this.list({ maxResults: 100 });
checkinID = this.#checkinMemberMap.get(customerID);
}
if (!checkinID)
throw new Error("customerID not found in checkinMemberMap");
const checkout = this.checkout(checkinID);
this.#checkinMemberMap.delete(customerID);
return checkout;
};
defaultLockerKeyParams = {
databaseId: void 0,
optlock: 0
};
changeLockerKey = async (checkinId, lockerKey, options) => {
return await this.fetch(
`/checkin/lockerkey/${checkinId}`,
{
method: "PUT",
body: {
...this.defaultLockerKeyParams,
...options,
checkinId,
lockerKey
}
}
);
};
};
// src/sales.ts
var Sales = class {
constructor(fetch, mgl) {
this.fetch = fetch;
this.mgl = mgl;
}
products = async (options) => {
const organizationUnitId = options?.organizationUnitId ?? await this.mgl.unitID;
return await this.fetch(
"/sales/productoverview",
{
query: {
organizationUnitId,
...options
}
}
);
};
};
// src/socket.ts
var import_ws = __toESM(require("ws"));
var MagicSocket = class {
constructor(mgl, unitID) {
this.mgl = mgl;
this.log = mgl.log.extend("socket");
this.sendLog = this.log.extend("send");
this.receiveLog = this.log.extend("receive");
this.connected = new Promise((resolve) => {
this.connectedResolve = resolve;
});
const socketURL = new URL(this.mgl.baseUrl);
socketURL.protocol = "wss:";
socketURL.pathname = "ws";
socketURL.searchParams.set("organizationUnitId", unitID.toString());
const headers2 = websocketHeaders(this.mgl);
this.socket = new import_ws.default(socketURL.href, { headers: headers2 });
this.socket.on("open", () => {
this.log("socket opened");
this.send(
this.buildMessage("CONNECT", {
"accept-version": "1.2,1.1,1.0",
"heart-beat": "10000,10000"
})
);
});
this.socket.on("message", (data) => {
this.onMessage(data.toString());
});
}
socket;
log;
sendLog;
receiveLog;
messageEnd = "\0";
heartbeatInterval;
subscriptions = /* @__PURE__ */ new Map();
subscriptionCounter = 0;
connected;
connectedResolve;
buildMessage = (type, metadata, payload) => {
const metadataString = Object.entries(metadata).map(([key, value]) => {
return `${key}:${value}`;
});
const metaBlock = [type, ...metadataString].join("\n");
return `${metaBlock}
${payload ?? ""}${this.messageEnd}`;
};
parseMessage = (message) => {
const cleaned = message.replace(this.messageEnd, "");
const [metaBlock, payload] = cleaned.split("\n\n");
if (!metaBlock)
throw new Error("metaBlock not found");
const [type, ...metadata] = metaBlock.split("\n");
return [type, metadata, payload];
};
onMessage = (message) => {
const [type, metadata, payload] = this.parseMessage(message);
const trimmed = message.replace(this.messageEnd, "").trim();
if (trimmed === "")
this.receiveLog("heartbeat");
else
this.receiveLog(trimmed);
switch (type) {
case "CONNECTED": {
this.handleTypeCONNECTED(metadata);
break;
}
case "MESSAGE": {
this.handleTypeMESSAGE(metadata, payload);
break;
}
}
};
send = (message) => {
const trimmed = message.replace(this.messageEnd, "").trim();
if (trimmed === "")
this.sendLog("heartbeat");
else
this.sendLog(trimmed);
this.socket.send(message);
};
sendHeartbeat = () => {
this.send("\n");
};
handleTypeCONNECTED = (metadata) => {
this.connectedResolve?.();
const heartbeatData = metadata.find((m) => m.startsWith("heart-beat:"));
if (!heartbeatData)
return this.log("no heartbeat data found");
const [, heartbeats] = heartbeatData.split(":");
if (!heartbeats)
throw new Error("heartbeats not found");
const [, heartbeatOutgoing] = heartbeats.split(",");
if (!heartbeatOutgoing)
throw new Error("heartbeatOutgoing not found");
this.log("heartbeat interval", heartbeatOutgoing);
this.heartbeatInterval = setInterval(
this.sendHeartbeat,
Number(heartbeatOutgoing)
);
};
handleTypeMESSAGE = (metadata, payload) => {
const destinationData = metadata.find((m) => m.startsWith("destination:"));
if (!destinationData)
return this.log("no destination data found");
const [, destination] = destinationData.split(":");
if (!destination)
throw new Error("destination not found");
const subscriptions = this.subscriptions.get(destination);
if (!subscriptions)
return;
for (const callback of subscriptions) {
callback(payload ? JSON.parse(payload) : void 0);
}
};
subscribe = (topic, callback) => {
const subscriptions = this.subscriptions.get(topic) ?? /* @__PURE__ */ new Set();
subscriptions.add(callback);
if (!this.subscriptions.has(topic)) {
this.subscriptions.set(topic, subscriptions);
}
this.send(
this.buildMessage("SUBSCRIBE", {
id: `${this.subscriptionCounter++}`,
destination: topic
})
);
return () => {
this.subscriptions.get(topic)?.delete(callback);
if (this.subscriptions.get(topic)?.size === 0) {
this.subscriptions.delete(topic);
}
};
};
/** fires when a customer gets checked in or out */
onCheckin = (callback) => {
return this.subscribe("/user/topic/checkin", callback);
};
/**
* fires when a card gets tapped to a checkin device
* using magicline device manager
*/
onCheckinRequest = (callback) => {
return this.subscribe("/user/topic/checkin/request", callback);
};
close = () => {
this.log("closing socket");
clearInterval(this.heartbeatInterval);
this.socket.removeAllListeners();
this.socket.close();
};
};
// src/leads.ts
var Leads = class {
constructor(fetch, mgl) {
this.fetch = fetch;
this.mgl = mgl;
}
defaultCreateLeadOptions = {
source: { type: "MANUAL" },
status: "PENDING",
customer: {
firstname: "",
lastname: "",
gender: "UNISEX",
address: { details: {}, country: "DE" },
placeOfBirth: ""
},
tenant: ""
};
/**
* create a new lead, provide at least customer.firstname and customer.lastname
*/
createLead = async (options) => {
if (options.facilityId === void 0) {
options.facilityId = await this.mgl.unitID;
}
return this.fetch("/leadmanagement", {
method: "POST",
body: {
...this.defaultCreateLeadOptions,
...options,
customer: {
...this.defaultCreateLeadOptions.customer,
...options.customer
}
}
});
};
/**
* Get all lead campaigns.
*/
getCampaigns = async (onlyActive = false, organizationUnitId) => {
const unitID = organizationUnitId ?? await this.mgl.unitID;
return this.fetch("/campaign", {
query: { organizationUnitsIds: unitID, onlyActive }
});
};
};
// src/index.ts
var import_ofetch = require("ofetch");
var Openmagicline = class {
// TODO: check version and warn if openmagicline is outdated
// TODO: remove default unit id
// TODO: make a responsedump folder to auto-generate types script
constructor(config) {
this.config = config;
this.log = (0, import_debug.default)("openmagicline");
this.baseUrl = `https://${this.config.gym}.web.magicline.com`;
const prefixUrl = `${this.baseUrl}/rest-api`;
const ofetchLogger = this.log.extend("fetch");
this.fetch = import_ofetch.ofetch.create({
baseURL: prefixUrl,
referrer: prefixUrl,
onRequest: ({ options }) => {
if (this.cookies)
options.headers.set("cookie", this.cookies);
for (const [key, value] of Object.entries(headers(this))) {
if (!options.headers.has(key))
options.headers.set(key, value);
}
},
onResponse: ({ response, options, request }) => {
let logString = `[${options.method ?? "GET"}](${response.status}) `;
if (typeof request === "string") {
logString += request.replace(prefixUrl, "");
}
ofetchLogger(logString);
},
onResponseError: async (context) => {
if (context.response.status === 401) {
this.log("token expired, re-authenticating");
await this.login();
context.response = await this.fetch(context.request);
}
}
});
this.customer = new Customer(this.fetch, this);
this.locale = new Locale(this.fetch);
this.organization = new Organization(this.fetch, this);
this.checkin = new Checkin(this.fetch, this);
this.util = new Util(this.fetch, this);
this.sales = new Sales(this.fetch, this);
this.disposal = this.sales;
this.leads = new Leads(this.fetch, this);
this.socket = async (unitID) => {
const _unitID = unitID ?? await this.unitID;
return new MagicSocket(this, _unitID);
};
this.#unitID = config.unitID;
}
log;
fetch;
baseUrl;
cookies;
#unitID;
get unitID() {
if (this.#unitID === void 0) {
const promise = this.util.getDefaultUnitID();
promise.then((unitID) => {
this.#unitID = unitID;
});
return promise;
}
return this.#unitID;
}
customer;
/** get locale information */
locale;
/** get organization information */
organization;
/** everything related to the checkin process */
checkin;
/** miscellaneous helpers and thingies */
util;
/** everything related to retail sales (magicline calls this disposal in some places) */
sales;
/** reference to this.sales */
disposal;
/** everything related to leads/interessenten */
leads;
/** event handler for magiclines websockets */
socket;
_login = async (cookies) => {
if (cookies) {
this.cookies = cookies;
this.login = (0, import_once.default)(this._login);
if (await this.util.testLogin())
return;
this.cookies = void 0;
throw new Error("invalid token");
}
const { username, password } = this.config;
if (!username || !password) {
throw new Error(
"username and password need to be set when cookies aren't provided"
);
}
const response = await import_ofetch.ofetch.raw("/login", {
method: "POST",
query: { username, password, client: "webclient" },
baseURL: this.baseUrl
});
this.login = (0, import_once.default)(this._login);
const newCookies = response.headers.get("set-cookie");
if (!newCookies)
throw new Error("no login cookies returned");
this.cookies = newCookies;
};
/**
* authenticate the Openmagicline instance using username/password from the instance config.
*
* if a token is passed, it will be validated and the request to `/login` will be skipped.
* @param cookies existing cookies, available after login at `.cookies`
* @returns instance for chaining
* @throws when not authenticated
*/
login = (0, import_once.default)(this._login);
};
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
Openmagicline
});