UNPKG

openmagicline

Version:
716 lines (702 loc) 20.8 kB
"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 });