diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f849990..65be934 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -2,25 +2,35 @@ name: Run Unit Tests on: push: - branches: [master] pull_request: branches: [master] +permissions: + contents: read + jobs: build: - runs-on: ubuntu-latest + runs-on: macos-latest strategy: matrix: - node-version: [18.x] + deno-version: [2.7] steps: - - uses: actions/checkout@v4 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v3 + - uses: actions/checkout@v6 + - name: Use Deno ${{ matrix.deno-version }} + uses: denoland/setup-deno@v2 with: - node-version: ${{ matrix.node-version }} - - run: npm ci --ignore-scripts - - run: npm run linter - - run: npm test - - run: npm run build + deno-version: ${{ matrix.deno-version }} + - name: Install Dependencies + run: deno install + - name: Format Check + run: deno task fmt:check + - name: Lint All + run: deno task lint:all + - name: Test All + run: deno task test:all + - name: Build App + run: deno task build:app + - name: Build Widgets + run: deno task build:widgets diff --git a/.gitignore b/.gitignore index 080486e..2519dc6 100644 --- a/.gitignore +++ b/.gitignore @@ -33,7 +33,8 @@ bower_components # Editors .idea *.iml -.vscode +*.env +.vscode/launch.json # OS metadata .DS_Store @@ -41,6 +42,7 @@ Thumbs.db # Ignore built ts files dist +_dist build .build .serverless @@ -51,7 +53,19 @@ yarn.lock # ignore local files *.LOCAL.* *.local.* -.tago-lock.kickstarter.lock +*.lock .tagoio -.tago-lock.tagoio init.lock -tagoconfig +.superpowers + +# vite +.vite +.tago-lock.dev.lock + +# E2E (Playwright) +e2e/test-results/ +e2e/playwright-report/ +e2e/playwright/.auth/ + +# Local tooling +.claude/ +.tmp-test-screenshots/ diff --git a/.npmrc b/.npmrc deleted file mode 100644 index ad58848..0000000 --- a/.npmrc +++ /dev/null @@ -1 +0,0 @@ -save-prefix="" diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..e98bbed --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,7 @@ +{ + "recommendations": [ + "editorconfig.editorconfig", + "denoland.vscode-deno", + "bradlc.vscode-tailwindcss" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..7eb7fd8 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,27 @@ +{ + // Deno LSP across the workspace (root deno.json lists all members) + "deno.enable": true, + "deno.lint": true, + "deno.codeLens.test": true, + "deno.codeLens.references": true, + // e2e/ is a Node project (Playwright + npm); let the built-in TS server handle it. + "deno.disablePaths": ["e2e"], + + // deno fmt as the default formatter — stays in sync with CI's lint/fmt checks + "editor.formatOnSave": true, + "[typescript]": { "editor.defaultFormatter": "denoland.vscode-deno" }, + "[typescriptreact]": { "editor.defaultFormatter": "denoland.vscode-deno" }, + "[javascript]": { "editor.defaultFormatter": "denoland.vscode-deno" }, + "[json]": { "editor.defaultFormatter": "denoland.vscode-deno" }, + "[jsonc]": { "editor.defaultFormatter": "denoland.vscode-deno" }, + + // Tailwind v4 is CSS-first — point IntelliSense at each widget's styles.css. + // Add a new key per widget as you create them. + "tailwindCSS.experimental.configFile": { + "widgets/line-chart/styles.css": "widgets/line-chart/**" + }, + "cSpell.words": [ + "CHECKIN", + "sendgrid" + ] +} diff --git a/README.MD b/README.MD index b24a227..4ac5447 100644 --- a/README.MD +++ b/README.MD @@ -1,159 +1,97 @@ -
-

- TagoIO -

- -# Kickstarter Application for TagoIO -This is a starter application to be used at TagoIO. It has main required features for an regular application to run, with a functionality to facilitate setting up new sensors within the application. You can use the scripts provided here to learn or to develop your own solution with it's foundations. - -# Components -The application contains the following features: -* Two levels: Organization and Groups. -* Three access levels: Super Administrator, Organization Administrator and Guest User. -* Organization plans for managing SMS, email and data retetion. -* Setup alerts. -* Generate scheduled reports. -* Navigation between dashboards and Run Buttons enabled. - -### Easy Installation -You can quickly install the application in your account by using the Import tool. -* Firstly, make sure your RUN is activated. Visit this [link](https://admin.tago.io/run/), click on "Start now" and then save the change. -* Go to your [profile settings](https://admin.tago.io/account/). -* Generate an Token in your profile, make sure to set **expire time** to never. -* Copy the Token. -* Access the Import tool by [clicking here](https://import-application.tago.run/dashboards/info/63e698562df1360009606d71?anonymousToken=00000000-5bbc-b03b-667d-7a002e56664b). -* Keep all entities selected, paste your token in the account token field. -* Add you account token to a new secret named: ACCOUNT_TOKEN, it is needed to allow Analysis to work. -* Press Import Application and you should receive notification in your account when it's done. -* To access the application using TagoRUN. Make sure to create a user using your TagoIO Developer account, adding the tag key **access** with the tag value **admin**. - -### Easy Update -To easy update your application when new version is out, you can just repeat the previous step. -If you had made changes to the application, you can choose only the entities you want to update, like only analysis for example. - -### Step-by-Step installation -You only want to follow this installation method, instead of the easy one, if you plan to have deep understading of how all TagoIO resources works and are used. I can't think of any other reason to go through this method. - -* Generate an account-token in your profile settings. -* Create a Custom HTTPs device, to be used as a internal storage for the application. Make sure you have the tag key **device_type** with tag value **settings** on it. -* Get Analysis and Dashboard templates from [Analysis documentation](https://github.com/tago-io/analysis-kickstarter/blob/master/src/analysis/README.md). -* Create actions as described in the header documentation on each analysis file at [Analysis folder](https://github.com/tago-io/analysis-kickstarter/tree/master/src/analysis). -* Create policies for your Run Users (work in progress) -* Create the Run Buttons (work in progress) -* Follow the **Code Setup and Installation** instructions below, to make sure scripts are up-to-date. -* You're done! - -### Code Setup and Installation -Using this repository, you will be able to change and update the analysis in your account. This step is not required unless you plan to make changes in the code. -* Install [Node.JS](https://help.tago.io/portal/en/kb/articles/464-node-js). -* Download the repository. -* Open your terminal and access the folder of the repository. -* Run `npm install`. -* Generate a account-profile token at TagoIO **My Settings** -> **Your Profile** -> **Tokens**. Make sure to generate a token with `Expire at` set to never. -* Open the `build.ts` and replace `Your-Account-Profile-Token` by a token of your profile. -* Go back to your terminal and run the template with `npm run start build`. -* It should take a few minutes for the script to build and import all the analysis and dashboards to your account. - -### Code Update -* Make sure you have the `build.ts` with your account profile token. -* Update the repository with last version. -* Go back to your terminal and run the template with `npm run start build`. -* It should take a few minutes for the script to build and import all the analysis and dashboards to your account. - -### How to use the application -The documentation on how to use the application is avaiable to download in the following link [analysis-kickstarter/docs/Kickstarter-Guide](https://github.com/tago-io/analysis-kickstarter/blob/master/docs/Kickstarter%20-%20Guide.pdf). - -### How to learn from this code -You will notice three folders in the `src/` folder. -* **analysis**: contains each analysis that must be present in your account. -* **lib**: useful list of functions commonly used between the scripts. -* **services**: services folders that are used in the analysis. Each service is related - -For additional informations on how to setup TagoIO with this code, and all the TagoIO resources used, check the Kickstarter Guide documentation at [analysis-kickstarter/docs/Kickstarter-Guide](https://github.com/tago-io/analysis-kickstarter/blob/master/docs/Kickstarter%20-%20Guide.pdf). - -### User Permissions Diagram - - -```mermaid -graph LR -subgraph TagoIO KickStarter User Permission Diagram V1.0.0 - subgraph Application - subgraph Entities - Organization(Organization) - Groups(Groups) - Sensors(Sensors) - end - subgraph Services - Plan(Plan System) - Alerts(Alerts) - Reports(Reports) - Indicators(System Indicators) - IndoorView(Indoor & Outdoor Plan) - end - end - style A fill:black,stroke:red,stroke-width:2px,color:#fff,stroke-dasharray: 5 5 - A((Administrator)) - - - A -- Can Invite --> O - A -- Can Manage --> Organization - A -- Can Manage --> Groups - A -- Can Manage --> Sensors - A -- Can Manage --> Plan - A -- Can Manage --> Alerts - A -- Can Manage --> Reports - A -- Can Manage --> Indicators - A -- Can Manage --> IndoorView - - linkStyle 0 stroke-width:2px,fill:none,stroke:red; - linkStyle 1 stroke-width:2px,fill:none,stroke:red; - linkStyle 2 stroke-width:2px,fill:none,stroke:red; - linkStyle 3 stroke-width:2px,fill:none,stroke:red; - linkStyle 4 stroke-width:2px,fill:none,stroke:red; - linkStyle 5 stroke-width:2px,fill:none,stroke:red; - linkStyle 6 stroke-width:2px,fill:none,stroke:red; - linkStyle 7 stroke-width:2px,fill:none,stroke:red; - linkStyle 8 stroke-width:2px,fill:none,stroke:red; - - - style O fill:black,stroke-width:2px,color:#fff,stroke-dasharray: 5 5 - O((Organization Admin)) - O -- Can Invite --> G - O -- Can Manage *1 --> Groups - O -- Can Manage *1 --> Sensors - O -- Can Manage *1 --> Alerts - O -- Can Manage *1 --> Reports - O -- Can Monitor *1 --> Indicators - O -- Can Manage *1 --> IndoorView - - linkStyle 9 stroke-width:2px,fill:none,stroke:white; - linkStyle 10 stroke-width:2px,fill:none,stroke:white; - linkStyle 11 stroke-width:2px,fill:none,stroke:white; - linkStyle 12 stroke-width:2px,fill:none,stroke:white; - linkStyle 13 stroke-width:2px,fill:none,stroke:white; - linkStyle 14 stroke-width:2px,fill:none,stroke:white; - linkStyle 15 stroke-width:2px,fill:none,stroke:white; - - style G fill:black,stroke:green,stroke-width:2px,color:#fff,stroke-dasharray: 5 5 - G((Guest)) - G -- Can View Only *1 --> Groups - G -- Can View Only *1 --> Sensors - G -- Can View Only *1 --> IndoorView - G -- Will Receive Only *1 --> Reports - G -- Will Receive Only *1 --> Alerts - G -- Can Monitor *1 --> Indicators - - linkStyle 16 stroke-width:2px,fill:none,stroke:green; - linkStyle 17 stroke-width:2px,fill:none,stroke:green; - linkStyle 18 stroke-width:2px,fill:none,stroke:green; - linkStyle 19 stroke-width:2px,fill:none,stroke:green; - linkStyle 20 stroke-width:2px,fill:none,stroke:green; - linkStyle 21 stroke-width:2px,fill:none,stroke:green; - -test[1* Only those related to the User's Company] -end +# Kickstarter + +Multi-tenant IoT template that runs on TagoIO. It ships the three things a complete TagoIO application needs: **Analyses** (Deno serverless handlers), **custom Dashboard Widgets** +(React iframes), and **dashboard helper docs** (the markdown rendered inside each dashboard's `Helper` tab in the application). + +The runtime is Deno end-to-end. Widgets add a Vite/React toolchain on top for the browser bundle. Everything is wired as a Deno workspace so analyses and widgets can share types +without a publish step. + +## Layout + ``` +analysis-kickstarter/ +├── app/ # Deno workspace — Analyses + bundle script +├── widgets/ # Vite/React workspace — one subfolder per widget +├── dashboard-helpers/ # Markdown files rendered inside each dashboard's Helper tab in the application +└── deno.json # Root workspace + top-level tasks +``` + +Detailed docs live next to the code: + +- [`app/README.md`](./app/README.md) — analyses, triggers, AnalysisRouter, bundling +- [`widgets/README.md`](./widgets/README.md) — widget stack, dev server, multi custom widget build + +## Application model + +Two complementary views of the same thing. The TagoIO side (devices and tags) is what the analyses manipulate; the navigation side is what a user sees in the browser. + +``` +TagoIO resources User navigation (TagoRUN) +───────────────── ─────────────────────────── +Settings device (settings_dev) Welcome +└── Organization device └── Organizations ──► Groups + (tag device_type=organization) ├── Map View └── Sensors (per group) + ├── Run users └── Create/Edit ├── Sensor List + │ (Super Admin │ Org Admin │ Guest) ├── Sensor Detail + ├── Alert rows (stored on the org device) │ (Temp gauge / Compressor / Door) + └── Group device └── All Devices (Admin) + (tag device_type=group) ──► Alerts (Sensor + Global) + └── Sensor device ──► Users (Org Admin / Guest) + (real device — tag device_type=device, + network, model, EUI) +``` + +The hierarchy is enforced through device tags: `organization_id` and `group_id` propagate down so TagoIO Access Policies isolate tenants automatically. + +## Dashboards + +Six dashboards, every one with a `Helper` tab that renders the matching file in [`dashboard-helpers/`](./dashboard-helpers). + +| Dashboard | Tabs | CRUD analysis (in [`app/analysis/dashboard/`](./app/analysis/dashboard/)) | Custom widget | +| ------------- | --------------------------------------- | ------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | +| Organizations | Overview (List + Map) · Helper | `crud-organization.ts` | — | +| Groups | Cold Rooms · Overview · Helper | `crud-group.ts` | [`cold-room-card-data`](./widgets/cold-room-card-data) on the Cold Rooms tab | +| Sensors | Overview · All Devices (Admin) · Helper | `crud-sensor.ts` | [`sensor-status`](./widgets/sensor-status) on Overview | +| Sensor Detail | Overview · Helper | — | [`cold-room-monitor`](./widgets/cold-room-monitor) on Overview | +| Alerts | Overview · Global Alerts · Helper | `crud-alert.ts` | — | +| Users | Overview · All Users (Admin) · Helper | `crud-user.ts` | — | + +Each CRUD analysis is dispatched by the SDK's `AnalysisRouter` based on the widget that fired the call (input form, custom button, or device-list action). The full mapping is +documented in [`app/README.md`](./app/README.md). + +Three more analyses run outside of any dashboard: + +- `actions/uplink-handler.ts` — Action-triggered. Turns each sensor uplink into the `cold_room_card_data` row that powers the Cold Rooms widget. +- `actions/alert-dispatcher.ts` — Action-triggered. Called by the per-alert Action created by `crud-alert.ts` whenever a condition is met. +- `scheduled/check-inactive-sensors.ts` — Scheduled (hourly). Flags sensors that haven't reported within the configured threshold and refreshes the connectivity summary shown on + the Sensors dashboard. + +## Top-level tasks + +Run from the repo root: + +| Task | What it does | +| ------------------------- | --------------------------------------------------- | +| `deno task build:app` | Bundle every analysis into `build/app/*.tagoio.js` | +| `deno task build:widgets` | Build every widget into `widgets/_dist//` | +| `deno task dev:widgets` | Start the widget dev server (needs `WIDGET=`) | +| `deno task test:all` | Run app tests | +| `deno task lint:all` | Lint app and widgets | +| `deno task fmt` | Format everything (`deno fmt`) | + +Per-workspace task lists live in the folder READMEs. + +## Prerequisites + +- Deno (latest) +- TagoIO CLI — `npm install -g @tago-io/cli`, then `tagoio login` + +## Conventions +- `kebab-case` for files and folders. +- Explicit block braces on every control structure — no single-line `if`/`for`/`while`. +- Analyses are ephemeral: no in-memory state between runs — persist via device data or entity records. +- Data queries always set `qty` explicitly; the SDK default (15) silently truncates. +- Buckets are deprecated — use device storage directly. -### Support -You can open an issue or question at [https://github.com/tago-io/analysis-kickstarter/issues](https://github.com/tago-io/analysis-kickstarter/issues). +Full guidance lives in [`CLAUDE.md`](./CLAUDE.md). diff --git a/app/README.md b/app/README.md new file mode 100644 index 0000000..bff6fa0 --- /dev/null +++ b/app/README.md @@ -0,0 +1,115 @@ +# `app/` — Analyses + +Deno workspace (`@app/application`) containing every TagoIO Analysis the Kickstarter ships. Each analysis is a single, self-contained TypeScript file under `analysis/`. There is no +build step: to deploy, you **copy the file's contents and paste it into the matching Analysis on the TagoIO admin panel**. + +## Layout + +``` +app/ +├── analysis/ +│ ├── dashboard/ # Dashboard widget interactions (input forms, buttons, list actions) +│ │ ├── crud-organization.ts +│ │ ├── crud-group.ts +│ │ ├── crud-sensor.ts +│ │ ├── crud-alert.ts +│ │ └── crud-user.ts +│ ├── actions/ # Triggered by TagoIO Actions on data events +│ │ ├── uplink-handler.ts +│ │ └── alert-dispatcher.ts +│ └── scheduled/ # Triggered by a Scheduled Action (cron) +│ └── check-inactive-sensors.ts +└── deno.json +``` + +## The self-contained file convention + +Every analysis under `analysis/` is a single TypeScript file with **no relative imports**. Constants, Zod schemas, helpers and handlers all live in the same file, grouped by +section headers. The goal is to let a developer new to TagoIO read one analysis top-to-bottom and understand every step it performs — and to make it trivial to copy/paste the whole +file into a TagoIO Analysis. + +Practical consequences: + +- Cross-analysis code duplication is intentional. Do not factor shared helpers into a `lib/` folder — keep each file self-explanatory. +- The only imports allowed are `npm:@tago-io/sdk`, `npm:zod`, `npm:luxon`, `npm:phone`, and `npm:json-2-csv`. +- `*.test.ts` files sit next to the analysis they cover and run with `deno task test`. + +## Handlers + +| File | Trigger type | What it does | +| ------------------------------------- | -------------------------------- | ----------------------------------------------------------------------------------------- | +| `dashboard/crud-organization.ts` | Dashboard widget callbacks | Create / edit / delete Organization devices (`device_type=organization`). | +| `dashboard/crud-group.ts` | Dashboard widget callbacks | Create / edit / delete Group devices (`device_type=group`) inside an Organization. | +| `dashboard/crud-sensor.ts` | Dashboard widget callbacks | Create / edit / delete Sensor devices (`device_type=device`) inside a Group. | +| `dashboard/crud-alert.ts` | Dashboard widget callbacks | Persist alert rules on the Organization device, provision per-alert Actions. | +| `dashboard/crud-user.ts` | Dashboard widget callbacks | Invite / edit / delete Run Users (Org Admin or Guest). Uses SendGrid for the invite mail. | +| `actions/uplink-handler.ts` | Action — Trigger by Variable | Writes `cold_room_card_data` rows on the Group and Organization devices on every uplink. | +| `actions/alert-dispatcher.ts` | Action — created by `crud-alert` | Renders the alert message and sends an in-app notification to each recipient. | +| `scheduled/check-inactive-sensors.ts` | Scheduled Action (hourly cron) | Marks sensors that missed the configured threshold as offline, refreshes the summary. | + +## Dashboard analyses and `AnalysisRouter` + +The five `crud-*.ts` analyses are called by TagoIO widgets on a dashboard. The SDK's `AnalysisRouter` (`@tago-io/sdk` → `Utils`) inspects the `scope` of the incoming data and +dispatches to the right handler based on the widget identifier: + +| Widget type | Router method | Identifier convention | +| ---------------- | -------------------------- | --------------------- | +| Input Form | `whenInputFormID` | `create-` | +| Custom Button | `whenCustomBtnID` | `edit-` | +| Device/User List | `whenDeviceListIdentifier` | `delete-` | + +For example, `crud-organization.ts` routes to `createOrganization`, `editOrganization`, and `deleteOrganization` based on whether the dashboard fired the `create-org` form, the +`edit-org` button, or the `delete-org` list action. The same shape repeats for groups, sensors, alerts and users. + +Every dashboard analysis reads `context.environment` through `Utils.envToJson` and validates the required env vars early (typically `config_id`, plus `T_ANALYSIS_TOKEN` which the +runtime injects). `crud-user.ts` additionally requires `SENDGRID_API_KEY` and `sendgrid_from_email`; `crud-alert.ts` additionally requires `alert_dispatcher_id`. + +## Action and scheduled analyses + +- `uplink-handler.ts` is wired to a TagoIO Action of type _Trigger by Variable_ that targets sensor devices (tag `device_type=device`) and listens to `temperature`, `compressor` + and `door`. On each match the analysis writes one `cold_room_card_data` record on both the parent Group device and the parent Organization device — that record powers the Cold + Rooms widget. +- `alert-dispatcher.ts` is wired by `crud-alert.ts` at alert creation time, **one Action per alert**. The Action condition encodes the rule the user picked on the dashboard; when + it fires, the analysis loads the persisted alert row, replaces placeholders (`#device_name#`, `#device_id#`, `#sensor_type#`, `#value#`, `#variable#`) and sends an in-app + notification to every recipient. +- `check-inactive-sensors.ts` runs hourly. It compares each sensor's last uplink against the per-org inactivity rules (or the global fallback stored on the settings device) and + writes the `last_uplink` parameter and the `device_connectivity_summary` row consumed by the Sensors dashboard. + +## Deploy + +To get an analysis running on TagoIO: + +1. Open the matching Analysis on the TagoIO admin panel (or create one with the same name). +2. Copy the contents of the `.ts` file from `analysis/` and paste it into the Analysis code editor. +3. Configure the Analysis environment variables expected by the handler (see each file's docstring; `T_ANALYSIS_TOKEN` is injected by TagoIO). +4. Save. + +There is no automated deploy and nothing to build locally — what you read in `analysis/` is what runs on TagoIO. + +## Tasks + +Run from `app/` (or as `deno task :app` from the repo root): + +| Task | What it does | +| ----------------------- | ------------------------------------------------- | +| `deno task test` | Run every `*.test.ts` in the workspace | +| `deno task test:single` | `deno test --allow-all` — pass a path to scope it | +| `deno task linter` | Lint (skips tests, allows `any`) | +| `deno task linter-fix` | Lint with `--fix` | + +## Dependencies + +Declared in `app/deno.json`: + +- `@tago-io/sdk` (inherited from root) — Analysis runtime primitives, `Resources`, `Services`, `Utils.AnalysisRouter`. +- `zod` — Schema validation for every form payload. +- `luxon` — Date/time math used across handlers. +- `phone` — Phone number normalization in `crud-user.ts`. +- `json-2-csv` — CSV import/export (when needed by a handler). + +## Rules that bite + +- **No in-memory state between runs** — analyses are ephemeral (Lambda-style). Persist via device data or entity records. +- **Always set `qty` on data queries** — the default of 15 silently truncates. +- **Buckets are deprecated** — use device storage directly. Flag any legacy bucket reference for migration. +- **Explicit block braces** on every `if` / `for` / `while`, even single-line. diff --git a/app/analysis/actions/alert-dispatcher.ts b/app/analysis/actions/alert-dispatcher.ts new file mode 100644 index 0000000..19d739d --- /dev/null +++ b/app/analysis/actions/alert-dispatcher.ts @@ -0,0 +1,335 @@ +/** + * Alert Dispatcher Analysis + * + * Educational single-file Analysis that runs every time a sensor variable + * crosses the threshold of a user-configured alert. This file is + * intentionally self-contained — it has no relative imports. + * + * What is an "alert dispatch"? + * --------------------------- + * The Alerts dashboard lets a user describe a condition ("temperature > 80°F", + * "door = open", ...). Behind the scenes, the `crud-alert` Analysis stores + * that rule on the organization device AND provisions a TagoIO Action that + * evaluates the condition on every uplink. When the condition is met, the + * Action calls this Analysis — one "dispatch" — so we can resolve the + * recipients, render the message, and notify everyone in-app. + * + * What it does + * ------------ + * 1. Reads the originating Action by id (`environment._action_id`) to find + * the `alert_id` and `organization_id` tags. + * 2. Reads the persisted alert row from the organization device — the + * same row the Alerts table widget renders. + * 3. Replaces the placeholder keywords (`#device_name#`, `#device_id#`, + * `#sensor_type#`, `#value#`, `#variable#`) with the values from the + * uplink that fired this alert. + * 4. Sends an in-app notification to every recipient listed on the row. + * + * Required environment variables + * ------------------------------ + * - T_ANALYSIS_TOKEN : provided automatically by the TagoIO runtime. + * - _action_id : provided automatically when the Action calls + * this Analysis. + * + * NOTE + * ---- + * This file is optimized for clarity, not performance. The goal is for a + * developer new to TagoIO to read it top-to-bottom and understand every step. + */ + +import { Analysis, type Data, Resources, type TagoContext, Utils } from "npm:@tago-io/sdk"; +import z from "npm:zod"; + +// ============================================================================ +// Constants +// ============================================================================ + +/** + * Tag keys carried by every alert Action. They tell us which row on the + * organization device drives this dispatch. + */ +const ACTION_TAG_ALERT_ID = "alert_id"; +const ACTION_TAG_ORG_ID = "organization_id"; + +/** Variables that make up one alert row on the organization device. */ +const VAR_SEND_TO = "alert_management_users"; +const VAR_MESSAGE = "alert_management_message"; +const VAR_MODEL = "alert_management_type"; + +/** Sensor tag key used to populate the `#sensor_type#` placeholder. */ +const TAG_SENSOR_TYPE = "sensor_type"; + +/** Title used on every in-app notification dispatched by this Analysis. */ +const NOTIFICATION_TITLE = "Alert triggered"; + +// ============================================================================ +// Validation schema +// ============================================================================ + +/** + * Shape of one data point inside the uplink batch we receive in `scope`. + * + * The Action only fires when the condition matches, but we still validate + * to fail fast (and log a clear reason) if the runtime ever calls us with + * an unexpected payload — that's safer than reading `undefined.variable` + * deep inside the dispatch. + */ +const triggerScopeModel = z.object({ + device: z.string().min(1, { error: "trigger is missing device id" }), + variable: z.string().default(""), + value: z.union([z.string(), z.number(), z.boolean()]).optional(), +}); + +// ============================================================================ +// Types +// ============================================================================ + +/** Everything we need to know about the alert that fired. */ +interface AlertContext { + alertID: string; + organizationID: string; + recipientIDs: string[]; + messageTemplate: string; + triggerVariable: "temperature" | "door" | "inactivity" | string; +} + +/** Everything we need to know about the uplink that fired the alert. */ +interface TriggerContext { + sensorID: string; + sensorName: string; + sensorType: string; + variable: string; + value: string; +} + +/** Values substituted into the message template. */ +interface RenderContext { + deviceName: string; + deviceID: string; + sensorType: string; + value: string; + variable: string; +} + +// ============================================================================ +// Helpers — generic +// ============================================================================ + +/** + * Splits a TagoIO comma-joined recipient list (e.g. `"id1, id2"`) into a + * clean array of trimmed non-empty strings. + */ +function splitCsv(value: unknown): string[] { + if (typeof value !== "string") { + return []; + } + return value + .split(",") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); +} + +// ============================================================================ +// Helpers — alert context lookup +// ============================================================================ + +/** + * Loads the Action by id, reads its tags to know which alert fired, and + * pulls the recipients + message template from the organization device. + * + * Returns undefined when any required piece is missing. The caller logs the + * reason and exits cleanly — a misconfigured Action should not crash the + * Analysis for other alerts that ARE configured correctly. + */ +async function loadAlertContext(actionID: string): Promise { + const action = await Resources.actions.info(actionID).catch(() => undefined); + if (!action) { + console.log(`Action ${actionID} not found; skipping`); + return; + } + + const alertID = action.tags?.find((tag) => tag.key === ACTION_TAG_ALERT_ID)?.value; + const organizationID = action.tags?.find((tag) => tag.key === ACTION_TAG_ORG_ID)?.value; + if (!alertID || !organizationID) { + console.log(`Action ${actionID} is missing ${ACTION_TAG_ALERT_ID} or ${ACTION_TAG_ORG_ID} tag; skipping`); + return; + } + + // Recipients and message live on the organization device as two variables + // sharing the same `group` (the alert id). + const rows = await Resources.devices + .getDeviceData(organizationID, { variables: [VAR_SEND_TO, VAR_MESSAGE, VAR_MODEL], groups: alertID, qty: 1 }); + + const recipientsRecord = rows.find((row) => row.variable === VAR_SEND_TO)?.value; + const messageRecord = rows.find((row) => row.variable === VAR_MESSAGE)?.value; + const modelRecord = rows.find((row) => row.variable === VAR_MODEL)?.value; + + const recipientIDs = splitCsv(recipientsRecord); + if (recipientIDs.length === 0) { + console.log(`Alert ${alertID} has no recipients; skipping`); + return; + } + + const alertContext: AlertContext = { + alertID, + organizationID, + recipientIDs, + messageTemplate: String(messageRecord ?? ""), + triggerVariable: String(modelRecord ?? ""), + }; + + return alertContext; +} + +// ============================================================================ +// Helpers — trigger context lookup +// ============================================================================ + +/** + * Picks the data point that matches the alert's monitored variable out of + * the uplink batch, then resolves the sensor's friendly name + type tag. + * + * The uplink batch (`scope`) carries every data point the sensor sent in + * one push (e.g. `door`, `compressor`, `temperature`). We must filter to + * the variable the alert actually watches — otherwise the `#value#` and + * `#variable#` placeholders would render whatever happened to come first + * in the array. + * + * The device lookup is tolerated to fail (the alert should still send even + * if we only know the id). When that happens, the placeholders fall back + * to the raw id, which keeps the notification useful. + */ +async function buildTriggerContext(scope: Data[], triggerVariable: string): Promise { + const match = scope.find((item) => item.variable === triggerVariable); + if (!match) { + console.log(`Scope does not contain variable "${triggerVariable}"; skipping`); + return; + } + + const parsed = await triggerScopeModel.safeParseAsync(match); + if (!parsed.success) { + // `safeParseAsync` returns the issues instead of throwing, so we log + // the first one and skip — calls without a real trigger are not + // something the user can fix. + console.log(`Skipping dispatch: ${parsed.error.issues[0]?.message ?? "trigger validation failed"}`); + return; + } + + const { device: sensorID, variable, value } = parsed.data; + + const sensorInfo = await Resources.devices.info(sensorID).catch(() => undefined); + const sensorName = sensorInfo?.name ?? sensorID; + const sensorType = sensorInfo?.tags.find((tag) => tag.key === TAG_SENSOR_TYPE)?.value ?? ""; + + return { + sensorID, + sensorName, + sensorType, + variable, + value: value === undefined ? "" : String(value), + }; +} + +// ============================================================================ +// Helpers — message rendering +// ============================================================================ + +/** + * Replaces every supported `#keyword#` in the message with the matching + * value from the uplink that fired the alert. Unknown keywords are left + * untouched so the user gets a visible hint if they typed something wrong. + */ +function renderMessage(template: string, context: RenderContext): string { + return template + .replaceAll("#device_name#", context.deviceName) + .replaceAll("#device_id#", context.deviceID) + .replaceAll("#sensor_type#", context.sensorType) + .replaceAll("#value#", context.value) + .replaceAll("#variable#", context.variable); +} + +// ============================================================================ +// Helpers — notification dispatch +// ============================================================================ + +/** + * Sends the rendered message to every recipient as an in-app notification. + * + * We dispatch serially (not in parallel) because TagoIO applies per-minute + * rate limits on the notification endpoint, and a tight `Promise.all` is + * the easiest way to trip them on a fan-out. + */ +async function dispatchNotifications(recipientIDs: string[], message: string): Promise { + for (const userID of recipientIDs) { + await Resources.run + .notificationCreate(userID, { title: NOTIFICATION_TITLE, message }) + .catch((error) => { + console.error(`Failed to notify ${userID}: ${(error as Error).message ?? error}`); + }); + } +} + +// ============================================================================ +// Entrypoint +// ============================================================================ + +/** + * Entrypoint invoked by the TagoIO Analysis runtime. + * + * Steps: + * 1. Read the Action id from the environment. Without it we have no way + * to know which alert fired. + * 2. Load the alert context: the recipients and message template stored + * on the organization device. Skip when missing. + * 3. Build the trigger context: the sensor name, type, variable, and + * value from the uplink that crossed the threshold. Skip when invalid. + * 4. Render the message with the placeholder values and dispatch one + * in-app notification per recipient. + */ +async function startAnalysis(context: TagoContext, scope: Data[]) { + console.log("Running Alert Dispatcher"); + + const environment = Utils.envToJson(context.environment); + + // Step 1 — identify which Action invoked us. + const actionID = environment._action_id; + if (!actionID) { + console.log("No _action_id in environment; skipping"); + return; + } + console.log(`Alert dispatch received: action=${actionID} scope_size=${scope?.length ?? 0}`); + + // Step 2 — load the alert row (recipients + message) for this Action. + const alert = await loadAlertContext(actionID); + if (!alert) { + return; + } + + // Step 3 — read the uplink data point that crossed the threshold. + const trigger = await buildTriggerContext(scope, alert.triggerVariable); + if (!trigger) { + return; + } + + // Step 4 — render the message and notify each recipient. + const renderedMessage = renderMessage(alert.messageTemplate, { + deviceName: trigger.sensorName, + deviceID: trigger.sensorID, + sensorType: trigger.sensorType, + value: trigger.value, + variable: trigger.variable, + }); + + await dispatchNotifications(alert.recipientIDs, renderedMessage); + + console.log(`Alert ${alert.alertID} dispatched to ${alert.recipientIDs.length} recipient(s)`); +} + +// The Analysis runtime sets `T_TEST` during local tests so the handler is +// not wired up automatically. In production the runtime sets +// `T_ANALYSIS_TOKEN` and calls `Analysis.use` below. +if (!Deno.env.get("T_TEST")) { + Analysis.use(startAnalysis, { token: Deno.env.get("T_ANALYSIS_TOKEN") }); +} + +export { startAnalysis }; diff --git a/app/analysis/actions/uplink-handler.ts b/app/analysis/actions/uplink-handler.ts new file mode 100644 index 0000000..8e1e47e --- /dev/null +++ b/app/analysis/actions/uplink-handler.ts @@ -0,0 +1,355 @@ +/** + * Uplink Handler Analysis + * + * Educational single-file Analysis that turns raw sensor uplinks into the + * `cold_room_card_data` record consumed by the Cold Room dashboards. + * This file is intentionally self-contained — it has no relative imports. + * + * What is an "uplink"? + * -------------------- + * An "uplink" is one batch of data points a sensor pushes into TagoIO + * (temperature, status, etc.). The platform stores it on the sensor device + * and, if an Action is configured, also calls this Analysis with the same + * data points in `scope`. + * + * How it is triggered + * ------------------- + * By a TagoIO Action of type "Trigger by Variable" that: + * - Targets devices tagged `device_type=device` (the sensors). + * - Listens to the variables `temperature`, `compressor`, `door` and others. + * - Runs this Analysis when any of those variables is written. + * + * What it writes + * -------------- + * For each uplink, the handler creates (or updates) one row of the variable + * `cold_room_card_data` on TWO devices: + * - The parent group device, so the group-level Cold Room dashboard sees it. + * - The parent organization device, so the org-level Cold Room dashboard + * sees it too. + * Each sensor owns one row on each device — the row is identified by the + * data point's `group` field set to the sensor id. + * + * Required environment variables + * ------------------------------ + * - T_ANALYSIS_TOKEN : provided automatically by the TagoIO runtime. + * + * NOTE + * ---- + * This file is optimized for clarity, not performance. The goal is for a + * developer new to TagoIO to read it top-to-bottom and understand every step. + */ + +import { Analysis, type Data, Resources, type TagoContext, Utils } from "npm:@tago-io/sdk"; +import z from "npm:zod"; + +// ============================================================================ +// Constants +// ============================================================================ + +/** + * Name of the variable written on the group and organization devices. The + * Cold Room dashboards read this variable to render one card per sensor. + */ +const CARD_VARIABLE = "cold_room_card_data"; + +/** + * Tag keys read from the sensor device. They tell us where the card record + * should be written (group + organization). + */ +const TAG_DEVICE_TYPE = "device_type"; +const TAG_GROUP_ID = "group_id"; +const TAG_ORGANIZATION_ID = "organization_id"; + +/** + * Tag value that marks a device as a sensor. Other device types (group, + * organization, config) are skipped so this Analysis only reacts to real + * sensor uplinks. + */ +const SENSOR_DEVICE_TYPE = "device"; + +/** + * Names of the variables we expect inside the uplink. These are the + * identifiers the decoder writes when it parses the sensor payload. + */ +const VAR_TEMPERATURE = "temperature"; +const VAR_COMPRESSOR = "compressor"; +const VAR_DOOR = "door"; + +// ============================================================================ +// Validation schema +// ============================================================================ + +/** + * Accepts a number or a numeric string and coerces it to a finite number. + * + * Decoders sometimes emit the temperature as a string (e.g. `"7.0"`) and + * sometimes as a number (e.g. `7`). Normalising at this boundary keeps the + * rest of the code simple — every caller can assume a real number. + */ +const numericLike = z.union([z.number(), z.string()]).transform((value, ctx) => { + // Convert to number if it's a string; leave it alone if it's already a number. + const parsed = typeof value === "number" ? value : Number(value); + + // Number(NaN), Infinity, and -Infinity are all invalid for our use case. + if (!Number.isFinite(parsed)) { + ctx.addIssue({ code: "custom", message: "not a finite number" }); + return z.NEVER; + } + + return parsed; +}); + +/** + * Shape of the cold-room card payload extracted from one uplink. + * + * Status fields are normalised (trim + lowercase) BEFORE checking against + * the enum, so common decoder variants like "ON" or " Open " are accepted. + * Without `.pipe`, `"ON"` would be rejected because the enum expects `"on"`. + */ +const cardPayloadModel = z.object({ + temperature_fahrenheit: numericLike, + compressor_status: z.string().trim().toLowerCase().pipe(z.enum(["on", "off"], { error: "compressor must be on or off" })), + door_status: z.string().trim().toLowerCase().pipe(z.enum(["open", "closed"], { error: "door must be open or closed" })), +}); + +// ============================================================================ +// Types +// ============================================================================ + +/** Routing context extracted from the sensor's tags. */ +interface SensorContext { + sensorID: string; + sensorName: string; + groupID: string; + groupName: string; + organizationID: string; +} + +/** + * Shape of the metadata stored on the `cold_room_card_data` record. + * + * Every field here ends up readable by the dashboard widget. Adding a field + * is how we expose new information to the UI without changing the widget's + * data source — for example, `group_id` and `group_name` let the Cold Room + * (Organization) widget cluster cards by their parent group. + */ +interface CardMetadata { + sensor_name: string; + group_name: string; + temperature_fahrenheit: number; + compressor_status: "on" | "off"; + door_status: "open" | "closed"; +} + +/** Parameters required to create or update a card record. */ +interface UpsertCardParam { + readonly targetDeviceID: string; + readonly sensorID: string; + readonly metadata: CardMetadata; + readonly time: Date; +} + +// ============================================================================ +// Helpers — sensor routing +// ============================================================================ + +/** + * Loads the sensor device and pulls out the tags we need to route the card. + * + * If the device is not a sensor, or is missing one of the required tags, + * we treat it as a configuration issue (not a runtime bug) and return null. + * The caller logs the reason and exits cleanly so the Action keeps working + * for the other sensors that ARE configured correctly. + */ +async function loadSensorContext(sensorID: string): Promise { + const sensorInfo = await Resources.devices.info(sensorID); + + // Skip anything that is not tagged as a sensor (group/organization/config + // devices share the same Run so they could in theory trigger the Action). + const deviceType = sensorInfo.tags.find((tag) => tag.key === TAG_DEVICE_TYPE)?.value; + if (deviceType !== SENSOR_DEVICE_TYPE) { + console.log(`Device ${sensorID} is not a sensor (${TAG_DEVICE_TYPE}=${deviceType ?? "missing"}); skipping`); + return null; + } + + const groupID = sensorInfo.tags.find((tag) => tag.key === TAG_GROUP_ID)?.value; + if (!groupID) { + console.log(`Sensor ${sensorID} has no ${TAG_GROUP_ID} tag; skipping`); + return null; + } + + const organizationID = sensorInfo.tags.find((tag) => tag.key === TAG_ORGANIZATION_ID)?.value; + if (!organizationID) { + console.log(`Sensor ${sensorID} has no ${TAG_ORGANIZATION_ID} tag; skipping`); + return null; + } + + const groupInfo = await Resources.devices.info(groupID); + + return { + sensorID, + sensorName: sensorInfo.name, + groupID, + groupName: groupInfo.name, + organizationID, + }; +} + +// ============================================================================ +// Helpers — payload parsing +// ============================================================================ + +/** + * Reads the three expected variables from the uplink scope and runs them + * through the Zod schema. + * + * All three values must arrive in the same uplink. If any is missing or + * fails validation, the function returns null and the upsert is skipped: + * a partial card would publish stale state for the missing fields. + */ +async function buildCardMetadata(scope: Data[], sensor: SensorContext): Promise { + const temperatureValue = scope.find((item) => item.variable === VAR_TEMPERATURE)?.value; + const compressorValue = scope.find((item) => item.variable === VAR_COMPRESSOR)?.value; + const doorValue = scope.find((item) => item.variable === VAR_DOOR)?.value; + + const parsed = await cardPayloadModel.safeParseAsync({ + temperature_fahrenheit: temperatureValue, + compressor_status: compressorValue, + door_status: doorValue, + }); + + if (!parsed.success) { + // `safeParseAsync` returns the issues instead of throwing, so we just + // log the first one and skip — uplinks are not user-driven and we don't + // want to crash the whole Action because of one malformed message. + console.log(`Skipping card upsert: ${parsed.error.issues[0]?.message ?? "validation failed"}`); + return null; + } + + return { sensor_name: sensor.sensorName, group_name: sensor.groupName, ...parsed.data }; +} + +/** + * Resolves the timestamp the card record should carry. + * + * We prefer the uplink's own timestamp so the widget's "x minutes ago" + * label reflects when the sensor produced the reading, not when this + * Analysis ran. If the scope entry has no time we fall back to `now`. + */ +function resolveCardTime(scope: Data[]): Date { + const rawTime = scope[0]?.time; + if (!rawTime) { + return new Date(); + } + return new Date(rawTime); +} + +// ============================================================================ +// Helpers — card upsert +// ============================================================================ + +/** + * Creates or updates ONE cold-room card row on the target device. + * + * Each sensor owns exactly one row on each parent device. We discriminate + * the rows by setting `group = sensorID` on the data point — TagoIO uses + * the `group` field as a sub-record key, so editing the row with the same + * `group` value updates that specific row instead of creating duplicates. + * + * On edit we set `time` explicitly. Spreading `existing` keeps the original + * creation timestamp, which would prevent the widget's relative-time label + * from ever advancing. + */ +async function upsertSensorCard({ targetDeviceID, sensorID, metadata, time }: UpsertCardParam): Promise<"created" | "updated"> { + const [existing] = await Resources.devices.getDeviceData(targetDeviceID, { + variables: CARD_VARIABLE, + groups: sensorID, + qty: 1, + }); + + if (existing) { + await Resources.devices.editDeviceData(targetDeviceID, { + ...existing, + value: sensorID, + group: sensorID, + time, + metadata: { ...existing.metadata, ...metadata }, + }); + return "updated"; + } + + await Resources.devices.sendDeviceData(targetDeviceID, { + variable: CARD_VARIABLE, + value: sensorID, + group: sensorID, + time, + metadata, + }); + return "created"; +} + +// ============================================================================ +// Entrypoint +// ============================================================================ + +/** + * Entrypoint invoked by the TagoIO Analysis runtime. + * + * Steps: + * 1. Read the sensor id from the scope. Every uplink carries the device + * that produced it in `scope[0].device`. + * 2. Load the sensor's routing context (group + organization). Skip when + * the device is not a sensor or has no routing tags. + * 3. Build and validate the card metadata from the uplink. Skip when the + * payload is partial or invalid. + * 4. Fan-out: write the card on both the group device and the + * organization device so dashboards at either level can render it. + */ +async function startAnalysis(context: TagoContext, scope: Data[]) { + console.log("Running Uplink Handler"); + + // `environment` is read so future versions can react to env flags + // (e.g. a "dry run" mode). It is not used today. + Utils.envToJson(context.environment); + + if (!scope || scope.length === 0) { + console.log("No data in scope, skipping"); + return; + } + + // Step 1 — identify which sensor produced this uplink. + const sensorID = scope[0].device; + console.log(`Uplink received: sensor=${sensorID} variables=[${scope.map((item) => item.variable).join(",")}]`); + + // Step 2 — load the sensor and check that it is routable. + const sensor = await loadSensorContext(sensorID); + if (!sensor) { + return; + } + + // Step 3 — extract the card payload from the uplink. + const metadata = await buildCardMetadata(scope, sensor); + if (!metadata) { + return; + } + + // Step 4 — fan-out: write the same card on the group AND the organization + // device. Two writes are intentional: the dashboards at each level pull + // data from the device that sits one hop above the sensor. + const time = resolveCardTime(scope); + + const groupResult = await upsertSensorCard({ targetDeviceID: sensor.groupID, sensorID, metadata, time }); + console.log(`Card on group ${sensor.groupID}: ${groupResult}`); + + const orgResult = await upsertSensorCard({ targetDeviceID: sensor.organizationID, sensorID, metadata, time }); + console.log(`Card on organization ${sensor.organizationID}: ${orgResult}`); +} + +// The Analysis runtime sets `T_TEST` during local tests so the handler is +// not wired up automatically. In production the runtime sets +// `T_ANALYSIS_TOKEN` and calls `Analysis.use` below. +if (!Deno.env.get("T_TEST")) { + Analysis.use(startAnalysis, { token: Deno.env.get("T_ANALYSIS_TOKEN") }); +} + +export { startAnalysis }; diff --git a/app/analysis/dashboard/crud-alert.ts b/app/analysis/dashboard/crud-alert.ts new file mode 100644 index 0000000..dc54c4d --- /dev/null +++ b/app/analysis/dashboard/crud-alert.ts @@ -0,0 +1,837 @@ +/** + * CRUD Alert Analysis + * + * Educational single-file Analysis that handles the full lifecycle of an + * Alert in the TagoIO Kickstarter project: create and delete. This + * file is intentionally self-contained — it has no relative imports. + * + * What an "alert" is in this project + * ---------------------------------- + * An alert is a rule the user sets up on the Alerts dashboard: "notify + * these run users when these sensors report this condition". Internally + * each alert is stored as a set of data rows on the organization device + * (so the Alerts table widget can render it) PLUS — for temperature, + * door and compressor models — a TagoIO Action that listens to the + * sensor variable and runs the dispatcher Analysis when the condition is + * met. For the "inactivity" model (no uplink for X hours) no Action is + * created: the scheduled Inactivity Check Analysis picks the row up. + * + * How it is triggered + * ------------------- + * A dashboard sends Data points to this Analysis. The Analysis Router + * from `@tago-io/sdk` inspects the scope and runs the matching handler: + * + * - Input Form "create-alert" -> createAlert + * - Custom Button "edit-alert" -> editAlert + * - Device List action "delete-alert" -> deleteAlert + * + * Required environment variables + * ------------------------------ + * - config_id : ID of the configuration device that stores the + * validation messages displayed back to the UI. + * - alert_dispatcher_id : ID of the `alert-dispatcher` Analysis. The + * Actions created here call it on every match. + * - T_ANALYSIS_TOKEN : Provided automatically by the TagoIO runtime. + * + * NOTE + * ---- + * This file is optimized for clarity, not performance. The goal is for a + * developer new to TagoIO to read it top-to-bottom and understand every step. + */ + +import { Analysis, type Conditionals, type Data, type DeviceListScope, type TagsObj } from "npm:@tago-io/sdk"; +import { Resources, type RouterConstructor, type TagoContext, Utils } from "npm:@tago-io/sdk"; +import { DateTime } from "npm:luxon"; +import z, { ZodError } from "npm:zod"; + +// ============================================================================ +// Constants +// ============================================================================ + +/** + * Variables written on the organization device, one row per column on the + * Alert List widget table. Every variable for the same alert shares the + * same `group` (the alert id), so editing/deleting can target the whole + * row through that group filter. + */ +const VAR_DEVICES = "alert_management_devices"; // Sensor(s) column +const VAR_MODEL = "alert_management_type"; // Model column +const VAR_CONDITION = "alert_management_condition"; // Condition column +const VAR_VALUE = "alert_management_value"; // Value column +const VAR_SEND_TO = "alert_management_users"; // Send to column +const VAR_MESSAGE = "alert_management_message"; // Message column + +/** + * Form variable names sent by the Input Form widget. See + * `alert-scope-creation.jsonc` at the repo root for the full reference. + */ +const FORM_SETUP_BY = "new_alert_selected_type"; +const FORM_SENSORS = "new_alert_selected_sensors"; +const FORM_MODEL = "new_alert_type"; +const FORM_CONDITION = "new_alert_condition"; +const FORM_TEMP_VALUE = "new_alert_temp_value"; +const FORM_TEMP_VALUE_BETWEEN = "new_alert_temp_value_between"; +const FORM_DOOR = "new_alert_door_enum"; +const FORM_COMPRESSOR = "new_alert_compressor_enum"; +const FORM_CHECKIN = "new_alert_inactivity_hours"; +const FORM_USERS = "new_alert_users"; +const FORM_MESSAGE = "new_alert_custom_message"; + +/** Tag identifiers stored on every TagoIO Action created by this CRUD. */ +const ACTION_TAG_ALERT_ID = "alert_id"; +const ACTION_TAG_ORG_ID = "organization_id"; +const ACTION_TAG_TYPE = "action_type"; +const ACTION_TAG_TYPE_VALUE = "alert"; + +/** + * Tag pairs the Action uses to match sensor devices at trigger time. + * + * `device_type=device` is the project-wide marker that flags a sensor + * (set by `crud-sensor.ts`). We use it for `all_sensors` alerts so the + * Action automatically covers every sensor in the organization. + * + * `alert_id=` is added to each selected sensor by this CRUD + * when the user picks specific sensors. The Action then matches only + * those sensors. The tag is removed on delete. + */ +const SENSOR_TAG_DEVICE_TYPE = "device_type"; +const SENSOR_TAG_DEVICE_VALUE = "device"; +const SENSOR_TAG_ALERT_ID = "alert_id"; + +/** Human-readable labels used in the metadata of the table rows. */ +const MODEL_LABELS: Record = { + temperature: "Temperature", + door: "Door Status", + compressor: "Compressor Status", + inactivity: "Inactivity", +}; + +const CONDITION_LABELS: Record = { + ">": "Greater than", + "<": "Less than", + "=": "Equal to", + "!": "Different from", + "><": "Between", +}; + +/** Maps each non-inactivity model to the sensor variable the Action listens to. */ +const MODEL_VARIABLE: Record = { + temperature: "temperature", + door: "door", + compressor: "compressor", +}; + +// ============================================================================ +// Validation schema +// ============================================================================ + +/** + * Form fields shared by every model. The model-specific fields are + * validated separately inside `parseFormFields`. + */ +const commonModel = z.object({ + setupAlertsBy: z.enum(["all_sensors", "sensors"], { error: "Setup alerts by is required" }), + sensors: z.array(z.string()).optional(), + recipients: z.array(z.string()).min(1, { error: "At least one recipient is required" }), + message: z + .string({ error: "Message is required" }) + .min(1, { error: "Message is required" }) + .max(500, { error: "Message must be 500 characters or fewer" }), +}); + +const temperatureModel = z.object({ + model: z.literal("temperature"), + condition: z.enum(["<", ">", "=", "!", "><"], { error: "Condition is required" }), + value: z.coerce.number({ error: "Value must be a number" }), + secondValue: z.coerce.number({ error: "Second value must be a number" }).optional(), +}); + +const doorModel = z.object({ + model: z.literal("door"), + value: z.enum(["open", "closed"], { error: "Door value must be open or closed" }), +}); + +const compressorModel = z.object({ + model: z.literal("compressor"), + value: z.enum(["on", "off"], { error: "Compressor value must be on or off" }), +}); + +const inactivityModel = z.object({ + model: z.literal("inactivity"), + inactivityHours: z.coerce + .number({ error: "Inactivity hours must be a number" }) + .int({ error: "Inactivity hours must be a whole number" }) + .positive({ error: "Inactivity hours must be greater than zero" }), +}); + +const modelDiscriminator = z.discriminatedUnion("model", [ + temperatureModel, + doorModel, + compressorModel, + inactivityModel, +]); + +type CommonFields = z.infer; +type ModelFields = z.infer; +type AlertFields = CommonFields & ModelFields; + +// ============================================================================ +// Helpers — error handling +// ============================================================================ + +/** + * Extracts a short, human-readable message from a Zod or generic error and + * re-throws it as a plain `Error`. Only the first Zod issue is surfaced + * to the user. + */ +function getZodErrorMessage(error: unknown): never { + if (error instanceof ZodError) { + const message = error.issues[0]?.message ?? "Validation error"; + throw new Error(message); + } + + if (error instanceof Error) { + throw error; + } + + throw new Error("Unknown error occurred"); +} + +// ============================================================================ +// Helpers — feedback to the dashboard +// ============================================================================ + +type ValidationLevel = "success" | "danger" | "warning"; + +interface ValidationConfig { + validationVariable: string; + deviceID: string; + sessionID?: string; +} + +/** + * Creates a `validate(message, level)` function tied to a specific + * validation variable on the configuration device. The dashboard listens + * to this variable through a Validation widget and renders the messages + * to the user. + */ +function initializeValidation(config: ValidationConfig) { + let messageIndex = 0; + + return async (message: string, level: ValidationLevel = "success"): Promise => { + if (!message?.trim()) { + throw new Error("Validation message cannot be empty"); + } + + const now = DateTime.now(); + const timeOffset = ++messageIndex * 200; + + await Promise.allSettled([ + Resources.devices.deleteDeviceData(config.deviceID, { + variables: config.validationVariable, + qty: 999, + end_date: now.minus({ minutes: 1 }).toJSDate(), + }), + Resources.devices.sendDeviceData(config.deviceID, { + variable: config.validationVariable, + value: message, + time: now.plus({ milliseconds: timeOffset }).toJSDate(), + metadata: { + type: level, + session_id: config.sessionID, + show_markdown: false, + }, + }), + ]); + + return message; + }; +} + +// ============================================================================ +// Helpers — form parsing +// ============================================================================ + +/** + * Splits a TagoIO comma-joined value (e.g. `"id1, id2, id3"`) into a clean + * array of trimmed non-empty strings. The form widget joins multi-select + * values with `", "` so we normalize at this boundary. + */ +function splitCsv(value: unknown): string[] { + if (typeof value !== "string") { + return []; + } + return value + .split(",") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); +} + +/** + * Reads the alert form from the scope and runs it through the Zod schemas. + * + * The form is variadic — different fields appear depending on the model. + * We pick out everything we might need first, then hand it to a + * discriminated union so each model only validates what's relevant. + */ +async function parseFormFields(scope: Data[]): Promise { + const setupAlertsBy = scope.find((item) => item.variable === FORM_SETUP_BY)?.value; + const sensors = splitCsv(scope.find((item) => item.variable === FORM_SENSORS)?.value); + const model = scope.find((item) => item.variable === FORM_MODEL)?.value; + const condition = scope.find((item) => item.variable === FORM_CONDITION)?.value; + const tempValue = scope.find((item) => item.variable === FORM_TEMP_VALUE)?.value; + const tempValueBetween = scope.find((item) => item.variable === FORM_TEMP_VALUE_BETWEEN)?.value; + const door = scope.find((item) => item.variable === FORM_DOOR)?.value; + const compressor = scope.find((item) => item.variable === FORM_COMPRESSOR)?.value; + const inactivity = scope.find((item) => item.variable === FORM_CHECKIN)?.value; + const recipients = splitCsv(scope.find((item) => item.variable === FORM_USERS)?.value); + const message = scope.find((item) => item.variable === FORM_MESSAGE)?.value; + + const common = await commonModel.parseAsync({ + setupAlertsBy, + sensors: sensors.length > 0 ? sensors : undefined, + recipients, + message, + }); + + if (common.setupAlertsBy === "sensors" && (!common.sensors || common.sensors.length === 0)) { + throw new Error("Select at least one sensor"); + } + + let modelFields: ModelFields; + if (model === "temperature") { + modelFields = await temperatureModel.parseAsync({ + model, + condition, + value: tempValue, + secondValue: condition === "><" ? tempValueBetween : undefined, + }); + if (modelFields.condition === "><" && modelFields.secondValue === undefined) { + throw new Error("Second value is required for the 'between' condition"); + } + } else if (model === "door") { + modelFields = await doorModel.parseAsync({ model, value: door }); + } else if (model === "compressor") { + modelFields = await compressorModel.parseAsync({ model, value: compressor }); + } else if (model === "inactivity") { + modelFields = await inactivityModel.parseAsync({ model, inactivityHours: inactivity }); + } else { + throw new Error("Model is required"); + } + + return { ...common, ...modelFields }; +} + +// ============================================================================ +// Helpers — sensor resolution +// ============================================================================ + +/** + * Fetches every sensor that belongs to the organization, returning a tiny + * shape with just `id` and `name`. The Action creation needs the ids; the + * row metadata stores the names so the table renders human-readable chips. + */ +async function listOrganizationSensors(organizationID: string): Promise<{ id: string; name: string; tags: TagsObj[] }[]> { + const sensors: { id: string; name: string; tags: TagsObj[] }[] = []; + for (let page = 1; page < 9999; page++) { + const batch = await Resources.devices.list({ + page, + amount: 100, + fields: ["id", "name", "tags"], + filter: { + tags: [ + { key: "organization_id", value: organizationID }, + { key: "device_type", value: "device" }, + ], + }, + resolveBucketName: false, + }); + + for (const device of batch) { + sensors.push({ id: device.id, name: device.name, tags: device.tags }); + } + + if (batch.length < 100) { + break; + } + } + + return sensors; +} + +/** + * Resolves the snapshot of sensors that the alert applies to. + * + * For `all_sensors` we list every sensor in the organization right now. + * For `sensors` we look up the user-selected ids inside that list to grab + * the display names. The names go on the chip metadata so the widget can + * render labels even when the row is rendered offline. + */ +async function resolveTargetSensors(organizationID: string, fields: AlertFields): Promise<{ id: string; name: string; tags: TagsObj[] }[]> { + const orgSensors = await listOrganizationSensors(organizationID); + + if (fields.setupAlertsBy === "all_sensors") { + if (orgSensors.length === 0) { + throw new Error("This organization has no sensors yet"); + } + return orgSensors; + } + + const wanted = new Set(fields.sensors ?? []); + const matched = orgSensors.filter((sensor) => wanted.has(sensor.id)); + if (matched.length !== wanted.size) { + throw new Error("One or more selected sensors do not belong to this organization"); + } + return matched; +} + +// ============================================================================ +// Helpers — recipient resolution +// ============================================================================ + +/** + * Resolves run-user ids into `{ id, name }` pairs by listing every user in + * the organization once and intersecting with the selection. + */ +async function resolveRecipients(organizationID: string, recipientIDs: string[]): Promise<{ id: string; name: string }[]> { + const wanted = new Set(recipientIDs); + const users = await Resources.run.listUsers({ + amount: 1000, + fields: ["id", "name"], + filter: { tags: [{ key: "organization_id", value: organizationID }] }, + }); + + const matched: { id: string; name: string }[] = []; + for (const user of users) { + if (wanted.has(user.id)) { + matched.push({ id: user.id, name: user.name }); + } + } + + if (matched.length !== wanted.size) { + throw new Error("One or more recipients do not belong to this organization"); + } + return matched; +} + +// ============================================================================ +// Helpers — table-row persistence on the organization device +// ============================================================================ + +interface AlertRowPayload { + organizationID: string; + alertID: string; + sensors: { id: string; name: string }[]; + recipients: { id: string; name: string }[]; + fields: AlertFields; +} + +/** + * Returns the value and human label that go into the `Value` column for the + * resolved model. Temperature `between` joins both numbers; the enum + * models map their value to a capitalized label. + */ +function buildValueColumn(fields: AlertFields): { value: string; label: string } { + if (fields.model === "temperature") { + if (fields.condition === "><" && fields.secondValue !== undefined) { + return { + value: `${fields.value},${fields.secondValue}`, + label: `${fields.value} – ${fields.secondValue}`, + }; + } + return { value: String(fields.value), label: String(fields.value) }; + } + if (fields.model === "door") { + return { value: fields.value, label: fields.value === "open" ? "Open" : "Closed" }; + } + if (fields.model === "compressor") { + return { value: fields.value, label: fields.value === "on" ? "On" : "Off" }; + } + return { + value: String(fields.inactivityHours), + label: `${fields.inactivityHours} hour${fields.inactivityHours === 1 ? "" : "s"}`, + }; +} + +/** + * Writes the six variables that make up one row on the Alert List widget. + * Every variable shares the same `group` (the alert id) so edit and delete + * can target the row through that group filter alone. + */ +async function persistAlertRow(payload: AlertRowPayload): Promise { + const { organizationID, alertID, sensors, recipients, fields } = payload; + + const devicesValue = fields.setupAlertsBy === "all_sensors" ? "all_sensors" : sensors.map((sensor) => sensor.id).join(", "); + const devicesLabel = fields.setupAlertsBy === "all_sensors" ? "All Sensors" : sensors.map((sensor) => sensor.name).join(", "); + const devicesChips = fields.setupAlertsBy === "all_sensors" + ? [{ label: "All Sensors", value: "all_sensors" }] + : sensors.map((sensor) => ({ label: sensor.name, value: sensor.id })); + + const condition = fields.model === "temperature" ? fields.condition : ""; + const conditionLabel = condition ? CONDITION_LABELS[condition] : ""; + + const valueColumn = buildValueColumn(fields); + + const recipientsValue = recipients.map((user) => user.id).join(", "); + const recipientsChips = recipients.map((user) => ({ label: user.name, value: user.id })); + + // We need each data point to share the SAME `group` (alertID) so widget + // queries by group return the whole row. Tag the rows with the org id + // and alert id metadata too — useful for downstream debugging. + const commonMetadata = { alert_id: alertID, organization_id: organizationID }; + + let unitForValue: { unit: string } | undefined = undefined; + if (fields.model === "temperature") { + unitForValue = { unit: "°F" }; + } + if (fields.model === "inactivity") { + unitForValue = { unit: "hour(s)" }; + } + + await Resources.devices.sendDeviceData(organizationID, [ + { + variable: VAR_DEVICES, + value: devicesValue, + group: alertID, + metadata: { ...commonMetadata, label: devicesLabel, sentValues: devicesChips }, + }, + { + variable: VAR_MODEL, + value: fields.model, + group: alertID, + metadata: { ...commonMetadata, label: MODEL_LABELS[fields.model] }, + }, + { + variable: VAR_CONDITION, + value: condition, + group: alertID, + metadata: { ...commonMetadata, label: conditionLabel }, + }, + { + variable: VAR_VALUE, + value: valueColumn.value, + ...unitForValue, + group: alertID, + metadata: { ...commonMetadata, label: valueColumn.label }, + }, + { + variable: VAR_SEND_TO, + value: recipientsValue, + group: alertID, + metadata: { ...commonMetadata, sentValues: recipientsChips }, + }, + { + variable: VAR_MESSAGE, + value: fields.message, + group: alertID, + metadata: { ...commonMetadata }, + }, + ]); +} + +/** + * Deletes every data row that shares the alert id as group on the + * organization device. Called from delete AND from edit (we re-create the + * row from scratch on edit so we don't have to update six points + * individually). + */ +async function deleteAlertRow(organizationID: string, alertID: string): Promise { + await Resources.devices.deleteDeviceData(organizationID, { + groups: alertID, + qty: 9999, + }); +} + +// ============================================================================ +// Helpers — TagoIO Action lifecycle +// ============================================================================ + +/** Subset of AlertFields that the Action layer can actually handle. */ +type ActionableAlertFields = CommonFields & Exclude; + +/** + * Builds the trigger array for the TagoIO Action. + * + * The Action listens to a SENSOR variable on every device that matches a + * tag pair. This way one Action covers many sensors at once, and new + * sensors that match the tag are automatically included + * + * Two cases: + * - `setupAlertsBy === "all_sensors"` → match every sensor in this org + * by the project-wide marker tag `device_type=device`. The Action's + * own `organization_id` tag is what keeps it scoped to one tenant + * (see `createAlertAction`). + * - `setupAlertsBy === "sensors"` → match sensors that carry the + * `alert_id` tag pointing at this alert. We add that tag to the + * selected sensors at create time (see `tagSensorsForAlert`). + */ +function buildTriggers(alertID: string, fields: ActionableAlertFields): Array<{ + tag_key: string; + tag_value: string; + variable: string; + is: Conditionals; + value: string; + second_value?: string; + value_type: "string" | "number" | "boolean" | "*"; +}> { + const variable = MODEL_VARIABLE[fields.model]; + + const tagPair = fields.setupAlertsBy === "all_sensors" + ? { tag_key: SENSOR_TAG_DEVICE_TYPE, tag_value: SENSOR_TAG_DEVICE_VALUE } + : { tag_key: SENSOR_TAG_ALERT_ID, tag_value: alertID }; + + if (fields.model === "temperature") { + return [{ + ...tagPair, + variable, + is: fields.condition, + value: String(fields.value), + ...(fields.condition === "><" && fields.secondValue !== undefined ? { second_value: String(fields.secondValue) } : {}), + value_type: "number", + }]; + } + + // door + compressor — equality match on a string value. + return [{ + ...tagPair, + variable, + is: "=", + value: fields.value, + value_type: "string", + }]; +} + +/** + * Creates the TagoIO Action that watches the configured sensors and calls + * the dispatcher Analysis on every match. Tags carry the alert id so we + * can find this Action again on edit/delete. + */ +async function createAlertAction(params: { + alertID: string; + organizationID: string; + dispatcherID: string; + fields: AlertFields; +}): Promise { + const { alertID, organizationID, dispatcherID, fields } = params; + + if (fields.model === "inactivity") { + return; + } + + const triggers = buildTriggers(alertID, fields); + + // The SDK only types `condition` triggers with a `device` id, but the + // TagoIO API also accepts `tag_key`/`tag_value` here — the platform + // matches every device whose tags include the pair. We cast through + // `unknown` to avoid silencing other shape errors. + await Resources.actions.create({ + name: `[alert] ${alertID}`, + active: true, + type: "condition", + tags: [ + { key: ACTION_TAG_ALERT_ID, value: alertID }, + { key: ACTION_TAG_ORG_ID, value: organizationID }, + { key: ACTION_TAG_TYPE, value: ACTION_TAG_TYPE_VALUE }, + ], + trigger: triggers as unknown as any, + action: { type: "script", script: [dispatcherID] }, + }); +} + +/** + * Adds the `alert_id` tag to every selected sensor. The Action then picks + * them up automatically via its `tag_key/tag_value` trigger. Existing tags + * on each sensor are preserved. + */ +async function tagSensorsForAlert(alertID: string, sensorList: { id: string; tags: TagsObj[] }[]): Promise { + for (const sensor of sensorList) { + const filteredTags = sensor.tags.filter((tag) => !(tag.key === SENSOR_TAG_ALERT_ID && tag.value === alertID)); + const nextTags = [...filteredTags, { key: SENSOR_TAG_ALERT_ID, value: alertID }]; + await Resources.devices.edit(sensor.id, { tags: nextTags }).catch((error) => { + console.error(`Failed to tag sensor ${sensor.id}: ${(error as Error).message}`); + }); + } +} + +/** + * Removes the `alert_id=alertID` tag from every sensor that currently + * carries it. We discover the sensors with `Resources.devices.list` + * filtered by the tag, so we do not need to remember which sensors were + * picked at create time. + */ +async function untagSensorsForAlert(alertID: string): Promise { + const sensors = await Resources.devices.list({ + amount: 1000, + fields: ["id", "tags"], + filter: { tags: [{ key: SENSOR_TAG_ALERT_ID, value: alertID }] }, + resolveBucketName: false, + }); + + for (const sensor of sensors) { + const nextTags = sensor.tags.filter((tag) => !(tag.key === SENSOR_TAG_ALERT_ID && tag.value === alertID)); + await Resources.devices.edit(sensor.id, { tags: nextTags }).catch((error) => { + console.error(`Failed to untag sensor ${sensor.id}: ${(error as Error).message}`); + }); + } +} + +/** + * Returns the id of the single Action tagged with the given alert id, or + * null if none exists. Used by delete so we never operate on stale + * references. + */ +async function findActionByAlertID(alertID: string): Promise { + const actions = await Resources.actions.list({ + amount: 10, + fields: ["id", "tags"], + filter: { tags: [{ key: ACTION_TAG_ALERT_ID, value: alertID }] }, + }); + + const action = actions[0]; + return action ? action.id : null; +} + +/** Deletes the TagoIO Action that backs the alert, if any. */ +async function deleteAlertAction(alertID: string): Promise { + const actionID = await findActionByAlertID(alertID); + if (actionID) { + await Resources.actions.delete(actionID).catch(console.error); + } +} + +// ============================================================================ +// CREATE flow +// ============================================================================ + +async function createAlert({ context, environment, scope }: RouterConstructor & { scope: Data[] }) { + console.log("[createAlert] start"); + + if (!("variable" in scope[0])) { + console.error("Not a valid TagoIO Data"); + return; + } + if (!context) { + throw "[Error] Missing analysis context."; + } + + const configDevID = environment.config_id; + const dispatcherID = environment.alert_dispatcher_id; + + const organizationID = scope[0].device; + if (!organizationID) { + throw "[Error] Missing organization ID in scope."; + } + + const sessionID = z.string().parse(scope.find((item) => item.variable === "create_alert_session_id")?.value); + const validate = initializeValidation({ validationVariable: "create_alert_validation", deviceID: configDevID, sessionID }); + + await validate("Creating alert, please wait...", "warning").catch(console.log); + + const fields = await parseFormFields(scope) + .catch(getZodErrorMessage) + .catch(async (error: Error) => { + await validate(error.message, "danger"); + throw error; + }); + + // Resolve sensors and recipients against the live tenant data so we can + // store labels and reject ids that don't belong to this organization. + const sensors = await resolveTargetSensors(organizationID, fields).catch(async (error: Error) => { + await validate(error.message, "danger"); + throw error; + }); + + const recipients = await resolveRecipients(organizationID, fields.recipients).catch(async (error: Error) => { + await validate(error.message, "danger"); + throw error; + }); + + // The data point id of the first row becomes the alert id. We generate + // it ourselves by deriving from `Date.now()` so it's predictable inside + // this function (Resources.devices.sendDeviceData does not return the + // generated id in the array shape). + const alertID = `alert_${Date.now()}_${Math.floor(Math.random() * 1_000_000)}`; + + await persistAlertRow({ organizationID, alertID, sensors, recipients, fields }); + + // When the user picked specific sensors, write the `alert_id` tag on each + // one BEFORE creating the Action — the Action's `tag_key/tag_value` + // trigger needs the tags to be already in place to match. + if (fields.setupAlertsBy === "sensors") { + await tagSensorsForAlert(alertID, sensors); + } + + await createAlertAction({ + alertID, + organizationID, + dispatcherID, + fields, + }).catch(async (error: Error) => { + // Rollback the table row AND the sensor tags if Action creation fails. + // The user shouldn't see a half-created alert. + await deleteAlertRow(organizationID, alertID).catch(console.error); + if (fields.setupAlertsBy === "sensors") { + await untagSensorsForAlert(alertID).catch(console.error); + } + await validate(`Failed to create alert action: ${error.message}`, "danger"); + throw error; + }); + + await validate("Alert created successfully!", "success"); +} + +// ============================================================================ +// DELETE flow +// ============================================================================ + +async function deleteAlert({ scope }: RouterConstructor & { scope: DeviceListScope[] }) { + console.log("[deleteAlert] start"); + + const entry = scope[0]; + const alertID = entry?.group; + const organizationID = entry?.device; + + if (!alertID) { + throw "[Error] Missing alert id (group) in scope."; + } + if (!organizationID) { + throw "[Error] Missing organization ID in scope."; + } + + await deleteAlertRow(organizationID, alertID); + await deleteAlertAction(alertID); + // Strip the `alert_id` tag from any sensor that still carries it. + // No-op for `all_sensors` alerts (we never tagged anything in that case). + await untagSensorsForAlert(alertID); +} + +// ============================================================================ +// Router entrypoint +// ============================================================================ + +async function startAnalysis(context: TagoContext, scope: Data[]): Promise { + console.log("Running CRUD Alert Analysis"); + console.log("Scope:", scope); + + const environment = Utils.envToJson(context.environment); + if (!environment.config_id) { + throw "Missing config_id environment variable"; + } + if (!environment.alert_dispatcher_id) { + throw "Missing alert_dispatcher_id environment variable"; + } + + const router = new Utils.AnalysisRouter({ scope, context, environment }); + + router.register(createAlert).whenInputFormID("create-alert"); + router.register(deleteAlert).whenVariableLike("alert_management_").whenWidgetExec("delete"); + + const result = await router.exec(); + console.log("Services found:", result.services); +} + +if (!Deno.env.get("T_TEST")) { + Analysis.use(startAnalysis, { token: Deno.env.get("T_ANALYSIS_TOKEN") }); +} + +export { startAnalysis }; diff --git a/app/analysis/dashboard/crud-group.ts b/app/analysis/dashboard/crud-group.ts new file mode 100644 index 0000000..93c619a --- /dev/null +++ b/app/analysis/dashboard/crud-group.ts @@ -0,0 +1,627 @@ +/** + * CRUD Group Analysis + * + * Educational single-file Analysis that handles the full lifecycle of a + * Group resource in the TagoIO Kickstarter project: create, edit and + * delete. This file is intentionally self-contained — it has no relative + * imports. + * + * How it is triggered + * ------------------- + * A dashboard sends Data points to this Analysis. The Analysis Router from + * `@tago-io/sdk` inspects the scope and runs the matching handler: + * + * - Input Form "create-group" -> createGroup + * - Custom Button "edit-group" -> editGroup + * - Device List action "delete-group" -> deleteGroup + * + * Required environment variables + * ------------------------------ + * - config_id : ID of the configuration device that stores the + * dashboard's per-organization data and is used to + * publish validation messages back to the UI. + * - T_ANALYSIS_TOKEN : Provided automatically by the TagoIO runtime. + * + * NOTE + * ---- + * This file is optimized for clarity, not performance. The goal is for a + * developer new to TagoIO to read it top-to-bottom and understand every step. + */ + +import { Analysis, type Data, type DeviceCreateInfo, type DeviceListScope } from "npm:@tago-io/sdk"; +import { Resources, type RouterConstructor, Services, type TagoContext, type TagsObj, Utils } from "npm:@tago-io/sdk"; +import { DateTime } from "npm:luxon"; +import z, { ZodError } from "npm:zod"; + +// ============================================================================ +// Constants +// ============================================================================ + +/** + * Custom HTTPS Storage Network ID. Used by configuration devices that only + * hold data (organizations, groups) instead of receiving real uplinks. + */ +const STORAGE_NETWORK_ID = "62336c32ab6e0d0012e06c04"; + +/** + * Database Connector ID paired with the storage network above. + */ +const DATABASE_CONNECTOR_ID = "62333bd36977fc001a2990c8"; + +/** + * Tag key/value used to find the dashboard that manages the sensors of a + * single group. The dashboard is matched by its `export_id` tag, which is + * set when the dashboard template is imported into the account. + */ +const GROUP_DASHBOARD_TAG_KEY = "export_id"; +const GROUP_DASHBOARD_TAG_VALUE = "sensor-management"; + +// ============================================================================ +// Validation schema +// ============================================================================ + +/** + * Address accepted as the already-normalized "lat,lng;label" string used by + * the Device List widget on the Edit flow. + */ +const addressStringSchema = z + .string() + .min(3, { error: "Address must be at least 3 characters" }) + .max(200, { error: "Address must be less than 200 characters" }); + +/** + * Address schema for the Create form. The dashboard sends the address as a + * TagoIO location Data point ({ value, location: { coordinates: [lng, lat] } }). + * We accept it as an object and transform it into the "lat,lng;label" string + * that TagoIO widgets expect when reading back the value from device params. + */ +const addressLocationSchema = z + .object({ + value: z + .string() + .min(3, { error: "Address must be at least 3 characters" }) + .max(200, { error: "Address must be less than 200 characters" }) + .optional(), + location: z.object({ + coordinates: z + .array(z.number(), { error: "Address Coordinates are required." }) + .length(2, { error: "Invalid coordinates" }), + }), + }) + .optional() + .transform(convertLocationToString); + +const groupModel = z.object({ + name: z + .string({ error: "Name is required" }) + .min(1, { error: "Name must be at least 1 character" }) + .max(40, { error: "Name must be less than 40 characters" }), + address: z.union([addressStringSchema, addressLocationSchema]).optional(), +}); + +/** + * Partial schema reused by the Edit flow — every field becomes optional so + * we only validate what the Device List widget actually sent. + */ +const groupEditModel = groupModel.partial(); + +// ============================================================================ +// Helpers — formatting and error handling +// ============================================================================ + +/** + * Converts a TagoIO location Data point into the "lat,lng;label" string + * format. Returns an empty string if the input is missing or malformed. + * + * The TagoIO `coordinates` array is stored as `[longitude, latitude]`, so + * we swap them when building the human-readable string. + */ +function convertLocationToString(data?: { value?: string; location?: { coordinates: number[] } }): string { + if (!data?.location?.coordinates || data.location.coordinates.length !== 2) { + return ""; + } + + const [lng, lat] = data.location.coordinates; + const label = data.value ?? ""; + return `${lat},${lng};${label}`; +} + +/** + * Extracts a short, human-readable message from a Zod or generic error and + * re-throws it as a plain `Error`. This keeps the validation feedback + * concise — only the first Zod issue is surfaced to the user. + */ +function getZodErrorMessage(error: unknown): never { + if (error instanceof ZodError) { + const message = error.issues[0]?.message ?? "Validation error"; + throw new Error(message); + } + + if (error instanceof Error) { + throw error; + } + + throw new Error("Unknown error occurred"); +} + +// ============================================================================ +// Helpers — feedback to the dashboard +// ============================================================================ + +type ValidationLevel = "success" | "danger" | "warning"; + +interface ValidationConfig { + validationVariable: string; + deviceID: string; + sessionID?: string; +} + +/** + * Creates a `validate(message, level)` function tied to a specific + * validation variable on the configuration device. The dashboard listens + * to this variable through a Validation widget and renders the messages + * to the user. + * + * Each call cleans up old validation entries (>1 minute) and writes a new + * one with a small timestamp offset, so multiple messages from the same + * run still appear in the correct order on the dashboard. + */ +function initializeValidation(config: ValidationConfig) { + let messageIndex = 0; + + return async (message: string, level: ValidationLevel = "success"): Promise => { + if (!message?.trim()) { + throw new Error("Validation message cannot be empty"); + } + + const now = DateTime.now(); + // Each subsequent message is pushed 200ms forward so the dashboard + // renders them in insertion order even if the API timestamps collide. + const timeOffset = ++messageIndex * 200; + + await Promise.allSettled([ + Resources.devices.deleteDeviceData(config.deviceID, { + variables: config.validationVariable, + qty: 999, + end_date: now.minus({ minutes: 1 }).toJSDate(), + }), + Resources.devices.sendDeviceData(config.deviceID, { + variable: config.validationVariable, + value: message, + time: now.plus({ milliseconds: timeOffset }).toJSDate(), + metadata: { + type: level, + session_id: config.sessionID, + show_markdown: false, + }, + }), + ]); + + return message; + }; +} + +/** + * Sends an in-app notification to the Run User who triggered the + * Analysis. Falls back to a developer notification if no user can be + * identified — useful for edit/delete flows where the dashboard doesn't + * expose a Validation widget. + */ +async function sendNotificationFeedback(params: { environment: Record; title?: string; message: string }): Promise { + const { environment, title, message } = params; + const userID = environment?._user_id; + + // No user context — notify the developer via the Analysis token. + if (!userID) { + const services = new Services({ token: Deno.env.get("T_ANALYSIS_TOKEN") }); + await services.notification.send({ title: title || "Operation error", message }); + return; + } + + // Confirm the user still exists before sending an in-app notification. + const user = await Resources.run.userInfo(userID).catch(() => null); + if (!user) { + const services = new Services({ token: Deno.env.get("T_ANALYSIS_TOKEN") }); + await services.notification.send({ title: title || "Operation error", message }); + return; + } + + await Resources.run.notificationCreate(userID, { + title: title || "Operation error", + message, + }); +} + +// ============================================================================ +// Helpers — resource lookups +// ============================================================================ + +/** + * Checks whether a device with the given name and/or tags already exists. + * + * @param isEdit - During an edit, the device being modified is itself + * returned by the search, so we only consider it a duplicate when more + * than one device matches. During a create, any match is a duplicate. + */ +async function deviceExists(params: { name?: string; tags: { key: string; value: string }[]; isEdit?: boolean }): Promise { + const { name, tags, isEdit = false } = params; + + // Paginate through the device list with a comfortable page size. The + // Kickstarter is unlikely to hit the upper bound, but we keep the cap + // explicit for safety. + const found: { id: string }[] = []; + for (let page = 1; page < 9999; page++) { + const batch = await Resources.devices.list({ + page, + amount: 100, + fields: ["id", "name", "tags"], + filter: { name, tags }, + resolveBucketName: false, + }); + + found.push(...batch); + if (batch.length < 100) { + break; + } + } + + if (isEdit) { + return found.length > 1; + } + + return found.length > 0; +} + +/** + * Finds a Dashboard ID by a tag value. Used to compose the URL that opens + * the per-group management dashboard right after creation. + */ +async function getDashboardIDByTag(tagKey: string, tagValue: string): Promise { + const [dashboard] = await Resources.dashboards.list({ + amount: 1, + fields: ["id", "tags"], + filter: { tags: [{ key: tagKey, value: tagValue }] }, + }); + + if (!dashboard?.id) { + throw new Error(`Dashboard with ${tagKey}=${tagValue} not found`); + } + + return dashboard.id; +} + +// ============================================================================ +// CREATE flow +// ============================================================================ + +/** + * Reads the form fields sent by the dashboard from the scope and runs + * them through the Zod schema. The schema also transforms the location + * Data point into a "lat,lng;label" string. + */ +function extractCreateFormFields(scope: Data[]) { + const newGroupName = scope.find((item: Data) => item.variable === "new_group_name")?.value; + const newGroupAddress = scope.find((item: Data) => item.variable === "new_group_address"); + + return groupModel.parseAsync({ + name: newGroupName, + address: newGroupAddress, + }); +} + +/** + * Creates the group device on TagoIO and applies the identity tags + * (`organization_id`, `group_id`, `device_type`). The device id is reused + * as the group id throughout the application. + * + * The device is created with the parent-org and type tags, and then a + * second edit adds the `group_id` tag pointing to its own id. This two + * step approach is needed because the id is only known after creation. + */ +async function installGroupDevice(params: { name: string; organizationID: string }): Promise { + const tags: TagsObj[] = [ + { key: "organization_id", value: params.organizationID }, + { key: "device_type", value: "group" }, + ]; + + const deviceData: DeviceCreateInfo = { + name: params.name, + type: "mutable", + network: STORAGE_NETWORK_ID, + connector: DATABASE_CONNECTOR_ID, + tags, + }; + + const newDevice = await Resources.devices.create(deviceData); + + const newTags: TagsObj[] = tags.concat([ + { key: "group_id", value: newDevice.device_id }, + ]); + + await Resources.devices.edit(newDevice.device_id, { tags: newTags }); + + return newDevice.device_id; +} + +/** + * Handles the "create-group" Input Form submission. + * + * Steps: + * 1. Confirm the scope is a Data array sent by the form. + * 2. Read the session id so validation messages reach the right user. + * 3. Validate form fields with Zod; surface the first issue if any. + * 4. Reject duplicate group names inside the parent organization. + * 5. Create the device, tag it, and store its params (URL + address). + * 6. Send a success message back to the dashboard. + */ +async function createGroup({ environment, scope }: RouterConstructor & { scope: Data[] }) { + // The router can hand us non-Data scopes for other triggers, so we + // double-check that the first element actually looks like Data. + if (!("variable" in scope[0])) { + console.error("Not a valid TagoIO Data"); + return; + } + + const configDevID = environment.config_id; + if (!configDevID) { + throw "[Error] Missing config_id environment variable."; + } + + // The form is rendered on the per-organization Groups dashboard, so the + // organization id is the device id stored in `scope[0].device`. + const organizationID = scope[0].device; + if (!organizationID) { + throw "[Error] Missing organization ID in scope."; + } + + // The session id is generated by the dashboard form and lets the + // Validation widget filter messages to the user who triggered the run. + const sessionID = z.string().parse(scope.find((item: Data) => item.variable === "create_group_session_id")?.value); + const validate = initializeValidation({ validationVariable: "create_group_validation", deviceID: configDevID, sessionID }); + + // Friendly "working on it" message now that validation passed. + await validate("Adding group, please wait...", "warning").catch(console.log); + + // Validate the form. If Zod fails, surface the first issue to the user + // and abort the run. The double `.catch` keeps the happy path readable. + const formFields = await extractCreateFormFields(scope) + .catch(getZodErrorMessage) + .catch(async (error: Error) => { + await validate(error.message, "danger"); + throw error; + }); + + // Reject duplicates within the same organization. + const isNameInUse = await deviceExists({ + name: formFields.name, + tags: [ + { key: "organization_id", value: organizationID }, + { key: "device_type", value: "group" }, + ], + }); + + if (isNameInUse) { + throw await validate( + `A group with name ${formFields.name} already exists within this organization.`, + "danger", + ); + } + + // Create the device and tag it as a group of the current organization. + const groupID = await installGroupDevice({ name: formFields.name, organizationID }); + + // Build the URL that opens the per-group sensors dashboard and store it + // as a device param so the front-end can read it later. + const dashboardID = await getDashboardIDByTag(GROUP_DASHBOARD_TAG_KEY, GROUP_DASHBOARD_TAG_VALUE); + const dashboardURL = `/dashboards/info/${dashboardID}?settings=${configDevID}&group_dev=${groupID}`; + + await Resources.devices.paramSet(groupID, [ + { key: "dashboard_url", value: dashboardURL, sent: true }, + { key: "group_address", value: formFields.address, sent: true }, + ]); + + await validate(`Group ${formFields.name} successfully added!`, "success"); +} + +// ============================================================================ +// EDIT flow +// ============================================================================ + +/** + * Restores a group device to its previous state when an edit fails + * validation. The Device List widget includes the previous values under + * `scope[0].old`, so we use that snapshot to roll back the change. + * + * Group edits only touch `name` and `param.group_address`, so we handle + * both fields explicitly instead of using a generic resolver. + */ +async function undoGroupChanges(groupID: string, scope: DeviceListScope[]): Promise { + const deviceScope = scope[0]; + const oldValues = deviceScope?.old ?? {}; + + for (const key of Object.keys(deviceScope)) { + // Roll back the device name. + if (key === "name" && typeof oldValues[key] === "string") { + await Resources.devices.edit(groupID, { name: oldValues[key] as string }); + continue; + } + + // Roll back any edited param. Only `group_address` is exposed on the + // UI today, but we keep the branch generic in case more params are + // added later. + if (key.startsWith("param.")) { + const paramKey = key.replace("param.", ""); + const oldValue = oldValues[key] as string | undefined; + if (oldValue === undefined) { + continue; + } + + // Look up the existing param entry so we keep its id when updating. + const paramList = await Resources.devices.paramList(groupID); + const existing = paramList.find((p) => p.key === paramKey); + await Resources.devices.paramSet(groupID, { + id: existing?.id, + key: paramKey, + value: oldValue, + sent: true, + }); + } + } +} + +/** + * Handles the "edit-group" Custom Button on the Device List widget. + * + * The Device List sends both the new and the old value for each edited + * field. We validate the new values, and on any failure we restore the + * old ones and notify the user. + */ +async function editGroup({ scope, environment }: RouterConstructor & { scope: DeviceListScope[] }) { + const groupID = scope[0].device; + if (!groupID) { + throw "[Error] Missing group ID in scope."; + } + + const newName = scope[0]?.name; + const newAddress = scope[0]?.["param.group_address"] || undefined; + + // Validate the partial payload. If Zod rejects it, undo the change and + // notify the user — then bubble the error up so the run is logged. + await groupEditModel + .parseAsync({ name: newName, address: newAddress }) + .catch(getZodErrorMessage) + .catch(async (error: Error) => { + await undoGroupChanges(groupID, scope); + await sendNotificationFeedback({ environment, message: error.message }); + throw error; + }); + + // If the name changed, make sure no other group inside this organization + // is using it. We read the parent organization from the group's tags. + if (newName) { + const groupInfo = await Resources.devices.info(groupID); + const organizationID = z + .string() + .parse(groupInfo.tags.find((tag) => tag.key === "organization_id")?.value); + + const isNameInUse = await deviceExists({ + name: newName, + tags: [ + { key: "organization_id", value: organizationID }, + { key: "device_type", value: "group" }, + ], + isEdit: true, + }); + + if (isNameInUse) { + await undoGroupChanges(groupID, scope); + await sendNotificationFeedback({ + environment, + message: `A group with name ${newName} already exists within this organization.`, + }); + throw `A group with name ${newName} already exists within this organization.`; + } + } +} + +// ============================================================================ +// DELETE flow +// ============================================================================ + +/** + * Deletes every device tagged with this group (sensors and any dummy + * device that carries the `group_id` tag of this group, except the group + * device itself, which has already been removed by the caller). + */ +async function deleteGroupDevices(groupID: string): Promise { + const devices = await Resources.devices.list({ + amount: 9999, + page: 1, + fields: ["id"], + filter: { tags: [{ key: "group_id", value: groupID }] }, + }); + + for (const device of devices) { + await Resources.devices.delete(device.id).catch(console.log); + } +} + +/** + * Handles the "delete-group" identifier on the Device List widget. + * + * Cascades: + * 1. Wipe the group's data row in the config device. + * 2. Capture the group's name before the device is gone. + * 3. Delete the group device itself. + * 4. Delete every sensor (and dummy device) that carried this group_id. + * 5. Notify the user. + */ +async function deleteGroup({ scope, environment }: RouterConstructor & { scope: DeviceListScope[] }) { + const configDevID = environment.config_id; + if (!configDevID) { + throw "[Error] Missing config_id environment variable."; + } + + const groupID = scope[0].device; + if (!groupID) { + throw "[Error] Missing group ID in scope."; + } + + // Capture the name now — the device will be gone before we send the + // notification at the end of the flow. + const groupInfo = await Resources.devices.info(groupID); + + // Remove the group's row from the config device storage. + await Resources.devices.deleteDeviceData(configDevID, { groups: groupID, qty: 9999 }); + + // Delete the group device itself first so the cascade below does not + // need to filter it out. + await Resources.devices.delete(groupID).catch(console.log); + + // Cascade into child resources (sensors and any dummy device sharing + // the same `group_id` tag). + await deleteGroupDevices(groupID); + + await sendNotificationFeedback({ + environment, + title: "Group removed", + message: `Group ${groupInfo.name} successfully removed!`, + }); +} + +// ============================================================================ +// Router entrypoint +// ============================================================================ + +/** + * Entrypoint invoked by the TagoIO Analysis runtime. Reads the scope and + * environment, sets up the router, and dispatches to the matching CRUD + * handler. + */ +async function startAnalysis(context: TagoContext, scope: Data[]): Promise { + console.log("Running CRUD Group Analysis"); + console.log("Scope:", scope); + + const environment = Utils.envToJson(context.environment); + if (!environment.config_id) { + throw "Missing config_id environment variable"; + } + + const router = new Utils.AnalysisRouter({ scope, context, environment }); + + router.register(createGroup).whenInputFormID("create-group"); + router.register(editGroup).whenCustomBtnID("edit-group"); + router.register(deleteGroup).whenDeviceListIdentifier("delete-group"); + + const result = await router.exec(); + console.log("Services found:", result.services); +} + +// The Analysis runtime sets `T_TEST` during local tests so the handler is +// not wired up automatically. In production the runtime sets +// `T_ANALYSIS_TOKEN` and calls `Analysis.use` below. +if (!Deno.env.get("T_TEST")) { + Analysis.use(startAnalysis, { token: Deno.env.get("T_ANALYSIS_TOKEN") }); +} + +export { startAnalysis }; diff --git a/app/analysis/dashboard/crud-organization.ts b/app/analysis/dashboard/crud-organization.ts new file mode 100644 index 0000000..7b717a8 --- /dev/null +++ b/app/analysis/dashboard/crud-organization.ts @@ -0,0 +1,662 @@ +/** + * CRUD Organization Analysis + * + * Educational single-file Analysis that handles the full lifecycle of an + * Organization resource in the TagoIO Kickstarter project: create, edit and + * delete. This file is intentionally self-contained — it has no relative + * imports. + * + * How it is triggered + * ------------------- + * A dashboard sends Data points to this Analysis. The Analysis Router from + * `@tago-io/sdk` inspects the scope and runs the matching handler: + * + * - Input Form "create-org" -> createOrganization + * - Custom Button "edit-org" -> editOrganization + * - Device List action "delete-org" -> deleteOrganization + * + * Required environment variables + * ------------------------------ + * - config_id : ID of the configuration device that stores the + * dashboard's per-organization data and is used to + * publish validation messages back to the UI. + * - T_ANALYSIS_TOKEN : Provided automatically by the TagoIO runtime. + * + * NOTE + * ---- + * This file is optimized for clarity, not performance. The goal is for a + * developer new to TagoIO to read it top-to-bottom and understand every step. + */ + +import { Analysis, type Data, type DataToSend, type DeviceCreateInfo, type DeviceListScope } from "npm:@tago-io/sdk"; +import { Resources, type RouterConstructor, Services, type TagoContext, type TagsObj, Utils } from "npm:@tago-io/sdk"; +import { DateTime } from "npm:luxon"; +import z, { ZodError } from "npm:zod"; + +// ============================================================================ +// Constants +// ============================================================================ + +/** + * Custom HTTPS Storage Network ID. Used by configuration devices that only + * hold data (organizations, groups) instead of receiving real uplinks. + */ +const STORAGE_NETWORK_ID = "62336c32ab6e0d0012e06c04"; + +/** + * Database Connector ID paired with the storage network above. + */ +const DATABASE_CONNECTOR_ID = "62333bd36977fc001a2990c8"; + +/** + * Tag key/value used to find the dashboard that manages a single + * organization. The dashboard is matched by its `export_id` tag, which is + * set when the dashboard template is imported into the account. + */ +const ORG_DASHBOARD_TAG_KEY = "export_id"; + +// ============================================================================ +// Validation schema +// ============================================================================ + +/** + * Address accepted as the already-normalized "lat,lng;label" string used by + * the Device List widget on the Edit flow. The Create flow does not reach + * this branch because it sends a TagoIO location Data point, which is + * handled by `addressLocationSchema` below. + */ +const addressStringSchema = z + .string() + .min(3, { error: "Address must be at least 3 characters" }) + .max(200, { error: "Address must be less than 200 characters" }); + +/** + * Address schema for the Create form. The dashboard sends the address as a + * TagoIO location Data point ({ value, location: { coordinates: [lng, lat] } }). + * We accept it as an object and transform it into the "lat,lng;label" string + * that TagoIO widgets expect when reading back the value from device params. + */ +const addressLocationSchema = z + .object({ + value: z + .string() + .min(3, { error: "Address must be at least 3 characters" }) + .max(200, { error: "Address must be less than 200 characters" }) + .optional(), + location: z.object({ + coordinates: z + .array(z.number(), { error: "Address Coordinates are required." }) + .length(2, { error: "Invalid coordinates" }), + }), + }) + .optional() + .transform(convertLocationToString); + +const orgModel = z.object({ + name: z + .string({ error: "Name is required" }) + .min(1, { error: "Name must be at least 1 character" }) + .max(40, { error: "Name must be less than 40 characters" }), + address: z.union([addressStringSchema, addressLocationSchema]).optional(), +}); + +/** + * Partial schema reused by the Edit flow — every field becomes optional so + * we only validate what the Device List widget actually sent. + */ +const orgEditModel = orgModel.partial(); + +// ============================================================================ +// Helpers — formatting and error handling +// ============================================================================ + +/** + * Converts a TagoIO location Data point into the "lat,lng;label" string + * format. Returns an empty string if the input is missing or malformed. + * + * The TagoIO `coordinates` array is stored as `[longitude, latitude]`, so + * we swap them when building the human-readable string. + */ +function convertLocationToString(data?: { value?: string; location?: { coordinates: number[] } }): string { + if (!data?.location?.coordinates || data.location.coordinates.length !== 2) { + return ""; + } + + const [lng, lat] = data.location.coordinates; + const label = data.value ?? ""; + return `${lat},${lng};${label}`; +} + +/** + * Extracts a short, human-readable message from a Zod or generic error and + * re-throws it as a plain `Error`. This keeps the validation feedback + * concise — only the first Zod issue is surfaced to the user. + */ +function getZodErrorMessage(error: unknown): never { + if (error instanceof ZodError) { + const message = error.issues[0]?.message ?? "Validation error"; + throw new Error(message); + } + + if (error instanceof Error) { + throw error; + } + + throw new Error("Unknown error occurred"); +} + +// ============================================================================ +// Helpers — feedback to the dashboard +// ============================================================================ + +type ValidationLevel = "success" | "danger" | "warning"; + +interface ValidationConfig { + validationVariable: string; + deviceID: string; + sessionID?: string; +} + +/** + * Creates a `validate(message, level)` function tied to a specific + * validation variable on the configuration device. The dashboard listens + * to this variable through a Validation widget and renders the messages + * to the user. + * + * Each call cleans up old validation entries (>1 minute) and writes a new + * one with a small timestamp offset, so multiple messages from the same + * run still appear in the correct order on the dashboard. + */ +function initializeValidation(config: ValidationConfig) { + let messageIndex = 0; + + return async (message: string, level: ValidationLevel = "success"): Promise => { + if (!message?.trim()) { + throw new Error("Validation message cannot be empty"); + } + + const now = DateTime.now(); + // Each subsequent message is pushed 200ms forward so the dashboard + // renders them in insertion order even if the API timestamps collide. + const timeOffset = ++messageIndex * 200; + + await Promise.allSettled([ + Resources.devices.deleteDeviceData(config.deviceID, { + variables: config.validationVariable, + qty: 999, + end_date: now.minus({ minutes: 1 }).toJSDate(), + }), + Resources.devices.sendDeviceData(config.deviceID, { + variable: config.validationVariable, + value: message, + time: now.plus({ milliseconds: timeOffset }).toJSDate(), + metadata: { + type: level, + session_id: config.sessionID, + show_markdown: false, + }, + }), + ]); + + return message; + }; +} + +/** + * Sends an in-app notification to the Run User who triggered the + * Analysis. Falls back to a developer notification if no user can be + * identified — useful for edit/delete flows where the dashboard doesn't + * expose a Validation widget. + */ +async function sendNotificationFeedback(params: { environment: Record; title?: string; message: string }): Promise { + const { environment, title, message } = params; + const userID = environment?._user_id; + + // No user context — notify the developer via the Analysis token. + if (!userID) { + const services = new Services({ token: Deno.env.get("T_ANALYSIS_TOKEN") }); + await services.notification.send({ title: title || "Operation error", message }); + return; + } + + // Confirm the user still exists before sending an in-app notification. + const user = await Resources.run.userInfo(userID).catch(() => null); + if (!user) { + const services = new Services({ token: Deno.env.get("T_ANALYSIS_TOKEN") }); + await services.notification.send({ title: title || "Operation error", message }); + return; + } + + await Resources.run.notificationCreate(userID, { + title: title || "Operation error", + message, + }); +} + +// ============================================================================ +// Helpers — resource lookups +// ============================================================================ + +/** + * Checks whether a device with the given name and/or tags already exists. + * + * @param isEdit - During an edit, the device being modified is itself + * returned by the search, so we only consider it a duplicate when more + * than one device matches. During a create, any match is a duplicate. + */ +async function deviceExists(params: { name?: string; tags: { key: string; value: string }[]; isEdit?: boolean }): Promise { + const { name, tags, isEdit = false } = params; + + // Paginate through the device list with a comfortable page size. The + // Kickstarter is unlikely to hit the upper bound, but we keep the cap + // explicit for safety. + const found: { id: string }[] = []; + for (let page = 1; page < 9999; page++) { + const batch = await Resources.devices.list({ + page, + amount: 100, + fields: ["id", "name", "tags"], + filter: { name, tags }, + resolveBucketName: false, + }); + + found.push(...batch); + if (batch.length < 100) { + break; + } + } + + if (isEdit) { + return found.length > 1; + } + + return found.length > 0; +} + +/** + * Finds a Dashboard ID by a tag value. Used to compose the URL that opens + * the per-organization management dashboard right after creation. + */ +async function getDashboardIDByTag(tagKey: string, tagValue: string): Promise { + const [dashboard] = await Resources.dashboards.list({ + amount: 1, + fields: ["id", "tags"], + filter: { tags: [{ key: tagKey, value: tagValue }] }, + }); + + if (!dashboard?.id) { + throw new Error(`Dashboard with ${tagKey}=${tagValue} not found`); + } + + return dashboard.id; +} + +// ============================================================================ +// CREATE flow +// ============================================================================ + +/** + * Reads the form fields sent by the dashboard from the scope and runs + * them through the Zod schema. The schema also transforms the location + * Data point into a "lat,lng;label" string. + */ +function extractCreateFormFields(scope: Data[]) { + const newOrgName = scope.find((item: Data) => item.variable === "new_organization_name")?.value; + const newOrgAddress = scope.find((item: Data) => item.variable === "new_organization_address"); + + return orgModel.parseAsync({ + name: newOrgName, + address: newOrgAddress, + }); +} + +/** + * Creates the organization device on TagoIO and applies the identity tags + * (`organization_id`, `device_type`). The device id is reused as the organization + * id throughout the application. + */ +async function installOrganizationDevice(name: string): Promise { + const tags: TagsObj[] = [ + { key: "device_type", value: "organization" }, + ]; + + const deviceData: DeviceCreateInfo = { + name, + type: "mutable", + network: STORAGE_NETWORK_ID, + connector: DATABASE_CONNECTOR_ID, + tags, + }; + + const newDevice = await Resources.devices.create(deviceData); + + const newTags: TagsObj[] = tags.concat([ + { key: "organization_id", value: newDevice.device_id }, + ]); + + await Resources.devices.edit(newDevice.device_id, { tags: newTags }); + + return newDevice.device_id; +} + +/** + * Handles the "create-org" Input Form submission. + * + * Steps: + * 1. Confirm the scope is a Data array sent by the form. + * 2. Read the session id so validation messages reach the right user. + * 3. Validate form fields with Zod; surface the first issue if any. + * 4. Reject duplicate organization names. + * 5. Create the device, tag it, and store its params (URL + address). + * 6. Send a success message back to the dashboard. + */ +async function createOrganization({ environment, scope }: RouterConstructor & { scope: Data[] }) { + // The router can hand us non-Data scopes for other triggers, so we + // double-check that the first element actually looks like Data. + if (!("variable" in scope[0])) { + console.error("Not a valid TagoIO Data"); + return; + } + + const configDevID = environment.config_id; + if (!configDevID) { + throw "[Error] Missing config_id environment variable."; + } + + // The session id is generated by the dashboard form and lets the + // Validation widget filter messages to the user who triggered the run. + const sessionID = z.string().parse(scope.find((item: Data) => item.variable === "create_organization_session_id")?.value); + const validate = initializeValidation({ validationVariable: "create_organization_validation", deviceID: configDevID, sessionID }); + + // Friendly "working on it" message while we hit the TagoIO API. + await validate("Adding organization, please wait...", "warning").catch(console.log); + + // Validate the form. If Zod fails, surface the first issue to the user + // and abort the run. The double `.catch` keeps the happy path readable. + const formFields = await extractCreateFormFields(scope) + .catch(getZodErrorMessage) + .catch(async (error: Error) => { + await validate(error.message, "danger"); + throw error; + }); + + // Reject duplicates before creating any device. + const isNameInUse = await deviceExists({ + name: formFields.name, + tags: [{ key: "device_type", value: "organization" }], + }); + + if (isNameInUse) { + throw await validate(`An organization with name ${formFields.name} already exists.`, "danger"); + } + + // Create the device and tag it as an organization. + const organizationID = await installOrganizationDevice(formFields.name); + + // Build the URL that opens the per-organization dashboard and store it + // as a device param so the front-end can read it later. + const dashboardID = await getDashboardIDByTag(ORG_DASHBOARD_TAG_KEY, "group-management"); + const dashboardURL = `/dashboards/info/${dashboardID}?settings=${configDevID}&org_dev=${organizationID}`; + + await Resources.devices.paramSet(organizationID, [ + { key: "dashboard_url", value: dashboardURL, sent: true }, + { key: "organization_address", value: formFields.address, sent: true }, + ]); + + const location = scope.find((x) => x.variable === "new_organization_address")?.location; + const organizationAddressData: DataToSend = { + variable: "organization_address", + metadata: { label: formFields.name, url: dashboardURL, color: "#B0B0B0" }, + location: location, + group: organizationID, + }; + await Resources.devices.sendDeviceData(configDevID, organizationAddressData); + + return validate(`Organization ${formFields.name} successfully added!`, "success"); +} + +// ============================================================================ +// EDIT flow +// ============================================================================ + +/** + * Restores an organization device to its previous state when an edit + * fails validation. The Device List widget includes the previous values + * under `scope[0].old`, so we use that snapshot to roll back the change. + * + * Organization edits only touch `name` and `param.organization_address`, + * so we handle both fields explicitly instead of using a generic resolver. + */ +async function undoOrganizationChanges(organizationID: string, scope: DeviceListScope[]): Promise { + const deviceScope = scope[0]; + const oldValues = deviceScope?.old ?? {}; + + for (const key of Object.keys(deviceScope)) { + // Roll back the device name. + if (key === "name" && typeof oldValues[key] === "string") { + await Resources.devices.edit(organizationID, { name: oldValues[key] as string }); + continue; + } + + // Roll back any edited param. Only `organization_address` is exposed + // on the UI today, but we keep the branch generic in case more params + // are added later. + if (key.startsWith("param.")) { + const paramKey = key.replace("param.", ""); + const oldValue = oldValues[key] as string | undefined; + if (oldValue === undefined) { + continue; + } + + // Look up the existing param entry so we keep its id when updating. + const paramList = await Resources.devices.paramList(organizationID); + const existing = paramList.find((p) => p.key === paramKey); + await Resources.devices.paramSet(organizationID, { + id: existing?.id, + key: paramKey, + value: oldValue, + sent: true, + }); + } + } +} + +/** + * Handles the "edit-org" Custom Button on the Device List widget. + * + * The Device List sends both the new and the old value for each edited + * field. We validate the new values, and on any failure we restore the + * old ones and notify the user. + */ +async function editOrganization({ scope, environment }: RouterConstructor & { scope: DeviceListScope[] }) { + const organizationID = scope[0].device; + if (!organizationID) { + throw "[Error] Missing organization ID in scope."; + } + + const configDevID = environment.config_id; + if (!configDevID) { + throw "[Error] Missing config_id environment variable."; + } + + const newName = scope[0]?.name; + const newAddress = scope[0]?.["param.organization_address"] || undefined; + + // Validate the partial payload. If Zod rejects it, undo the change and + // notify the user — then bubble the error up so the run is logged. + await orgEditModel + .parseAsync({ name: newName, address: newAddress }) + .catch(getZodErrorMessage) + .catch(async (error: Error) => { + await undoOrganizationChanges(organizationID, scope); + await sendNotificationFeedback({ environment, message: error.message }); + throw error; + }); + + // If the name changed, make sure no other organization is using it. + if (newName) { + const isNameInUse = await deviceExists({ + name: newName, + tags: [{ key: "device_type", value: "organization" }], + isEdit: true, + }); + + if (isNameInUse) { + await undoOrganizationChanges(organizationID, scope); + await sendNotificationFeedback({ + environment, + message: `An organization with name ${newName} already exists.`, + }); + throw `An organization with name ${newName} already exists.`; + } + } + + // Keep the `organization_address` row on the config device in sync with + // the edited values. This is what powers the Organization List and the + // Map View on the Organizations dashboard. + const [orgAddressData] = await Resources.devices.getDeviceData(configDevID, { + variables: "organization_address", + groups: organizationID, + qty: 1, + }); + + if (orgAddressData) { + const [locationValue] = (newAddress ?? "").split(";"); + const [latString, lngString] = locationValue.split(","); + + await Resources.devices.editDeviceData(configDevID, { + ...orgAddressData, + metadata: { + ...orgAddressData.metadata, + label: newName || orgAddressData.metadata?.label, + }, + location: { lat: Number(latString), lng: Number(lngString) }, + }); + } +} + +// ============================================================================ +// DELETE flow +// ============================================================================ + +/** + * Deletes every Run User tagged with this organization. + */ +async function deleteOrganizationUsers(organizationID: string): Promise { + // Paginate through every user tagged with this organization id. + const users: { id: string }[] = []; + for (let page = 1; page < 9999; page++) { + const batch = await Resources.run.listUsers({ + page, + amount: 40, + fields: ["id"], + filter: { tags: [{ key: "organization_id", value: organizationID }] }, + }); + + users.push(...batch); + if (batch.length < 40) { + break; + } + } + + for (const user of users) { + await Resources.run.userDelete(user.id).catch(console.log); + } +} + +/** + * Deletes every device that belongs to the organization (groups, sensors, + * dummy devices, etc.). The organization device itself is removed by the + * caller after this function returns. + */ +async function deleteOrganizationDevices(organizationID: string): Promise { + const devices = await Resources.devices.list({ + amount: 9999, + page: 1, + fields: ["id"], + filter: { tags: [{ key: "organization_id", value: organizationID }] }, + }); + + for (const device of devices) { + await Resources.devices.delete(device.id).catch(console.log); + } +} + +/** + * Handles the "delete-org" identifier on the Device List widget. + * + * Cascades: + * 1. Wipe the organization's data row in the config device. + * 2. Delete all related Run Users. + * 3. Delete all related devices (groups, sensors, ...). + * 4. Delete the organization device itself. + * 5. Notify the user. + */ +async function deleteOrganization({ scope, environment }: RouterConstructor & { scope: DeviceListScope[] }) { + const configDevID = environment.config_id; + if (!configDevID) { + throw "[Error] Missing config_id environment variable."; + } + + const organizationID = scope[0].device; + if (!organizationID) { + throw "[Error] Missing organization ID in scope."; + } + + // Capture the name now — the device will be gone before we send the + // notification at the end of the flow. + const organizationInfo = await Resources.devices.info(organizationID); + + // Remove the organization's row from the config device storage. + await Resources.devices.deleteDeviceData(configDevID, { groups: organizationID, qty: 9999 }); + + // Delete the organization device itself. + await Resources.devices.delete(organizationID).catch(console.log); + + // Cascade into child resources. + await deleteOrganizationUsers(organizationID); + await deleteOrganizationDevices(organizationID); + + await sendNotificationFeedback({ + environment, + title: "Organization removed", + message: `Organization ${organizationInfo.name} successfully removed!`, + }); +} + +// ============================================================================ +// Router entrypoint +// ============================================================================ + +/** + * Entrypoint invoked by the TagoIO Analysis runtime. Reads the scope and + * environment, sets up the router, and dispatches to the matching CRUD + * handler. + */ +async function startAnalysis(context: TagoContext, scope: Data[]): Promise { + console.log("Running CRUD Organization Analysis"); + console.log("Scope:", scope); + + const environment = Utils.envToJson(context.environment); + if (!environment.config_id) { + throw "Missing config_id environment variable"; + } + + const router = new Utils.AnalysisRouter({ scope, context, environment }); + + router.register(createOrganization).whenInputFormID("create-organization"); + router.register(editOrganization).whenCustomBtnID("edit-organization"); + router.register(deleteOrganization).whenDeviceListIdentifier("delete-organization"); + + const result = await router.exec(); + console.log("Services found:", result.services); +} + +// The Analysis runtime sets `T_TEST` during local tests so the handler is +// not wired up automatically. In production the runtime sets +// `T_ANALYSIS_TOKEN` and calls `Analysis.use` below. +if (!Deno.env.get("T_TEST")) { + Analysis.use(startAnalysis, { token: Deno.env.get("T_ANALYSIS_TOKEN") }); +} + +export { startAnalysis }; diff --git a/app/analysis/dashboard/crud-sensor.ts b/app/analysis/dashboard/crud-sensor.ts new file mode 100644 index 0000000..08d384b --- /dev/null +++ b/app/analysis/dashboard/crud-sensor.ts @@ -0,0 +1,664 @@ +/** + * CRUD Sensor Analysis + * + * Educational single-file Analysis that handles the full lifecycle of a + * Sensor resource in the TagoIO Kickstarter project: create, edit and + * delete. This file is intentionally self-contained — it has no relative + * imports. + * + * How it is triggered + * ------------------- + * A dashboard sends Data points to this Analysis. The Analysis Router from + * `@tago-io/sdk` inspects the scope and runs the matching handler: + * + * - Input Form "create-sensor" -> createSensor + * - Custom Button "edit-sensor" -> editSensor + * - Device List action "delete-sensor" -> deleteSensor + * + * Required environment variables + * ------------------------------ + * - config_id : ID of the configuration device that stores the + * dashboard's per-organization data and is used to + * publish validation messages back to the UI. + * - T_ANALYSIS_TOKEN : Provided automatically by the TagoIO runtime. + * + * NOTE + * ---- + * This file is optimized for clarity, not performance. The goal is for a + * developer new to TagoIO to read it top-to-bottom and understand every step. + */ + +import { Analysis, type Data, type DeviceCreateInfo, type DeviceListScope } from "npm:@tago-io/sdk"; +import { Resources, type RouterConstructor, Services, type TagoContext, type TagsObj, Utils } from "npm:@tago-io/sdk"; +import { DateTime } from "npm:luxon"; +import z, { ZodError } from "npm:zod"; + +// ============================================================================ +// Constants +// ============================================================================ + +/** + * Tag key/value used to find the dashboard that opens a single sensor's + * detail view. The dashboard is matched by its `export_id` tag, set when + * the dashboard template is imported into the account. + */ +const SENSOR_DASHBOARD_TAG_KEY = "export_id"; +const SENSOR_DASHBOARD_TAG_VALUE = "sensor-freezer-dash"; + +/** + * Variable used on the group device to expose the sensor connectivity + * summary (total registered, online, offline) that powers the Sensor + * Status cards on the Sensors dashboard. + */ +const SUMMARY_VARIABLE = "device_connectivity_summary"; + +// ============================================================================ +// Validation schema +// ============================================================================ + +const sensorModel = z.object({ + name: z + .string({ error: "Name is required" }) + .min(1, { error: "Name must be at least 1 character" }) + .max(40, { error: "Name must be less than 40 characters" }), + eui: z + .string({ error: "EUI is required" }) + .regex(/^[0-9a-fA-F]{16}$/, { error: "EUI must be 16 hexadecimal characters" }), + network: z + .string({ error: "Network is required" }) + .min(1, { error: "Network is required" }), + connector: z + .string({ error: "Connector is required" }) + .min(1, { error: "Connector is required" }), +}); + +/** + * Partial schema reused by the Edit flow. The Device List widget only + * exposes the name today, so every other field becomes optional and is + * skipped when not present. + */ +const sensorEditModel = sensorModel.partial(); + +// ============================================================================ +// Helpers — error handling +// ============================================================================ + +/** + * Extracts a short, human-readable message from a Zod or generic error and + * re-throws it as a plain `Error`. Only the first Zod issue is surfaced + * to the user. + */ +function getZodErrorMessage(error: unknown): never { + if (error instanceof ZodError) { + const message = error.issues[0]?.message ?? "Validation error"; + throw new Error(message); + } + + if (error instanceof Error) { + throw error; + } + + throw new Error("Unknown error occurred"); +} + +// ============================================================================ +// Helpers — feedback to the dashboard +// ============================================================================ + +type ValidationLevel = "success" | "danger" | "warning"; + +interface ValidationConfig { + validationVariable: string; + deviceID: string; + sessionID?: string; +} + +/** + * Creates a `validate(message, level)` function tied to a specific + * validation variable on the configuration device. The dashboard listens + * to this variable through a Validation widget and renders the messages + * to the user. + * + * Each call cleans up old validation entries (>1 minute) and writes a new + * one with a small timestamp offset, so multiple messages from the same + * run still appear in the correct order on the dashboard. + */ +function initializeValidation(config: ValidationConfig) { + let messageIndex = 0; + + return async (message: string, level: ValidationLevel = "success"): Promise => { + if (!message?.trim()) { + throw new Error("Validation message cannot be empty"); + } + + const now = DateTime.now(); + // Each subsequent message is pushed 200ms forward so the dashboard + // renders them in insertion order even if the API timestamps collide. + const timeOffset = ++messageIndex * 200; + + await Promise.allSettled([ + Resources.devices.deleteDeviceData(config.deviceID, { + variables: config.validationVariable, + qty: 999, + end_date: now.minus({ minutes: 1 }).toJSDate(), + }), + Resources.devices.sendDeviceData(config.deviceID, { + variable: config.validationVariable, + value: message, + time: now.plus({ milliseconds: timeOffset }).toJSDate(), + metadata: { + type: level, + session_id: config.sessionID, + show_markdown: false, + }, + }), + ]); + + return message; + }; +} + +/** + * Sends an in-app notification to the Run User who triggered the + * Analysis. Falls back to a developer notification if no user can be + * identified — useful for edit/delete flows where the dashboard doesn't + * expose a Validation widget. + */ +async function sendNotificationFeedback(params: { environment: Record; title?: string; message: string }): Promise { + const { environment, title, message } = params; + const userID = environment?._user_id; + + if (!userID) { + const services = new Services({ token: Deno.env.get("T_ANALYSIS_TOKEN") }); + await services.notification.send({ title: title || "Operation error", message }); + return; + } + + const user = await Resources.run.userInfo(userID).catch(() => null); + if (!user) { + const services = new Services({ token: Deno.env.get("T_ANALYSIS_TOKEN") }); + await services.notification.send({ title: title || "Operation error", message }); + return; + } + + await Resources.run.notificationCreate(userID, { + title: title || "Operation error", + message, + }); +} + +// ============================================================================ +// Helpers — resource lookups +// ============================================================================ + +/** + * Checks whether a device with the given name and/or tags already exists. + * + * @param isEdit - During an edit, the device being modified is itself + * returned by the search, so we only consider it a duplicate when more + * than one device matches. During a create, any match is a duplicate. + */ +async function deviceExists(params: { name?: string; tags: { key: string; value: string }[]; isEdit?: boolean }): Promise { + const { name, tags, isEdit = false } = params; + + const found: { id: string }[] = []; + for (let page = 1; page < 9999; page++) { + const batch = await Resources.devices.list({ + page, + amount: 100, + fields: ["id", "name", "tags"], + filter: { name, tags }, + resolveBucketName: false, + }); + + found.push(...batch); + if (batch.length < 100) { + break; + } + } + + if (isEdit) { + return found.length > 1; + } + + return found.length > 0; +} + +/** + * Finds a Dashboard ID by a tag value. Used to compose the URL that + * opens the sensor detail dashboard right after creation. + */ +async function getDashboardIDByTag(tagKey: string, tagValue: string): Promise { + const [dashboard] = await Resources.dashboards.list({ + amount: 1, + fields: ["id", "tags"], + filter: { tags: [{ key: tagKey, value: tagValue }] }, + }); + + if (!dashboard?.id) { + throw new Error(`Dashboard with ${tagKey}=${tagValue} not found`); + } + + return dashboard.id; +} + +// ============================================================================ +// Helpers — sensor summary +// ============================================================================ + +/** + * Recounts the sensors registered under a group and upserts the + * `device_connectivity_summary` data record on the group device. The + * Sensor Status cards on the Sensors dashboard read this record. + * + * The total is always recomputed from a fresh device list so concurrent + * create/delete runs do not drift the counter. If the record does not + * exist yet, it is created. If it exists, only `total_registered` is + * refreshed; `online` and `offline` are preserved. + */ +async function updateSensorSummary(groupID: string): Promise { + const sensors: { id: string }[] = []; + for (let page = 1; page < 9999; page++) { + const batch = await Resources.devices.list({ + page, + amount: 100, + fields: ["id"], + filter: { + tags: [ + { key: "group_id", value: groupID }, + { key: "device_type", value: "device" }, + ], + }, + resolveBucketName: false, + }); + + sensors.push(...batch); + if (batch.length < 100) { + break; + } + } + + const totalRegistered = sensors.length; + + const [existing] = await Resources.devices.getDeviceData(groupID, { + variables: SUMMARY_VARIABLE, + qty: 1, + }); + + if (existing) { + await Resources.devices.editDeviceData(groupID, { + ...existing, + value: totalRegistered, + metadata: { + ...existing.metadata, + total_registered: totalRegistered, + }, + }); + return; + } + + await Resources.devices.sendDeviceData(groupID, { + variable: SUMMARY_VARIABLE, + value: totalRegistered, + metadata: { + total_registered: totalRegistered, + online: undefined, + offline: undefined, + }, + }); +} + +// ============================================================================ +// CREATE flow +// ============================================================================ + +/** + * Reads the form fields sent by the dashboard from the scope and runs + * them through the Zod schema. + */ +function extractCreateFormFields(scope: Data[]) { + const newSensorName = scope.find((item: Data) => item.variable === "new_sensor_name")?.value; + const newSensorEui = scope.find((item: Data) => item.variable === "new_sensor_eui")?.value; + const newSensorNetwork = scope.find((item: Data) => item.variable === "new_sensor_network")?.value; + const newSensorConnector = scope.find((item: Data) => item.variable === "new_sensor_connector")?.value; + + return sensorModel.parseAsync({ + name: newSensorName, + eui: newSensorEui, + network: newSensorNetwork, + connector: newSensorConnector, + }); +} + +/** + * Creates the sensor device on TagoIO. The device is created with the + * parent-org, group, eui and type tags already attached. A second edit + * adds the `sensor_id` tag pointing to its own id, since the id is only + * known after creation. + */ +interface InstallSensorParams { + name: string; + eui: string; + network: string; + connector: string; + organizationID: string; + groupID: string; +} + +async function installSensorDevice(params: InstallSensorParams): Promise { + const tags: TagsObj[] = [ + { key: "organization_id", value: params.organizationID }, + { key: "group_id", value: params.groupID }, + { key: "device_eui", value: params.eui }, + { key: "device_type", value: "device" }, + { key: "sensor_type", value: "freezer" }, + ]; + + const deviceData: DeviceCreateInfo = { + name: params.name, + type: "immutable", + chunk_period: "month", + chunk_retention: 1, + serie_number: params.eui, + network: params.network, + connector: params.connector, + tags, + }; + + const newDevice = await Resources.devices.create(deviceData); + + const newTags = tags.concat({ key: "sensor_id", value: newDevice.device_id }); + await Resources.devices.edit(newDevice.device_id, { tags: newTags }); + + return newDevice.device_id; +} + +/** + * Handles the "create-sensor" Input Form submission. + * + * Steps: + * 1. Confirm the scope is a Data array sent by the form. + * 2. Read the session id so validation messages reach the right user. + * 3. Validate form fields with Zod; surface the first issue if any. + * 4. Reject duplicate sensor names within the parent group. + * 5. Reject duplicate EUIs across the whole application. + * 6. Create the device, tag it, and store its params (URL + EUI). + * 7. Refresh the group's `device_connectivity_summary` record. + * 8. Send a success message back to the dashboard. + */ +async function createSensor({ environment, scope }: RouterConstructor & { scope: Data[] }) { + if (!("variable" in scope[0])) { + console.error("Not a valid TagoIO Data"); + return; + } + + const configDevID = environment.config_id; + if (!configDevID) { + throw "[Error] Missing config_id environment variable."; + } + + // The form is rendered on the per-group Sensors dashboard, so the + // group id is the device id stored in `scope[0].device`. + const groupID = scope[0].device; + if (!groupID) { + throw "[Error] Missing group ID in scope."; + } + + const sessionID = z.string().parse(scope.find((item: Data) => item.variable === "create_sensor_session_id")?.value); + const validate = initializeValidation({ validationVariable: "create_sensor_validation", deviceID: configDevID, sessionID }); + + // Friendly "working on it" message now that validation passed. + await validate("Adding sensor, please wait...", "warning").catch(console.log); + + // Validate the form. If Zod fails, surface the first issue to the user + // and abort the run. + const formFields = await extractCreateFormFields(scope) + .catch(getZodErrorMessage) + .catch(async (error: Error) => { + await validate(error.message, "danger"); + throw error; + }); + + // Reject duplicate names inside the same group. + const isNameInUse = await deviceExists({ + name: formFields.name, + tags: [ + { key: "group_id", value: groupID }, + { key: "device_type", value: "device" }, + ], + }); + + if (isNameInUse) { + throw await validate( + `A sensor with name ${formFields.name} already exists within this group.`, + "danger", + ); + } + + // Reject duplicate EUIs anywhere in the application. + const isEuiInUse = await deviceExists({ + tags: [{ key: "device_eui", value: formFields.eui }], + }); + + if (isEuiInUse) { + throw await validate(`A sensor with EUI ${formFields.eui} already exists.`, "danger"); + } + + // The organization id lives on the parent group device's tags. We + // need it so the new sensor inherits the same tenant scope. + const groupInfo = await Resources.devices.info(groupID); + const organizationID = z.string().parse(groupInfo.tags.find((x) => x.key === "organization_id")?.value); + + const sensorID = await installSensorDevice({ + name: formFields.name, + eui: formFields.eui, + network: formFields.network, + connector: formFields.connector, + organizationID, + groupID, + }); + + // Build the URL that opens the sensor detail dashboard and store it + // (plus the EUI) as device params for the front-end. + const dashboardID = await getDashboardIDByTag(SENSOR_DASHBOARD_TAG_KEY, SENSOR_DASHBOARD_TAG_VALUE); + const dashboardURL = `/dashboards/info/${dashboardID}?settings=${configDevID}&sensor_dev=${sensorID}`; + + // The simulator supports two types of freezers, each sending different data values. + // By default, it uses type 1, so we randomly assign a different type to ensure + // data variation across the sensors. + const freezerType = Math.random() < 0.5 ? "1" : "2"; + + await Resources.devices.paramSet(sensorID, [ + { key: "dashboard_url", value: dashboardURL, sent: true }, + { key: "sensor_eui", value: formFields.eui, sent: true }, + { key: "freezer", value: freezerType, sent: true }, + ]); + + // Refresh the group's connectivity summary so the Sensor Status cards + // reflect the new total. + await updateSensorSummary(groupID); + + await validate(`Sensor ${formFields.name} successfully added!`, "success"); +} + +// ============================================================================ +// EDIT flow +// ============================================================================ + +/** + * Restores a sensor device to its previous state when an edit fails + * validation. The Device List widget includes the previous values under + * `scope[0].old`, so we use that snapshot to roll back the change. + * + * Sensor edits only touch the `name` field today, but the loop is kept + * generic so future params can be added without rewriting the rollback. + */ +async function undoSensorChanges(sensorID: string, scope: DeviceListScope[]): Promise { + const deviceScope = scope[0]; + const oldValues = deviceScope?.old ?? {}; + + for (const key of Object.keys(deviceScope)) { + if (key === "name" && typeof oldValues[key] === "string") { + await Resources.devices.edit(sensorID, { name: oldValues[key] as string }); + continue; + } + + if (key.startsWith("param.")) { + const paramKey = key.replace("param.", ""); + const oldValue = oldValues[key] as string | undefined; + if (oldValue === undefined) { + continue; + } + + const paramList = await Resources.devices.paramList(sensorID); + const existing = paramList.find((p) => p.key === paramKey); + await Resources.devices.paramSet(sensorID, { + id: existing?.id, + key: paramKey, + value: oldValue, + sent: true, + }); + } + } +} + +/** + * Handles the "edit-sensor" Custom Button on the Device List widget. + * + * The Device List sends both the new and the old value for each edited + * field. We validate the new values, and on any failure we restore the + * old ones and notify the user. + */ +async function editSensor({ scope, environment }: RouterConstructor & { scope: DeviceListScope[] }) { + const sensorID = scope[0].device; + if (!sensorID) { + throw "[Error] Missing sensor ID in scope."; + } + + const newName = scope[0]?.name; + + // Validate the partial payload. If Zod rejects it, undo the change and + // notify the user — then bubble the error up so the run is logged. + await sensorEditModel + .parseAsync({ name: newName }) + .catch(getZodErrorMessage) + .catch(async (error: Error) => { + await undoSensorChanges(sensorID, scope); + await sendNotificationFeedback({ environment, message: error.message }); + throw error; + }); + + // If the name changed, make sure no other sensor inside this group is + // using it. We read the parent group from the sensor's tags. + if (newName) { + const sensorInfo = await Resources.devices.info(sensorID); + const groupID = z + .string() + .parse(sensorInfo.tags.find((tag) => tag.key === "group_id")?.value); + + const isNameInUse = await deviceExists({ + name: newName, + tags: [ + { key: "group_id", value: groupID }, + { key: "device_type", value: "device" }, + ], + isEdit: true, + }); + + if (isNameInUse) { + await undoSensorChanges(sensorID, scope); + await sendNotificationFeedback({ + environment, + message: `A sensor with name ${newName} already exists within this group.`, + }); + throw `A sensor with name ${newName} already exists within this group.`; + } + } +} + +// ============================================================================ +// DELETE flow +// ============================================================================ + +/** + * Handles the "delete-sensor" identifier on the Device List widget. + * + * Steps: + * 1. Clean any rows the dashboard wrote to the config device keyed + * to this sensor. + * 2. Capture the sensor info (name, organization id, group id) before + * the device is removed. + * 3. Delete the sensor device. + * 4. Clean any rows keyed to this sensor on the parent organization + * and group devices. Without these the org and group dashboards + * keep showing the removed sensor's rows. + * 5. Refresh the group's `device_connectivity_summary` record. + * 6. Notify the user. + */ +async function deleteSensor({ scope, environment }: RouterConstructor & { scope: DeviceListScope[] }) { + const configDevID = environment.config_id; + if (!configDevID) { + throw "[Error] Missing config_id environment variable."; + } + + const sensorID = scope[0].device; + if (!sensorID) { + throw "[Error] Missing sensor ID in scope."; + } + + await Resources.devices.deleteDeviceData(configDevID, { groups: sensorID, qty: 9999 }).catch(console.error); + + const sensorInfo = await Resources.devices.info(sensorID); + const organizationID = z.string().parse(sensorInfo.tags.find((tag) => tag.key === "organization_id")?.value); + const groupID = z.string().parse(sensorInfo.tags.find((tag) => tag.key === "group_id")?.value); + + await Resources.devices.delete(sensorID).catch(console.log); + + await Resources.devices.deleteDeviceData(organizationID, { groups: sensorID, qty: 9999 }).catch(console.log); + await Resources.devices.deleteDeviceData(groupID, { groups: sensorID, qty: 9999 }).catch(console.log); + + await updateSensorSummary(groupID); + + await sendNotificationFeedback({ + environment, + title: "Sensor removed", + message: `Sensor ${sensorInfo.name} successfully removed!`, + }); +} + +// ============================================================================ +// Router entrypoint +// ============================================================================ + +/** + * Entrypoint invoked by the TagoIO Analysis runtime. Reads the scope and + * environment, sets up the router, and dispatches to the matching CRUD + * handler. + */ +async function startAnalysis(context: TagoContext, scope: Data[]): Promise { + console.log("Running CRUD Sensor Analysis"); + console.log("Scope:", scope); + + const environment = Utils.envToJson(context.environment); + if (!environment.config_id) { + throw "Missing config_id environment variable"; + } + + const router = new Utils.AnalysisRouter({ scope, context, environment }); + + router.register(createSensor).whenInputFormID("create-sensor"); + router.register(editSensor).whenCustomBtnID("edit-sensor"); + router.register(deleteSensor).whenDeviceListIdentifier("delete-sensor"); + + const result = await router.exec(); + console.log("Services found:", result.services); +} + +// The Analysis runtime sets `T_TEST` during local tests so the handler is +// not wired up automatically. In production the runtime sets +// `T_ANALYSIS_TOKEN` and calls `Analysis.use` below. +if (!Deno.env.get("T_TEST")) { + Analysis.use(startAnalysis, { token: Deno.env.get("T_ANALYSIS_TOKEN") }); +} + +export { startAnalysis }; diff --git a/app/analysis/dashboard/crud-user.ts b/app/analysis/dashboard/crud-user.ts new file mode 100644 index 0000000..cb95752 --- /dev/null +++ b/app/analysis/dashboard/crud-user.ts @@ -0,0 +1,591 @@ +/** + * CRUD User Analysis + * + * Educational single-file Analysis that handles the full lifecycle of a + * Run User in the TagoIO Kickstarter project: create, edit and delete. + * This file is intentionally self-contained — it has no relative imports. + * + * How it is triggered + * ------------------- + * A dashboard sends Data points to this Analysis. The Analysis Router from + * `@tago-io/sdk` inspects the scope and runs the matching handler: + * + * - Input Form "create-user" -> createUser + * - User List edit action "edit-user" -> editUser + * - User List delete action "delete-user" -> deleteUser + * + * Required environment variables + * ------------------------------ + * - config_id : ID of the configuration device that stores the + * dashboard's per-organization data and is used to + * publish validation messages back to the UI. + * - SENDGRID_API_KEY : Secret used to send the invite email. + * - sendgrid_from_email : Verified sender address used by SendGrid. + * - T_ANALYSIS_TOKEN : Provided automatically by the TagoIO runtime. + * + * NOTE + * ---- + * This file is optimized for clarity, not performance. The goal is for a + * developer new to TagoIO to read it top-to-bottom and understand every step. + */ + +import { Analysis, type Data, type TagoContext, type TagsObj, type UserListScope } from "npm:@tago-io/sdk"; +import { Resources, type RouterConstructor, Services, Utils } from "npm:@tago-io/sdk"; +import { DateTime } from "npm:luxon"; +import { phone as parsePhone } from "npm:phone"; +import z, { ZodError } from "npm:zod"; + +// ============================================================================ +// Validation schema +// ============================================================================ + +/** + * Allowed access levels. Each maps to a different Access Policy on TagoIO + * Run, which controls which dashboards, devices and entities the user can + * see. + */ +const userAccessModel = z.enum(["admin", "org_admin", "guest"], { error: "Invalid user access level" }); + +const userModel = z.object({ + name: z + .string({ error: "Name is required" }) + .min(3, { message: "Name must be at least 3 characters long" }) + .max(40, { message: "Name must be less than 40 characters long" }), + email: z.email({ message: "Invalid email address" }), + // Phone is optional. When present, it must start with the country code + // and be a valid international number. The transform normalizes the + // value into the canonical `+CCNNN...` shape returned by `phone`. + phone: z.preprocess( + (x) => (x === undefined || x === null || x === "" ? undefined : String(x)), + z + .string() + .refine((x) => x.startsWith("+"), { message: "Phone number should have the country code, e.g. +1 for US" }) + .refine((x) => parsePhone(x).isValid, { message: "Invalid phone number" }) + .transform((val) => parsePhone(val).phoneNumber ?? val) + .optional(), + ), + access: userAccessModel, +}); + +/** Partial schema reused by the Edit flow. */ +const userEditModel = userModel.partial(); + +// ============================================================================ +// Helpers — error handling +// ============================================================================ + +/** + * Extracts a short, human-readable message from a Zod or generic error and + * re-throws it as a plain `Error`. Only the first Zod issue is surfaced + * to the user. + */ +function getZodErrorMessage(error: unknown): never { + if (error instanceof ZodError) { + const message = error.issues[0]?.message ?? "Validation error"; + throw new Error(message); + } + + if (error instanceof Error) { + throw error; + } + + throw new Error("Unknown error occurred"); +} + +// ============================================================================ +// Helpers — feedback to the dashboard +// ============================================================================ + +type ValidationLevel = "success" | "danger" | "warning"; + +interface ValidationConfig { + validationVariable: string; + deviceID: string; + sessionID?: string; +} + +/** + * Creates a `validate(message, level)` function tied to a specific + * validation variable on the configuration device. The dashboard listens + * to this variable through a Validation widget and renders the messages + * to the user. + * + * Each call cleans up old validation entries (>1 minute) and writes a new + * one with a small timestamp offset, so multiple messages from the same + * run still appear in the correct order on the dashboard. + */ +function initializeValidation(config: ValidationConfig) { + let messageIndex = 0; + + return async (message: string, level: ValidationLevel = "success"): Promise => { + if (!message?.trim()) { + throw new Error("Validation message cannot be empty"); + } + + const now = DateTime.now(); + // Each subsequent message is pushed 200ms forward so the dashboard + // renders them in insertion order even if the API timestamps collide. + const timeOffset = ++messageIndex * 200; + + await Promise.allSettled([ + Resources.devices.deleteDeviceData(config.deviceID, { + variables: config.validationVariable, + qty: 999, + end_date: now.minus({ minutes: 1 }).toJSDate(), + }), + Resources.devices.sendDeviceData(config.deviceID, { + variable: config.validationVariable, + value: message, + time: now.plus({ milliseconds: timeOffset }).toJSDate(), + metadata: { + type: level, + session_id: config.sessionID, + show_markdown: false, + }, + }), + ]); + + return message; + }; +} + +/** + * Sends an in-app notification to the Run User who triggered the + * Analysis. Falls back to a developer notification if no user can be + * identified — useful for edit/delete flows where the dashboard doesn't + * expose a Validation widget. + */ +async function sendNotificationFeedback(params: { environment: Record; title?: string; message: string }): Promise { + const { environment, title, message } = params; + const userID = environment?._user_id; + + if (!userID) { + const services = new Services({ token: Deno.env.get("T_ANALYSIS_TOKEN") }); + await services.notification.send({ title: title || "Operation error", message }); + return; + } + + const user = await Resources.run.userInfo(userID).catch(() => null); + if (!user) { + const services = new Services({ token: Deno.env.get("T_ANALYSIS_TOKEN") }); + await services.notification.send({ title: title || "Operation error", message }); + return; + } + + await Resources.run.notificationCreate(userID, { + title: title || "Operation error", + message, + }); +} + +// ============================================================================ +// Helpers — invite flow +// ============================================================================ + +/** + * Generates a random temporary password used as the initial credential + * for the invited user. The password always includes at least one + * uppercase letter, lowercase letter, digit and special character. + */ +function generatePassword(): string { + const upper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + const lower = "abcdefghijklmnopqrstuvwxyz"; + const digits = "0123456789"; + const symbols = "@!&"; + const all = upper + lower + digits + symbols; + + const randomIndex = (max: number): number => { + const bytes = new Uint32Array(1); + const limit = Math.floor(0x100000000 / max) * max; + let value = 0; + do { + globalThis.crypto.getRandomValues(bytes); + value = bytes[0]; + } while (value >= limit); + return value % max; + }; + const pick = (chars: string) => chars[randomIndex(chars.length)]; + + const out = Array.from({ length: 12 }, () => pick(all)); + // Force the first four characters to one of each pool so the password + // satisfies a typical "at least one of each" policy. + out[0] = pick(upper); + out[1] = pick(lower); + out[2] = pick(digits); + out[3] = pick(symbols); + + return out.join(""); +} + +interface InviteEmailParams { + context: TagoContext; + toEmail: string; + name: string; + password: string; + runURL: string; +} + +/** + * Sends the welcome email with the temporary password through SendGrid. + * The template `user_registration` must already exist in the SendGrid + * account and accept `name`, `email`, `password` and `url_platform` + * params. + */ +async function sendInviteEmail({ context, toEmail, name, password, runURL }: InviteEmailParams): Promise { + const environment = Utils.envToJson(context.environment); + const sendgrid = new Services({ token: context.token }).sendgrid; + + await sendgrid + .send({ + from: environment.sendgrid_from_email, + to: [toEmail], + template: { + name: "user_registration", + params: { + name, + email: toEmail, + password, + url_platform: runURL, + }, + }, + sendgrid_api_key: environment.SENDGRID_API_KEY, + }) + .catch((error) => { + console.error("Email sending failed:", error); + }); +} + +interface InviteUserParams { + context: TagoContext; + email: string; + name: string; + phone?: string; + tags: TagsObj[]; + runURL: string; +} + +/** + * Invites a Run User: sends the welcome email, then creates the account + * with the temporary password. If creation fails because the user + * already exists, the existing user's tags are merged with the new ones + * so the same email can be re-invited into another organization. + */ +async function inviteUser({ context, email, name, phone, tags, runURL }: InviteUserParams): Promise { + const normalizedEmail = email.toLowerCase(); + const password = generatePassword(); + + const accountInfo = await Resources.account.info(); + const timezone = accountInfo.timezone || "America/New_York"; + + await sendInviteEmail({ context, toEmail: normalizedEmail, name, password, runURL }); + + const userPayload = { + active: true, + company: "", + email: normalizedEmail, + language: "en", + name, + phone: String(phone ?? ""), + tags, + timezone, + password, + }; + + const created = await Resources.run.userCreate(userPayload).catch((error) => { + console.error("User creation failed:", error); + return null; + }); + + if (created?.user) { + return created.user; + } + + // Fallback: the user already exists. Merge the new tags into the + // existing user so the invite still grants access to the new tenant. + const existing = (await Resources.run.listUsers({ + amount: 1, + fields: ["id", "tags"], + filter: { email: normalizedEmail }, + }))[0]; + + if (!existing) { + throw new Error("Failed to create or update user account"); + } + + const keptTags = (existing.tags ?? []).filter((tag) => !tags.some((next) => next.key === tag.key)); + await Resources.run.userEdit(existing.id, { tags: [...keptTags, ...tags] }); + return existing.id; +} + +// ============================================================================ +// CREATE flow +// ============================================================================ + +/** + * Reads the form fields sent by the dashboard from the scope and runs + * them through the Zod schema. + */ +function extractCreateFormFields(scope: Data[]) { + const newUserName = scope.find((item: Data) => item.variable === "new_user_name")?.value; + const newUserEmail = scope.find((item: Data) => item.variable === "new_user_email")?.value; + const newUserPhone = scope.find((item: Data) => item.variable === "new_user_phone")?.value; + const newUserAccess = scope.find((item: Data) => item.variable === "new_user_access")?.value; + + return userModel.parseAsync({ + name: newUserName, + email: newUserEmail, + phone: newUserPhone, + access: newUserAccess, + }); +} + +/** + * Handles the "create-user" Input Form submission. + * + * Steps: + * 1. Confirm the scope is a Data array sent by the form. + * 2. Read the session id so validation messages reach the right user. + * 3. Validate form fields with Zod; surface the first issue if any. + * 4. Reject duplicate email addresses. + * 5. Build the access tags. Admins are application-wide and have no + * organization scope; org_admin and guest are scoped to the parent + * organization. + * 6. Send the invite email and create (or update) the Run User. + * 7. Send a success message back to the dashboard. + */ +async function createUser({ context, environment, scope }: RouterConstructor & { scope: Data[] }) { + if (!("variable" in scope[0])) { + console.error("Not a valid TagoIO Data"); + return; + } + + if (!context) { + throw "[Error] Missing analysis context."; + } + + if (!environment.SENDGRID_API_KEY || !environment.sendgrid_from_email) { + throw "[Error] Missing secrets 'SENDGRID_API_KEY' or 'sendgrid_from_email'."; + } + + const configDevID = environment.config_id; + if (!configDevID) { + throw "[Error] Missing config_id environment variable."; + } + + // The form is rendered on the per-organization Users dashboard, so + // the organization id is the device id stored in `scope[0].device`. + const organizationID = scope[0].device; + if (!organizationID) { + throw "[Error] Missing organization ID in scope."; + } + + const sessionID = z.string().parse(scope.find((item: Data) => item.variable === "create_user_session_id")?.value); + const validate = initializeValidation({ validationVariable: "create_user_validation", deviceID: configDevID, sessionID }); + + // Friendly "working on it" message now that we have the session id. + await validate("Adding user, please wait...", "warning").catch(console.log); + + // Validate the form. If Zod fails, surface the first issue to the user + // and abort the run. + const formFields = await extractCreateFormFields(scope) + .catch(getZodErrorMessage) + .catch(async (error: Error) => { + await validate(error.message, "danger"); + throw error; + }); + + // Reject duplicate email addresses anywhere in the Run. + const existingUsers = await Resources.run.listUsers({ + amount: 1, + fields: ["id"], + filter: { email: formFields.email.toLowerCase() }, + }); + if (existingUsers.length > 0) { + throw await validate("This email address is already in use.", "danger"); + } + + // Build the tag list. Application admins span every organization so + // they don't get an `organization_id` tag; the other levels are + // scoped to the organization that triggered the form. + const tags: TagsObj[] = [ + { key: "access", value: formFields.access }, + { key: "visualize_user", value: "true" }, + ]; + if (formFields.access !== "admin") { + tags.push({ key: "user_organization_id", value: organizationID }); + tags.push({ key: "organization_id", value: organizationID }); + } + + // The invite email links back to the same Run instance the user is + // signing up to, so pull the URL from the Run info endpoint. + const { url: runURL } = await Resources.run.info(); + + await inviteUser({ + context, + email: formFields.email, + name: formFields.name, + phone: formFields.phone, + tags, + runURL, + }).catch(async (error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + throw await validate(message, "danger"); + }); + + await validate(`User ${formFields.name} successfully added!`, "success"); +} + +// ============================================================================ +// EDIT flow +// ============================================================================ + +/** + * Restores a user to its previous state when an edit fails validation. + * The User List widget includes the previous values under `scope[0].old`, + * so we use that snapshot to roll back the change. + * + * The `tags.access` key needs special handling: tags are an array on + * TagoIO, so the rollback merges the previous access value into the + * existing tag set instead of overwriting all tags. + */ +async function undoUserChanges(scope: UserListScope[]): Promise { + const userID = scope[0].user; + const old = scope[0]?.old ?? {}; + + const updates: { name?: string; phone?: string } = {}; + if (typeof old.name === "string") { + updates.name = old.name; + } + if (typeof old.phone === "string") { + updates.phone = old.phone; + } + if (Object.keys(updates).length > 0) { + await Resources.run.userEdit(userID, updates).catch(console.error); + } + + const oldAccess = old["tags.access"]; + if (oldAccess !== undefined) { + const userInfo = await Resources.run.userInfo(userID); + const mergedTags: TagsObj[] = (userInfo.tags ?? []).map((tag) => tag.key === "access" ? { key: "access", value: String(oldAccess) } : tag); + await Resources.run.userEdit(userID, { tags: mergedTags }).catch(console.error); + } +} + +/** + * Handles the "edit-user" action on the User List widget. + * + * The User List sends both the new and the old value for each edited + * field. We validate the new values; on any failure we restore the old + * ones and notify the user. The `access` field is stored as a tag, so + * updates have to merge it back into the existing tag array. + */ +async function editUser({ scope, environment }: RouterConstructor & { scope: UserListScope[] }) { + const userID = scope[0]?.user; + if (!userID) { + throw "[Error] Missing user ID in scope."; + } + + const newName = scope[0]?.name; + const newPhone = scope[0]?.phone || undefined; + const newAccess = scope[0]?.["tags.access"]; + + await userEditModel + .parseAsync({ name: newName, phone: newPhone, access: newAccess }) + .catch(getZodErrorMessage) + .catch(async (error: Error) => { + await undoUserChanges(scope); + await sendNotificationFeedback({ environment, message: error.message }); + throw error; + }); + + if (newAccess !== undefined) { + const userInfo = await Resources.run.userInfo(userID); + const existingTags: TagsObj[] = userInfo.tags ?? []; + const hasAccessTag = existingTags.some((tag) => tag.key === "access"); + + const mergedTags = hasAccessTag + ? existingTags.map((tag) => (tag.key === "access" ? { key: "access", value: String(newAccess) } : tag)) + : [...existingTags, { key: "access", value: String(newAccess) }]; + + await Resources.run.userEdit(userID, { tags: mergedTags }); + } +} + +// ============================================================================ +// DELETE flow +// ============================================================================ + +/** + * Handles the "delete-user" identifier on the User List widget. + * + * Steps: + * 1. Capture the user info (email) before the account is removed, so + * we can include it in the success notification. + * 2. Clean any rows the dashboard wrote to the config device keyed to + * this user. Without this, ghost rows would remain on widgets that + * group data by `user_id`. + * 3. Delete the Run User. + * 4. Notify the operator. + */ +async function deleteUser({ scope, environment }: RouterConstructor & { scope: UserListScope[] }) { + const userID = scope[0]?.user; + if (!userID) { + throw "[Error] Missing user ID in scope."; + } + + const configDevID = environment.config_id; + if (!configDevID) { + throw "[Error] Missing config_id environment variable."; + } + + const userInfo = await Resources.run.userInfo(userID); + + await Resources.devices + .deleteDeviceData(configDevID, { groups: userID, qty: 9999 }) + .catch(console.error); + + await Resources.run.userDelete(userID); + + await sendNotificationFeedback({ + environment, + title: "User removed", + message: `User ${userInfo.email} successfully removed!`, + }); +} + +// ============================================================================ +// Router entrypoint +// ============================================================================ + +/** + * Entrypoint invoked by the TagoIO Analysis runtime. Reads the scope and + * environment, sets up the router, and dispatches to the matching CRUD + * handler. + */ +async function startAnalysis(context: TagoContext, scope: Data[]): Promise { + console.log("Running CRUD User Analysis"); + console.log("Scope:", scope); + + const environment = Utils.envToJson(context.environment); + if (!environment.config_id) { + throw "Missing config_id environment variable"; + } + + const router = new Utils.AnalysisRouter({ scope, context, environment }); + + router.register(createUser).whenInputFormID("create-user"); + router.register(editUser).whenUserListIdentifier("edit-user"); + router.register(deleteUser).whenUserListIdentifier("delete-user"); + + const result = await router.exec(); + console.log("Services found:", result.services); +} + +// The Analysis runtime sets `T_TEST` during local tests so the handler is +// not wired up automatically. In production the runtime sets +// `T_ANALYSIS_TOKEN` and calls `Analysis.use` below. +if (!Deno.env.get("T_TEST")) { + Analysis.use(startAnalysis, { token: Deno.env.get("T_ANALYSIS_TOKEN") }); +} + +export { startAnalysis }; diff --git a/app/analysis/scheduled/check-inactive-sensors.ts b/app/analysis/scheduled/check-inactive-sensors.ts new file mode 100644 index 0000000..4409097 --- /dev/null +++ b/app/analysis/scheduled/check-inactive-sensors.ts @@ -0,0 +1,658 @@ +/** + * Check Inactive Sensors Analysis + * + * Educational single-file Analysis that scans every sensor in the + * application, marks the ones that have not reported within the configured + * threshold as offline, and refreshes the connectivity summary used by the + * Sensors dashboard. This file is intentionally self-contained — it has no + * relative imports. + * + * How it is triggered + * ------------------- + * By a TagoIO Scheduled Action (cron). The recommended cadence is hourly: + * the inactivity threshold itself is configured in hours, so running more + * often just creates extra load without changing the outcome. + * + * Execution model + * --------------- + * The hot path is "no sensor changed state". We exploit that by short- + * circuiting: after fetching the sensor list we ask whether any sensor is + * even capable of producing a notification or recovery transition this + * run. The cheapest sufficient condition is "(sensor's last_input is older + * than one hour) OR (sensor already carries the inactivity_notified tag)" + * — because the smallest configurable threshold is one hour, and recovery + * only matters when the notified tag is present. When no sensor matches + * that, we skip loading the global config AND the per-org Checkin rules + * entirely and only refresh `last_uplink` plus the connectivity summary. + * + * Per-organization Checkin rules are now loaded lazily and cached per + * organization id during a single execution. An organization without any + * Checkin rule is cached as an empty array so we never re-query it. + * + * What it reads + * ------------- + * From the configuration device (`environment.config_id`), under the + * `global-inactivity` group — used as a fallback when no per-organization + * Checkin rule applies: + * - `global_alert_value` (number, e.g. `2`) — how many units of time + * without uplink mean "inactive". + * - `global_alert_value` `unit` field — must be `"hour"` today. + * - `global_alert_message` (string) — text used in the in-app + * notification sent to organization users. Optional; falls back to + * `"Device inactivity detected"`. + * + * From each organization device (tag `device_type=organization`) WHEN + * one of its sensors is a candidate this run: + * - The Checkin alert rows persisted by `crud-alert.ts`. Each rule + * overrides the global threshold and recipients for the sensors it + * covers (or for every sensor in the org when `all_sensors` was + * selected at creation time). + * + * What it writes + * -------------- + * - On every sensor: the `last_uplink` device parameter, set to the + * integer number of hours since the last uplink. The Sensor List + * widget reads this to render the `Last seen(h)` column. + * - On every group device that owns at least one sensor: the + * `device_connectivity_summary` row, with `online` and `offline` + * counts inside `metadata`. `total_registered` is owned by the + * sensor CRUD analysis and preserved on edit. + * - On the sensors that just went inactive: the tag + * `inactivity_notified=true`. This is what stops the next run from + * re-notifying the same sensor every hour. + * - To the users tagged with the sensor's `organization_id`: an in-app + * notification announcing the inactivity. + * + * Required environment variables + * ------------------------------ + * - config_id : ID of the configuration device that stores the + * inactivity threshold and alert message. + * - T_ANALYSIS_TOKEN : provided automatically by the TagoIO runtime. + */ + +import { Analysis, type Data, type DeviceListItem, type DeviceQuery, Resources, type TagoContext, type TagsObj, type UserInfo, type UserQuery, Utils } from "npm:@tago-io/sdk"; +import z from "npm:zod"; + +// ============================================================================ +// Constants +// ============================================================================ + +const CONFIG_GROUP = "global-inactivity"; +const SUMMARY_VARIABLE = "device_connectivity_summary"; +const NOTIFIED_TAG = "inactivity_notified"; +const LAST_UPLINK_PARAM = "last_uplink"; +const DEFAULT_ALERT_MESSAGE = "Device inactivity detected"; +const NOTIFICATION_TITLE = "Device inactivity"; + +const ALERT_VAR_MODEL = "alert_management_type"; +const ALERT_VAR_DEVICES = "alert_management_devices"; +const ALERT_VAR_VALUE = "alert_management_value"; +const ALERT_VAR_SEND_TO = "alert_management_users"; +const ALERT_VAR_MESSAGE = "alert_management_message"; + +const CONFIG_VAR_VALUE = "global_alert_value"; +const CONFIG_VAR_MESSAGE = "global_alert_message"; + +const MS_PER_HOUR = 60 * 60 * 1000; + +/** + * Only `hour` is supported today. To accept more units (minute, day, ...) + * extend BOTH this map AND the `unit` enum inside `thresholdModel` below. + */ +const UNIT_MS: Record = { + hour: 60 * 60 * 1000, +}; + +// ============================================================================ +// Validation schema +// ============================================================================ + +const thresholdModel = z.object({ + value: z.union([z.number(), z.string()]).transform((rawValue, ctx) => { + const parsed = typeof rawValue === "number" ? rawValue : Number(rawValue); + if (!Number.isFinite(parsed) || parsed <= 0) { + ctx.addIssue({ code: "custom", message: "global_alert_value must be a positive number" }); + return z.NEVER; + } + return parsed; + }), + unit: z.string().trim().toLowerCase().pipe(z.enum(["hour"], { error: "unit must be 'hour'" })), +}); + +// ============================================================================ +// Types +// ============================================================================ + +interface InactivityConfig { + thresholdMs: number; + alertMessage: string; +} + +interface SensorBucket { + online: number; + offline: number; +} + +type SensorRecord = Pick; +type UserRecord = Pick; + +interface OrgCheckinRule { + organizationID: string; + thresholdMs: number; + sensorIDs: Set | "all"; + recipientIDs: string[]; + message: string; +} + +interface NewlyInactive { + sensor: SensorRecord; + rule: OrgCheckinRule | null; +} + +// Lazy per-organization cache built during a single execution. An empty +// array means "already checked, no Checkin rules" — that's what stops us +// from re-querying the same org twice. Use `cache.has(orgID)` to tell +// "not yet queried" from "queried and empty". +type RuleCache = Map; + +// ============================================================================ +// Helpers — paginated list fetchers +// ============================================================================ + +async function fetchDeviceList(filter: DeviceQuery["filter"]): Promise { + const FIELDS: (keyof DeviceListItem)[] = ["id", "name", "tags", "last_input", "created_at"]; + const PAGE_SIZE = 100; + const MAX_PAGES = 9999; + + const devices: SensorRecord[] = []; + + for (let page = 1; page < MAX_PAGES; page++) { + const batch = await Resources.devices.list({ + page, + fields: FIELDS, + filter, + resolveBucketName: false, + amount: PAGE_SIZE, + }); + + devices.push(...(batch as SensorRecord[])); + + if (batch.length < PAGE_SIZE) { + break; + } + } + + return devices; +} + +async function fetchUserList(filter: UserQuery["filter"]): Promise { + const FIELDS: (keyof UserInfo)[] = ["id", "name", "phone", "company", "tags", "active", "email", "timezone"]; + const PAGE_SIZE = 40; + const MAX_PAGES = 9999; + + const users: UserRecord[] = []; + + for (let page = 1; page < MAX_PAGES; page++) { + const batch = await Resources.run.listUsers({ + page, + fields: FIELDS, + filter, + amount: PAGE_SIZE, + }); + + users.push(...(batch as UserRecord[])); + + if (batch.length < PAGE_SIZE) { + break; + } + } + + return users; +} + +// ============================================================================ +// Helpers — config loading +// ============================================================================ + +/** + * Reads the inactivity threshold and alert message from the config device + * in a single getDeviceData call (variables accepts an array). + */ +async function loadInactivityConfig(configDevID: string): Promise { + // `qty` on getDeviceData is per-variable, so qty:1 returns at most one + // record per requested variable. + const records = await Resources.devices.getDeviceData(configDevID, { + variables: [CONFIG_VAR_VALUE, CONFIG_VAR_MESSAGE], + groups: CONFIG_GROUP, + qty: 1, + }); + + const valueRecord = records.find((record) => record.variable === CONFIG_VAR_VALUE); + if (!valueRecord) { + console.log("No global_alert_value found in config, skipping"); + return null; + } + + const parsed = await thresholdModel.safeParseAsync({ + value: valueRecord.value, + unit: valueRecord.unit, + }); + + if (!parsed.success) { + const firstIssue = parsed.error.issues[0]?.message ?? "Validation error"; + console.log(`Invalid global_alert_value/unit: ${firstIssue}`); + return null; + } + + const messageRecord = records.find((record) => record.variable === CONFIG_VAR_MESSAGE); + + return { + thresholdMs: parsed.data.value * UNIT_MS[parsed.data.unit], + alertMessage: String(messageRecord?.value ?? DEFAULT_ALERT_MESSAGE), + }; +} + +// ============================================================================ +// Helpers — per-organization Checkin alerts (lazy + cached) +// ============================================================================ + +/** + * Returns the Checkin rules for a single organization, using `cache` to + * avoid re-fetching the same org twice during one execution. When the org + * has no Checkin rules we still store an empty array so the next caller + * short-circuits without hitting the API. + */ +async function getRulesForOrganization(organizationID: string, cache: RuleCache): Promise { + const cached = cache.get(organizationID); + if (cached !== undefined) { + return cached; + } + + const rules: OrgCheckinRule[] = []; + + const modelRecords = await Resources.devices.getDeviceData(organizationID, { + variables: ALERT_VAR_MODEL, + qty: 9999, + }); + const checkinModelRecords = modelRecords.filter((record) => record.value === "inactivity"); + + for (const modelRecord of checkinModelRecords) { + const alertID = modelRecord.group; + if (!alertID) { + continue; + } + + // Fetch all four alert fields in a single getDeviceData call by + // passing the variables array. Records come back unordered, so we + // pick them up by `record.variable`. + const alertRecords = await Resources.devices + .getDeviceData(organizationID, { + variables: [ALERT_VAR_DEVICES, ALERT_VAR_VALUE, ALERT_VAR_SEND_TO, ALERT_VAR_MESSAGE], + groups: alertID, + qty: 1, + }) + .catch(() => []); + + const devicesRecord = alertRecords.find((record) => record.variable === ALERT_VAR_DEVICES); + const valueRecord = alertRecords.find((record) => record.variable === ALERT_VAR_VALUE); + const recipientsRecord = alertRecords.find((record) => record.variable === ALERT_VAR_SEND_TO); + const messageRecord = alertRecords.find((record) => record.variable === ALERT_VAR_MESSAGE); + + const inactivityHours = Number(valueRecord?.value); + if (!Number.isFinite(inactivityHours) || inactivityHours <= 0) { + console.log(`Alert ${alertID} on org ${organizationID} has invalid inactivity hours; skipping`); + continue; + } + + const rawDevices = String(devicesRecord?.value ?? "").trim(); + const sensorIDs: Set | "all" = rawDevices === "all_sensors" ? "all" : new Set( + rawDevices + .split(",") + .map((value) => value.trim()) + .filter((value) => value.length > 0), + ); + + const recipientIDs = String(recipientsRecord?.value ?? "") + .split(",") + .map((value) => value.trim()) + .filter((value) => value.length > 0); + + if (recipientIDs.length === 0) { + console.log(`Alert ${alertID} on org ${organizationID} has no recipients; skipping`); + continue; + } + + rules.push({ + organizationID, + thresholdMs: inactivityHours * MS_PER_HOUR, + sensorIDs, + recipientIDs, + message: String(messageRecord?.value ?? DEFAULT_ALERT_MESSAGE), + }); + } + + cache.set(organizationID, rules); + return rules; +} + +/** + * Picks the first per-organization Checkin rule that applies to the given + * sensor. Multiple Checkin alerts targeting the same sensor in the same + * org is not a supported workflow today. + */ +function findRuleForSensor(rules: OrgCheckinRule[], sensorID: string): OrgCheckinRule | null { + for (const rule of rules) { + if (rule.sensorIDs === "all" || rule.sensorIDs.has(sensorID)) { + return rule; + } + } + return null; +} + +// ============================================================================ +// Helpers — classification +// ============================================================================ + +function isSensorInactive(sensor: SensorRecord, now: number, thresholdMs: number): boolean { + if (!sensor.last_input) { + return false; + } + return now - new Date(sensor.last_input).getTime() > thresholdMs; +} + +function wasAlreadyNotified(sensor: SensorRecord): boolean { + return sensor.tags.some((tag) => tag.key === NOTIFIED_TAG && tag.value === "true"); +} + +/** + * Returns true if any sensor in the list could possibly change state this + * run: either it has been silent for more than the minimum threshold (1h), + * or it is already flagged as notified and therefore eligible for recovery. + * When this returns false we skip loading the global config and the + * per-org rules entirely. + */ +function hasAlertCandidates(sensors: SensorRecord[], now: number): boolean { + for (const sensor of sensors) { + if (wasAlreadyNotified(sensor)) { + return true; + } + if (sensor.last_input && now - new Date(sensor.last_input).getTime() > MS_PER_HOUR) { + return true; + } + } + return false; +} + +async function syncLastUplinkHours(sensorID: string, sensorLastInput: Date, now: number) { + const hours = Math.floor((now - new Date(sensorLastInput).getTime()) / MS_PER_HOUR); + const nextValue = String(hours); + + const params = await Resources.devices.paramList(sensorID); + const existing = params.find((param) => param.key === LAST_UPLINK_PARAM); + if (existing?.value === nextValue) { + return; + } + + await Resources.devices.paramSet(sensorID, { + id: existing?.id, + key: LAST_UPLINK_PARAM, + value: nextValue, + sent: true, + }); +} + +// ============================================================================ +// Helpers — summary upsert +// ============================================================================ + +async function upsertGroupSummary(groupID: string, counts: SensorBucket) { + const [existing] = await Resources.devices.getDeviceData(groupID, { + variables: SUMMARY_VARIABLE, + qty: 1, + }); + + if (existing) { + await Resources.devices.editDeviceData(groupID, { + ...existing, + metadata: { + ...existing.metadata, + online: counts.online, + offline: counts.offline, + }, + }); + return; + } + + const total = counts.online + counts.offline; + await Resources.devices.sendDeviceData(groupID, { + variable: SUMMARY_VARIABLE, + value: total, + metadata: { + total_registered: total, + online: counts.online, + offline: counts.offline, + }, + }); +} + +// ============================================================================ +// Helpers — notifications & state transitions +// ============================================================================ + +async function notifyOrganizationUsers(organizationID: string, message: string) { + const users = await fetchUserList({ tags: [{ key: "organization_id", value: organizationID }] }); + for (const user of users) { + await Resources.run.notificationCreate(user.id, { + title: NOTIFICATION_TITLE, + message, + }); + } +} + +async function notifyUsers(userIDs: string[], message: string) { + for (const userID of userIDs) { + await Resources.run.notificationCreate(userID, { + title: NOTIFICATION_TITLE, + message, + }).catch((error) => { + console.error(`Failed to notify ${userID}: ${(error as Error).message ?? error}`); + }); + } +} + +/** + * `Resources.devices.edit({ tags })` REPLACES the full tag array, so + * callers must always send every tag they want to keep. + */ +function setOrReplaceTag(tags: TagsObj[], key: string, value: string): TagsObj[] { + const otherTags = tags.filter((tag) => tag.key !== key); + return [...otherTags, { key, value }]; +} + +async function handleNewlyInactive(entry: NewlyInactive, globalAlertMessage: string) { + const { sensor, rule } = entry; + const organizationID = sensor.tags.find((tag) => tag.key === "organization_id")?.value; + + if (rule) { + await notifyUsers(rule.recipientIDs, rule.message); + } else if (organizationID) { + await notifyOrganizationUsers(organizationID, globalAlertMessage); + } + + await Resources.devices.edit(sensor.id, { + tags: setOrReplaceTag(sensor.tags, NOTIFIED_TAG, "true"), + }); + console.log(`Sensor ${sensor.id} marked inactive and notified (${rule ? "per-org rule" : "global"})`); +} + +async function handleRecovered(sensor: SensorRecord) { + await Resources.devices.edit(sensor.id, { + tags: sensor.tags.filter((tag) => tag.key !== NOTIFIED_TAG), + }); + console.log(`Sensor ${sensor.id} recovered, tag cleared`); +} + +// ============================================================================ +// Sensor processing +// ============================================================================ + +/** + * Walks every sensor, refreshes its `last_uplink` parameter, increments + * the per-group online/offline bucket, and (when `fullFlow` is true) + * collects state transitions to notify or recover. + * + * `fullFlow=false` runs the short-circuit path: we know up front that no + * sensor can be inactive (every last_input is within the last hour and no + * sensor carries the notified tag), so we just count everyone as online + * and avoid touching config or rules. `globalThresholdMs` is unused in + * that path; `ruleCache` is consulted only in the full path. + */ +async function processSensors( + sensors: SensorRecord[], + now: number, + fullFlow: boolean, + globalThresholdMs: number, + ruleCache: RuleCache, +): Promise<{ + countsByGroup: Map; + newlyInactive: NewlyInactive[]; + recovered: SensorRecord[]; +}> { + const countsByGroup = new Map(); + const newlyInactive: NewlyInactive[] = []; + const recovered: SensorRecord[] = []; + + for (const sensor of sensors) { + if (!sensor.last_input) { + continue; + } + + const groupID = sensor.tags.find((tag) => tag.key === "group_id")?.value; + const organizationID = sensor.tags.find((tag) => tag.key === "organization_id")?.value; + if (!groupID || !organizationID) { + continue; + } + + await syncLastUplinkHours(sensor.id, sensor.last_input, now); + + const bucket = countsByGroup.get(groupID) ?? { online: 0, offline: 0 }; + + if (!fullFlow) { + bucket.online += 1; + countsByGroup.set(groupID, bucket); + continue; + } + + const orgRules = await getRulesForOrganization(organizationID, ruleCache); + const rule = findRuleForSensor(orgRules, sensor.id); + const thresholdMs = rule ? rule.thresholdMs : globalThresholdMs; + + const inactive = isSensorInactive(sensor, now, thresholdMs); + const alreadyNotified = wasAlreadyNotified(sensor); + + if (inactive) { + bucket.offline += 1; + } else { + bucket.online += 1; + } + countsByGroup.set(groupID, bucket); + + if (inactive && !alreadyNotified) { + newlyInactive.push({ sensor, rule }); + } else if (!inactive && alreadyNotified) { + recovered.push(sensor); + } + } + + return { countsByGroup, newlyInactive, recovered }; +} + +// ============================================================================ +// Entrypoint +// ============================================================================ + +/** + * Entrypoint invoked by the TagoIO Analysis runtime. + * + * Steps: + * 1. Fetch every sensor in the application. + * 2. Decide between short-circuit and full flow via `hasAlertCandidates`. + * Short-circuit kicks in when no sensor has been silent for over an + * hour AND no sensor still carries the `inactivity_notified` tag — + * in that case no state transition can happen and we skip loading + * both the global config and the per-org Checkin rules. + * 3. Walk the sensors via `processSensors`, refreshing `last_uplink` + * on each. In the full flow we also resolve the per-org threshold + * (lazy-loaded and cached) and collect newly-inactive / recovered + * sensors. + * 4. Upsert `device_connectivity_summary` on every group device that + * owns at least one sensor. + * 5. Notify users and tag the newly-inactive sensors so we don't spam + * them next run. + * 6. Clear the notified tag for sensors that came back online. + */ +async function startAnalysis(context: TagoContext, _scope: Data[]) { + console.log("Running Inactivity Check Analysis"); + + const environment = Utils.envToJson(context.environment); + + const configDevID = environment.config_id; + if (!configDevID) { + console.log("Missing config_id environment variable, skipping"); + return; + } + + const sensors = await fetchDeviceList({ tags: [{ key: "device_type", value: "device" }] }); + console.log(`Scanning ${sensors.length} sensors`); + + const now = Date.now(); + const ruleCache: RuleCache = new Map(); + + // Step 2 — decide between short-circuit and full flow. + const fullFlow = hasAlertCandidates(sensors, now); + let config: InactivityConfig | null = null; + + if (fullFlow) { + config = await loadInactivityConfig(configDevID); + if (!config) { + return; + } + console.log(`Global threshold = ${config.thresholdMs}ms; per-org rules will load lazily.`); + } else { + console.log("No alert candidates this run — skipping config and rule loading."); + } + + // Step 3 — walk sensors. + const { countsByGroup, newlyInactive, recovered } = await processSensors( + sensors, + now, + fullFlow, + config?.thresholdMs ?? 0, + ruleCache, + ); + + // Step 4 — refresh group summaries. + for (const [groupID, counts] of countsByGroup) { + await upsertGroupSummary(groupID, counts); + } + + // Step 5 — notify + flag newly inactive sensors. + for (const entry of newlyInactive) { + await handleNewlyInactive(entry, config?.alertMessage ?? DEFAULT_ALERT_MESSAGE); + } + + // Step 6 — clear the flag for sensors that recovered. + for (const sensor of recovered) { + await handleRecovered(sensor); + } + + console.log( + `Inactivity scan finished. Groups updated: ${countsByGroup.size}, newly inactive: ${newlyInactive.length}, recovered: ${recovered.length}`, + ); +} + +if (!Deno.env.get("T_TEST")) { + Analysis.use(startAnalysis, { token: Deno.env.get("T_ANALYSIS_TOKEN") }); +} + +export { startAnalysis }; diff --git a/app/deno.json b/app/deno.json new file mode 100644 index 0000000..b22b6a4 --- /dev/null +++ b/app/deno.json @@ -0,0 +1,25 @@ +{ + "name": "@app/application", + "version": "1.0.0", + "type": "module", + "exports": {}, + "tasks": { + "test": "deno test --allow-all .", + "test:single": "deno test --allow-all", + "linter": "deno lint --rules-exclude=no-explicit-any,hugoalh/fmt-jsdoc --ignore='**/*.test.ts' .", + "linter-fix": "deno lint --fix .", + "build": "deno run --allow-read --allow-write --allow-run bundle.ts" + }, + "imports": { + "json-2-csv": "npm:json-2-csv@^5.5.10", + "luxon": "npm:luxon@^3.7.2", + "phone": "npm:phone@^3.1.50", + "zod": "npm:zod@^4.3.6", + "@std/fs": "jsr:@std/fs@^1.0.23", + "@std/path": "jsr:@std/path@^1.1.4" + }, + "test": { + "include": ["**/*.test.ts"], + "exclude": ["build/"] + } +} diff --git a/biome.json b/biome.json deleted file mode 100644 index eec48cf..0000000 --- a/biome.json +++ /dev/null @@ -1,119 +0,0 @@ -{ - "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", - "organizeImports": { "enabled": true }, - "linter": { - "enabled": true, - "rules": { - "recommended": false, - "complexity": { - "noBannedTypes": "error", - "noExtraBooleanCast": "error", - "noForEach": "warn", - "noMultipleSpacesInRegularExpressionLiterals": "error", - "noUselessCatch": "error", - "noUselessThisAlias": "error", - "noUselessTypeConstraint": "error", - "noWith": "error", - "useArrowFunction": "warn", - "useFlatMap": "warn" - }, - "correctness": { - "noConstAssign": "error", - "noConstantCondition": "error", - "noEmptyCharacterClassInRegex": "error", - "noEmptyPattern": "error", - "noGlobalObjectCalls": "error", - "noInnerDeclarations": "error", - "noInvalidConstructorSuper": "error", - "noNewSymbol": "error", - "noNonoctalDecimalEscape": "error", - "noPrecisionLoss": "error", - "noSelfAssign": "error", - "noSetterReturn": "error", - "noSwitchDeclarations": "error", - "noUndeclaredVariables": "error", - "noUnreachable": "error", - "noUnreachableSuper": "error", - "noUnsafeFinally": "error", - "noUnsafeOptionalChaining": "error", - "noUnusedLabels": "error", - "noUnusedVariables": "error", - "useIsNan": "error", - "useValidForDirection": "error", - "useYield": "error" - }, - "style": { - "noDefaultExport": "warn", - "noNamespace": "error", - "useAsConstAssertion": "error", - "useBlockStatements": "error", - "useFilenamingConvention": { - "level": "error", - "options": { "requireAscii": true, "filenameCases": ["kebab-case"] } - } - }, - "suspicious": { - "noAssignInExpressions": "error", - "noAsyncPromiseExecutor": "error", - "noCatchAssign": "error", - "noClassAssign": "error", - "noCompareNegZero": "error", - "noControlCharactersInRegex": "error", - "noDebugger": "error", - "noDuplicateCase": "error", - "noDuplicateClassMembers": "error", - "noDuplicateObjectKeys": "error", - "noDuplicateParameters": "error", - "noEmptyBlockStatements": "error", - "noExplicitAny": "warn", - "noExtraNonNullAssertion": "error", - "noFallthroughSwitchClause": "error", - "noFunctionAssign": "error", - "noGlobalAssign": "error", - "noImportAssign": "error", - "noMisleadingCharacterClass": "error", - "noMisleadingInstantiator": "error", - "noPrototypeBuiltins": "error", - "noRedeclare": "error", - "noShadowRestrictedNames": "error", - "noUnsafeDeclarationMerging": "error", - "noUnsafeNegation": "error", - "useAwait": "off", - "useGetterReturn": "error", - "useIsArray": "error", - "useValidTypeof": "error" - } - }, - "ignore": [ - ".eslintrc.js", - "tsconfig.json", - "**/lib-cov", - "**/coverage", - "**/__mocks__", - "infrastructure/overwrite", - "**/newrelic.js", - "**/__test__", - "**/*.NOTE.*", - "**/*.js", - "**/build", - "cli/*" - ] - }, - "javascript": { - "globals": ["test", "it", "describe", "expect", "vitest"], - "formatter": { - "jsxQuoteStyle": "double", - "quoteProperties": "asNeeded", - "trailingCommas": "es5", - "semicolons": "always", - "arrowParentheses": "always", - "bracketSpacing": true, - "bracketSameLine": false, - "quoteStyle": "double", - "attributePosition": "auto", - "lineWidth": 180, - "indentStyle": "space", - "indentWidth": 2 - } - } -} diff --git a/dashboard-helpers/HELPER_ALERTS.md b/dashboard-helpers/HELPER_ALERTS.md new file mode 100644 index 0000000..9794816 --- /dev/null +++ b/dashboard-helpers/HELPER_ALERTS.md @@ -0,0 +1,88 @@ +# 🚨 Alerts + +## 💡 What is an Alert? + +An **Alert** is a rule that watches your sensors and notifies the right people the moment something needs attention — a freezer warming up, a door left open, a compressor that +stopped, or a sensor that went silent. Each alert is scoped to one **organization**, so users in one tenant only see (and only receive) the alerts that belong to them. + +You can create as many alerts as you need, mix and match sensor selections, and customize the message that goes out so the recipient knows exactly what happened and where. + +## 🛠️ What can I do on this dashboard? + +This dashboard lists every alert configured for the organization you opened. + +- 📋 **By Sensor — Alert List** — A table with every alert: which sensor(s) it watches, the model (Temperature, Door, Compressor, Inactivity), the condition, the value, the + recipients, and the message. From here, you can: + - **Delete** an alert (the trash icon under _Controls_). Deletion is irreversible — the rule is removed and no more notifications fire for it. +- 🌐 **Global Alerts** — A read-only tab showing the application-wide defaults (used when an organization has no specific Inactivity rule of its own). +- ➕ **Create Alert** — Opens the form to add a new alert. You choose which sensors to watch, the model, the condition, the recipients, and the message. + +## 🧩 The four alert types + +| Model | What it watches | Example condition | +| ------------------ | ------------------------------------------ | ---------------------------------------------------------- | +| 🌡️ **Temperature** | The `temperature` variable from the sensor | `> 80°F`, `< 20°F`, `between 30-40°F`, `= 50°F`, `!= 60°F` | +| 🚪 **Door** | The `door` variable (open/closed) | `door = open` | +| ⚙️ **Compressor** | The `compressor` variable (on/off) | `compressor = off` | +| ⏰ **Inactivity** | How long since the sensor's last uplink | `no data for 2 hours` | + +Temperature is always in **°F**. Door and Compressor are enums chosen from a dropdown. Inactivity is measured in hours. + +## ✉️ The notification message + +The message is sent **in-app** (the bell icon at the top of the dashboard) to every recipient you picked. You can personalize it with placeholders that are replaced at the moment +the alert fires: + +- `#device_name#` — the friendly name of the sensor +- `#device_id#` — the sensor's internal ID +- `#sensor_type#` — the sensor's type tag (e.g. `freezer`) +- `#value#` — the value that crossed the threshold +- `#variable#` — the variable name that triggered (e.g. `temperature`) + +Example: _"Freezer 02 is too hot: temperature reached #value#°F"_ becomes _"Freezer 02 is too hot: temperature reached 84°F"_. + +## ⚙️ How it works behind the scenes + +Each alert is one logical record on the **organization device**, written as six variables sharing the same `group` (which doubles as the alert ID). The widget table reads those +rows directly — that's why a new alert shows up on the table right after creation. + +For **Temperature, Door, and Compressor** alerts, the `createAlert` analysis function also provisions a TagoIO **Action** of type `condition`: + +1. ✅ Validates the form fields with a Zod schema (model, condition, value, recipients, message). +2. 💾 Writes the six alert variables on the organization device, grouped by a fresh alert ID. +3. 🏷️ If the alert targets specific sensors, it tags each chosen sensor with `alert_id = ` so the Action can find them by tag. +4. 🎬 Creates a TagoIO Action whose trigger matches either _every device in the organization_ (`device_type = device`) or _only the sensors with the matching `alert_id` tag_. +5. 📨 The Action calls the `alert-dispatcher` analysis whenever the condition is met. The dispatcher reads the alert row, substitutes the placeholders, and sends the in-app + notification to each recipient. + +**Inactivity** alerts work differently — they are NOT backed by a TagoIO Action (the platform cannot natively detect "no data for X hours"). Instead, the scheduled +`check-inactive-sensors` analysis runs periodically, reads every organization's Inactivity rules, and fires notifications using the per-organization recipients and message. + +The delete flow runs in reverse: it removes the six variables from the organization device, removes the `alert_id` tag from any sensors that were tagged for it, and deletes the +TagoIO Action (when there is one). + +## ❓ Common questions + +**What's the difference between _All Sensors_ and _Sensors_ in "Setup alerts by"?** _All Sensors_ makes the alert watch every sensor currently in the organization, including any +sensor added later — the Action's trigger is keyed by the `device_type = device` tag. _Sensors_ lets you pick a specific list; only the sensors you check are tagged and watched. + +**If I create an All Sensors alert and add a new sensor later, will it be covered?** Yes, automatically — because the Action filters by the `device_type` tag, which every sensor in +the organization already has. + +**Why don't I see an Action created for my Inactivity alert?** By design. Inactivity is detected by a scheduled scan, not by a TagoIO condition Action. The rule is stored on the +organization device and read on each run of the `check-inactive-sensors` analysis. + +**Do recipients receive an email or SMS?** No — only in-app notifications (the bell at the top of the dashboard). Email and SMS are out of scope for this template. + +**What happens to old notifications if I delete an alert?** Already-sent notifications stay in the recipient's inbox; only future notifications stop. Deleting the alert removes the +rule, the row in the table, the Action (if any), and the `alert_id` tag on the targeted sensors. + +**Can two alerts target the same sensor?** Yes — a sensor can be watched by as many alerts as you want, and each fires independently. + +## 💎 Tips + +- Use placeholders in the message so the same alert template works for every sensor. _"#device_name# reported #variable# = #value#"_ is more useful than a static text. +- For Temperature _Between_, set the lower bound first and the upper bound second. The alert fires when the value falls **inside** the range. +- Inactivity alerts are great as a safety net: even if a specific Temperature or Door alert is missing, an Inactivity rule will catch sensors that simply stopped reporting. +- Prefer one _All Sensors_ alert over creating a separate alert per sensor — easier to maintain and automatically covers newly added sensors. +- Keep the recipient list lean. Notification fatigue makes important alerts get ignored. diff --git a/dashboard-helpers/HELPER_FREEZER_SIMULATOR.md b/dashboard-helpers/HELPER_FREEZER_SIMULATOR.md new file mode 100644 index 0000000..d5ba4e5 --- /dev/null +++ b/dashboard-helpers/HELPER_FREEZER_SIMULATOR.md @@ -0,0 +1,59 @@ +# 🔍 Freezer Simulator + +## 💡 What is this dashboard? + +The **Freezer Simulator** dashboard is the deep-dive view of a single sensor. You land here by clicking the **View** icon next to a sensor on the Sensors dashboard — there is no +entry for it on the sidebar because it always needs a specific sensor in scope. + +## 🛠️ What can I do on this dashboard? + +The header keeps the breadcrumb selectors (Organization, Group, Sensor) populated from the deep-link, so you always know which sensor you are looking at. The dashboard itself has +two tabs: + +- 📊 **Overview tab** — the default view, made up of three areas: + - **Live Cold Room Monitor** (custom widget) — three large cards at the top: _TEMPERATURE_ (gauge + value in °F), _COMPRESSOR STATUS_ (ON/OFF), and _DOOR STATUS_ (OPEN/CLOSED). + Each card shows a "— Xm ago" label so you can tell at a glance whether the value is fresh. + - **Sensor History table** — a paginated list of the last readings with columns _Temperature_, _Door Status_, _Compressor Status_, and _Date and Time_. The table loads up to + 1,000 records, paginated; scroll back through the pages to see older uplinks. + - **Temperature History (header button)** — opens a 24-hour line chart of the temperature in a modal over the dashboard. It supports drag-to-zoom; a _Reset Zoom_ button brings + you back to the full window. Use it when you need to spot trends, drops, or warm-ups that a single value cannot show. +- 📘 **Helper tab** — this document. + +## ⚙️ How it works behind the scenes + +There is no per-sensor dashboard _device_ — the dashboard reads the sensor's own device directly. The deep-link from the Sensors table sets `sensor_dev=` in the URL, and +every widget on this page is bound to that scope through TagoIO Blueprints. + +- The three **live cards** in the Cold Room Monitor are fed by the sensor device's latest values for the variables `temperature`, `compressor`, and `door`. The custom widget + formats them and computes the "X minutes ago" label from each value's timestamp. +- The **Sensor History table** queries those same variables on the sensor device, ordered by time descending. It is a regular TagoIO data table, which is why pagination and the + total record count work out of the box. +- The **Temperature History chart** is a Line Chart widget bound to the `temperature` variable on the sensor device, configured for the last 24 hours. + +Because every widget reads the sensor device directly, no analysis runs when you open this dashboard. The data you see was written by the `uplink-handler` analysis (and by the +decoder) when the sensor pushed its uplinks. + +## ❓ Common questions + +**Why isn't this dashboard in the sidebar?** It only makes sense for one sensor at a time, and that sensor comes from the URL parameters set by the Sensors dashboard. Without that +scope the dashboard would have nothing to render, so it is intentionally accessed only via the **View** icon. + +**The cards show "— Xh ago" with a large number. What does that mean?** The sensor hasn't pushed an uplink for that long. It is the same signal the Cold Rooms tab uses on the +Groups dashboard. Check the device's connectivity (network, battery) if the time keeps climbing. + +**Why does the Temperature History chart only cover 24 hours?** It is configured for a 24-hour window because that matches the most common operational question (_"what has this +freezer done today?"_). + +**Can I edit the sensor from here?** No — this is a read-only operational view. To rename or delete the sensor, go back to the Sensors dashboard and use the row controls. + +**My table is empty even though the cards show data. Why?** The cards read the _latest_ value, while the table queries the last 1,000 ordered by time. If you just provisioned the +sensor and only one uplink arrived, the table will have a single row. + +## 💎 Tips + +- Use the Temperature History modal as your first stop when investigating an alert: a chart usually answers _"is this a spike or a sustained problem?"_ faster than scrolling the + table. +- The Sensor History table lines up _Temperature_, _Door Status_, and _Compressor Status_ on the same row, so it is the right place to correlate events — for example, to check + whether a temperature spike coincided with the door opening or the compressor turning off. +- The `— Xm ago` label is your fastest health check. Two of the three cards lagging behind usually means the sensor itself is offline; one card alone usually means a decoder or + payload issue. diff --git a/dashboard-helpers/HELPER_GROUPS.md b/dashboard-helpers/HELPER_GROUPS.md new file mode 100644 index 0000000..fc232ed --- /dev/null +++ b/dashboard-helpers/HELPER_GROUPS.md @@ -0,0 +1,67 @@ +# 🧩 Groups + +## 💡 What is a Group? + +A **Group** is a subdivision inside an Organization. It represents a physical or logical area where sensors are installed — for example, a cold room, a warehouse, a production +line, a floor, or a zone. Each sensor in the application belongs to exactly one group, and each group belongs to exactly one organization. + +Groups are the layer that makes large deployments manageable. Instead of looking at every sensor at once, users navigate Organization → Group → Sensor, which keeps each screen +focused on the right scope. + +## 🛠️ What can I do on this dashboard? + +This dashboard shows every group that belongs to the organization selected at the top. It is split into three tabs: + +- ❄️ **Cold Rooms tab** — A live-monitoring view showing every sensor in the organization grouped by its parent group. Each group is rendered as a section header with a count (e.g: + _"5 SENSORS"_) followed by one card per sensor. Every card surfaces the sensor name, time since the last uplink, current temperature (°F), compressor status (ON/OFF), and door + status (OPEN/CLOSED). A search button in the top-right filters the cards by sensor name, which is handy when an organization has many sensors. This is the tab to open first when + a user wants to know the current state of their cold rooms. +- 🏢 **Organization selector** — The dropdown at the top lets you switch between organizations without leaving this dashboard. It uses TagoIO's Blueprint feature: changing the + organization rewrites every widget in place to show that organization's groups. +- 📋 **Overview tab — Group List** — A table with the name and address of every group inside the current organization. From here, you can: + - **View** a group (the icon on the left) — opens the Sensors dashboard already filtered for that group. + - **Edit** the group's name (the pencil icon under _Controls_). + - **Delete** the group (the trash icon under _Controls_). Deleting a group also deletes every sensor that belongs to it. +- ➕ **Create Group** — Opens a form to add a new group to the current organization. You provide a name and an address; the group is created inside the organization you are + viewing. + +## ⚙️ How it works behind the scenes + +Each group is stored as a TagoIO **device** of type `mutable` with the tags `device_type = group` and `organization_id = `. This device stores the group's metadata +(name, address) and acts as the anchor for every sensor that belongs to it. + +When you create a group, the `createGroup` analysis function runs and: + +1. ✅ Validates the form fields using a Zod schema. +2. 🔍 Checks that the name is unique inside the parent organization (the same name is allowed in different organizations). +3. 🏷️ Creates the device, applies the `organization_id`, `group_id`, and `device_type` tags, and stores the address as a device parameter. + +Sensors that you create later carry the matching `group_id` tag, which is how this dashboard's Sensors view knows what to display. + +The **Cold Rooms tab** is powered by a TagoIO **custom widget** that reads the `cold_room_card_data` variable from the organization device. That variable is written by the +`uplink-handler` analysis on every sensor uplink: one record per sensor, grouped by sensor id, with metadata for the sensor name, parent group name, temperature, compressor status, +and door status. The widget uses the `group_name` field to cluster the cards by group at render time — no extra query is needed. + +## ❓ Common questions + +**Why is the organization selector at the top?** Because this same dashboard is reused for every organization. The selector tells the dashboard which organization to scope to, and +the Blueprint feature reloads every widget with that organization's data. This is a TagoIO pattern that lets you keep one dashboard configuration while serving many tenants. + +**Can two groups have the same name?** Two groups in the _same_ organization cannot share a name — the analysis blocks it. Two groups in _different_ organizations can share a name +without any conflict. + +**What happens if I delete a group that already has sensors?** The delete action removes every sensor inside the group along with the group device itself. The action is +irreversible, so make sure you are deleting the right one. If you only want to move sensors to another group, edit the sensors first. + +**Why does a sensor card on the Cold Rooms tab show stale data or `— Xh ago`?** The card mirrors whatever the sensor last reported. If the time-since-last-uplink keeps climbing, +the device is likely offline or out of battery — open the Sensors view for that group to investigate. + +**A new sensor doesn't appear on the Cold Rooms tab. Why?** The card only shows up after the sensor produces its first uplink (the `uplink-handler` writes the `cold_room_card_data` +record on the organization device the first time it sees a value from that sensor). Once the device sends data, the card appears automatically. + +## 💎 Tips + +- Use names that match the real world (for example, _Cold Room A_, _Floor 2 — North Wing_). The group name is shown across the application and in notifications. +- The address is informative only at this level — the Sensors dashboard does not show it on a map. Still, providing an accurate address makes the data easier to interpret later. +- An organization can have any number of groups. Start with the structure that mirrors your physical sites, then refine as you learn what your users need. +- Use the search field on the Cold Rooms tab to jump straight to a sensor by name when the organization has many sensors — much faster than scrolling. diff --git a/dashboard-helpers/HELPER_ORGANIZATIONS.md b/dashboard-helpers/HELPER_ORGANIZATIONS.md new file mode 100644 index 0000000..52c30ff --- /dev/null +++ b/dashboard-helpers/HELPER_ORGANIZATIONS.md @@ -0,0 +1,51 @@ +# 🏢 Organizations + +## 💡 What is an Organization? + +An **Organization** is the top-level entity in this application. It represents a customer, a site, a team, or any tenant that owns a set of groups, sensors, dashboards, and users. +Everything in the application — devices, data, alerts, access — is scoped to one Organization, which is what makes this template multi-tenant by default. + +If you are building an IoT application where each of your customers should see only their own data, the Organization is the unit you create for each customer. + +## 🛠️ What can I do on this dashboard? + +This dashboard is your starting point for managing every organization in the application. + +- 📋 **Organization List** — A table with the name and address of every organization. From here, you can: + - **View** an organization (the icon on the left) — opens the Groups dashboard already filtered for that organization. + - **Edit** the name or address (the pencil icon under _Controls_). + - **Delete** the organization (the trash icon under _Controls_). Deleting an organization also removes its dummy device and any data linked to it. +- 🗺️ **Map View** — Shows every organization as a pin on a map, based on its address. Click a pin to open a popup with the organization name, the last update timestamp, and a _Go + to organization_ link that opens the Groups dashboard. Use this view when you want a geographic overview of all your tenants. +- ➕ **Create Organization** — Opens a form to add a new organization. You provide a name and an address; the address is geocoded so the new organization appears on the Map View. + +## ⚙️ How it works behind the scenes + +Each organization is stored as a TagoIO **device** of type `mutable` with the tag `device_type = organization`. This device acts as a small store for the organization's metadata +(name, address, location) and as the anchor for everything that belongs to it. + +When you create an organization, the `createOrganization` analysis function runs and: + +1. ✅ Validates the form fields using a Zod schema. +2. 🔍 Checks that the name is unique inside this application. +3. 🏷️ Creates the device, applies the `organization_id` and `device_type` tags, and stores the address as a device parameter. + +Groups, sensors, and run users that you create later carry the matching `organization_id` tag, which is how Access Policies isolate data between tenants. + +## ❓ Common questions + +**Where does the data come from?** From the application's settings device (the dummy device passed in the URL as `settings_dev`). The list and the map both read the same +`organization` data variable that the analysis writes when you create or edit an organization. + +**Why can't I find a group or a sensor on this dashboard?** This dashboard only shows organizations. To see what belongs to an organization, click _View_ on its row — you will land +on the Groups dashboard scoped to that organization. + +**What happens if I delete an organization that already has groups and sensors?** The delete action removes the organization device and triggers cleanup, but you should remove its +groups and sensors first to avoid orphan devices. Check the _Groups_ dashboard inside the organization before deleting it. + +## 💎 Tips + +- Keep organization names clear and unique. They are shown across every dashboard and used in notifications. +- Use a real address when creating an organization — it powers the Map View and helps users locate sites quickly. +- This dashboard is intended for application administrators (the `admin` access level). Org admins and guests typically land directly on the Groups dashboard of their own + organization. diff --git a/dashboard-helpers/HELPER_SENSORS.md b/dashboard-helpers/HELPER_SENSORS.md new file mode 100644 index 0000000..d23efcb --- /dev/null +++ b/dashboard-helpers/HELPER_SENSORS.md @@ -0,0 +1,57 @@ +# 📡 Sensors + +## 💡 What is a Sensor? + +A **Sensor** is an IoT device that sends data to TagoIO — a temperature probe, a GPS tracker, an energy meter, or any hardware that produces measurements. In this application each +sensor belongs to one group, which in turn belongs to one organization, so the data is always scoped to the right tenant and the right area. + +Each sensor is identified by a unique **EUI**, the hardware code printed on the device. The EUI is what TagoIO uses to route incoming packets to the correct sensor. + +## 🛠️ What can I do on this dashboard? + +This dashboard shows every sensor inside the group you opened from the Groups dashboard. + +- 📊 **Sensor Status cards** — A summary at the top with three counts: total _Registered_, _Active_ (sending data right now), and _Inactive_ (not sending data). The total is + updated automatically every time you create or delete a sensor. +- 📋 **Sensor List** — A table with name, EUI, network, model, last seen, and battery for every sensor in the group. From here, you can: + - **View** a sensor (the icon on the left) — opens the sensor's detail dashboard with charts and recent data. + - **Edit** the sensor's name (the pencil icon under _Controls_). + - **Delete** the sensor (the trash icon under _Controls_). Deletion is irreversible — the device and all its stored data are removed. +- 🛡️ **All Devices (Admin)** — An admin-only tab that lists every device of this group, including dummy devices (like the group's own device). Use it only when you need to inspect + or repair the underlying TagoIO objects. +- ➕ **Create Sensor** — Opens a form to add a new sensor. You provide a name, pick the network and model, and enter the EUI. The sensor is created inside the current group. + +## ⚙️ How it works behind the scenes + +Each sensor is stored as a TagoIO **device** of type `immutable` (time-series storage) with the tags `device_type = device`, `sensor_id`, `device_eui`, `group_id`, and +`organization_id`. The network and connector chosen on the form define how incoming payloads are decoded. + +When you create a sensor, the `createSensor` analysis function runs and: + +1. ✅ Validates the form fields using a Zod schema (name length, EUI format, network, connector). +2. 🔍 Checks that the name is unique inside the parent group, and that the EUI is unique across the whole application (no two sensors can share a hardware code). +3. 🏷️ Creates the device, applies the tags above, and stores the EUI and a deep-link to the sensor dashboard as device parameters. +4. 🔄 Updates the group's `device_connectivity_summary` record so the Sensor Status cards reflect the new total. + +The delete flow runs in reverse: it removes the device and then refreshes the same summary record on the group, so the counter goes down right away. + +## ❓ Common questions + +**What is the EUI and where do I find it?** The EUI is a 16-character hexadecimal identifier (`0123456789ABCDEF`). It is printed on the device itself or on its box, and the +manufacturer guarantees it is unique. You can also scan it with the _Scan QR Code_ button if the device has a QR code. + +**Why does the Sensor Status widget show `—` for the active and inactive counts?** The total registered count is maintained by the create and delete analyses, but the active and +inactive counts come from a separate monitoring flow that watches each sensor's last uplink. In a fresh installation, those values will stay as `—` until a monitoring action starts +updating them. + +**Can two sensors have the same EUI?** No. The application enforces uniqueness at creation time, because the EUI is what links incoming data to the right sensor. If two sensors +shared an EUI, their data would collide. + +**What happens if I delete a sensor?** The device, all its time-series data, parameters, and tags are removed. The action is irreversible, so make sure you exported anything you +need before deleting. + +## 💎 Tips + +- Use names that describe the role and the location, like _Freezer 02 — Top Shelf_. The sensor name shows up in dashboards, alerts, and notifications. +- The model defines which decoder is applied to incoming data. Choosing the right model is what makes the sensor's data show up in the correct units. +- If a sensor stops appearing in _Active_, check the _Last seen_ column first — long gaps usually mean the device is offline or out of battery. diff --git a/dashboard-helpers/HELPER_USERS.md b/dashboard-helpers/HELPER_USERS.md new file mode 100644 index 0000000..0eae2a9 --- /dev/null +++ b/dashboard-helpers/HELPER_USERS.md @@ -0,0 +1,81 @@ +# 👥 Users + +## 💡 What is a User? + +A **User** is a person who logs into your application through TagoRUN — a customer, a technician, an operator, or anyone who needs to see data. Every user belongs to one +organization (with the exception of Application Admins, who are not tied to any) and has an **access level** that decides what they can do. + +This dashboard is where you invite users, change what they can see, and remove access when needed. + +## 🛠️ What can I do on this dashboard? + +This dashboard shows every user that belongs to the organization selected at the top. + +- 🏢 **Organization selector** — The dropdown at the top scopes the dashboard to a single organization. Switching it reloads the list with that organization's users without leaving + the page. +- 📋 **Users List** — A table with name, email, phone, and access level for every user. From here, you can: + - **Change password** (the key icon under _Controls_) — opens a small dialog to set a new password for the user, bypassing the email flow. + - **Edit** (the pencil icon under _Controls_) — opens a form to update the name, phone, or access level. Email is fixed once the user is created. + - **Delete** (the trash icon under _Controls_) — removes the user account from TagoRUN. The action is irreversible. +- 🛡️ **All Users (Admin)** — An admin-only tab that lists every user in the application, including Application Admins not tied to any organization. Use it when you need a global + view. +- ➕ **Create User** — Opens a form to invite a new user. You provide name, email, an optional phone, and the access level. The new user receives an email with a link to set their + password. + +## 🔐 Access levels + +The kickstarter ships with three levels, defined in the user model. Pick the one that matches the user's role: + +- **Application Admin** — full access to every organization in the application. Use it for the team that maintains the platform itself. +- **Organization Admin** — full access to a single organization. Can create, edit, and delete groups, sensors, and other users _inside that organization_. +- **Guest** — read-only access to a single organization. Can view dashboards but cannot create, edit, or delete anything. + +The access level is stored as a tag (`access`) on the run user. Org Admin and Guest also receive an `organization_id` tag that scopes their visibility through Access Policies. + +## ⚙️ How it works behind the scenes + +When you create a user, the `createUser` analysis function runs and: + +1. ✅ Validates name, email, phone, and access level using a Zod schema. The phone must include a country code (for example, `+1` for US numbers); the email must be a valid + address. +2. 🔍 Checks that the email is not already in use anywhere in the application. +3. 🏷️ Creates the run user, applies the `access` tag, and (for Org Admin and Guest) the `organization_id` and `user_organization_id` tags that link them to the current + organization. +4. 📧 Sends an invite email with the temporary password. **The reference implementation uses SendGrid**, but the call lives in a single helper (`sendInviteEmail`) — swap it for any + provider (Mailgun, SES, Resend, SMTP, etc.) without touching the rest of the flow. + +When you edit a user, only the fields you changed are sent. If validation fails, the analysis runs `undoUserChanges` to restore the previous values, so the UI never drifts out of +sync with the backend. + +## 📧 Email provider (SendGrid) + +The kickstarter expects two analysis environment variables: + +- `SENDGRID_API_KEY` — API key with _Mail Send_ scope. +- `sendgrid_from_email` — verified sender address. + +**Errors you may see:** + +- Missing vars → `[Error] Missing secrets 'SENDGRID_API_KEY' or 'sendgrid_from_email'.` +- SendGrid rejects (wrong key, unverified sender, missing template) → log shows `Email sending failed: ...` check the analysis logs to see the SendGrid response. + +## ❓ Common questions + +**Why isn't there a phone number for some users?** Phone is optional on the form. If you don't fill it in, the user is created without a phone — you can add one later through the +edit dialog. + +**The invite email never arrives. What now?** Check the SendGrid configuration in the application's environment variables (`SENDGRID_API_KEY` and `sendgrid_from_email`). Without +them set, the invite step fails. As a fallback, you can also use _Change password_ to set a password directly and share it with the user out of band. + +**Can I change a user's email?** No. Email is the primary identifier in TagoRUN and is fixed after creation. If a user changes email, the cleanest path is to delete the old account +and invite them again. + +**Can I move a user to a different organization?** There is no built-in flow for that. The user's `organization_id` tag would need to be edited directly. Delete and re-invite is +the safest path today. + +## 💎 Tips + +- Use _Organization Admin_ sparingly. It gives full control inside the organization, including the power to delete every group and sensor. +- _Guest_ is the right level for stakeholders who only need to look at dashboards — they cannot break anything. +- _Application Admin_ is unscoped. Only assign it to the team that maintains the platform; everyone else should have an organization tag. +- Keep names readable. The user name shows up in audit logs and notifications, so "Maria — Field Tech" is clearer than just "Maria". diff --git a/deno.json b/deno.json new file mode 100644 index 0000000..59e5de7 --- /dev/null +++ b/deno.json @@ -0,0 +1,50 @@ +{ + "name": "@app/root", + "version": "1.0.0", + "exports": "./app/mod.ts", + "nodeModulesDir": "auto", + "workspace": ["./app", "./widgets", "./widgets/sensor-status", "./widgets/cold-room-monitor", "./widgets/cold-room-card-data"], + "tasks": { + "test:app": "cd app && deno task test", + "test:all": "deno test --allow-all --ignore=build/,_dist/,node_modules/", + "lint:app": "cd app && deno task linter", + "lint:widgets": "deno lint widgets/", + "lint:all": "deno lint --ignore=build/,_dist/,node_modules/,.github/", + "fmt": "deno fmt", + "fmt:check": "deno fmt --check", + "build:app": "deno check app/", + "build:widgets": "deno check widgets/ &&cd widgets && deno task build", + "dev:widgets": "cd widgets && deno task dev" + }, + "fmt": { + "useTabs": false, + "lineWidth": 180, + "indentWidth": 2, + "semiColons": true, + "singleQuote": false, + "exclude": ["build/", "_dist/", "node_modules/", ".github/", ".superpowers/"] + }, + "imports": { + "@tago-io/sdk": "npm:@tago-io/sdk@^12.2.1", + "@std/assert": "jsr:@std/assert@^1.0.19", + "@std/testing": "jsr:@std/testing@^1.0.18" + }, + "compilerOptions": { + "strict": true, + "noImplicitAny": false, + "strictNullChecks": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "lib": ["ES2023", "DOM", "Deno.NS"] + }, + "lint": { + "plugins": [ + "jsr:@hugoalh/deno-lint-rules@^0.14.1" + ], + "rules": { + "exclude": ["no-explicit-any", "hugoalh/fmt-jsdoc", "hugoalh/no-duplicate-import-sources", "no-unversioned-import", "no-import-prefix"] + } + }, + "exclude": ["build/", "node_modules/", "_dist/", "**/.vite/", ".claude/"], + "lock": false +} diff --git a/docs/Kickstarter - Guide.pdf b/docs/Kickstarter - Guide.pdf deleted file mode 100644 index 9193830..0000000 Binary files a/docs/Kickstarter - Guide.pdf and /dev/null differ diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index 6b18c10..0000000 --- a/package-lock.json +++ /dev/null @@ -1,5892 +0,0 @@ -{ - "name": "template-analysis", - "version": "2.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "template-analysis", - "version": "2.0.0", - "license": "Copyright", - "dependencies": { - "@tago-io/sdk": "11.3.9", - "async": "3.2.6", - "axios": "1.8.4", - "bson-objectid": "2.0.4", - "geolib": "3.3.4", - "luxon": "3.6.1", - "puppeteer": "24.6.0" - }, - "devDependencies": { - "@biomejs/biome": "1.9.4", - "@tago-io/builder": "3.1.3", - "@types/async": "3.2.24", - "@types/luxon": "3.6.2", - "@types/uuid": "10.0.0", - "husky": "9.1.7", - "prettier": "3.5.3", - "ts-node": "10.9.2", - "ts-node-dev": "2.0.0", - "typescript": "5.8.2", - "unplugin-swc": "1.5.1", - "uuid": "11.1.0", - "vitest": "3.1.1" - } - }, - "node_modules/@babel/code-frame": { - "version": "7.26.2", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", - "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.25.9", - "js-tokens": "^4.0.0", - "picocolors": "^1.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", - "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@biomejs/biome": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-1.9.4.tgz", - "integrity": "sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog==", - "dev": true, - "hasInstallScript": true, - "license": "MIT OR Apache-2.0", - "bin": { - "biome": "bin/biome" - }, - "engines": { - "node": ">=14.21.3" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/biome" - }, - "optionalDependencies": { - "@biomejs/cli-darwin-arm64": "1.9.4", - "@biomejs/cli-darwin-x64": "1.9.4", - "@biomejs/cli-linux-arm64": "1.9.4", - "@biomejs/cli-linux-arm64-musl": "1.9.4", - "@biomejs/cli-linux-x64": "1.9.4", - "@biomejs/cli-linux-x64-musl": "1.9.4", - "@biomejs/cli-win32-arm64": "1.9.4", - "@biomejs/cli-win32-x64": "1.9.4" - } - }, - "node_modules/@biomejs/cli-darwin-arm64": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-1.9.4.tgz", - "integrity": "sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=14.21.3" - } - }, - "node_modules/@biomejs/cli-darwin-x64": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-1.9.4.tgz", - "integrity": "sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=14.21.3" - } - }, - "node_modules/@biomejs/cli-linux-arm64": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-1.9.4.tgz", - "integrity": "sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=14.21.3" - } - }, - "node_modules/@biomejs/cli-linux-arm64-musl": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-1.9.4.tgz", - "integrity": "sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=14.21.3" - } - }, - "node_modules/@biomejs/cli-linux-x64": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-1.9.4.tgz", - "integrity": "sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=14.21.3" - } - }, - "node_modules/@biomejs/cli-linux-x64-musl": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-1.9.4.tgz", - "integrity": "sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=14.21.3" - } - }, - "node_modules/@biomejs/cli-win32-arm64": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-1.9.4.tgz", - "integrity": "sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=14.21.3" - } - }, - "node_modules/@biomejs/cli-win32-x64": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-1.9.4.tgz", - "integrity": "sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=14.21.3" - } - }, - "node_modules/@cspotcode/source-map-support": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", - "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "0.3.9" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.2.tgz", - "integrity": "sha512-wCIboOL2yXZym2cgm6mlA742s9QeJ8DjGVaL39dLN4rRwrOgOyYSnOaFPhKZGLb2ngj4EyfAFjsNJwPXZvseag==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.15.13", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.15.13.tgz", - "integrity": "sha512-RY2fVI8O0iFUNvZirXaQ1vMvK0xhCcl0gqRj74Z6yEiO1zAUa7hbsdwZM1kzqbxHK7LFyMizipfXT3JME+12Hw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.2.tgz", - "integrity": "sha512-5ZAX5xOmTligeBaeNEPnPaeEuah53Id2tX4c2CVP3JaROTH+j4fnfHCkr1PjXMd78hMst+TlkfKcW/DlTq0i4w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.2.tgz", - "integrity": "sha512-Ffcx+nnma8Sge4jzddPHCZVRvIfQ0kMsUsCMcJRHkGJ1cDmhe4SsrYIjLUKn1xpHZybmOqCWwB0zQvsjdEHtkg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.2.tgz", - "integrity": "sha512-MpM6LUVTXAzOvN4KbjzU/q5smzryuoNjlriAIx+06RpecwCkL9JpenNzpKd2YMzLJFOdPqBpuub6eVRP5IgiSA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.2.tgz", - "integrity": "sha512-5eRPrTX7wFyuWe8FqEFPG2cU0+butQQVNcT4sVipqjLYQjjh8a8+vUTfgBKM88ObB85ahsnTwF7PSIt6PG+QkA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.2.tgz", - "integrity": "sha512-mLwm4vXKiQ2UTSX4+ImyiPdiHjiZhIaE9QvC7sw0tZ6HoNMjYAqQpGyui5VRIi5sGd+uWq940gdCbY3VLvsO1w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.2.tgz", - "integrity": "sha512-6qyyn6TjayJSwGpm8J9QYYGQcRgc90nmfdUb0O7pp1s4lTY+9D0H9O02v5JqGApUyiHOtkz6+1hZNvNtEhbwRQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.2.tgz", - "integrity": "sha512-UHBRgJcmjJv5oeQF8EpTRZs/1knq6loLxTsjc3nxO9eXAPDLcWW55flrMVc97qFPbmZP31ta1AZVUKQzKTzb0g==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.2.tgz", - "integrity": "sha512-gq/sjLsOyMT19I8obBISvhoYiZIAaGF8JpeXu1u8yPv8BE5HlWYobmlsfijFIZ9hIVGYkbdFhEqC0NvM4kNO0g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.2.tgz", - "integrity": "sha512-bBYCv9obgW2cBP+2ZWfjYTU+f5cxRoGGQ5SeDbYdFCAZpYWrfjjfYwvUpP8MlKbP0nwZ5gyOU/0aUzZ5HWPuvQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.15.13", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.15.13.tgz", - "integrity": "sha512-+BoyIm4I8uJmH/QDIH0fu7MG0AEx9OXEDXnqptXCwKOlOqZiS4iraH1Nr7/ObLMokW3sOCeBNyD68ATcV9b9Ag==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.2.tgz", - "integrity": "sha512-hDDRlzE6rPeoj+5fsADqdUZl1OzqDYow4TB4Y/3PlKBD0ph1e6uPHzIQcv2Z65u2K0kpeByIyAjCmjn1hJgG0Q==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.2.tgz", - "integrity": "sha512-tsHu2RRSWzipmUi9UBDEzc0nLc4HtpZEI5Ba+Omms5456x5WaNuiG3u7xh5AO6sipnJ9r4cRWQB2tUjPyIkc6g==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.2.tgz", - "integrity": "sha512-k4LtpgV7NJQOml/10uPU0s4SAXGnowi5qBSjaLWMojNCUICNu7TshqHLAEbkBdAszL5TabfvQ48kK84hyFzjnw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.2.tgz", - "integrity": "sha512-GRa4IshOdvKY7M/rDpRR3gkiTNp34M0eLTaC1a08gNrh4u488aPhuZOCpkF6+2wl3zAN7L7XIpOFBhnaE3/Q8Q==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.2.tgz", - "integrity": "sha512-QInHERlqpTTZ4FRB0fROQWXcYRD64lAoiegezDunLpalZMjcUcld3YzZmVJ2H/Cp0wJRZ8Xtjtj0cEHhYc/uUg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.2.tgz", - "integrity": "sha512-talAIBoY5M8vHc6EeI2WW9d/CkiO9MQJ0IOWX8hrLhxGbro/vBXJvaQXefW2cP0z0nQVTdQ/eNyGFV1GSKrxfw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.2.tgz", - "integrity": "sha512-voZT9Z+tpOxrvfKFyfDYPc4DO4rk06qamv1a/fkuzHpiVBMOhpjK+vBmWM8J1eiB3OLSMFYNaOaBNLXGChf5tg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.2.tgz", - "integrity": "sha512-dcXYOC6NXOqcykeDlwId9kB6OkPUxOEqU+rkrYVqJbK2hagWOMrsTGsMr8+rW02M+d5Op5NNlgMmjzecaRf7Tg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.2.tgz", - "integrity": "sha512-t/TkWwahkH0Tsgoq1Ju7QfgGhArkGLkF1uYz8nQS/PPFlXbP5YgRpqQR3ARRiC2iXoLTWFxc6DJMSK10dVXluw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.2.tgz", - "integrity": "sha512-cfZH1co2+imVdWCjd+D1gf9NjkchVhhdpgb1q5y6Hcv9TP6Zi9ZG/beI3ig8TvwT9lH9dlxLq5MQBBgwuj4xvA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.2.tgz", - "integrity": "sha512-7Loyjh+D/Nx/sOTzV8vfbB3GJuHdOQyrOryFdZvPHLf42Tk9ivBU5Aedi7iyX+x6rbn2Mh68T4qq1SDqJBQO5Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.2.tgz", - "integrity": "sha512-WRJgsz9un0nqZJ4MfhabxaD9Ft8KioqU3JMinOTvobbX6MOSUigSBlogP8QB3uxpJDsFS6yN+3FDBdqE5lg9kg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.2.tgz", - "integrity": "sha512-kM3HKb16VIXZyIeVrM1ygYmZBKybX8N4p754bw390wGO3Tf2j4L2/WYL+4suWujpgf6GBYs3jv7TyUivdd05JA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", - "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.0.3", - "@jridgewell/sourcemap-codec": "^1.4.10" - } - }, - "node_modules/@pnpm/config.env-replace": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz", - "integrity": "sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.22.0" - } - }, - "node_modules/@pnpm/network.ca-file": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@pnpm/network.ca-file/-/network.ca-file-1.0.2.tgz", - "integrity": "sha512-YcPQ8a0jwYU9bTdJDpXjMi7Brhkr1mXsXrUJvjqM2mQDgkRiz8jFaQGOdaLxgjtUfQgZhKy/O3cG/YwmgKaxLA==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "4.2.10" - }, - "engines": { - "node": ">=12.22.0" - } - }, - "node_modules/@pnpm/network.ca-file/node_modules/graceful-fs": { - "version": "4.2.10", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", - "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==", - "dev": true, - "license": "ISC" - }, - "node_modules/@pnpm/npm-conf": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@pnpm/npm-conf/-/npm-conf-2.3.1.tgz", - "integrity": "sha512-c83qWb22rNRuB0UaVCI0uRPNRr8Z0FWnEIvT47jiHAmOIUHbBOg5XvV7pM5x+rKn9HRpjxquDbXYSXr3fAKFcw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@pnpm/config.env-replace": "^1.1.0", - "@pnpm/network.ca-file": "^1.0.1", - "config-chain": "^1.1.11" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@puppeteer/browsers": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.9.0.tgz", - "integrity": "sha512-8+xM+cFydYET4X/5/3yZMHs7sjS6c9I6H5I3xJdb6cinzxWUT/I2QVw4avxCQ8QDndwdHkG/FiSZIrCjAbaKvQ==", - "license": "Apache-2.0", - "dependencies": { - "debug": "^4.4.0", - "extract-zip": "^2.0.1", - "progress": "^2.0.3", - "proxy-agent": "^6.5.0", - "semver": "^7.7.1", - "tar-fs": "^3.0.8", - "yargs": "^17.7.2" - }, - "bin": { - "browsers": "lib/cjs/main-cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@rollup/pluginutils": { - "version": "5.1.4", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.4.tgz", - "integrity": "sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0", - "estree-walker": "^2.0.2", - "picomatch": "^4.0.2" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } - } - }, - "node_modules/@rollup/pluginutils/node_modules/picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.39.0.tgz", - "integrity": "sha512-lGVys55Qb00Wvh8DMAocp5kIcaNzEFTmGhfFd88LfaogYTRKrdxgtlO5H6S49v2Nd8R2C6wLOal0qv6/kCkOwA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.39.0.tgz", - "integrity": "sha512-It9+M1zE31KWfqh/0cJLrrsCPiF72PoJjIChLX+rEcujVRCb4NLQ5QzFkzIZW8Kn8FTbvGQBY5TkKBau3S8cCQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.39.0.tgz", - "integrity": "sha512-lXQnhpFDOKDXiGxsU9/l8UEGGM65comrQuZ+lDcGUx+9YQ9dKpF3rSEGepyeR5AHZ0b5RgiligsBhWZfSSQh8Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.39.0.tgz", - "integrity": "sha512-mKXpNZLvtEbgu6WCkNij7CGycdw9cJi2k9v0noMb++Vab12GZjFgUXD69ilAbBh034Zwn95c2PNSz9xM7KYEAQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.39.0.tgz", - "integrity": "sha512-jivRRlh2Lod/KvDZx2zUR+I4iBfHcu2V/BA2vasUtdtTN2Uk3jfcZczLa81ESHZHPHy4ih3T/W5rPFZ/hX7RtQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.39.0.tgz", - "integrity": "sha512-8RXIWvYIRK9nO+bhVz8DwLBepcptw633gv/QT4015CpJ0Ht8punmoHU/DuEd3iw9Hr8UwUV+t+VNNuZIWYeY7Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.39.0.tgz", - "integrity": "sha512-mz5POx5Zu58f2xAG5RaRRhp3IZDK7zXGk5sdEDj4o96HeaXhlUwmLFzNlc4hCQi5sGdR12VDgEUqVSHer0lI9g==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.39.0.tgz", - "integrity": "sha512-+YDwhM6gUAyakl0CD+bMFpdmwIoRDzZYaTWV3SDRBGkMU/VpIBYXXEvkEcTagw/7VVkL2vA29zU4UVy1mP0/Yw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.39.0.tgz", - "integrity": "sha512-EKf7iF7aK36eEChvlgxGnk7pdJfzfQbNvGV/+l98iiMwU23MwvmV0Ty3pJ0p5WQfm3JRHOytSIqD9LB7Bq7xdQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.39.0.tgz", - "integrity": "sha512-vYanR6MtqC7Z2SNr8gzVnzUul09Wi1kZqJaek3KcIlI/wq5Xtq4ZPIZ0Mr/st/sv/NnaPwy/D4yXg5x0B3aUUA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loongarch64-gnu": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.39.0.tgz", - "integrity": "sha512-NMRUT40+h0FBa5fb+cpxtZoGAggRem16ocVKIv5gDB5uLDgBIwrIsXlGqYbLwW8YyO3WVTk1FkFDjMETYlDqiw==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.39.0.tgz", - "integrity": "sha512-0pCNnmxgduJ3YRt+D+kJ6Ai/r+TaePu9ZLENl+ZDV/CdVczXl95CbIiwwswu4L+K7uOIGf6tMo2vm8uadRaICQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.39.0.tgz", - "integrity": "sha512-t7j5Zhr7S4bBtksT73bO6c3Qa2AV/HqiGlj9+KB3gNF5upcVkx+HLgxTm8DK4OkzsOYqbdqbLKwvGMhylJCPhQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.39.0.tgz", - "integrity": "sha512-m6cwI86IvQ7M93MQ2RF5SP8tUjD39Y7rjb1qjHgYh28uAPVU8+k/xYWvxRO3/tBN2pZkSMa5RjnPuUIbrwVxeA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.39.0.tgz", - "integrity": "sha512-iRDJd2ebMunnk2rsSBYlsptCyuINvxUfGwOUldjv5M4tpa93K8tFMeYGpNk2+Nxl+OBJnBzy2/JCscGeO507kA==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.39.0.tgz", - "integrity": "sha512-t9jqYw27R6Lx0XKfEFe5vUeEJ5pF3SGIM6gTfONSMb7DuG6z6wfj2yjcoZxHg129veTqU7+wOhY6GX8wmf90dA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.39.0.tgz", - "integrity": "sha512-ThFdkrFDP55AIsIZDKSBWEt/JcWlCzydbZHinZ0F/r1h83qbGeenCt/G/wG2O0reuENDD2tawfAj2s8VK7Bugg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.39.0.tgz", - "integrity": "sha512-jDrLm6yUtbOg2TYB3sBF3acUnAwsIksEYjLeHL+TJv9jg+TmTwdyjnDex27jqEMakNKf3RwwPahDIt7QXCSqRQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.39.0.tgz", - "integrity": "sha512-6w9uMuza+LbLCVoNKL5FSLE7yvYkq9laSd09bwS0tMjkwXrmib/4KmoJcrKhLWHvw19mwU+33ndC69T7weNNjQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.39.0.tgz", - "integrity": "sha512-yAkUOkIKZlK5dl7u6dg897doBgLXmUHhIINM2c+sND3DZwnrdQkkSiDh7N75Ll4mM4dxSkYfXqU9fW3lLkMFug==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@sindresorhus/is": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-5.6.0.tgz", - "integrity": "sha512-TV7t8GKYaJWsn00tFDqBw8+Uqmr8A0fRU1tvTQhyZzGv0sJCGRQL3JGMI3ucuKo3XIZdUP+Lx7/gh2t3lewy7g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sindresorhus/is?sponsor=1" - } - }, - "node_modules/@socket.io/component-emitter": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", - "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", - "license": "MIT" - }, - "node_modules/@swc/core": { - "version": "1.11.16", - "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.11.16.tgz", - "integrity": "sha512-wgjrJqVUss8Lxqilg0vkiE0tkEKU3mZkoybQM1Ehy+PKWwwB6lFAwKi20cAEFlSSWo8jFR8hRo19ZELAoLDowg==", - "dev": true, - "hasInstallScript": true, - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@swc/counter": "^0.1.3", - "@swc/types": "^0.1.21" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/swc" - }, - "optionalDependencies": { - "@swc/core-darwin-arm64": "1.11.16", - "@swc/core-darwin-x64": "1.11.16", - "@swc/core-linux-arm-gnueabihf": "1.11.16", - "@swc/core-linux-arm64-gnu": "1.11.16", - "@swc/core-linux-arm64-musl": "1.11.16", - "@swc/core-linux-x64-gnu": "1.11.16", - "@swc/core-linux-x64-musl": "1.11.16", - "@swc/core-win32-arm64-msvc": "1.11.16", - "@swc/core-win32-ia32-msvc": "1.11.16", - "@swc/core-win32-x64-msvc": "1.11.16" - }, - "peerDependencies": { - "@swc/helpers": "*" - }, - "peerDependenciesMeta": { - "@swc/helpers": { - "optional": true - } - } - }, - "node_modules/@swc/core-darwin-arm64": { - "version": "1.11.16", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.11.16.tgz", - "integrity": "sha512-l6uWMU+MUdfLHCl3dJgtVEdsUHPskoA4BSu0L1hh9SGBwPZ8xeOz8iLIqZM27lTuXxL4KsYH6GQR/OdQ/vhLtg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-darwin-x64": { - "version": "1.11.16", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.11.16.tgz", - "integrity": "sha512-TH0IW8Ao1WZ4ARFHIh29dAQHYBEl4YnP74n++rjppmlCjY+8v3s5nXMA7IqxO3b5LVHyggWtU4+46DXTyMJM7g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-arm-gnueabihf": { - "version": "1.11.16", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.11.16.tgz", - "integrity": "sha512-2IxD9t09oNZrbv37p4cJ9cTHMUAK6qNiShi9s2FJ9LcqSnZSN4iS4hvaaX6KZuG54d58vWnMU7yycjkdOTQcMg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-arm64-gnu": { - "version": "1.11.16", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.11.16.tgz", - "integrity": "sha512-AYkN23DOiPh1bf3XBf/xzZQDKSsgZTxlbyTyUIhprLJpAAAT0ZCGAUcS5mHqydk0nWQ13ABUymodvHoroutNzw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-arm64-musl": { - "version": "1.11.16", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.11.16.tgz", - "integrity": "sha512-n/nWXDRCIhM51dDGELfBcTMNnCiFatE7LDvsbYxb7DJt1HGjaCNvHHCKURb/apJTh/YNtWfgFap9dbsTgw8yPA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-x64-gnu": { - "version": "1.11.16", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.11.16.tgz", - "integrity": "sha512-xr182YQrF47n7Awxj+/ruI21bYw+xO/B26KFVnb+i3ezF9NOhqoqTX+33RL1ZLA/uFTq8ksPZO/y+ZVS/odtQA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-x64-musl": { - "version": "1.11.16", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.11.16.tgz", - "integrity": "sha512-k2JBfiwWfXCIKrBRjFO9/vEdLSYq0QLJ+iNSLdfrejZ/aENNkbEg8O7O2GKUSb30RBacn6k8HMfJrcPLFiEyCQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-win32-arm64-msvc": { - "version": "1.11.16", - "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.11.16.tgz", - "integrity": "sha512-taOb5U+abyEhQgex+hr6cI48BoqSvSdfmdirWcxprIEUBHCxa1dSriVwnJRAJOFI9T+5BEz88by6rgbB9MjbHA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-win32-ia32-msvc": { - "version": "1.11.16", - "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.11.16.tgz", - "integrity": "sha512-b7yYggM9LBDiMY+XUt5kYWvs5sn0U3PXSOGvF3CbLufD/N/YQiDcYON2N3lrWHYL8aYnwbuZl45ojmQHSQPcdA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-win32-x64-msvc": { - "version": "1.11.16", - "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.11.16.tgz", - "integrity": "sha512-/ibq/YDc3B5AROkpOKPGxVkSyCKOg+ml8k11RxrW7FAPy6a9y5y9KPcWIqV74Ahq4RuaMNslTQqHWAGSm0xJsQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/counter": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", - "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", - "dev": true, - "license": "Apache-2.0", - "peer": true - }, - "node_modules/@swc/types": { - "version": "0.1.21", - "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.21.tgz", - "integrity": "sha512-2YEtj5HJVbKivud9N4bpPBAyZhj4S2Ipe5LkUG94alTpr7in/GU/EARgPAd3BwU+YOmFVJC2+kjqhGRi3r0ZpQ==", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@swc/counter": "^0.1.3" - } - }, - "node_modules/@szmarczak/http-timer": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-5.0.1.tgz", - "integrity": "sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==", - "dev": true, - "license": "MIT", - "dependencies": { - "defer-to-connect": "^2.0.1" - }, - "engines": { - "node": ">=14.16" - } - }, - "node_modules/@tago-io/builder": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/@tago-io/builder/-/builder-3.1.3.tgz", - "integrity": "sha512-TWyqDomBiHz6merW/hZ8lIsg7Aqk7KUMEa8iM+ZixyfcjlNTGop9UkrToUh/KIIV2/dioVGNsfMMHJxicDcWfg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "chalk": "5.1.2", - "commander": "9.4.1", - "esbuild": "0.15.13", - "luxon": "3.1.0", - "typescript": "4.8.4", - "update-notifier": "6.0.2" - }, - "bin": { - "analysis-builder": "index.js", - "tago-builder": "index.js" - }, - "engines": { - "node": ">=14.0.0", - "npm": ">=6.0.0" - } - }, - "node_modules/@tago-io/builder/node_modules/luxon": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.1.0.tgz", - "integrity": "sha512-7w6hmKC0/aoWnEsmPCu5Br54BmbmUp5GfcqBxQngRcXJ+q5fdfjEzn7dxmJh2YdDhgW8PccYtlWKSv4tQkrTQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/@tago-io/builder/node_modules/typescript": { - "version": "4.8.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.8.4.tgz", - "integrity": "sha512-QCh+85mCy+h0IGff8r5XWzOVSbBO+KfeYrMQh7NJ58QujwcE22u+NUSmUxqF+un70P9GXKxa2HCNiTTMJknyjQ==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=4.2.0" - } - }, - "node_modules/@tago-io/sdk": { - "version": "11.3.9", - "resolved": "https://registry.npmjs.org/@tago-io/sdk/-/sdk-11.3.9.tgz", - "integrity": "sha512-xZo7mSzjfy4RhY3zY8fK2QCwsXOymWtfemwp3MqWqemrZJwt8wHFyOxL1trQSYCtniL2kMt1PNgiIrkgce31/A==", - "license": "Apache-2.0", - "dependencies": { - "axios": "1.7.4", - "form-data": "4.0.0", - "nanoid": "3.3.7", - "papaparse": "5.4.1", - "qs": "6.12.0", - "socket.io-client": "4.7.5" - }, - "engines": { - "node": ">=16.0.0", - "npm": ">=6.0.0" - }, - "optionalDependencies": { - "eventsource": "2.0.2" - } - }, - "node_modules/@tago-io/sdk/node_modules/axios": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.4.tgz", - "integrity": "sha512-DukmaFRnY6AzAALSH4J2M3k6PkaC+MfaAGdEERRWcC9q3/TWQwLpHR8ZRLKTdQ3aBDL64EdluRDjJqKw+BPZEw==", - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.0", - "proxy-from-env": "^1.1.0" - } - }, - "node_modules/@tootallnate/quickjs-emscripten": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", - "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", - "license": "MIT" - }, - "node_modules/@tsconfig/node10": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", - "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tsconfig/node12": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", - "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tsconfig/node14": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", - "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tsconfig/node16": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", - "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/async": { - "version": "3.2.24", - "resolved": "https://registry.npmjs.org/@types/async/-/async-3.2.24.tgz", - "integrity": "sha512-8iHVLHsCCOBKjCF2KwFe0p9Z3rfM9mL+sSP8btyR5vTjJRAqpBYD28/ZLgXPf0pjG1VxOvtCV/BgXkQbpSe8Hw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", - "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/http-cache-semantics": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz", - "integrity": "sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/luxon": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.6.2.tgz", - "integrity": "sha512-R/BdP7OxEMc44l2Ex5lSXHoIXTB2JLNa3y2QISIbr58U/YcsffyQrYW//hZSdrfxrjRZj3GcUoxMPGdO8gSYuw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "22.14.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.0.tgz", - "integrity": "sha512-Kmpl+z84ILoG+3T/zQFyAJsU6EPTmOCj8/2+83fSN6djd6I4o7uOuGIH6vq3PrjY5BGitSbFuMN18j3iknubbA==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/@types/strip-bom": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@types/strip-bom/-/strip-bom-3.0.0.tgz", - "integrity": "sha512-xevGOReSYGM7g/kUBZzPqCrR/KYAo+F0yiPc85WFTJa0MSLtyFTVTU6cJu/aV4mid7IffDIWqo69THF2o4JiEQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/strip-json-comments": { - "version": "0.0.30", - "resolved": "https://registry.npmjs.org/@types/strip-json-comments/-/strip-json-comments-0.0.30.tgz", - "integrity": "sha512-7NQmHra/JILCd1QqpSzl8+mJRc8ZHz3uDm8YV1Ks9IhK0epEiTw8aIErbvH9PI+6XbqhyIQy3462nEsn7UVzjQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/uuid": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", - "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/yauzl": { - "version": "2.10.3", - "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", - "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", - "license": "MIT", - "optional": true, - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@vitest/expect": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.1.1.tgz", - "integrity": "sha512-q/zjrW9lgynctNbwvFtQkGK9+vvHA5UzVi2V8APrp1C6fG6/MuYYkmlx4FubuqLycCeSdHD5aadWfua/Vr0EUA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "3.1.1", - "@vitest/utils": "3.1.1", - "chai": "^5.2.0", - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/mocker": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.1.1.tgz", - "integrity": "sha512-bmpJJm7Y7i9BBELlLuuM1J1Q6EQ6K5Ye4wcyOpOMXMcePYKSIYlpcrCm4l/O6ja4VJA5G2aMJiuZkZdnxlC3SA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "3.1.1", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.17" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^5.0.0 || ^6.0.0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "node_modules/@vitest/mocker/node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/@vitest/pretty-format": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.1.1.tgz", - "integrity": "sha512-dg0CIzNx+hMMYfNmSqJlLSXEmnNhMswcn3sXO7Tpldr0LiGmg3eXdLLhwkv2ZqgHb/d5xg5F7ezNFRA1fA13yA==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.1.1.tgz", - "integrity": "sha512-X/d46qzJuEDO8ueyjtKfxffiXraPRfmYasoC4i5+mlLEJ10UvPb0XH5M9C3gWuxd7BAQhpK42cJgJtq53YnWVA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "3.1.1", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.1.1.tgz", - "integrity": "sha512-bByMwaVWe/+1WDf9exFxWWgAixelSdiwo2p33tpqIlM14vW7PRV5ppayVXtfycqze4Qhtwag5sVhX400MLBOOw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "3.1.1", - "magic-string": "^0.30.17", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/spy": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.1.1.tgz", - "integrity": "sha512-+EmrUOOXbKzLkTDwlsc/xrwOlPDXyVk3Z6P6K4oiCndxz7YLpp/0R0UsWVOKT0IXWjjBJuSMk6D27qipaupcvQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyspy": "^3.0.2" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.1.1.tgz", - "integrity": "sha512-1XIjflyaU2k3HMArJ50bwSh3wKWPD6Q47wz/NUSmRV0zNywPc4w79ARjg/i/aNINHwA+mIALhUVqD9/aUvZNgg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "3.1.1", - "loupe": "^3.1.3", - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/acorn": { - "version": "8.14.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", - "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-walk": { - "version": "8.3.4", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", - "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "acorn": "^8.11.0" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/agent-base": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", - "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/ansi-align": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz", - "integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^4.1.0" - } - }, - "node_modules/ansi-align/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-align/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/ansi-align/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-align/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "license": "ISC", - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/arg": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", - "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", - "dev": true, - "license": "MIT" - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "license": "Python-2.0" - }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/ast-types": { - "version": "0.13.4", - "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", - "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", - "license": "MIT", - "dependencies": { - "tslib": "^2.0.1" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/async": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", - "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", - "license": "MIT" - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "license": "MIT" - }, - "node_modules/axios": { - "version": "1.8.4", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.8.4.tgz", - "integrity": "sha512-eBSYY4Y68NNlHbHBMdeDmKNtDgXWhQsJcGqzO3iLUM0GraQFSS9cVgPX5I9b3lbdFKyYoAEGAZF1DwhTaljNAw==", - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.0", - "proxy-from-env": "^1.1.0" - } - }, - "node_modules/b4a": { - "version": "1.6.7", - "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.7.tgz", - "integrity": "sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg==", - "license": "Apache-2.0" - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/bare-events": { - "version": "2.5.4", - "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.5.4.tgz", - "integrity": "sha512-+gFfDkR8pj4/TrWCGUGWmJIkBwuxPS5F+a5yWjOHQt2hHvNZd5YLzadjmDUtFmMM4y429bnKLa8bYBMHcYdnQA==", - "license": "Apache-2.0", - "optional": true - }, - "node_modules/bare-fs": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.1.2.tgz", - "integrity": "sha512-8wSeOia5B7LwD4+h465y73KOdj5QHsbbuoUfPBi+pXgFJIPuG7SsiOdJuijWMyfid49eD+WivpfY7KT8gbAzBA==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "bare-events": "^2.5.4", - "bare-path": "^3.0.0", - "bare-stream": "^2.6.4" - }, - "engines": { - "bare": ">=1.16.0" - }, - "peerDependencies": { - "bare-buffer": "*" - }, - "peerDependenciesMeta": { - "bare-buffer": { - "optional": true - } - } - }, - "node_modules/bare-os": { - "version": "3.6.1", - "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.1.tgz", - "integrity": "sha512-uaIjxokhFidJP+bmmvKSgiMzj2sV5GPHaZVAIktcxcpCyBFFWO+YlikVAdhmUo2vYFvFhOXIAlldqV29L8126g==", - "license": "Apache-2.0", - "optional": true, - "engines": { - "bare": ">=1.14.0" - } - }, - "node_modules/bare-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", - "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "bare-os": "^3.0.1" - } - }, - "node_modules/bare-stream": { - "version": "2.6.5", - "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.6.5.tgz", - "integrity": "sha512-jSmxKJNJmHySi6hC42zlZnq00rga4jjxcgNZjY9N5WlOe/iOoGRtdwGsHzQv2RlH2KOYMwGUXhf2zXd32BA9RA==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "streamx": "^2.21.0" - }, - "peerDependencies": { - "bare-buffer": "*", - "bare-events": "*" - }, - "peerDependenciesMeta": { - "bare-buffer": { - "optional": true - }, - "bare-events": { - "optional": true - } - } - }, - "node_modules/basic-ftp": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.0.5.tgz", - "integrity": "sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/boxen": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/boxen/-/boxen-7.1.1.tgz", - "integrity": "sha512-2hCgjEmP8YLWQ130n2FerGv7rYpfBmnmp9Uy2Le1vge6X3gZIfSmEzP5QTDElFxcvVcXlEn8Aq6MU/PZygIOog==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-align": "^3.0.1", - "camelcase": "^7.0.1", - "chalk": "^5.2.0", - "cli-boxes": "^3.0.0", - "string-width": "^5.1.2", - "type-fest": "^2.13.0", - "widest-line": "^4.0.1", - "wrap-ansi": "^8.1.0" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/boxen/node_modules/chalk": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", - "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/bson-objectid": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/bson-objectid/-/bson-objectid-2.0.4.tgz", - "integrity": "sha512-vgnKAUzcDoa+AeyYwXCoHyF2q6u/8H46dxu5JN+4/TZeq/Dlinn0K6GvxsCLb3LHUJl0m/TLiEK31kUwtgocMQ==", - "license": "Apache-2.0" - }, - "node_modules/buffer-crc32": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", - "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/cacheable-lookup": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-7.0.0.tgz", - "integrity": "sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.16" - } - }, - "node_modules/cacheable-request": { - "version": "10.2.14", - "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-10.2.14.tgz", - "integrity": "sha512-zkDT5WAF4hSSoUgyfg5tFIxz8XQK+25W/TLVojJTMKBaxevLBBtLxgqguAuVQB8PVW79FVjHcU+GJ9tVbDZ9mQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/http-cache-semantics": "^4.0.2", - "get-stream": "^6.0.1", - "http-cache-semantics": "^4.1.1", - "keyv": "^4.5.3", - "mimic-response": "^4.0.0", - "normalize-url": "^8.0.0", - "responselike": "^3.0.0" - }, - "engines": { - "node": ">=14.16" - } - }, - "node_modules/cacheable-request/node_modules/get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/camelcase": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-7.0.1.tgz", - "integrity": "sha512-xlx1yCK2Oc1APsPXDL2LdlNP6+uu8OCDdhOBSVT279M/S+y75O30C2VuD8T2ogdePBBl7PfPF4504tnLgX3zfw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/chai": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.2.0.tgz", - "integrity": "sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==", - "dev": true, - "license": "MIT", - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/chalk": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.1.2.tgz", - "integrity": "sha512-E5CkT4jWURs1Vy5qGJye+XwCkNj7Od3Af7CP6SujMetSMkLs8Do2RWJK5yx1wamHV/op8Rz+9rltjaTQWDnEFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/check-error": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", - "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - } - }, - "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/chromium-bidi": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-3.0.0.tgz", - "integrity": "sha512-ZOGRDAhBMX1uxL2Cm2TDuhImbrsEz5A/tTcVU6RpXEWaTNUNwsHW6njUXizh51Ir6iqHbKAfhA2XK33uBcLo5A==", - "license": "Apache-2.0", - "dependencies": { - "mitt": "^3.0.1", - "zod": "^3.24.1" - }, - "peerDependencies": { - "devtools-protocol": "*" - } - }, - "node_modules/ci-info": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", - "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/cli-boxes": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", - "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/cliui/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/cliui/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, - "node_modules/cliui/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/commander": { - "version": "9.4.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-9.4.1.tgz", - "integrity": "sha512-5EEkTNyHNGFPD2H+c/dXXfQZYa/scCKasxWcXJaWnNJ99pnQN9Vnmqow+p+PlFPE63Q6mThaZws1T+HxfpgtPw==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.20.0 || >=14" - } - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, - "node_modules/config-chain": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", - "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ini": "^1.3.4", - "proto-list": "~1.2.1" - } - }, - "node_modules/config-chain/node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "dev": true, - "license": "ISC" - }, - "node_modules/configstore": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/configstore/-/configstore-6.0.0.tgz", - "integrity": "sha512-cD31W1v3GqUlQvbBCGcXmd2Nj9SvLDOP1oQ0YFuLETufzSPaKp11rYBsSOm7rCsW3OnIRAFM3OxRhceaXNYHkA==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "dot-prop": "^6.0.1", - "graceful-fs": "^4.2.6", - "unique-string": "^3.0.0", - "write-file-atomic": "^3.0.3", - "xdg-basedir": "^5.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/yeoman/configstore?sponsor=1" - } - }, - "node_modules/cosmiconfig": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", - "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", - "license": "MIT", - "dependencies": { - "env-paths": "^2.2.1", - "import-fresh": "^3.3.0", - "js-yaml": "^4.1.0", - "parse-json": "^5.2.0" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/d-fischer" - }, - "peerDependencies": { - "typescript": ">=4.9.5" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/create-require": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", - "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/crypto-random-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-4.0.0.tgz", - "integrity": "sha512-x8dy3RnvYdlUcPOjkEHqozhiwzKNSq7GcPuXFbnyMOCHxX8V3OgIg/pYuabl2sbUPfIJaeAQB7PMOK8DFIdoRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "type-fest": "^1.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/crypto-random-string/node_modules/type-fest": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz", - "integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/data-uri-to-buffer": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", - "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/debug": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/decompress-response": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-response": "^3.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/decompress-response/node_modules/mimic-response": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/deep-extend": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/defer-to-connect": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", - "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/degenerator": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", - "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", - "license": "MIT", - "dependencies": { - "ast-types": "^0.13.4", - "escodegen": "^2.1.0", - "esprima": "^4.0.1" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/devtools-protocol": { - "version": "0.0.1425554", - "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1425554.tgz", - "integrity": "sha512-uRfxR6Nlzdzt0ihVIkV+sLztKgs7rgquY/Mhcv1YNCWDh5IZgl5mnn2aeEnW5stYTE0wwiF4RYVz8eMEpV1SEw==", - "license": "BSD-3-Clause" - }, - "node_modules/diff": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", - "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.3.1" - } - }, - "node_modules/dot-prop": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-6.0.1.tgz", - "integrity": "sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-obj": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/dynamic-dedupe": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/dynamic-dedupe/-/dynamic-dedupe-0.3.0.tgz", - "integrity": "sha512-ssuANeD+z97meYOqd50e04Ze5qp4bPqo8cCkI4TRjZkzAUgIDTrXV1R8QCdINpiI+hw14+rYazvTRdQrz0/rFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "xtend": "^4.0.0" - } - }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true, - "license": "MIT" - }, - "node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, - "license": "MIT" - }, - "node_modules/end-of-stream": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", - "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", - "license": "MIT", - "dependencies": { - "once": "^1.4.0" - } - }, - "node_modules/engine.io-client": { - "version": "6.5.4", - "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.5.4.tgz", - "integrity": "sha512-GeZeeRjpD2qf49cZQ0Wvh/8NJNfeXkXXcoGh+F77oEAgo9gUHwT1fCRxSNU+YEEaysOJTnsFHmM5oAcPy4ntvQ==", - "license": "MIT", - "dependencies": { - "@socket.io/component-emitter": "~3.1.0", - "debug": "~4.3.1", - "engine.io-parser": "~5.2.1", - "ws": "~8.17.1", - "xmlhttprequest-ssl": "~2.0.0" - } - }, - "node_modules/engine.io-client/node_modules/debug": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", - "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/engine.io-client/node_modules/ws": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", - "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/engine.io-parser": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", - "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/env-paths": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", - "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/error-ex": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", - "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", - "license": "MIT", - "dependencies": { - "is-arrayish": "^0.2.1" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-module-lexer": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.6.0.tgz", - "integrity": "sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/esbuild": { - "version": "0.15.13", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.15.13.tgz", - "integrity": "sha512-Cu3SC84oyzzhrK/YyN4iEVy2jZu5t2fz66HEOShHURcjSkOSAVL8C/gfUT+lDJxkVHpg8GZ10DD0rMHRPqMFaQ==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/android-arm": "0.15.13", - "@esbuild/linux-loong64": "0.15.13", - "esbuild-android-64": "0.15.13", - "esbuild-android-arm64": "0.15.13", - "esbuild-darwin-64": "0.15.13", - "esbuild-darwin-arm64": "0.15.13", - "esbuild-freebsd-64": "0.15.13", - "esbuild-freebsd-arm64": "0.15.13", - "esbuild-linux-32": "0.15.13", - "esbuild-linux-64": "0.15.13", - "esbuild-linux-arm": "0.15.13", - "esbuild-linux-arm64": "0.15.13", - "esbuild-linux-mips64le": "0.15.13", - "esbuild-linux-ppc64le": "0.15.13", - "esbuild-linux-riscv64": "0.15.13", - "esbuild-linux-s390x": "0.15.13", - "esbuild-netbsd-64": "0.15.13", - "esbuild-openbsd-64": "0.15.13", - "esbuild-sunos-64": "0.15.13", - "esbuild-windows-32": "0.15.13", - "esbuild-windows-64": "0.15.13", - "esbuild-windows-arm64": "0.15.13" - } - }, - "node_modules/esbuild-android-64": { - "version": "0.15.13", - "resolved": "https://registry.npmjs.org/esbuild-android-64/-/esbuild-android-64-0.15.13.tgz", - "integrity": "sha512-yRorukXBlokwTip+Sy4MYskLhJsO0Kn0/Fj43s1krVblfwP+hMD37a4Wmg139GEsMLl+vh8WXp2mq/cTA9J97g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-android-arm64": { - "version": "0.15.13", - "resolved": "https://registry.npmjs.org/esbuild-android-arm64/-/esbuild-android-arm64-0.15.13.tgz", - "integrity": "sha512-TKzyymLD6PiVeyYa4c5wdPw87BeAiTXNtK6amWUcXZxkV51gOk5u5qzmDaYSwiWeecSNHamFsaFjLoi32QR5/w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-darwin-64": { - "version": "0.15.13", - "resolved": "https://registry.npmjs.org/esbuild-darwin-64/-/esbuild-darwin-64-0.15.13.tgz", - "integrity": "sha512-WAx7c2DaOS6CrRcoYCgXgkXDliLnFv3pQLV6GeW1YcGEZq2Gnl8s9Pg7ahValZkpOa0iE/ojRVQ87sbUhF1Cbg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-darwin-arm64": { - "version": "0.15.13", - "resolved": "https://registry.npmjs.org/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.15.13.tgz", - "integrity": "sha512-U6jFsPfSSxC3V1CLiQqwvDuj3GGrtQNB3P3nNC3+q99EKf94UGpsG9l4CQ83zBs1NHrk1rtCSYT0+KfK5LsD8A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-freebsd-64": { - "version": "0.15.13", - "resolved": "https://registry.npmjs.org/esbuild-freebsd-64/-/esbuild-freebsd-64-0.15.13.tgz", - "integrity": "sha512-whItJgDiOXaDG/idy75qqevIpZjnReZkMGCgQaBWZuKHoElDJC1rh7MpoUgupMcdfOd+PgdEwNQW9DAE6i8wyA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-freebsd-arm64": { - "version": "0.15.13", - "resolved": "https://registry.npmjs.org/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.15.13.tgz", - "integrity": "sha512-6pCSWt8mLUbPtygv7cufV0sZLeylaMwS5Fznj6Rsx9G2AJJsAjQ9ifA+0rQEIg7DwJmi9it+WjzNTEAzzdoM3Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-linux-32": { - "version": "0.15.13", - "resolved": "https://registry.npmjs.org/esbuild-linux-32/-/esbuild-linux-32-0.15.13.tgz", - "integrity": "sha512-VbZdWOEdrJiYApm2kkxoTOgsoCO1krBZ3quHdYk3g3ivWaMwNIVPIfEE0f0XQQ0u5pJtBsnk2/7OPiCFIPOe/w==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-linux-64": { - "version": "0.15.13", - "resolved": "https://registry.npmjs.org/esbuild-linux-64/-/esbuild-linux-64-0.15.13.tgz", - "integrity": "sha512-rXmnArVNio6yANSqDQlIO4WiP+Cv7+9EuAHNnag7rByAqFVuRusLbGi2697A5dFPNXoO//IiogVwi3AdcfPC6A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-linux-arm": { - "version": "0.15.13", - "resolved": "https://registry.npmjs.org/esbuild-linux-arm/-/esbuild-linux-arm-0.15.13.tgz", - "integrity": "sha512-Ac6LpfmJO8WhCMQmO253xX2IU2B3wPDbl4IvR0hnqcPrdfCaUa2j/lLMGTjmQ4W5JsJIdHEdW12dG8lFS0MbxQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-linux-arm64": { - "version": "0.15.13", - "resolved": "https://registry.npmjs.org/esbuild-linux-arm64/-/esbuild-linux-arm64-0.15.13.tgz", - "integrity": "sha512-alEMGU4Z+d17U7KQQw2IV8tQycO6T+rOrgW8OS22Ua25x6kHxoG6Ngry6Aq6uranC+pNWNMB6aHFPh7aTQdORQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-linux-mips64le": { - "version": "0.15.13", - "resolved": "https://registry.npmjs.org/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.15.13.tgz", - "integrity": "sha512-47PgmyYEu+yN5rD/MbwS6DxP2FSGPo4Uxg5LwIdxTiyGC2XKwHhHyW7YYEDlSuXLQXEdTO7mYe8zQ74czP7W8A==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-linux-ppc64le": { - "version": "0.15.13", - "resolved": "https://registry.npmjs.org/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.15.13.tgz", - "integrity": "sha512-z6n28h2+PC1Ayle9DjKoBRcx/4cxHoOa2e689e2aDJSaKug3jXcQw7mM+GLg+9ydYoNzj8QxNL8ihOv/OnezhA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-linux-riscv64": { - "version": "0.15.13", - "resolved": "https://registry.npmjs.org/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.15.13.tgz", - "integrity": "sha512-+Lu4zuuXuQhgLUGyZloWCqTslcCAjMZH1k3Xc9MSEJEpEFdpsSU0sRDXAnk18FKOfEjhu4YMGaykx9xjtpA6ow==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-linux-s390x": { - "version": "0.15.13", - "resolved": "https://registry.npmjs.org/esbuild-linux-s390x/-/esbuild-linux-s390x-0.15.13.tgz", - "integrity": "sha512-BMeXRljruf7J0TMxD5CIXS65y7puiZkAh+s4XFV9qy16SxOuMhxhVIXYLnbdfLrsYGFzx7U9mcdpFWkkvy/Uag==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-netbsd-64": { - "version": "0.15.13", - "resolved": "https://registry.npmjs.org/esbuild-netbsd-64/-/esbuild-netbsd-64-0.15.13.tgz", - "integrity": "sha512-EHj9QZOTel581JPj7UO3xYbltFTYnHy+SIqJVq6yd3KkCrsHRbapiPb0Lx3EOOtybBEE9EyqbmfW1NlSDsSzvQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-openbsd-64": { - "version": "0.15.13", - "resolved": "https://registry.npmjs.org/esbuild-openbsd-64/-/esbuild-openbsd-64-0.15.13.tgz", - "integrity": "sha512-nkuDlIjF/sfUhfx8SKq0+U+Fgx5K9JcPq1mUodnxI0x4kBdCv46rOGWbuJ6eof2n3wdoCLccOoJAbg9ba/bT2w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-sunos-64": { - "version": "0.15.13", - "resolved": "https://registry.npmjs.org/esbuild-sunos-64/-/esbuild-sunos-64-0.15.13.tgz", - "integrity": "sha512-jVeu2GfxZQ++6lRdY43CS0Tm/r4WuQQ0Pdsrxbw+aOrHQPHV0+LNOLnvbN28M7BSUGnJnHkHm2HozGgNGyeIRw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-windows-32": { - "version": "0.15.13", - "resolved": "https://registry.npmjs.org/esbuild-windows-32/-/esbuild-windows-32-0.15.13.tgz", - "integrity": "sha512-XoF2iBf0wnqo16SDq+aDGi/+QbaLFpkiRarPVssMh9KYbFNCqPLlGAWwDvxEVz+ywX6Si37J2AKm+AXq1kC0JA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-windows-64": { - "version": "0.15.13", - "resolved": "https://registry.npmjs.org/esbuild-windows-64/-/esbuild-windows-64-0.15.13.tgz", - "integrity": "sha512-Et6htEfGycjDrtqb2ng6nT+baesZPYQIW+HUEHK4D1ncggNrDNk3yoboYQ5KtiVrw/JaDMNttz8rrPubV/fvPQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-windows-arm64": { - "version": "0.15.13", - "resolved": "https://registry.npmjs.org/esbuild-windows-arm64/-/esbuild-windows-arm64-0.15.13.tgz", - "integrity": "sha512-3bv7tqntThQC9SWLRouMDmZnlOukBhOCTlkzNqzGCmrkCJI7io5LLjwJBOVY6kOUlIvdxbooNZwjtBvj+7uuVg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/escape-goat": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-goat/-/escape-goat-4.0.0.tgz", - "integrity": "sha512-2Sd4ShcWxbx6OY1IHyla/CVNwvg7XwZVoXZHcSu9w9SReNP1EzzD5T8NWKIR38fIqEns9kDWKUQTXXAmlDrdPg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/escodegen": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", - "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", - "license": "BSD-2-Clause", - "dependencies": { - "esprima": "^4.0.1", - "estraverse": "^5.2.0", - "esutils": "^2.0.2" - }, - "bin": { - "escodegen": "bin/escodegen.js", - "esgenerate": "bin/esgenerate.js" - }, - "engines": { - "node": ">=6.0" - }, - "optionalDependencies": { - "source-map": "~0.6.1" - } - }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "dev": true, - "license": "MIT" - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/eventsource": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", - "integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/expect-type": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.1.tgz", - "integrity": "sha512-/kP8CAwxzLVEeFrMm4kMmy4CCDlpipyA7MYLVrdJIkV0fYF0UaigQHRsxHiuY/GEea+bh4KSv3TIlgr+2UL6bw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/extract-zip": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", - "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", - "license": "BSD-2-Clause", - "dependencies": { - "debug": "^4.1.1", - "get-stream": "^5.1.0", - "yauzl": "^2.10.0" - }, - "bin": { - "extract-zip": "cli.js" - }, - "engines": { - "node": ">= 10.17.0" - }, - "optionalDependencies": { - "@types/yauzl": "^2.9.1" - } - }, - "node_modules/fast-fifo": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", - "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", - "license": "MIT" - }, - "node_modules/fd-slicer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", - "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", - "license": "MIT", - "dependencies": { - "pend": "~1.2.0" - } - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/follow-redirects": { - "version": "1.15.9", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", - "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, - "node_modules/form-data": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", - "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/form-data-encoder": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-2.1.4.tgz", - "integrity": "sha512-yDYSgNMraqvnxiEXO4hi88+YZxaHC6QKzb5N84iRCTDeRO7ZALpir/lVmf/uXUhnwUr2O4HU8s/n6x+yNjQkHw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.17" - } - }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true, - "license": "ISC" - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/geolib": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/geolib/-/geolib-3.3.4.tgz", - "integrity": "sha512-EicrlLLL3S42gE9/wde+11uiaYAaeSVDwCUIv2uMIoRBfNJCn8EsSI+6nS3r4TCKDO6+RQNM9ayLq2at+oZQWQ==", - "license": "MIT" - }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "license": "ISC", - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/get-stream": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", - "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", - "license": "MIT", - "dependencies": { - "pump": "^3.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/get-uri": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.4.tgz", - "integrity": "sha512-E1b1lFFLvLgak2whF2xDBcOy6NLVGZBqqjJjsIhvopKfWWEi64pLVTWWehV8KlLerZkfNTA95sTe2OdJKm1OzQ==", - "license": "MIT", - "dependencies": { - "basic-ftp": "^5.0.2", - "data-uri-to-buffer": "^6.0.2", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/global-dirs": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-3.0.1.tgz", - "integrity": "sha512-NBcGGFbBA9s1VzD41QXDG+3++t9Mn5t1FpLdhESY6oKY4gYTFpX4wO3sqGUa0Srjtbfj3szX0RnemmrVRUdULA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ini": "2.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/got": { - "version": "12.6.1", - "resolved": "https://registry.npmjs.org/got/-/got-12.6.1.tgz", - "integrity": "sha512-mThBblvlAF1d4O5oqyvN+ZxLAYwIJK7bpMxgYqPD9okW0C3qm5FFn7k811QrcuEBwaogR3ngOFoCfs6mRv7teQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sindresorhus/is": "^5.2.0", - "@szmarczak/http-timer": "^5.0.1", - "cacheable-lookup": "^7.0.0", - "cacheable-request": "^10.2.8", - "decompress-response": "^6.0.0", - "form-data-encoder": "^2.1.2", - "get-stream": "^6.0.1", - "http2-wrapper": "^2.1.10", - "lowercase-keys": "^3.0.0", - "p-cancelable": "^3.0.0", - "responselike": "^3.0.0" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sindresorhus/got?sponsor=1" - } - }, - "node_modules/got/node_modules/get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-yarn": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-yarn/-/has-yarn-3.0.0.tgz", - "integrity": "sha512-IrsVwUHhEULx3R8f/aA8AHuEzAorplsab/v8HBzEiIukwq5i/EC+xmOW+HfP1OaDP+2JkgT1yILHN2O3UFIbcA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/http-cache-semantics": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", - "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==", - "dev": true, - "license": "BSD-2-Clause" - }, - "node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/http2-wrapper": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-2.2.1.tgz", - "integrity": "sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "quick-lru": "^5.1.1", - "resolve-alpn": "^1.2.0" - }, - "engines": { - "node": ">=10.19.0" - } - }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/husky": { - "version": "9.1.7", - "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", - "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", - "dev": true, - "license": "MIT", - "bin": { - "husky": "bin.js" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/typicode" - } - }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/import-lazy": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/import-lazy/-/import-lazy-4.0.0.tgz", - "integrity": "sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, - "license": "ISC", - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/ini": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ini/-/ini-2.0.0.tgz", - "integrity": "sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/ip-address": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz", - "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==", - "license": "MIT", - "dependencies": { - "jsbn": "1.1.0", - "sprintf-js": "^1.1.3" - }, - "engines": { - "node": ">= 12" - } - }, - "node_modules/is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "license": "MIT" - }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, - "license": "MIT", - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-ci": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-3.0.1.tgz", - "integrity": "sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ci-info": "^3.2.0" - }, - "bin": { - "is-ci": "bin.js" - } - }, - "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", - "dev": true, - "license": "MIT", - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-installed-globally": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-0.4.0.tgz", - "integrity": "sha512-iwGqO3J21aaSkC7jWnHP/difazwS7SFeIqxv6wEtLU8Y5KlzFTjyqcSIT0d8s4+dDhKytsk9PJZ2BkS5eZwQRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "global-dirs": "^3.0.0", - "is-path-inside": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-npm": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/is-npm/-/is-npm-6.0.0.tgz", - "integrity": "sha512-JEjxbSmtPSt1c8XTkVrlujcXdKV1/tvuQ7GwKcAlyiVLeYFQ2VHat8xfrDJsIkhCdF/tZ7CiIR3sy141c6+gPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-obj": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", - "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-path-inside": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", - "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-typedarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", - "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", - "dev": true, - "license": "MIT" - }, - "node_modules/is-yarn-global": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/is-yarn-global/-/is-yarn-global-0.4.1.tgz", - "integrity": "sha512-/kppl+R+LO5VmhYSEWARUFjodS25D68gvj8W7z0I7OWhUla5xWu8KL6CtB2V0R6yqhnRgbcaREMr4EEM6htLPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "license": "MIT" - }, - "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/jsbn": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", - "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==", - "license": "MIT" - }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "license": "MIT" - }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" - } - }, - "node_modules/latest-version": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/latest-version/-/latest-version-7.0.0.tgz", - "integrity": "sha512-KvNT4XqAMzdcL6ka6Tl3i2lYeFDgXNCuIX+xNx6ZMVR1dFq+idXd9FLKNMOIx0t9mJ9/HudyX4oZWXZQ0UJHeg==", - "dev": true, - "license": "MIT", - "dependencies": { - "package-json": "^8.1.0" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "license": "MIT" - }, - "node_modules/load-tsconfig": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.5.tgz", - "integrity": "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - } - }, - "node_modules/loupe": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.3.tgz", - "integrity": "sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==", - "dev": true, - "license": "MIT" - }, - "node_modules/lowercase-keys": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-3.0.0.tgz", - "integrity": "sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lru-cache": { - "version": "7.18.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", - "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/luxon": { - "version": "3.6.1", - "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.6.1.tgz", - "integrity": "sha512-tJLxrKJhO2ukZ5z0gyjY1zPh3Rh88Ej9P7jNrZiHMUXHae1yvI2imgOZtL1TO8TW6biMMKfTtAOoEJANgtWBMQ==", - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/magic-string": { - "version": "0.30.17", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", - "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0" - } - }, - "node_modules/make-error": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", - "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "dev": true, - "license": "ISC" - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mimic-response": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-4.0.0.tgz", - "integrity": "sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/mitt": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", - "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", - "license": "MIT" - }, - "node_modules/mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", - "dev": true, - "license": "MIT", - "bin": { - "mkdirp": "bin/cmd.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", - "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/netmask": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz", - "integrity": "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==", - "license": "MIT", - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/normalize-url": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-8.0.1.tgz", - "integrity": "sha512-IO9QvjUMWxPQQhs60oOu10CRkWCiZzSUkzbXGGV9pviYl1fXYcvkzQ5jV9z8Y6un8ARoVRl4EtC6v6jNqbaJ/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/p-cancelable": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-3.0.0.tgz", - "integrity": "sha512-mlVgR3PGuzlo0MmTdk4cXqXWlwQDLnONTAg6sm62XkMJEiRxN3GL3SffkYvqwonbkJBcrI7Uvv5Zh9yjvn2iUw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.20" - } - }, - "node_modules/pac-proxy-agent": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", - "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==", - "license": "MIT", - "dependencies": { - "@tootallnate/quickjs-emscripten": "^0.23.0", - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "get-uri": "^6.0.1", - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.6", - "pac-resolver": "^7.0.1", - "socks-proxy-agent": "^8.0.5" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/pac-resolver": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", - "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", - "license": "MIT", - "dependencies": { - "degenerator": "^5.0.0", - "netmask": "^2.0.2" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/package-json": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/package-json/-/package-json-8.1.1.tgz", - "integrity": "sha512-cbH9IAIJHNj9uXi196JVsRlt7cHKak6u/e6AkL/bkRelZ7rlL3X1YKxsZwa36xipOEKAsdtmaG6aAJoM1fx2zA==", - "dev": true, - "license": "MIT", - "dependencies": { - "got": "^12.1.0", - "registry-auth-token": "^5.0.1", - "registry-url": "^6.0.0", - "semver": "^7.3.7" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/papaparse": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.4.1.tgz", - "integrity": "sha512-HipMsgJkZu8br23pW15uvo6sib6wne/4woLZPlFf3rpDyMe9ywEXUsuD7+6K9PRkJlVT51j/sCOYDKGGS3ZJrw==", - "license": "MIT" - }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/parse-json": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.0.0", - "error-ex": "^1.3.1", - "json-parse-even-better-errors": "^2.3.0", - "lines-and-columns": "^1.1.6" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true, - "license": "MIT" - }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, - "node_modules/pathval": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.0.tgz", - "integrity": "sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.16" - } - }, - "node_modules/pend": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", - "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", - "license": "MIT" - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/postcss": { - "version": "8.5.3", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", - "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.8", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/postcss/node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/prettier": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz", - "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==", - "dev": true, - "license": "MIT", - "bin": { - "prettier": "bin/prettier.cjs" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } - }, - "node_modules/progress": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", - "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/proto-list": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", - "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", - "dev": true, - "license": "ISC" - }, - "node_modules/proxy-agent": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", - "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "http-proxy-agent": "^7.0.1", - "https-proxy-agent": "^7.0.6", - "lru-cache": "^7.14.1", - "pac-proxy-agent": "^7.1.0", - "proxy-from-env": "^1.1.0", - "socks-proxy-agent": "^8.0.5" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" - }, - "node_modules/pump": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz", - "integrity": "sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==", - "license": "MIT", - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, - "node_modules/pupa": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/pupa/-/pupa-3.1.0.tgz", - "integrity": "sha512-FLpr4flz5xZTSJxSeaheeMKN/EDzMdK7b8PTOC6a5PYFKTucWbdqjgqaEyH0shFiSJrVB1+Qqi4Tk19ccU6Aug==", - "dev": true, - "license": "MIT", - "dependencies": { - "escape-goat": "^4.0.0" - }, - "engines": { - "node": ">=12.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/puppeteer": { - "version": "24.6.0", - "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.6.0.tgz", - "integrity": "sha512-wYTB8WkzAr7acrlsp+0at1PZjOJPOxe6dDWKOG/kaX4Zjck9RXCFx3CtsxsAGzPn/Yv6AzgJC/CW1P5l+qxsqw==", - "hasInstallScript": true, - "license": "Apache-2.0", - "dependencies": { - "@puppeteer/browsers": "2.9.0", - "chromium-bidi": "3.0.0", - "cosmiconfig": "^9.0.0", - "devtools-protocol": "0.0.1425554", - "puppeteer-core": "24.6.0", - "typed-query-selector": "^2.12.0" - }, - "bin": { - "puppeteer": "lib/cjs/puppeteer/node/cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/puppeteer-core": { - "version": "24.6.0", - "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.6.0.tgz", - "integrity": "sha512-Cukxysy12m0v350bhl/Gzof0XQYmtON9l2VvGp3D4BOQZVgyf+y5wIpcjDZQ/896Okoi95dKRGRV8E6a7SYAQQ==", - "license": "Apache-2.0", - "dependencies": { - "@puppeteer/browsers": "2.9.0", - "chromium-bidi": "3.0.0", - "debug": "^4.4.0", - "devtools-protocol": "0.0.1425554", - "typed-query-selector": "^2.12.0", - "ws": "^8.18.1" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/qs": { - "version": "6.12.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.12.0.tgz", - "integrity": "sha512-trVZiI6RMOkO476zLGaBIzszOdFPnCCXHPG9kn0yuS1uz6xdVxPfZdB3vUig9pxPFDM9BRAgz/YUIVQ1/vuiUg==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.0.6" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/quick-lru": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", - "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/rc": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", - "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", - "dev": true, - "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", - "dependencies": { - "deep-extend": "^0.6.0", - "ini": "~1.3.0", - "minimist": "^1.2.0", - "strip-json-comments": "~2.0.1" - }, - "bin": { - "rc": "cli.js" - } - }, - "node_modules/rc/node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "dev": true, - "license": "ISC" - }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/registry-auth-token": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-5.1.0.tgz", - "integrity": "sha512-GdekYuwLXLxMuFTwAPg5UKGLW/UXzQrZvH/Zj791BQif5T05T0RsaLfHc9q3ZOKi7n+BoprPD9mJ0O0k4xzUlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@pnpm/npm-conf": "^2.1.0" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/registry-url": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/registry-url/-/registry-url-6.0.1.tgz", - "integrity": "sha512-+crtS5QjFRqFCoQmvGduwYWEBng99ZvmFvF+cUJkGYF1L1BfU8C6Zp9T7f5vPAwyLkUExpvK+ANVZmGU49qi4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "rc": "1.2.8" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/resolve": { - "version": "1.22.10", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", - "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-core-module": "^2.16.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/resolve-alpn": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", - "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", - "dev": true, - "license": "MIT" - }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/responselike": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/responselike/-/responselike-3.0.0.tgz", - "integrity": "sha512-40yHxbNcl2+rzXvZuVkrYohathsSJlMTXKryG5y8uciHv1+xDLHQpgjG64JUO9nrEq2jGLH6IZ8BcZyw3wrweg==", - "dev": true, - "license": "MIT", - "dependencies": { - "lowercase-keys": "^3.0.0" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/rimraf": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", - "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "dev": true, - "license": "ISC", - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - } - }, - "node_modules/rollup": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.39.0.tgz", - "integrity": "sha512-thI8kNc02yNvnmJp8dr3fNWJ9tCONDhp6TV35X6HkKGGs9E6q7YWCHbe5vKiTa7TAiNcFEmXKj3X/pG2b3ci0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.7" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.39.0", - "@rollup/rollup-android-arm64": "4.39.0", - "@rollup/rollup-darwin-arm64": "4.39.0", - "@rollup/rollup-darwin-x64": "4.39.0", - "@rollup/rollup-freebsd-arm64": "4.39.0", - "@rollup/rollup-freebsd-x64": "4.39.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.39.0", - "@rollup/rollup-linux-arm-musleabihf": "4.39.0", - "@rollup/rollup-linux-arm64-gnu": "4.39.0", - "@rollup/rollup-linux-arm64-musl": "4.39.0", - "@rollup/rollup-linux-loongarch64-gnu": "4.39.0", - "@rollup/rollup-linux-powerpc64le-gnu": "4.39.0", - "@rollup/rollup-linux-riscv64-gnu": "4.39.0", - "@rollup/rollup-linux-riscv64-musl": "4.39.0", - "@rollup/rollup-linux-s390x-gnu": "4.39.0", - "@rollup/rollup-linux-x64-gnu": "4.39.0", - "@rollup/rollup-linux-x64-musl": "4.39.0", - "@rollup/rollup-win32-arm64-msvc": "4.39.0", - "@rollup/rollup-win32-ia32-msvc": "4.39.0", - "@rollup/rollup-win32-x64-msvc": "4.39.0", - "fsevents": "~2.3.2" - } - }, - "node_modules/semver": { - "version": "7.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", - "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/semver-diff": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/semver-diff/-/semver-diff-4.0.0.tgz", - "integrity": "sha512-0Ju4+6A8iOnpL/Thra7dZsSlOHYAHIeMxfhWQRI1/VLcT3WDBZKKtQt/QkBOsiIN9ZpuvHE6cGZ0x4glCMmfiA==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, - "node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/smart-buffer": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", - "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", - "license": "MIT", - "engines": { - "node": ">= 6.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socket.io-client": { - "version": "4.7.5", - "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.7.5.tgz", - "integrity": "sha512-sJ/tqHOCe7Z50JCBCXrsY3I2k03iOiUe+tj1OmKeD2lXPiGH/RUCdTZFoqVyN7l1MnpIzPrGtLcijffmeouNlQ==", - "license": "MIT", - "dependencies": { - "@socket.io/component-emitter": "~3.1.0", - "debug": "~4.3.2", - "engine.io-client": "~6.5.2", - "socket.io-parser": "~4.2.4" - }, - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/socket.io-client/node_modules/debug": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", - "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/socket.io-parser": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", - "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==", - "license": "MIT", - "dependencies": { - "@socket.io/component-emitter": "~3.1.0", - "debug": "~4.3.1" - }, - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/socket.io-parser/node_modules/debug": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", - "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/socks": { - "version": "2.8.4", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.4.tgz", - "integrity": "sha512-D3YaD0aRxR3mEcqnidIs7ReYJFVzWdd6fXJYUM8ixcQcJRGTka/b3saV0KflYhyVJXKhb947GndU35SxYNResQ==", - "license": "MIT", - "dependencies": { - "ip-address": "^9.0.5", - "smart-buffer": "^4.2.0" - }, - "engines": { - "node": ">= 10.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks-proxy-agent": { - "version": "8.0.5", - "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", - "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "socks": "^2.8.3" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "devOptional": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, - "node_modules/sprintf-js": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", - "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", - "license": "BSD-3-Clause" - }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, - "node_modules/std-env": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz", - "integrity": "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==", - "dev": true, - "license": "MIT" - }, - "node_modules/streamx": { - "version": "2.22.0", - "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.22.0.tgz", - "integrity": "sha512-sLh1evHOzBy/iWRiR6d1zRcLao4gGZr3C1kzNz4fopCOKJb6xD9ub8Mpi9Mr1R6id5o43S+d93fI48UC5uM9aw==", - "license": "MIT", - "dependencies": { - "fast-fifo": "^1.3.2", - "text-decoder": "^1.1.0" - }, - "optionalDependencies": { - "bare-events": "^2.2.0" - } - }, - "node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/strip-bom": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", - "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/strip-json-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/tar-fs": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.8.tgz", - "integrity": "sha512-ZoROL70jptorGAlgAYiLoBLItEKw/fUxg9BSYK/dF/GAGYFJOJJJMvjPAKDJraCXFwadD456FCuvLWgfhMsPwg==", - "license": "MIT", - "dependencies": { - "pump": "^3.0.0", - "tar-stream": "^3.1.5" - }, - "optionalDependencies": { - "bare-fs": "^4.0.1", - "bare-path": "^3.0.0" - } - }, - "node_modules/tar-stream": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", - "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", - "license": "MIT", - "dependencies": { - "b4a": "^1.6.4", - "fast-fifo": "^1.2.0", - "streamx": "^2.15.0" - } - }, - "node_modules/text-decoder": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz", - "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==", - "license": "Apache-2.0", - "dependencies": { - "b4a": "^1.6.4" - } - }, - "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinypool": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.0.2.tgz", - "integrity": "sha512-al6n+QEANGFOMf/dmUMsuS5/r9B06uwlyNjZZql/zv8J7ybHCgoihBNORZCY2mzUuAnomQa2JdhyHKzZxPCrFA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, - "node_modules/tinyrainbow": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", - "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tinyspy": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", - "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/tree-kill": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", - "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", - "dev": true, - "license": "MIT", - "bin": { - "tree-kill": "cli.js" - } - }, - "node_modules/ts-node": { - "version": "10.9.2", - "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", - "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@cspotcode/source-map-support": "^0.8.0", - "@tsconfig/node10": "^1.0.7", - "@tsconfig/node12": "^1.0.7", - "@tsconfig/node14": "^1.0.0", - "@tsconfig/node16": "^1.0.2", - "acorn": "^8.4.1", - "acorn-walk": "^8.1.1", - "arg": "^4.1.0", - "create-require": "^1.1.0", - "diff": "^4.0.1", - "make-error": "^1.1.1", - "v8-compile-cache-lib": "^3.0.1", - "yn": "3.1.1" - }, - "bin": { - "ts-node": "dist/bin.js", - "ts-node-cwd": "dist/bin-cwd.js", - "ts-node-esm": "dist/bin-esm.js", - "ts-node-script": "dist/bin-script.js", - "ts-node-transpile-only": "dist/bin-transpile.js", - "ts-script": "dist/bin-script-deprecated.js" - }, - "peerDependencies": { - "@swc/core": ">=1.2.50", - "@swc/wasm": ">=1.2.50", - "@types/node": "*", - "typescript": ">=2.7" - }, - "peerDependenciesMeta": { - "@swc/core": { - "optional": true - }, - "@swc/wasm": { - "optional": true - } - } - }, - "node_modules/ts-node-dev": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ts-node-dev/-/ts-node-dev-2.0.0.tgz", - "integrity": "sha512-ywMrhCfH6M75yftYvrvNarLEY+SUXtUvU8/0Z6llrHQVBx12GiFk5sStF8UdfE/yfzk9IAq7O5EEbTQsxlBI8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "chokidar": "^3.5.1", - "dynamic-dedupe": "^0.3.0", - "minimist": "^1.2.6", - "mkdirp": "^1.0.4", - "resolve": "^1.0.0", - "rimraf": "^2.6.1", - "source-map-support": "^0.5.12", - "tree-kill": "^1.2.2", - "ts-node": "^10.4.0", - "tsconfig": "^7.0.0" - }, - "bin": { - "ts-node-dev": "lib/bin.js", - "tsnd": "lib/bin.js" - }, - "engines": { - "node": ">=0.8.0" - }, - "peerDependencies": { - "node-notifier": "*", - "typescript": "*" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } - } - }, - "node_modules/tsconfig": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/tsconfig/-/tsconfig-7.0.0.tgz", - "integrity": "sha512-vZXmzPrL+EmC4T/4rVlT2jNVMWCi/O4DIiSj3UHg1OE5kCKbk4mfrXc6dZksLgRM/TZlKnousKH9bbTazUWRRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/strip-bom": "^3.0.0", - "@types/strip-json-comments": "0.0.30", - "strip-bom": "^3.0.0", - "strip-json-comments": "^2.0.0" - } - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" - }, - "node_modules/type-fest": { - "version": "2.19.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", - "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=12.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/typed-query-selector": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.0.tgz", - "integrity": "sha512-SbklCd1F0EiZOyPiW192rrHZzZ5sBijB6xM+cpmrwDqObvdtunOHHIk9fCGsoK5JVIYXoyEp4iEdE3upFH3PAg==", - "license": "MIT" - }, - "node_modules/typedarray-to-buffer": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", - "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-typedarray": "^1.0.0" - } - }, - "node_modules/typescript": { - "version": "5.8.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz", - "integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==", - "devOptional": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/unique-string": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-3.0.0.tgz", - "integrity": "sha512-VGXBUVwxKMBUznyffQweQABPRRW1vHZAbadFZud4pLFAqRGvv/96vafgjWFqzourzr8YonlQiPgH0YCJfawoGQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "crypto-random-string": "^4.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/unplugin": { - "version": "1.16.1", - "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-1.16.1.tgz", - "integrity": "sha512-4/u/j4FrCKdi17jaxuJA0jClGxB1AvU2hw/IuayPc4ay1XGaJs/rbb4v5WKwAjNifjmXK9PIFyuPiaK8azyR9w==", - "dev": true, - "license": "MIT", - "dependencies": { - "acorn": "^8.14.0", - "webpack-virtual-modules": "^0.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/unplugin-swc": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/unplugin-swc/-/unplugin-swc-1.5.1.tgz", - "integrity": "sha512-/ZLrPNjChhGx3Z95pxJ4tQgfI6rWqukgYHKflrNB4zAV1izOQuDhkTn55JWeivpBxDCoK7M/TStb2aS/14PS/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rollup/pluginutils": "^5.1.0", - "load-tsconfig": "^0.2.5", - "unplugin": "^1.11.0" - }, - "peerDependencies": { - "@swc/core": "^1.2.108" - } - }, - "node_modules/update-notifier": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/update-notifier/-/update-notifier-6.0.2.tgz", - "integrity": "sha512-EDxhTEVPZZRLWYcJ4ZXjGFN0oP7qYvbXWzEgRm/Yql4dHX5wDbvh89YHP6PK1lzZJYrMtXUuZZz8XGK+U6U1og==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "boxen": "^7.0.0", - "chalk": "^5.0.1", - "configstore": "^6.0.0", - "has-yarn": "^3.0.0", - "import-lazy": "^4.0.0", - "is-ci": "^3.0.1", - "is-installed-globally": "^0.4.0", - "is-npm": "^6.0.0", - "is-yarn-global": "^0.4.0", - "latest-version": "^7.0.0", - "pupa": "^3.1.0", - "semver": "^7.3.7", - "semver-diff": "^4.0.0", - "xdg-basedir": "^5.1.0" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/yeoman/update-notifier?sponsor=1" - } - }, - "node_modules/uuid": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", - "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", - "dev": true, - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/esm/bin/uuid" - } - }, - "node_modules/v8-compile-cache-lib": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", - "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", - "dev": true, - "license": "MIT" - }, - "node_modules/vite": { - "version": "6.2.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.2.5.tgz", - "integrity": "sha512-j023J/hCAa4pRIUH6J9HemwYfjB5llR2Ps0CWeikOtdR8+pAURAk0DoJC5/mm9kd+UgdnIy7d6HE4EAvlYhPhA==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.25.0", - "postcss": "^8.5.3", - "rollup": "^4.30.1" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "jiti": ">=1.21.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/vite-node": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.1.1.tgz", - "integrity": "sha512-V+IxPAE2FvXpTCHXyNem0M+gWm6J7eRyWPR6vYoG/Gl+IscNOjXzztUhimQgTxaAoUoj40Qqimaa0NLIOOAH4w==", - "dev": true, - "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.4.0", - "es-module-lexer": "^1.6.0", - "pathe": "^2.0.3", - "vite": "^5.0.0 || ^6.0.0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/vite/node_modules/@esbuild/android-arm": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.2.tgz", - "integrity": "sha512-NQhH7jFstVY5x8CKbcfa166GoV0EFkaPkCKBQkdPJFvo5u+nGXLEH/ooniLb3QI8Fk58YAx7nsPLozUWfCBOJA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-loong64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.2.tgz", - "integrity": "sha512-SHNGiKtvnU2dBlM5D8CXRFdd+6etgZ9dXfaPCeJtz+37PIUlixvlIhI23L5khKXs3DIzAn9V8v+qb1TRKrgT5w==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/esbuild": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.2.tgz", - "integrity": "sha512-16854zccKPnC+toMywC+uKNeYSv+/eXkevRAfwRD/G9Cleq66m8XFIrigkbvauLLlCfDL45Q2cWegSg53gGBnQ==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.2", - "@esbuild/android-arm": "0.25.2", - "@esbuild/android-arm64": "0.25.2", - "@esbuild/android-x64": "0.25.2", - "@esbuild/darwin-arm64": "0.25.2", - "@esbuild/darwin-x64": "0.25.2", - "@esbuild/freebsd-arm64": "0.25.2", - "@esbuild/freebsd-x64": "0.25.2", - "@esbuild/linux-arm": "0.25.2", - "@esbuild/linux-arm64": "0.25.2", - "@esbuild/linux-ia32": "0.25.2", - "@esbuild/linux-loong64": "0.25.2", - "@esbuild/linux-mips64el": "0.25.2", - "@esbuild/linux-ppc64": "0.25.2", - "@esbuild/linux-riscv64": "0.25.2", - "@esbuild/linux-s390x": "0.25.2", - "@esbuild/linux-x64": "0.25.2", - "@esbuild/netbsd-arm64": "0.25.2", - "@esbuild/netbsd-x64": "0.25.2", - "@esbuild/openbsd-arm64": "0.25.2", - "@esbuild/openbsd-x64": "0.25.2", - "@esbuild/sunos-x64": "0.25.2", - "@esbuild/win32-arm64": "0.25.2", - "@esbuild/win32-ia32": "0.25.2", - "@esbuild/win32-x64": "0.25.2" - } - }, - "node_modules/vitest": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.1.1.tgz", - "integrity": "sha512-kiZc/IYmKICeBAZr9DQ5rT7/6bD9G7uqQEki4fxazi1jdVl2mWGzedtBs5s6llz59yQhVb7FFY2MbHzHCnT79Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/expect": "3.1.1", - "@vitest/mocker": "3.1.1", - "@vitest/pretty-format": "^3.1.1", - "@vitest/runner": "3.1.1", - "@vitest/snapshot": "3.1.1", - "@vitest/spy": "3.1.1", - "@vitest/utils": "3.1.1", - "chai": "^5.2.0", - "debug": "^4.4.0", - "expect-type": "^1.2.0", - "magic-string": "^0.30.17", - "pathe": "^2.0.3", - "std-env": "^3.8.1", - "tinybench": "^2.9.0", - "tinyexec": "^0.3.2", - "tinypool": "^1.0.2", - "tinyrainbow": "^2.0.0", - "vite": "^5.0.0 || ^6.0.0", - "vite-node": "3.1.1", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@types/debug": "^4.1.12", - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.1.1", - "@vitest/ui": "3.1.1", - "happy-dom": "*", - "jsdom": "*" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@types/debug": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - } - } - }, - "node_modules/webpack-virtual-modules": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", - "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, - "bin": { - "why-is-node-running": "cli.js" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/widest-line": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-4.0.1.tgz", - "integrity": "sha512-o0cyEG0e8GPzT4iGHphIOh0cJOV8fivsXxddQasHPHfoZf1ZexrfeA21w2NaEN1RHE+fXlfISmOE8R9N3u3Qig==", - "dev": true, - "license": "MIT", - "dependencies": { - "string-width": "^5.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC" - }, - "node_modules/write-file-atomic": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.3.tgz", - "integrity": "sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==", - "dev": true, - "license": "ISC", - "dependencies": { - "imurmurhash": "^0.1.4", - "is-typedarray": "^1.0.0", - "signal-exit": "^3.0.2", - "typedarray-to-buffer": "^3.1.5" - } - }, - "node_modules/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/xdg-basedir": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-5.1.0.tgz", - "integrity": "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/xmlhttprequest-ssl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz", - "integrity": "sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/xtend": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.4" - } - }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "license": "MIT", - "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/yargs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/yargs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, - "node_modules/yargs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/yargs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/yauzl": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", - "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", - "license": "MIT", - "dependencies": { - "buffer-crc32": "~0.2.3", - "fd-slicer": "~1.1.0" - } - }, - "node_modules/yn": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", - "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/zod": { - "version": "3.24.2", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.2.tgz", - "integrity": "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - } - } -} diff --git a/package.json b/package.json deleted file mode 100644 index 0182318..0000000 --- a/package.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "name": "template-analysis", - "version": "2.0.0", - "author": "Tago LLC", - "license": "Copyright", - "private": true, - "scripts": { - "test": "vitest .", - "test:single": "vitest --", - "linter": "biome lint ./src --no-errors-on-unmatched --diagnostic-level=error", - "linter-fix": "biome lint --apply ./src", - "build": "rm -rf ./build; tsc --build", - "deploy-analysis": "tsnd ./.github/scripts/deploy-analysis.ts" - }, - "devDependencies": { - "@biomejs/biome": "1.9.4", - "@tago-io/builder": "3.1.3", - "@types/async": "3.2.24", - "@types/luxon": "3.6.2", - "@types/uuid": "10.0.0", - "husky": "9.1.7", - "prettier": "3.5.3", - "ts-node": "10.9.2", - "ts-node-dev": "2.0.0", - "typescript": "5.8.2", - "unplugin-swc": "1.5.1", - "uuid": "11.1.0", - "vitest": "3.1.1" - }, - "dependencies": { - "@tago-io/sdk": "11.3.9", - "async": "3.2.6", - "axios": "1.8.4", - "bson-objectid": "2.0.4", - "geolib": "3.3.4", - "luxon": "3.6.1", - "puppeteer": "24.6.0" - } -} diff --git a/src/analysis/README.md b/src/analysis/README.md deleted file mode 100644 index 25f8fce..0000000 --- a/src/analysis/README.md +++ /dev/null @@ -1,41 +0,0 @@ -# Analysis -The files in this folder represents analysis scripts that must be uploaded to your account. - -* **handler**: The script that handles actions take on the dashboards, such clicking in a button to create/delete/edit a user and other entities. -* **alertCentral**: Same as handler, but it handles alert buttons. -* **dataRetention**: When ran, it will make sure all the devices has the correct data retention set. It must paired with a scheduled action. -* **deviceUpdater**: When ran, it will update all device configuration parameters with last checkin, battery, and also send alerts if any. It must paired with a scheduled action. -* **sendReport**: Send the scheduled report of an organization. The handler script will automatically generate actions for this analysis. -* **alertTrigger**: Send an alert of an organization. The alertCentral script will automatically generate actions for this analysis. -* **sendReport**: Responsible to generate the PDF report via email. -* **monthlyUsageReset**: Responsible for reset the monthly usage of SMS and Email from all clients. -* **uplinkHandler**: The script handles uplinks from devices, like geolocation for outdoor tracking. -* **userSignUp**: The script handles users that sign up in the application, if it is enabled in your Run configuration. - -# Analysis Template -You can get the template for each analysis, with all the Environment Variables in place (you still need to update environment variables parameters). - -* **Uplink Handler**: https://admin.tago.io/template/61c1c1346aec8f001844ea3b -* **User Signup**: https://admin.tago.io/template/61b327b8e3f46d00192153b7 -* **Send Report**: https://admin.tago.io/template/61b2f6199e269200196d4344 -* **Handler**: https://admin.tago.io/template/61b2f617e3f46d00191d997c -* **Data Retention**: https://admin.tago.io/template/61c310d6d6df77001acb54a4 -* **Device Updater**: https://admin.tago.io/template/61b2f6124edcc00019b44f0b -* **Alert Trigger**: https://admin.tago.io/template/61b2f610a14c040018c6672f - -# Dashboard Template -You can get the dashboard templates to use with the analysis in the following links: -* **Administrator**: https://admin.tago.io/template/61b2f61c9da1b800183a3284 -* **Alerts**: https://admin.tago.io/template/61b2f61c9e269200196d434d -* **Groups**: https://admin.tago.io/template/61b2f61da14c040018c6680c -* **Group** View: https://admin.tago.io/template/61b2f61e9da1b800183a32c1 -* **User List for Administrators**: https://admin.tago.io/template/61b2f61f561da800197abfde -* **User List**: https://admin.tago.io/template/61b2f622e3f46d00191d9aa7 -* **Plan Management**: https://admin.tago.io/template/61b2f620e3f46d00191d9a1f -* **Sensor** List: https://admin.tago.io/template/61b2f621561da800197ac053 -* **Reports**: https://admin.tago.io/template/61bc7b19dd44bf0019a56d22 -* **Organization** Details: https://admin.tago.io/template/61c0d2c05fb101001b417fbd - -# Sensor dashboard templates -The following templates are optional, for specific sensor dashboards. You can use as an example for building your own. -* Door Sensor: https://admin.tago.io/template/61b2f61c9da1b800183a329a diff --git a/src/analysis/alert-handler.ts b/src/analysis/alert-handler.ts deleted file mode 100644 index 25b0c13..0000000 --- a/src/analysis/alert-handler.ts +++ /dev/null @@ -1,54 +0,0 @@ -/* - * KickStarter Analysis - * Alert Handler - * - * Work same as the handler analysis, but only for alerts. - * This analysis handles most of buttons clickable by dashboard input form widgets such as dynamic table and input form widgets. - * - * Handles the following actions: - * - Add, edit and delete an Alert - */ -import { Analysis, Utils } from "@tago-io/sdk"; -import { Data, TagoContext } from "@tago-io/sdk/lib/types"; - -import { editAlert } from "../services/alerts/edit"; -import { createAlert } from "../services/alerts/register"; -import { deleteAlert } from "../services/alerts/remove"; - -/** - * Function that starts the analysis - * @param context Context is a variable sent by the analysis - * @param scope Scope is a variable sent by the analysis - */ -async function startAnalysis(context: TagoContext, scope: Data[]): Promise { - if (!scope[0]) { - return console.error("Not a valid TagoIO Data"); - } - - console.debug(JSON.stringify(scope)); - console.debug("Alert analysis started"); - - // Get the environment variables. - const environment = Utils.envToJson(context.environment); - - const router = new Utils.AnalysisRouter({ - environment, - scope, - context, - }); - - router.register(createAlert).whenInputFormID("create-alert-dev"); - router.register(editAlert).whenWidgetExec("edit"); - router.register(deleteAlert).whenWidgetExec("delete"); - - const result = await router.exec(); - - console.debug("Script end. Functions that run:"); - console.debug(result.services); -} - -if (!process.env.T_TEST) { - Analysis.use(startAnalysis, { token: process.env.T_ANALYSIS_TOKEN }); -} - -export { startAnalysis }; diff --git a/src/analysis/alert-trigger.ts b/src/analysis/alert-trigger.ts deleted file mode 100644 index ce1a16c..0000000 --- a/src/analysis/alert-trigger.ts +++ /dev/null @@ -1,273 +0,0 @@ -/* - * KickStarter Analysis - * Alert Trigger - * - * The analysis runs every time a device uplink matches an alert and must send an email, sms or notification. - */ -import { Analysis, Resources, Services, Utils } from "@tago-io/sdk"; -import { Conditionals, Data, DeviceInfo, TagoContext, UserInfo } from "@tago-io/sdk/lib/types"; - -import { checkAndChargeUsage } from "../services/plan/check-and-charge-usage"; - -interface IMessageDetail { - device_name: string; - device_id: string; - sensor_type: string; - value: string; - variable: string; -} - -type triggerType = { - device: string; - variable: string; - is: Conditionals; - value: string; - second_value?: string; - value_type: "string" | "number" | "boolean" | "*"; - unlock?: boolean; -}; - -/** - * Notification messages to be sent - * @param type Type of message to be sent - * @param context Context is a variable sent by the analysis - * @param org_id Organization ID of the device that triggered the alert - * @param to_dispatch_qty Number of messages to be sent - * @param users_info Array of users to receive the message - * @param message Message to be sent - */ -async function notificationMessages(type: string[], context: TagoContext, org_id: string, to_dispatch_qty: number, users_info: UserInfo[], message: string) { - if (type.includes("notification_run")) { - const has_service_limit = await checkAndChargeUsage(context, org_id, to_dispatch_qty, "notification_run"); - - if (has_service_limit) { - for (const user of users_info) { - void Resources.run - .notificationCreate(user.id, { - message, - title: "Alert Trigger", - }) - .then(() => console.debug("Notification sent")); - } - } else { - await Resources.devices.sendDeviceData(org_id, { - variable: "plan_status", - value: `Attempt to send ${to_dispatch_qty} alert(s) was not successful. No notification service limit available, check your service usage at "Info" to learn more about your plan status.`, - }); - } - } -} - -/** - * Email messages to be sent - * @param type Type of message to be sent - * @param context Context is a variable sent by the analysis - * @param org_id Organization ID of the device that triggered the alert - * @param to_dispatch_qty Number of messages to be sent - * @param users_info Array of users to receive the message - * @param device_info Device information - * @param message Message to be sent - */ -async function emailMessages(type: string[], context: TagoContext, org_id: string, to_dispatch_qty: number, users_info: UserInfo[], device_info: any, message: string) { - if (type.includes("email")) { - const has_service_limit = await checkAndChargeUsage(context, org_id, to_dispatch_qty, "email"); - - if (has_service_limit) { - const email = new Services({ token: context.token }).email; - - void email - .send({ - to: users_info.map((x) => x.email).join(","), - template: { - name: "email_alert", - params: { - device_name: device_info.name, - alert_message: message, - }, - }, - }) - .then((msg) => console.debug(msg)); - } else { - await Resources.devices.sendDeviceData(org_id, { - variable: "plan_status", - value: `Attempt to send ${to_dispatch_qty} alert(s) was not successful. No email service limit available, check your service usage at "Info" to learn more about your plan status.`, - }); - } - } -} - -/** - * Sms messages to be sent - * @param type Type of message to be sent - * @param context Context is a variable sent by the analysis - * @param org_id Organization ID of the device that triggered the alert - * @param to_dispatch_qty Number of messages to be sent - * @param users_info Array of users to receive the message - * @param message Message to be sent - */ -async function smsMessages(type: string[], context: TagoContext, org_id: string, to_dispatch_qty: number, users_info: UserInfo[], message: string) { - if (type.includes("sms")) { - const has_service_limit = await checkAndChargeUsage(context, org_id, to_dispatch_qty, "sms"); - - if (has_service_limit) { - for (const user of users_info) { - const smsService = new Services({ token: context.token }).sms; - if (!user.phone) { - throw "user.phone not found"; - } - void smsService - .send({ - message, - to: user.phone, - }) - .then((msg) => console.debug(msg)); - } - } else { - await Resources.devices.sendDeviceData(org_id, { - variable: "plan_status", - value: `Attempt to send ${to_dispatch_qty} alert(s) was not successful. No SMS service limit available, check your service usage at "Info" to learn more about your plan status.`, - }); - } - } -} - -/** - * Function that starts the analysis and handles the alert trigger and message dispatch - * @param type Type of message to be sent - * @param context Context is a variable sent by the analysis - * @param org_id Organization ID of the device that triggered the alert - * @param to_dispatch_qty Number of messages to be sent - * @param users_info Array of users to receive the message - * @param message Message to be sent - * @param device_info Device information - */ -async function dispatchMessages(type: string[], context: TagoContext, org_id: string, to_dispatch_qty: number, users_info: UserInfo[], message: string, device_info: DeviceInfo) { - await notificationMessages(type, context, org_id, to_dispatch_qty, users_info, message); - - await emailMessages(type, context, org_id, to_dispatch_qty, users_info, device_info, message); - - await smsMessages(type, context, org_id, to_dispatch_qty, users_info, message); -} - -/** - * Function that replaces the message with the variables - * @param message Message to be sent - * @param replace_details Object with the variables to be replaced - */ -function replaceMessage(message: string, replace_details: IMessageDetail) { - for (const key of Object.keys(replace_details)) { - message = message.replaceAll(new RegExp(`#${key}#`, "g"), (replace_details as any)[key]); - console.debug((replace_details as any)[key]); - } - - return message; -} - -/** - * Function that get the users information - * @param send_to Array of users to receive the message - */ -async function getUsers(send_to: string[]) { - const func_list = send_to.map((user_id) => Resources.run.userInfo(user_id).catch(() => null)); - - return (await Promise.all(func_list)).filter((x) => x) as UserInfo[]; -} - -/** - * Function that starts the analysis and handles the alert trigger - * @param context Context is a variable sent by the analysis - * @param scope Scope is an array of data sent by the analysis - */ -async function analysisAlert(context: TagoContext, scope: Data[]): Promise { - console.debug("Running Analysis"); - if (!scope[0]) { - return console.debug("This analysis must be triggered by an action."); - } - - console.debug(JSON.stringify(scope)); - // Get the environment variables. - const environment_variables = Utils.envToJson(context.environment); - - const action_id = environment_variables._action_id; - if (!action_id) { - return console.debug("This analysis must be triggered by an action."); - } - - // Get action details - const action_info = await Resources.actions.info(action_id); - if (!action_info.tags) { - throw "action_info.tags not found"; - } - - if (!action_info.trigger) { - throw "action_info.trigger not found"; - } - - const send_to = action_info.tags - .find((x) => x.key === "send_to") - ?.value?.replace(/;/g, ",") - .split(","); - const type = action_info.tags - .find((x) => x.key === "action_type") - ?.value?.replace(/;/g, ",") - .split(","); - - if (!send_to) { - throw "send_to not found"; - } - - if (!type) { - throw "type not found"; - } - // const alert_id = action_info.tags.find((x) => x.key === "action_id")?.value; - const alert_id = action_id; - - // Get action message - const org_id = action_info.tags.find((x) => x.key === "organization_id")?.value; - - if (!org_id) { - throw "org_id not found"; - } - const [message_var] = await Resources.devices.getDeviceData(org_id, { variables: ["action_list_message", "action_group_message"], groups: alert_id, qty: 1 }); - - // Get the triggered variable - const trigger = action_info.trigger as unknown as triggerType[]; - const trigger_variables = trigger?.filter((x) => !x.unlock).map((x) => x.variable); - const trigger_variable = scope.find((x) => trigger_variables.includes(x.variable)); - - if (!trigger_variable) { - throw "trigger_variable.value not found"; - } - - const device_id = scope[0].device; - const device_info = await Resources.devices.info(device_id); - - const sensor_type = device_info?.tags?.find((tag) => tag.key === "sensor")?.value; - if (!sensor_type) { - throw "sensoor_type not found"; - } - - const replace_details: IMessageDetail = { - device_name: device_info?.name, - device_id: device_info?.id, - sensor_type: sensor_type, - value: String(trigger_variable?.value), - variable: trigger_variable?.variable, - }; - - const message = replaceMessage(message_var.value as string, replace_details); - - const users_info = await getUsers(send_to); - - const to_dispatch_qty = users_info.length; - - await dispatchMessages(type, context, org_id, to_dispatch_qty, users_info, message, device_info); - - return console.debug("Analysis Finished!"); -} - -if (!process.env.T_TEST) { - Analysis.use(analysisAlert, { token: process.env.T_ANALYSIS_TOKEN }); -} - -export { analysisAlert }; diff --git a/src/analysis/battery-updater.ts b/src/analysis/battery-updater.ts deleted file mode 100644 index 6152db1..0000000 --- a/src/analysis/battery-updater.ts +++ /dev/null @@ -1,52 +0,0 @@ -/* - * KickStarter Analysis - * Battery Updater - * - * This analysis is responsible to - * update sensor's last checkin parameter. - * - * Battery Updater will run when: - * - When the scheduled action (Battery Updater Trigger) triggers this script. (Default 1 day) - */ - -import { Analysis, Resources } from "@tago-io/sdk"; - -import { fetchDeviceList } from "../lib/fetch-device-list"; - -async function resolveDevice(org_id: string, device_id: string) { - if (!org_id || !device_id) { - throw "Missing Router parameter"; - } - - const device_params = await Resources.devices.paramList(device_id); - const dev_battery_param = device_params.find((param) => param.key === "dev_battery") || { key: "dev_battery", value: "N/A", sent: false }; - - const [dev_battery] = await Resources.devices.getDeviceData(device_id, { variables: ["bat", "battery_capacity"], qty: 1 }); - - if (dev_battery?.value) { - await Resources.devices.paramSet(device_id, { ...dev_battery_param, value: String(dev_battery.value) }); - } -} - -async function startAnalysis() { - console.debug("Running Analysis"); - - try { - const sensorList = await fetchDeviceList({ tags: [{ key: "device_type", value: "device" }] }); - - sensorList.map((device) => - resolveDevice(device.tags.find((tag) => tag.key === "organization_id")?.value as string, device.tags.find((tag) => tag.key === "device_id")?.value as string) - ); - - console.debug("Analysis finished"); - } catch (error) { - console.debug(error); - console.debug(error.message || JSON.stringify(error)); - } -} - -if (!process.env.T_TEST) { - Analysis.use(startAnalysis, { token: process.env.T_ANALYSIS_TOKEN }); -} - -export { startAnalysis }; diff --git a/src/analysis/clear-buckets.ts b/src/analysis/clear-buckets.ts deleted file mode 100644 index fbac945..0000000 --- a/src/analysis/clear-buckets.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Analysis, Resources } from "@tago-io/sdk"; - -import { fetchDeviceList } from "../lib/fetch-device-list"; - -/** - * Function to start the analysis and clear variables from devices of type organization - * @param context - * @param scope - */ -async function startAnalysis() { - const deviceList = await fetchDeviceList({ tags: [{ key: "device_type", value: "organization" }] }); - - for (const device of deviceList) { - const result = await Resources.devices.deleteDeviceData(device.id, { variables: ["device_qty", "plan_usage"], qty: 9999 }); - console.debug(result); - } -} - -if (!process.env.T_TEST) { - Analysis.use(startAnalysis, { token: process.env.T_ANALYSIS_TOKEN }); -} diff --git a/src/analysis/data-retention.ts b/src/analysis/data-retention.ts deleted file mode 100644 index bdecf5b..0000000 --- a/src/analysis/data-retention.ts +++ /dev/null @@ -1,84 +0,0 @@ -/* - * KickStarter Analysis - * Data Retention Updater - * - * This analysis gets all sensors in the application and make sure bucket data retention is set properly. - */ - -import { Analysis, Resources } from "@tago-io/sdk"; -import { DeviceListItem } from "@tago-io/sdk/lib/types"; - -import { fetchDeviceList } from "../lib/fetch-device-list"; - -/** - * Function that resolves the data retention of the organization - * @param org_id Organization ID to resolve the data retention - */ -async function resolveDataRetentionByOrg(org_id: string) { - const device_list: DeviceListItem[] = await fetchDeviceList({ - tags: [ - { key: "device_type", value: "device" }, - { key: "organization_id", value: org_id }, - ], - }); - - for (const device_obj of device_list) { - const variables = await Resources.devices.getDeviceData(device_obj.id, { qty: 9999 }); - if (!variables[0]) { - return; - } - - const bucket_vars = variables.map((v) => v.variable); - const data_retention_ignore = bucket_vars - .map((r) => { - if (!r.includes("action")) { - return null; - } - return r; - }) - .filter((x) => x); - // @ts-ignore: Unreachable code error - await Resources.buckets.edit(device_obj.bucket.id, { data_retention_ignore }); - } -} - -/** - * Function that updates the data retention of the application - */ -async function updateDataRetention() { - console.debug("Running"); - - const organization_list: DeviceListItem[] = await fetchDeviceList({ tags: [{ key: "device_type", value: "organization" }] }); - - for (const org of organization_list) { - const org_id = org.id; - const org_param_list = await Resources.devices.paramList(org_id); - const plan_data_retention = org_param_list.find((x) => x.key === "plan_data_retention")?.value || ""; - - if (plan_data_retention !== "") { - await resolveDataRetentionByOrg(org_id); - } - } - - console.debug("success"); -} - -/** - * Function that starts the analysis - */ -async function startAnalysis() { - await updateDataRetention() - .then(() => { - console.debug("Script end."); - }) - .catch((error) => { - console.debug(error); - console.debug(error.message || JSON.stringify(error)); - }); -} - -if (!process.env.T_TEST) { - Analysis.use(startAnalysis, { token: process.env.T_ANALYSIS_TOKEN }); -} - -export { startAnalysis }; diff --git a/src/analysis/handler.ts b/src/analysis/handler.ts deleted file mode 100644 index 5aa1c95..0000000 --- a/src/analysis/handler.ts +++ /dev/null @@ -1,114 +0,0 @@ -/* - * KickStarter Analysis - * Handler - * - * This analysis handles most of buttons clickable by dashboard input form widgets such as dynamic table and input form widgets. - * - * Handles the following actions: - * - Add, edit and delete an Organization. - * - Add, edit and delete a Group. - * - Add, edit and delete a Sensor. - * - Add, edit and delete a User. - * - Add, edit and delete scheduled reports. - */ - -import { Analysis, Utils } from "@tago-io/sdk"; -import { Data, TagoContext } from "@tago-io/sdk/lib/types"; - -import { sensorEdit } from "../services/device/edit"; -import { sensorPlacement } from "../services/device/place-sensor"; -import { sensorAdd } from "../services/device/register"; -import { sensorDel } from "../services/device/remove"; -import { groupEdit } from "../services/group/edit"; -import { groupAdd } from "../services/group/register"; -import { groupDel } from "../services/group/remove"; -import { orgEdit } from "../services/organization/edit"; -import { orgAdd } from "../services/organization/register"; -import { orgDel } from "../services/organization/remove"; -import { planEdit } from "../services/plan/edit"; -import { planAdd } from "../services/plan/register"; -import { planDel } from "../services/plan/remove"; -import { reportAdd } from "../services/reports/create"; -import { reportEdit } from "../services/reports/edit"; -import { reportDel } from "../services/reports/remove"; -import { userEdit } from "../services/user/edit"; -import { userAdd } from "../services/user/register"; -import { userDel } from "../services/user/remove"; - -// import { createAlert } from "../services/alerts/register"; -// import { deleteAlert } from "../services/alerts/remove"; -// import { editAlert } from "../services/alerts/edit"; - -/** - * This function is the main function of the analysis. - * @param context The context of the analysis, containing the environment variables and parameters. - * @param scope The scope of the analysis, containing the data sent to the analysis. - */ -async function startAnalysis(context: TagoContext, scope: Data[]): Promise { - context.log("Running Analysis"); - console.log("Scope:", scope); - - // Convert the environment variables from [{ key, value }] to { key: value }; - const environment = Utils.envToJson(context.environment); - console.log("Environment:", environment); - - // Check if all tokens needed for the application were provided. - if (!environment.config_id) { - throw "Missing config_id environment var"; - } else if (environment.config_id.length !== 24) { - return context.log('Invalid "config_id" in the environment variable'); - } - - // Just a little hack to set the device_list_button_id that come from the scope - // and set it to the environment variables instead. It makes easier to use router function later. - environment._input_id = (scope as any).find((x: any) => x.device_list_button_id)?.device_list_button_id; - - // Instance the router class of Utils.router - const router = new Utils.AnalysisRouter({ scope, context, environment }); - - // Organization Routing - router.register(orgAdd).whenInputFormID("create-org"); - router.register(orgDel).whenDeviceListIdentifier("delete-org"); - router.register(orgEdit).whenCustomBtnID("edit-org"); - - // Sensor routing - router.register(sensorAdd).whenInputFormID("create-dev"); - router.register(sensorDel).whenDeviceListIdentifier("delete-dev"); - router.register(sensorEdit).whenDeviceListIdentifier("edit-dev"); - - // Sensor uplink routing - router.register(sensorPlacement).whenVariables(["set_dev_pin_id"]); - - // group routing - router.register(groupAdd).whenInputFormID("create-group"); - router.register(groupDel).whenDeviceListIdentifier("delete-group"); - router.register(groupEdit).whenCustomBtnID("edit-group"); - - // User routing - router.register(userAdd).whenInputFormID("create-user"); - router.register(userDel).whenUserListIdentifier("delete-user"); - router.register(userEdit).whenCustomBtnID("edit-user"); - - //Plan routing - router.register(planAdd).whenInputFormID("create-plan"); - router.register(planDel).whenVariableLike("plan_").whenWidgetExec("delete"); - router.register(planEdit).whenVariableLike("plan_").whenWidgetExec("edit"); - - // //Alert routing - // router.register(createAlert).whenInputFormID("create-alert"); - // router.register(editAlert).whenVariableLike("action_").whenWidgetExec("edit"); - // router.register(deleteAlert).whenVariableLike("action_").whenWidgetExec("delete"); - - // Report routing - router.register(reportAdd).whenInputFormID("create-report"); - router.register(reportDel).whenVariableLike("report_").whenWidgetExec("delete"); - router.register(reportEdit).whenVariableLike("report_").whenWidgetExec("edit"); - - await router.exec(); -} - -if (!process.env.T_TEST) { - Analysis.use(startAnalysis, { token: process.env.T_ANALYSIS_TOKEN }); -} - -export { startAnalysis }; diff --git a/src/analysis/monthly-usage-reset.ts b/src/analysis/monthly-usage-reset.ts deleted file mode 100644 index 1c78cf1..0000000 --- a/src/analysis/monthly-usage-reset.ts +++ /dev/null @@ -1,46 +0,0 @@ -/* - * KickStarter Analysis - * Monthly Usage Reset - * - * This analysis will reset the monthly usage of SMS and Email from all clients. - * - * How it works: - * - The action "[TagoIO] - Monthly plan reset trigger" will trigger this analysis on the first day of each month at 00:00 UTC. - * - Organization's SMS and Email usage will be reset to 0. - */ - -import { Analysis, Resources } from "@tago-io/sdk"; -import { DeviceListItem } from "@tago-io/sdk/lib/types"; - -import { fetchDeviceList } from "../lib/fetch-device-list"; - -/** - * Function that initializes the analysis - * @param context Context is a variable sent by the analysis - * @param scope Scope is a variable sent by the analysis - */ -async function init(): Promise { - console.debug("Monthly usage reset analysis started"); - - const org_list: DeviceListItem[] = await fetchDeviceList({ tags: [{ key: "device_type", value: "organization" }] }); - - for (const org of org_list) { - const org_params = await Resources.devices.paramList(org.id); - - const plan_email_limit_usage = org_params.find((x) => x.key === "plan_email_limit_usage") || { key: "plan_email_limit_usage", value: "0", sent: false }; - const plan_sms_limit_usage = org_params.find((x) => x.key === "plan_sms_limit_usage") || { key: "plan_sms_limit_usage", value: "0", sent: false }; - const plan_notif_limit_usage = org_params.find((x) => x.key === "plan_notif_limit_usage") || { key: "plan_notif_limit_usage", value: "0", sent: false }; - - await Resources.devices.paramSet(org.id, { ...plan_email_limit_usage, value: "0", sent: false }); - await Resources.devices.paramSet(org.id, { ...plan_sms_limit_usage, value: "0", sent: false }); - await Resources.devices.paramSet(org.id, { ...plan_notif_limit_usage, value: "0", sent: false }); - } - - return console.debug("Analysis finished successfuly!"); -} - -if (!process.env.T_TEST) { - Analysis.use(init, { token: process.env.T_ANALYSIS_TOKEN }); -} - -export { init }; diff --git a/src/analysis/send-report.ts b/src/analysis/send-report.ts deleted file mode 100644 index 69cf5e5..0000000 --- a/src/analysis/send-report.ts +++ /dev/null @@ -1,250 +0,0 @@ -/* - * KickStarter Analysis - * Send Report - * - * This analysis is responsible to generate the PDF and its content. - * - * Reports are generated when: - * - On the dashboard Report, through Send Now button; - * - When setting a scheduled report, an action will trigger this script. - */ - -import { DateTime } from "luxon"; - -import { Analysis, Resources } from "@tago-io/sdk"; -import { ActionInfo, TagoContext, UserInfo } from "@tago-io/sdk/lib/types"; - -import { htmlBody } from "../lib/html-body"; -import { createPDF } from "../lib/send-pdf"; -import { checkAndChargeUsage } from "../services/plan/check-and-charge-usage"; - -interface SensorData { - name: string; - status: string; - battery: string; - rssi: string; - date: string; -} - -/** - * Function that resolves the report of the organization and send it to the user - * @param context Context is a variable sent by the analysis - * @param action_info Action information of the action that triggered the analysis - * @param org_id Organization ID to resolve the report - * @param via Via is a string that defines how the report was triggered - */ -async function resolveReport(context: TagoContext, action_info: ActionInfo, org_id: string, via?: string) { - if (!context || !action_info || !org_id) { - throw "Missing Router parameter"; - } - const { name: org_name } = await Resources.devices.info(org_id); - - let sensor_id_list: string[] = []; - - if (!action_info.tags) { - throw console.debug("action_info.tags is undefined"); - } - - const action_sensor_list = action_info.tags.find((x) => x.key === "sensor_list")?.value; - const action_group_list = action_info.tags.find((x) => x.key === "group_list")?.value; - - if (action_sensor_list) { - sensor_id_list = action_sensor_list.split(", "); - } else if (action_group_list) { - const site_id_list = action_group_list.split(", "); - - for (const site_id of site_id_list) { - const site_dev_id_list = await Resources.devices.getDeviceData(site_id, { variables: "dev_id", qty: 9999 }); - for (const dev_id_data of site_dev_id_list) { - const existing_sensor = sensor_id_list.find((id) => id === (dev_id_data?.value as string)); - - if (!existing_sensor) { - sensor_id_list.push(dev_id_data.value as string); - } - } - } - } else { - throw console.debug("Error - no sensor on scheduled action"); - } - - const report_data: SensorData[] = []; - - for (const id of sensor_id_list) { - const sensor_info = await Resources.devices.info(id); - - if (!sensor_info) { - continue; //sensor has been deleted - } - - const sensor_data = await Resources.devices.getDeviceData(id, { variables: ["temperature", "humidity", "compressor", "battery", "rssi"], qty: 1 }); - // const status_history = sensor_data.find((x) => x.variable === "status_history"); - const temperature = sensor_data.find((x) => x.variable === "temperature"); - const humidity = sensor_data.find((x) => x.variable === "humidity"); - const compressor = sensor_data.find((x) => x.variable === "compressor"); - const temp_status = `Temp: ${temperature?.value ?? "N/A"}${temperature?.unit ?? ""}`; - const hum_status = `Hum: ${humidity?.value ?? "N/A"}${humidity?.unit ?? ""}`; - const compressor_status = `Compressor: ${compressor?.value ?? "N/A"}`; - const status_history = `${temp_status} | ${hum_status} | ${compressor_status}`; - const battery = sensor_data.find((x) => x.variable === "battery"); - const rssi = sensor_data.find((x) => x.variable === "rssi"); - - report_data.push({ - name: sensor_info.name, - status: status_history, - battery: `${(battery?.value as string) ?? "N/A"}${battery?.unit ?? ""}`, - rssi: (rssi?.value as string) ?? "N/A", - date: DateTime.fromISO(String(sensor_info.last_input)).toFormat("yyyy-MM-dd HH:mm:ss"), - }); - } - - const table_header = ` - Sensor - Status - Battery - RSSI - Last Input`; - - let final_html_body = htmlBody; - final_html_body = final_html_body.replace("$TABLE_HEADER$", table_header); - - let report_table = ``; - - for (const data of report_data) { - let report_row = ` - - $NAME$ - $STATUS$ - $BATTERY$ - $RSSI$ - $DATE$ - `; - report_row = report_row.replace("$NAME$", data.name); - report_row = report_row.replace("$STATUS$", data?.status || "No data sent yet"); - report_row = report_row.replace("$BATTERY$", data.battery); - report_row = report_row.replace("$RSSI$", data.rssi); - report_row = report_row.replace("$DATE$", data.date); - - report_table = report_table.concat(report_row); - } - - final_html_body = final_html_body.replace("$REPORT_TABLE$", report_table); - - const org_indicators = await Resources.devices.getDeviceData(org_id, { variables: ["device_qty"], qty: 1 }); - const total_qty = org_indicators[0].value || "0"; - const active_qty = org_indicators[0]?.metadata?.active_qty || "0"; - const inactive_qty = org_indicators[0]?.metadata?.inactive_qty || "0"; - - final_html_body = final_html_body.replace("$TOTAL_QTY$", String(total_qty)); - final_html_body = final_html_body.replace("$ACTIVE_QTY$", active_qty); - final_html_body = final_html_body.replace("$INACTIVE_QTY$", inactive_qty); - - const action_report_contact = action_info.tags.find((x) => x.key === "report_contact")?.value; - - if (!action_report_contact) { - throw console.debug("action_report_contact not found"); - } - - const users_id_list: string[] = action_report_contact.split(", "); - - //fetch users set first - - const users_info_list: UserInfo[] = []; - const all_users_label: string[] = []; - - for (const user_id_from_list of users_id_list) { - const current_user_info = await Resources.run.userInfo(user_id_from_list).catch((error) => console.debug(error)); - if (!current_user_info) { - //user has been deleted - continue; - } - users_info_list.push(current_user_info); - all_users_label.push(current_user_info?.email); - } - - //check if users are invited and still existing first -> charge from which will be actually being sent. - const to_dispatch_qty = users_info_list.length; - - const plan_service_status = await checkAndChargeUsage(context, org_id, to_dispatch_qty, "email"); - - if (plan_service_status === false) { - return await Resources.devices.sendDeviceData(org_id, [ - { - variable: "plan_status", - value: `Attempt to send ${to_dispatch_qty} report(s) was not successful. No email service limit available, check your plan status or get in touch with us.`, - }, - { variable: "report_sent", value: `Report has not been sent. No plan service usage available.`, metadata: { users: "-" } }, - ]); - } - - let filename: string | undefined; - if (users_id_list.length > 0 && plan_service_status) { - filename = await createPDF(context, final_html_body, users_info_list, org_name, org_id); - } else if (users_id_list.length === 0) { - return await Resources.devices.sendDeviceData(org_id, [{ variable: "report_sent", value: `Report has not been sent. No user registered.`, metadata: { users: "-" } }]); - } - - const all_users_string = all_users_label.join(", "); - - const url_file = filename ? `https://api.tago.io/file/61b2f46e561da800197a9c43${filename}` : "-"; - await Resources.devices.sendDeviceData(org_id, [{ variable: "report_sent", value: `Report has been sent. Via: ${via}.`, metadata: { users: all_users_string, url_file } }]); -} - -/** - * Function to start the analysis - * @param context Context is a variable sent by the analysis - * @param scope Scope is a variable sent by the analysis - */ -async function startAnalysis(context: TagoContext, scope: any) { - console.debug("Running Analysis"); - - let org_id: string | undefined = ""; - - const action_id = context.environment.find((x) => x.key === "_action_id")?.value as string; - - if (action_id) { - //THROUGH ACTION - const action_info = await Resources.actions.info(action_id); - - const { tags } = action_info; - - if (!tags) { - return console.debug("tags not found"); - } - - org_id = tags.find((x) => x.key === "organization_id")?.value; - if (!org_id) { - throw "organization_id not found"; - } - - void resolveReport(context, action_info, org_id, "Squeduled Action"); - } else if (scope) { - //THROUGH BUTTON SEND NOW - const action_group = scope[0]?.group; - - const [action_registered] = await Resources.actions.list({ - page: 1, - fields: ["id", "name", "tags"], - filter: { - tags: [{ key: "action_group", value: action_group }], - }, - amount: 1, - }); - - if (!action_registered.tags) { - return console.debug("ERROR - No action found"); - } - - org_id = action_registered.tags.find((x) => x.key === "organization_id")?.value; - if (!org_id) { - throw "organization_id not found"; - } - - void resolveReport(context, action_registered, org_id, "Button"); - } -} - -if (!process.env.T_TEST) { - Analysis.use(startAnalysis, { token: process.env.T_ANALYSIS_TOKEN }); -} - -export { startAnalysis }; diff --git a/src/analysis/status-updater.ts b/src/analysis/status-updater.ts deleted file mode 100644 index 95934ff..0000000 --- a/src/analysis/status-updater.ts +++ /dev/null @@ -1,215 +0,0 @@ -/* - * KickStarter Analysis - * Status Updater - * - * This analysis is responsible to update organization's plan usage (displayed at Info Dashboard), - * update the indicators from the organization (total, active and inactive), - * update sensor's params (last checkin and battery) and update sensors location. - * - * Status Updater will run when: - * - When the scheduled action (Status Updater Trigger) triggers this script. (Default 1 minute) - */ - -import async from "async"; -import { DateTime } from "luxon"; - -import { Analysis, Resources, Utils } from "@tago-io/sdk"; -import { DeviceInfo, DeviceListItem, TagoContext } from "@tago-io/sdk/lib/types"; - -import { parseTagoObject } from "../lib/data.logic"; -import { fetchDeviceList } from "../lib/fetch-device-list"; -import { checkInTrigger } from "../services/alerts/check-in-alerts"; - -/** - * Function that update the organization's plan usage - * @param org Organization device - */ -async function resolveOrg(org: DeviceListItem) { - let total_qty = 0; - let active_qty = 0; - let inactive_qty = 0; - const org_id = org.id; - - const sensorList = await fetchDeviceList({ - tags: [ - { key: "organization_id", value: org.id }, - { key: "device_type", value: "device" }, - ], - }); - - for (const sensor of sensorList) { - const last_input = DateTime.fromISO(String(sensor.last_input || "")); - const now = DateTime.now(); - const diff_time = now.diff(last_input, "hours").hours; - total_qty++; - if (diff_time < 24) { - active_qty++; - } else { - inactive_qty++; - } - } - - const org_params = await Resources.devices.paramList(org_id); - - const plan_email_limit_usage = org_params.find((x) => x.key === "plan_email_limit_usage")?.value || "0"; - const plan_sms_limit_usage = org_params.find((x) => x.key === "plan_sms_limit_usage")?.value || "0"; - const plan_notif_limit_usage = org_params.find((x) => x.key === "plan_notif_limit_usage")?.value || "0"; - const plan_data_retention = org_params.find((x) => x.key === "plan_data_retention")?.value || "0"; - - const to_tago = { - device_qty: { value: total_qty, metadata: { total_qty: total_qty, active_qty: active_qty, inactive_qty: inactive_qty } }, - plan_usage: { - value: plan_email_limit_usage, - metadata: { - plan_email_limit_usage: plan_email_limit_usage, - plan_sms_limit_usage: plan_sms_limit_usage, - plan_notif_limit_usage: plan_notif_limit_usage, - plan_data_retention: plan_data_retention, - }, - }, - }; - //CONSIDER INSTEAD OF DELETING VARIABLES, PLACE A DATA RETENTION RULE AND SHOW THEM IN A HISTORIC GRAPHIC ON THE WIDGET HEADER BUTTON - const old_data = await Resources.devices.getDeviceData(org_id, { - variables: ["device_qty", "plan_usage"], - query: "last_item", - }); - const device_data = old_data.find((x) => x.variable === "device_qty"); - const plan_data = old_data.find((x) => x.variable === "plan_usage"); - const new_data = parseTagoObject(to_tago); - - if (!device_data || !plan_data) { - await Resources.devices.sendDeviceData(org_id, new_data); - return; - } - - await Resources.devices.editDeviceData(org_id, [ - { ...device_data, ...new_data[0] }, - { ...plan_data, ...new_data[1] }, - ]); -} - -/** - * Function that update the sensor's params (last checkin and battery) - * @param sensor_info - Sensor information - */ -const checkLocation = async (sensor_info: DeviceInfo) => { - const [location_data] = await Resources.devices.getDeviceData(sensor_info.id, { variables: "location", qty: 1 }); - - if (!location_data) { - return "No location sent by device"; - } - - const site_id = sensor_info.tags.find((x) => x.key === "site_id")?.value; - - if (!site_id) { - return "No site addressed to the sensor"; - } - - const [dev_id] = await Resources.devices.getDeviceData(site_id, { variables: "dev_id", groups: sensor_info.id, qty: 1 }); - if ( - (dev_id?.location as any).coordinates[0] === (location_data.location as any).coordinates[0] && - (dev_id?.location as any).coordinates[1] === (location_data.location as any).coordinates[1] - ) { - return "Same position"; - } - await Resources.devices.editDeviceData(site_id, { ...dev_id, location: location_data.location }); -}; - -/** - * Function that update the sensor's params (last checkin and battery) - * @param context - * @param org_id - * @param device_id - */ -async function resolveDevice(context: TagoContext, org_id: string, device_id: string) { - console.debug("Resolving device", device_id); - const sensor_info = await Resources.devices.info(device_id); - - if (!sensor_info) { - return Promise.reject("Device not found"); - } - - checkLocation(sensor_info).catch((error) => console.debug(error)); - - const device_info = await Resources.devices.info(device_id); - if (!device_info.last_input) { - return Promise.reject("Device not found"); - } - - const checkin_date = DateTime.fromISO(device_info.last_input.toString()); - - if (!checkin_date.isValid) { - return "no data"; - } - - let diff_hours: string | number = DateTime.now().diff(checkin_date, "hours").hours; - - if (isNaN(diff_hours)) { - diff_hours = "-"; - } //checking for NaN - - const device_params = await Resources.devices.paramList(device_id); - const dev_lastcheckin_param = device_params.find((param) => param.key === "dev_lastcheckin") || { key: "dev_lastcheckin", value: String(diff_hours), sent: false }; - - await checkInTrigger(context, org_id, { device_id, last_input: device_info.last_input }); - - await Resources.devices.paramSet(device_id, { ...dev_lastcheckin_param, value: String(diff_hours), sent: (diff_hours as number) >= 24 ? true : false }); - - console.debug("Device resolved", device_id); -} - -async function handler(context: TagoContext): Promise { - console.debug("Running Analysis"); - - const environment = Utils.envToJson(context.environment); - if (!environment) { - return; - } - - const orgList = await fetchDeviceList({ tags: [{ key: "device_type", value: "organization" }] }); - - orgList.map((org) => resolveOrg(org)); - - const sensorList = await fetchDeviceList({ tags: [{ key: "device_type", value: "device" }] }); - - const processSensorQueue = async.queue(async function (sensorItem: DeviceListItem, callback) { - const organization_id = sensorItem?.tags?.find((tag) => tag.key === "organization_id")?.value as string; - const device_id = sensorItem?.tags?.find((tag) => tag.key === "device_id")?.value as string; - await resolveDevice(context, organization_id, device_id).catch((error) => console.debug(`${error} - ${sensorItem.id}`)); - callback(); - }, 1); - - //populating the queue - for (const sensorItem of sensorList) { - void processSensorQueue.push(sensorItem); - } - - // console.debug("Queue populated", processSensorQueue.length()); - - await processSensorQueue.drain(); - - //throwing possible errors generated while running the queue - processSensorQueue.error((error) => { - console.debug(error); - process.exit(); - }); -} - -/** - * Start the analysis - */ -async function startAnalysis(context: TagoContext) { - try { - await handler(context); - console.debug("Analysis finished"); - } catch (error) { - console.debug(error); - console.debug(error.message || JSON.stringify(error)); - } -} - -if (!process.env.T_TEST) { - Analysis.use(startAnalysis, { token: process.env.T_ANALYSIS_TOKEN }); -} - -export { startAnalysis }; diff --git a/src/analysis/uplink-handler.ts b/src/analysis/uplink-handler.ts deleted file mode 100644 index 2aee260..0000000 --- a/src/analysis/uplink-handler.ts +++ /dev/null @@ -1,63 +0,0 @@ -/* - * KickStarter Analysis - * Handler - * - * This analysis handles most of buttons clickable by dashboard input form widgets such as dynamic table and input form widgets. - * - * Handles the following actions: - * - Add, edit and delete an Organization. - * - Add, edit and delete a Group. - * - Add, edit and delete a Sensor. - * - Add, edit and delete a User. - * - Add, edit and delete scheduled reports. - */ - -import { Analysis, Utils } from "@tago-io/sdk"; -import { Data, TagoContext } from "@tago-io/sdk/lib/types"; - -import { sensorUplinkLocation } from "../services/uplinks/sensor-uplink-location"; -import { sensorUplinkStatus } from "../services/uplinks/sensor-uplink-status"; -import { sensorUplinkTempHum } from "../services/uplinks/sensor-uplink-temp-hum"; - -/** - * - * @param context - * @param scope - * @returns - */ -async function startAnalysis(context: TagoContext, scope: Data[]): Promise { - context.log("Running Analysis"); - console.log("Scope:", scope); - - // Convert environment variables to a JSON. - const environment = Utils.envToJson(context.environment); - console.log("Environment:", environment); - - // Check if all tokens needed for the application were provided. - if (!environment.config_id) { - throw "Missing config_id environment var"; - } else if (environment.config_id.length !== 24) { - return context.log('Invalid "config_id" in the environment variable'); - } - - // Just a little hack to set the device_list_button_id that come sfrom the scope - // and set it to the environment variables instead. It makes easier to use router function later. - environment._input_id = (scope as any).find((x: any) => x.device_list_button_id)?.device_list_button_id; - - // The router class will help you route the function the analysis must run - // based on what had been received in the analysis. - const router = new Utils.AnalysisRouter({ scope, context, environment }); - - // Sensor uplink routing - router.register(sensorUplinkLocation).whenVariables(["location"]); - router.register(sensorUplinkStatus).whenVariables(["status", "water_leakage_detected"]); - router.register(sensorUplinkTempHum).whenVariables(["temperature", "relative_humidity"]); - - await router.exec(); -} - -if (!process.env.T_TEST) { - Analysis.use(startAnalysis, { token: process.env.T_ANALYSIS_TOKEN }); -} - -export { startAnalysis }; diff --git a/src/analysis/user-sign-up.ts b/src/analysis/user-sign-up.ts deleted file mode 100644 index c46d646..0000000 --- a/src/analysis/user-sign-up.ts +++ /dev/null @@ -1,78 +0,0 @@ -/* - * KickStarter Analysis - * User Signup - * - * This analysis handles new users that register themselves in the application. It requires the RUN to have user auto-signup enabled. - * - * - * How to setup this analysis - * Make sure you have the following enviroment variables: - * - config_token: the value must be a token from a HTTPs device, that stores general information of the application. - * - account_token: the value must be a token from your profile. See how to generate account-token at: https://help.tago.io/portal/en/kb/articles/495-account-token. - * - * In the RUN Settings, enable user to auto-signup. - * Create an Action of type Resource whenever an user is registered in the application to run this analysis. - */ - -import { Analysis, Resources, Utils } from "@tago-io/sdk"; -import { TagoContext, UserInfo } from "@tago-io/sdk/lib/types"; - -import { parseTagoObject } from "../lib/data.logic"; -import { orgAdd } from "../services/organization/register"; - -/** - * - * @param context - * @param scope - * @returns - */ -async function startAnalysis(context: TagoContext, scope: UserInfo[]): Promise { - console.debug("SCOPE:", JSON.stringify(scope, null, 4)); - console.debug("CONTEXT:", JSON.stringify(context, null, 4)); - console.debug("Running Analysis"); - - // Convert the environment variables from [{ key, value }] to { key: value }; - const environment = Utils.envToJson(context.environment); - if (!environment) { - return; - } - - const config_id = environment.config_id; - if (!config_id) { - throw "[Error] No config device ID: config_id."; - } - - const organization_scope: any = parseTagoObject({ - new_org_name: scope[0].name, - new_org_address: "N/A", - new_org_plan_group: environment.plan_group, - }).map((x) => ({ ...x, device: " ", time: new Date() })); - - let org_id: string | void = ""; - - try { - org_id = await orgAdd({ context, scope: organization_scope, environment }); - } catch (error) { - await Resources.run.userDelete(scope[0].id); - return console.debug(error); - } - - if (!org_id) { - throw "Error creating organization"; - } - - await Resources.run.userEdit(scope[0].id, { - tags: [ - { key: "organization_id", value: org_id }, - { key: "access", value: "orgadmin" }, - ], - }); - - await Resources.devices.sendDeviceData(org_id, { variable: "user_id", value: scope[0].id, metadata: { label: `${scope[0].name} (${scope[0].email})` } }); -} - -if (!process.env.T_TEST) { - Analysis.use(startAnalysis, { token: process.env.T_ANALYSIS_TOKEN }); -} - -export { startAnalysis }; diff --git a/src/lib/README.MD b/src/lib/README.MD deleted file mode 100644 index 3f1b177..0000000 --- a/src/lib/README.MD +++ /dev/null @@ -1,36 +0,0 @@ -# Alerts Folder Overview -This folder is responsible for the alerts handling. Mostly actions are triggered by the "Alert Central" dashboard. - -## Files -The Alert folder contains the following files: -* Register an alert - register.ts -* Edit an alert - edit.ts -* Remove an alert - remove.ts - -### Diagram - -```mermaid -sequenceDiagram - participant RUN Application - participant Handler Analysis - participant Alerts Script Folder - participant TagoIO - alt register.ts - RUN Application ->> Handler Analysis: "Create New" command trigger - Handler Analysis ->> Alerts Script Folder: Redirects - Alerts Script Folder -->> TagoIO: Creates TagoIO action - Alerts Script Folder -->> TagoIO: Send new alert to "Alert List" table (create action_list variable) - end - alt edit.ts - RUN Application ->> Handler Analysis: Controls "Edit" command trigger - Handler Analysis ->> Alerts Script Folder: Redirects - Alerts Script Folder -->> TagoIO: Edit TagoIO action - Alerts Script Folder -->> TagoIO: Edit existing alert on "Alert List" table - end - alt delete.ts - RUN Application ->> Handler Analysis: Controls "Delete" command trigger - Handler Analysis ->> Alerts Script Folder: Redirects - Alerts Script Folder -->> TagoIO: Delete TagoIO action - Alerts Script Folder -->> TagoIO: Delete existing alert on "Alert List" table - end -``` diff --git a/src/lib/auditlog-setup.ts b/src/lib/auditlog-setup.ts deleted file mode 100644 index b87d129..0000000 --- a/src/lib/auditlog-setup.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { Resources } from "@tago-io/sdk"; - -import { parseTagoObject } from "./data.logic"; - -/** - * Function that edit user information and send audit log - * @param device_id Device ID - */ -function auditLogSetup(device_id: string) { - return async function _(new_value: string, siren_state?: string, user_id?: string) { - if (!new_value) { - throw "Missing new_value"; - } - let name = "System"; - if (user_id) { - name = (await Resources.run.userInfo(user_id))?.name || "System"; - if (name === "System") { - user_id = "System"; - } - } - - await Resources.devices.sendDeviceData( - device_id, - parseTagoObject( - { - audit_user: { value: user_id || "System", metadata: { label: name } }, - audit_new_value: new_value, - siren_state: siren_state, - }, - String(Date.now()) - ) - ); - }; -} - -export { auditLogSetup }; diff --git a/src/lib/create-dash-url.ts b/src/lib/create-dash-url.ts deleted file mode 100644 index aa90e0d..0000000 --- a/src/lib/create-dash-url.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Creates a URL for a TagoIO dashboard with the given ID and query parameters. - * @param dashID - The ID of the dashboard to create the URL for. - * @param params - An object containing key-value pairs to be used as query parameters in the URL. - * @returns The URL for the dashboard with the given ID and query parameters. - */ -function createDashURL(dashID: string, params: { [key: string]: string }) { - const url = `https://admin.tago.io/dashboards/info/${dashID}`; - const paramsURL = new URLSearchParams(params).toString(); - if (paramsURL.length === 0) { - return `${url}`; - } - return `${url}?${paramsURL}`; -} - -export { createDashURL }; diff --git a/src/lib/data.logic.ts b/src/lib/data.logic.ts deleted file mode 100644 index 0f73aea..0000000 --- a/src/lib/data.logic.ts +++ /dev/null @@ -1,34 +0,0 @@ -// ? ==================================== (c) TagoIO ==================================== -// ? What is this file? -// * This file is all logics of parseInt (example script). -// ? ==================================================================================== - -import { DataToSend } from "@tago-io/sdk/lib/types"; - -interface GenericBody { - [index: string]: any; -} -/** - * Function that parse the body of the request to TagoIO - * @param body Body of the request - * @param group Group of the request - */ -function parseTagoObject(body: GenericBody, group?: string): DataToSend[] { - if (!group) { - group = String(Date.now()); - } - return Object.keys(body) - .map((item) => { - return { - variable: item, - value: body[item] instanceof Object ? body[item].value : body[item], - group, - time: body[item] instanceof Object ? body[item].time : null, - location: body[item] instanceof Object ? body[item].location : null, - metadata: body[item] instanceof Object ? body[item].metadata : null, - }; - }) - .filter((item) => item.value !== null && item.value !== undefined); -} - -export { parseTagoObject }; diff --git a/src/lib/device-name-exists.test.ts b/src/lib/device-name-exists.test.ts deleted file mode 100644 index 701aa16..0000000 --- a/src/lib/device-name-exists.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { deviceNameExists } from "./device-name-exists"; - -vitest.mock("../lib/fetch-device-list", () => { - return { - fetchDeviceList: vitest - .fn() - .mockImplementationOnce(() => Promise.resolve([])) - .mockImplementationOnce(() => Promise.resolve([{ id: "orgOne" }])) - .mockImplementationOnce(() => Promise.resolve([{ id: "orgOne" }, { id: "orgTwo" }])), - }; -}); - -describe("deviceNameExists", () => { - test("should return false when device name does not exist", async () => { - const result = await deviceNameExists({ name: "My Organization", tags: [{ key: "device_type", value: "organization" }] }); - expect(result).toBe(false); - }); - - test("should return true when device name exists and isEdit is false", async () => { - const result = await deviceNameExists({ name: "My Organization", tags: [{ key: "device_type", value: "organization" }] }); - expect(result).toBe(true); - }); - - test("should return true when device name exists and isEdit is true with more than 1 matching device", async () => { - const result = await deviceNameExists({ name: "My Organization", tags: [{ key: "device_type", value: "organization" }], isEdit: true }); - expect(result).toBe(true); - }); -}); diff --git a/src/lib/device-name-exists.ts b/src/lib/device-name-exists.ts deleted file mode 100644 index 27400e3..0000000 --- a/src/lib/device-name-exists.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { TagsObj } from "@tago-io/sdk/lib/types"; - -import { fetchDeviceList } from "./fetch-device-list"; - -interface DeviceSource { - name: string; - tags: TagsObj[]; - isEdit?: boolean; -} - -/** - * The Device Creation and Edit utilize this method. - * @description Check if device name exists - * @param {string} name Device name - * @param {TagsObj[]} tags Device tags - * @param {boolean} isEdit When editing a device, if a device with the same name already exists, it should return two devices. - * This is because the frontend automatically handles the editing process. - */ -async function deviceNameExists({ name, tags, isEdit = false }: DeviceSource) { - const device = await fetchDeviceList({ - name, - tags, - }); - - if (isEdit && device.length > 1) { - return true; - } else if (!isEdit && device.length > 0) { - return true; - } - - return false; -} - -export { deviceNameExists }; diff --git a/src/lib/edit.params.test.ts b/src/lib/edit.params.test.ts deleted file mode 100644 index 41e0a65..0000000 --- a/src/lib/edit.params.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { ParamResolver } from "./edit.params"; - -describe("LIB | edit.params Resolver", () => { - test("Success Resolver", async () => { - const paramResolver = ParamResolver([{ key: "test", value: "1", sent: false, id: "1234" }], true); - paramResolver.setParam("test2", "23423"); - paramResolver.setParam("test", "5555"); - const result = await paramResolver.apply(""); - - expect(result).toStrictEqual([ - { key: "test2", value: "23423", sent: true }, - { key: "test", value: "5555", sent: true, id: "1234" }, - ]); - }); - - test("Invalid Key Type", () => { - const paramResolver = ParamResolver([]); - - // @ts-expect-error we are testing an invalid key - expect(() => paramResolver.setTag(1234, "test")).toThrow(); - }); - - test("Invalid Value Type", () => { - const paramResolver = ParamResolver([]); - - // @ts-expect-error we are testing an invalid key - expect(() => paramResolver.setTag("test", 1234)).toThrow(); - }); -}); diff --git a/src/lib/edit.params.ts b/src/lib/edit.params.ts deleted file mode 100644 index bd88943..0000000 --- a/src/lib/edit.params.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { Resources } from "@tago-io/sdk"; -import { ConfigurationParams } from "@tago-io/sdk/lib/types"; - -/** - * Creates a resolver to add/update configuration params on the devices. - * It automatically identifies if the data already exists or not. - * @example - * const paramList = await Resources.devices.list(deviceID); - * const editParam = ParamResolver(paramList); - * editParam.setParam("device_status", "ON"); - * await editParam.apply(account, deviceID); - * - * @param {ConfigurationParams[]} rawParams param list if you already have in your code. - * @param debug - * @returns - */ -function ParamResolver(rawParams: ConfigurationParams[], debug: boolean = false) { - const paramList: ConfigurationParams[] = []; - - const paramResolver = { - /** - * Set the configuration parameter for your Device - * @param {string} key key of the Tag - * @param {string} value value of the Tag - * @param {string} [sent] optional sent value - * @returns - */ - setParam: function (key: string, value: string, sent: boolean = true) { - if (typeof key !== "string") { - throw "[ParamResolver] key is not a string"; - } - if (typeof value !== "string") { - throw "[ParamResolver] key is not a string"; - } - const oldParam = rawParams.find((x) => x.key === key); - paramList.push({ ...oldParam, key, value, sent }); - return this; - }, - - /** - * Apply the changes to the configuration parameters. - * @param {string} deviceID Device ID to apply the changes - * @returns - */ - apply: async function (deviceID: string) { - if (debug) { - return paramList; - } - await Resources.devices.paramSet(deviceID, paramList); - }, - - /** - * Check if there is any change to be applied. - * @returns {boolean} true if there is any change to be applied. - */ - hasChanged: function () { - return paramList.length > 0; - }, - }; - - return paramResolver; -} - -export { ParamResolver }; diff --git a/src/lib/edit.tag.test.ts b/src/lib/edit.tag.test.ts deleted file mode 100644 index 19650b5..0000000 --- a/src/lib/edit.tag.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { TagResolver } from "./edit.tag"; - -describe("LIB | edit.tag Resolver", () => { - test("Success Resolver", async () => { - const tagResolver = TagResolver([{ key: "test", value: "1" }], true); - tagResolver.setTag("test2", "23423"); - tagResolver.setTag("test", "5555"); - const result = await tagResolver.apply(""); - - expect(result).toStrictEqual([ - { key: "test2", value: "23423" }, - { key: "test", value: "5555" }, - ]); - }); - - test("Invalid Key Type", () => { - const tagResolver = TagResolver([]); - - // @ts-expect-error we are testing an invalid key - expect(() => tagResolver.setTag(1234, "test")).toThrow(); - }); - - test("Invalid Value Type", () => { - const tagResolver = TagResolver([]); - - // @ts-expect-error we are testing an invalid key - expect(() => tagResolver.setTag("test", 1234)).toThrow(); - }); -}); diff --git a/src/lib/edit.tag.ts b/src/lib/edit.tag.ts deleted file mode 100644 index ddcef48..0000000 --- a/src/lib/edit.tag.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { Resources } from "@tago-io/sdk"; -import { TagsObj } from "@tago-io/sdk/lib/types"; - -/** - * Creates a resolver to add/update tags on the devices. - * It automatically identifies if the tag already exists or not. - * @example - * const { tags } = await Resources.devices.info(deviceID); - * const editTags = TagResolver(tags); - * editTags.setTag("device_status", "ON"); - * await editTags.apply(deviceID); - * - * @param {TagsObj[]} rawTags list of your device existing Tags - * @param debug - * @returns - */ -function TagResolver(rawTags: TagsObj[], debug: boolean = false) { - const tags = JSON.parse(JSON.stringify(rawTags)) as TagsObj[]; - const newTags: TagsObj[] = []; - - const tagResolver = { - /** - * Set the Tag for your Device - * @param {string} key key of the Tag - * @param {string} value value of the Tag - * @returns - */ - setTag: function (key: string, value: string) { - if (typeof key !== "string") { - throw "[TagResolver] key is not a string"; - } - if (typeof value !== "string") { - throw "[TagResolver] key is not a string"; - } - const tagExist = tags.find((x) => x.key === key); - if (!tagExist || tagExist.value !== value) { - newTags.push({ key, value }); - } - return this; - }, - - /** - * Apply the changes to the tags - * @param {string} deviceID Device ID to apply the changes - * @returns - */ - apply: async function (deviceID: string) { - if (debug) { - return newTags; - } - // merge tags and newTags, replacing the old tags with the new ones. - for (const newTag of newTags) { - const oldTagIndex = tags.findIndex((x) => x.key === newTag.key); - if (oldTagIndex >= 0) { - tags[oldTagIndex] = newTag; - } else { - tags.push(newTag); - } - } - - await Resources.devices.edit(deviceID, { tags }); - }, - - /** - * Check if there is any change to be applied. - * @returns {boolean} true if there is any change to be applied. - * @memberof TagResolver - */ - hasChanged: function () { - return newTags.length > 0; - }, - }; - - return tagResolver; -} - -export { TagResolver }; diff --git a/src/lib/fetch-device-list.ts b/src/lib/fetch-device-list.ts deleted file mode 100644 index 167edb4..0000000 --- a/src/lib/fetch-device-list.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { Resources } from "@tago-io/sdk"; -import { DeviceListItem, DeviceQuery } from "@tago-io/sdk/lib/types"; - -type FetchDeviceResponse = Pick; -/** - * Fetchs the device list using filters. - * Automatically apply pagination to not run on throughtput errors. - * @param filter filter conditions of the request - * @returns - */ -async function fetchDeviceList(filter: DeviceQuery["filter"]): Promise { - let device_list: FetchDeviceResponse[] = []; - - for (let index = 1; index < 9999; index++) { - const amount = 100; - const foundDevices = await Resources.devices.list({ - page: index, - fields: ["id", "name", "tags", "last_input", "created_at"], - filter, - resolveBucketName: false, - amount, - }); - - device_list = device_list.concat(foundDevices); - if (foundDevices.length < amount) { - return device_list; - } - } - - return device_list; -} - -export { fetchDeviceList, FetchDeviceResponse }; diff --git a/src/lib/fetch-user-list.ts b/src/lib/fetch-user-list.ts deleted file mode 100644 index 4402f4c..0000000 --- a/src/lib/fetch-user-list.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { Resources } from "@tago-io/sdk"; -import { UserInfo, UserQuery } from "@tago-io/sdk/lib/types"; - -type FetchUserResponse = Pick; - -/** - * Fetchs the user list using filters. - * Automatically apply pagination to not run on throughtput errors. - * @param filter filter conditions of the request - * @returns - */ -async function fetchUserList(filter: UserQuery["filter"]): Promise { - let userList: FetchUserResponse[] = []; - - for (let index = 1; index < 9999; index++) { - const amount = 40; - const foundUsers = await Resources.run - .listUsers({ - page: index, - fields: ["id", "name", "phone", "company", "tags", "active", "email", "timezone"], - filter, - amount, - }) - .then((r) => r as FetchUserResponse[]); - - userList = userList.concat(foundUsers); - if (foundUsers.length < amount) { - return userList; - } - } - - return userList; -} - -export { fetchUserList }; diff --git a/src/lib/find-resource.ts b/src/lib/find-resource.ts deleted file mode 100644 index 20edd3a..0000000 --- a/src/lib/find-resource.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { Resources } from "@tago-io/sdk"; - -/** - * Get the ANalysis ID by it's tag export_id value - * @param resources Resources with Account Token - * @param tagValue tag value string - * @returns - */ -async function getAnalysisByTagID(resources: Resources, tagValue: string, tagKey: string = "export_id") { - // Should be pass the Resources with Account Token because the Access Management doesn't have access to the Analysis - const [analysis] = await resources.analysis.list({ - amount: 1, - fields: ["id", "tags"], - filter: { tags: [{ key: tagKey, value: tagValue }] }, - }); - if (!analysis) { - throw `Analysis ${tagValue} not found`; - } - - return analysis?.id; -} - -/** - * Get the Dashboard ID by it's tag export_id value - * @param tagValue tag value string - * @returns - */ -async function getDashboardByTagID(tagValue: string, tagKey: string = "export_id") { - const [dash] = await Resources.dashboards.list({ - amount: 1, - fields: ["id", "tags"], - filter: { tags: [{ key: tagKey, value: tagValue }] }, - }); - if (!dash) { - throw `Dashboard ${tagValue} not found`; - } - - return dash?.id; -} - -/** - * Get the Mutable Device ID of a device using the device_type tag - * Matches the tag device_id with the deviceID param - * @param deviceID - * @param deviceType tag value string - * @returns - */ -async function getLinkedDeviceID(deviceID: string, deviceType: string = "device-storage") { - const [device] = await Resources.devices.list({ - amount: 1, - fields: ["id", "tags"], - filter: { - tags: [ - { key: "device_id", value: deviceID }, - { key: "device_type", value: deviceType }, - ], - }, - }); - - if (!device) { - throw `Linked Device ${deviceID} not found`; - } - - return device.id; -} - -/** - * Get the Dashboard ID by it's tag connector_id value - * @param tagValue tag value string - * @returns - */ -async function getDashboardByConnectorID(connector_id: string) { - const [dash] = await Resources.dashboards.list({ amount: 1, fields: ["id", "tags"], filter: { tags: [{ key: "connector_id", value: connector_id }] } }); - if (!dash) { - throw `Dashboard ${connector_id} not found`; - } - - return { id: dash?.id }; -} - -export { getDashboardByTagID, getAnalysisByTagID, getLinkedDeviceID, getDashboardByConnectorID }; diff --git a/src/lib/get-push-message.ts b/src/lib/get-push-message.ts deleted file mode 100644 index 35395ab..0000000 --- a/src/lib/get-push-message.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Resources } from "@tago-io/sdk"; - -interface NotificationMessage { - [key: string]: any; - device?: string; - percent?: string; - location?: string; -} - -async function getPushMessage(message_builder: NotificationMessage, template_name: string) { - const run = await Resources.run.info(); - const template = run.email_templates[template_name]; - - template.value = template.value.replaceAll("$", ""); - for (const key of Object.keys(message_builder)) { - const regex = new RegExp(`${key}`, "g"); - template.value = template.value.replace(regex, message_builder[key]); - } - - return template; -} -export { getPushMessage }; diff --git a/src/lib/html-body.ts b/src/lib/html-body.ts deleted file mode 100644 index fbe79d0..0000000 --- a/src/lib/html-body.ts +++ /dev/null @@ -1,185 +0,0 @@ -const htmlBody = ` - - - - - - -
-
-
-

Registered Sensor(s):

-

$TOTAL_QTY$

-
-
-

Active Sensor(s):

-

$ACTIVE_QTY$

-
-
-

Inactive Sensor(s):

-

$INACTIVE_QTY$

-
-
- - - $TABLE_HEADER$ - - - $REPORT_TABLE$ - -
-
- - -`; - -export { htmlBody }; diff --git a/src/lib/install-template.ts b/src/lib/install-template.ts deleted file mode 100644 index b72e4c3..0000000 --- a/src/lib/install-template.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { Resources } from "@tago-io/sdk"; -import { DashboardInfo } from "@tago-io/sdk/lib/types"; - -/* eslint-disable no-loop-func */ -function replaceJSON(item: any, replaceObj: any) { - item = JSON.stringify(item); - for (const x of Object.keys(replaceObj)) { - item = item.replaceAll(new RegExp(x, "g"), replaceObj[x]); - } - item = JSON.parse(item); - - return item; -} -type Writeable = { -readonly [P in keyof T]: T[P] }; - -async function InstallTemplate(templates: string[], replaceObj: any) { - const dashboards = await Promise.all(templates.map((id) => Resources.template.installTemplate(id, { replace: replaceObj }))); - const dash_list = dashboards.map((x) => x.dashboard as string); - - for (const [i, dashboard] of dash_list.entries()) { - replaceObj[templates[i]] = dashboard; - } - - for (const dashboard of dash_list) { - let dash_info = (await Resources.dashboards.info(dashboard)) as Writeable & { setup: Record }; - const hidden_var = dash_info.tags?.find((x) => x.key === "hidden"); - dash_info.visible = !hidden_var; - dash_info = replaceJSON(dash_info, replaceObj); - dash_info.setup = {}; - - await Resources.dashboards.edit(dashboard, dash_info); - - if (!dash_info.arrangement) { - continue; - } - - const widget_list = await Promise.all(dash_info.arrangement.map((x) => Resources.dashboards.widgets.info(dashboard, x.widget_id))); - - for (let x of widget_list) { - // Change any "Username" in widget variables, labels, etc to "John Doe". - x = replaceJSON(x, replaceObj); - - if (!x.id) { - continue; - } - - await Resources.dashboards.widgets.edit(dashboard, x.id, x); - } - } - - return replaceObj; -} - -export { InstallTemplate }; diff --git a/src/lib/pdf-template.ts b/src/lib/pdf-template.ts deleted file mode 100644 index adec318..0000000 --- a/src/lib/pdf-template.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { DateTime } from "luxon"; - -const headerTemplate = ` -
-
-

$ORG_NAME$ - Sensor Report

-

${DateTime.now().toFormat("yyyy-MM-dd HH:mm:ss")}

-
-
- mycompany logo -
-
-`; - -const footerTemplate = ` -
-
- -
-
-`; - -export { headerTemplate, footerTemplate }; diff --git a/src/lib/register-user.ts b/src/lib/register-user.ts deleted file mode 100644 index 22337a4..0000000 --- a/src/lib/register-user.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { Resources, Services } from "@tago-io/sdk"; -import { TagoContext, TagsObj } from "@tago-io/sdk/lib/types"; - -import { fetchUserList } from "./fetch-user-list"; - -/** Account Summary - * @param {Object} context analysis context - * @param {Object} user user object with their data - * Example: { name: 'John Doe', phone: '+1444562367', email: 'johndoe@tago.io', timezone: 'America/Chicago' } - * @param {Array} tags tags to be added/update in to the user - * Example: [{ key: 'country', value: 'United States' }] - * @return {Promise} - */ - -interface UserData { - email: string; - name: string; - phone?: string | number | boolean | void; - timezone: string; - tags?: TagsObj[]; - password?: string; -} - -async function updateUserAndReturnID(user_data: UserData) { - // If got an error, try to find the user_data. - const [user] = await fetchUserList({ email: user_data.email }); - if (!user) { - throw "Couldn`t find user data"; - } - - // If found, update the tags. - user.tags = user.tags?.filter((x) => user_data.tags?.find((y) => x.key !== y.key)); - user.tags = user.tags?.concat(user_data.tags || []); - - await Resources.run.userEdit(user.id, { tags: user_data.tags }); - - return user.id; -} - -/** - * Function that register new user - * @param {Class} resources This is a class with resources should be used with account token because the Access Management doesn't have permission - * @param {Object} context Context is a variable sent by the analysis - * @param {Object} user_data User data - * @param {String} domain_url Domain URL - */ -async function inviteUser(resources: Resources, context: TagoContext, user_data: UserData, domain_url: string) { - user_data.email = user_data.email.toLowerCase(); - - // Generate a Random Password - const password = user_data.password || `A${Math.random().toString(36).slice(2, 12)}!`; - // the parameter "resources" should be used with account token because the Access Management doesn't have permission - const { timezone } = await resources.account.info(); - - let createError = ""; - // Try to create the user. - const result = await Resources.run - .userCreate({ - active: true, - company: "", - email: user_data.email, - language: "en", - name: user_data.name, - phone: String(user_data.phone || ""), - tags: user_data.tags, - timezone: user_data.timezone || timezone || "America/New_York", - password, - }) - .catch((error) => { - createError = error; - return null; - }); - - if (!result) { - return updateUserAndReturnID(user_data).catch(() => { - throw createError; - }); - } - - // If success, send an email with the password - const emailService = new Services({ token: context.token }).email; - void emailService - .send({ - to: user_data.email, - subject: "Account Details", - message: `Your account for the application was created! \n\nYour Login is: ${user_data.email}\nYour password is: ${password}\n\n In order to access it, visit our website \n${domain_url}`, - }) - .catch((error) => console.log(error)); - - return result.user; -} - -export { inviteUser }; diff --git a/src/lib/send-notification.ts b/src/lib/send-notification.ts deleted file mode 100644 index c485162..0000000 --- a/src/lib/send-notification.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { Resources, Services } from "@tago-io/sdk"; - -interface INotificationError { - environment: { [key: string]: string } | string; - title?: string; - message: string; -} - -async function sendNotificationToDeveloper({ title, message }: Omit) { - const services = new Services({ token: process.env.T_ANALYSIS_TOKEN }); - await services.notification.send({ - title: title || "Operation error", - message, - }); -} - -/** - * Get the tago device class from the device id - * Requires RUN User Permissions and Notification Permission - */ -async function sendNotificationFeedback({ environment, title, message }: INotificationError) { - let user_id: string; - if (typeof environment === "string") { - user_id = environment; - } else { - user_id = environment?._user_id; - } - if (!user_id) { - await sendNotificationToDeveloper({ title, message }); - return; - } - - const user = await Resources.run.userInfo(user_id).catch(() => null); - if (!user) { - await sendNotificationToDeveloper({ title, message }); - return; - } - - await Resources.run.notificationCreate(user_id, { - title: title || "Operation error", - message, - }); -} - -export { sendNotificationFeedback }; diff --git a/src/lib/send-pdf.ts b/src/lib/send-pdf.ts deleted file mode 100644 index 67ae209..0000000 --- a/src/lib/send-pdf.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { PaperFormat, PDFOptions } from "puppeteer"; -import { v4 as uuidv4 } from "uuid"; - -import { Resources, Services } from "@tago-io/sdk"; -import { TagoContext, UserInfo } from "@tago-io/sdk/lib/types"; - -import { footerTemplate, headerTemplate } from "./pdf-template"; - -// ? ==================================== (c) TagoIO ==================================== -// ? What is in this file? -// * This file creates a PDF and send it via email, the PDF file will contain a headerTemplate and footerTemplate and should receive a HTML body throught the function call. -// ? ==================================================================================== - -//code below is a base64 code for MyCompany logo - -const options: PDFOptions = { - path: "example.pdf", //changes the path where the file will be stored - displayHeaderFooter: true, - headerTemplate, - footerTemplate, - format: "A4" as PaperFormat, - margin: { - bottom: 70, - left: 25, - right: 35, - top: 110, - }, - printBackground: true, -}; - -async function createPDF(context: TagoContext, htmlBody: string, users_info_list: Array, org_name: string, org_id: string) { - // Start the email service - const email = new Services({ token: context.token }).email; - - // Start the PDF service - const pdf = new Services({ token: context.token }).pdf; - - options.headerTemplate = headerTemplate.replace("$ORG_NAME$", org_name); - const base64 = Buffer.from(htmlBody).toString("base64"); - - const report = await pdf.generate({ base64, options }).catch((error) => console.debug(error)); - - // Send the email. - for (const user_info of users_info_list) { - if (user_info?.email) { - await email - .send({ - to: user_info?.email, - subject: "System Report", - message: "Find attached your PDF below.", - attachment: { - archive: (report as any).result, - type: "base64", - filename: "sensor_report.pdf", - } as any, - }) - .then((msg) => console.debug(msg)) - .catch((error) => console.debug(error)); - } else { - console.debug("Error - couldnt find user"); - } - } - - const filename = `/reports/${org_id}/sensor_report_${uuidv4()}.pdf`; - - await Resources.files - .uploadBase64([ - { - filename, - file: (report as any).result, - public: true, - }, - ]) - .then((msg) => console.debug(msg)) - .catch((error) => console.debug(`Error - failed to send pdf to FILE - ${error}`)); - - console.debug("PDF Generated!"); - - return filename; -} - -export { createPDF }; diff --git a/src/lib/undo-device-changes.ts b/src/lib/undo-device-changes.ts deleted file mode 100644 index 00da4e4..0000000 --- a/src/lib/undo-device-changes.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { Resources } from "@tago-io/sdk"; -import { DeviceListScope } from "@tago-io/sdk/lib/modules/Utils/router/router.types"; -import { DeviceInfo } from "@tago-io/sdk/lib/types"; - -import { ParamResolver } from "./edit.params"; -import { TagResolver } from "./edit.tag"; - -/** - * @description Undo device changes - */ -async function undoDeviceChanges({ scope, deviceInfo }: { scope: DeviceListScope[]; deviceInfo: DeviceInfo }) { - const paramList = await Resources.devices.paramList(deviceInfo.id); - const paramResolver = ParamResolver(paramList); - const tagResolver = TagResolver(deviceInfo.tags); - - // Device List editions are always scoped to a single device. - const deviceScope = scope[0]; - - for (const key of Object.keys(deviceScope)) { - if (key === "name") { - const oldName = deviceScope?.old?.[key] as string; - await Resources.devices.edit(deviceInfo.id, { name: oldName }); - } else if (key.includes("param.")) { - const paramKey = key.replace("param.", ""); - const oldValue = deviceScope?.old?.[key] as string; - paramResolver.setParam(paramKey, oldValue); - } else if (key.includes("tags.")) { - const tagKey = key.replace("tags.", ""); - const oldValue = deviceScope?.old?.[key] as string; - tagResolver.setTag(tagKey, oldValue); - } - } - - if (paramResolver.hasChanged()) { - await paramResolver.apply(deviceInfo.id); - } - - if (tagResolver.hasChanged()) { - await tagResolver.apply(deviceInfo.id); - } -} - -export { undoDeviceChanges }; diff --git a/src/lib/validation.ts b/src/lib/validation.ts deleted file mode 100644 index 809ef66..0000000 --- a/src/lib/validation.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { DateTime } from "luxon"; - -import { Resources } from "@tago-io/sdk"; - -type validation_type = "success" | "danger" | "warning" | string; -interface IValidateOptions { - show_markdown?: boolean; - user_id?: string; -} - -/** - * Setup function to send validation data to widgets. - * - * @returns a new function to be used to send the actual validation message. - * @param validation_var variable of the validation in the widget - * @param device device associated to the variable in the widget - * @param show_markdown enable/disable markdown - */ -function initializeValidation(validationVariable: string, device_id: string, opts?: IValidateOptions) { - let i = 0; - return async function _(message: string, type: validation_type = "success") { - if (!message || !type) { - throw "Missing message or type"; - } - - i += 1; - // clean validation old entries - await Resources.devices - .deleteDeviceData(device_id, { - variables: validationVariable, - qty: 999, - end_date: DateTime.now().minus({ minutes: 1 }).toJSDate(), - }) - .catch(console.log); - - // inser the new entry - await Resources.devices - .sendDeviceData(device_id, { - variable: validationVariable, - value: message, - time: DateTime.now() - .plus({ milliseconds: i * 200 }) - .toJSDate(), //increment time by i - metadata: { - type: ["success", "danger", "warning"].includes(type) ? type : null, - color: !["success", "danger", "warning"].includes(type) ? type : undefined, - show_markdown: !!opts?.show_markdown, - user_id: opts?.user_id, - }, - }) - .catch(console.error); - - return message; - }; -} - -export { initializeValidation }; diff --git a/src/services/alerts/check-in-alerts.ts b/src/services/alerts/check-in-alerts.ts deleted file mode 100644 index 6d685ef..0000000 --- a/src/services/alerts/check-in-alerts.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { DateTime } from "luxon"; - -import { Resources } from "@tago-io/sdk"; -import { TagoContext } from "@tago-io/sdk/lib/types"; - -import { IAlertTrigger, sendAlert } from "./send-alert"; - -interface ICheckInParam { - device_id: string; - last_input?: Date; -} - -/** - * Function used to trigger the checkin alert - * @param context context is a variable sent by the analysis - * @param org_id Id of the organization - * @param params parameters parameters that will be used to trigger the checkin - */ -async function checkInTrigger(context: TagoContext, org_id: string, params: ICheckInParam) { - const { last_input, device_id } = params; - const checkin_date = last_input ? DateTime.fromJSDate(last_input) : null; - if (!checkin_date) { - return "no data"; - } - - const paramList = await Resources.devices.paramList(device_id); - - const actionList = paramList.filter((param) => param.key.startsWith("checkin")); - for (const param of actionList) { - const [interval] = param.value.split(","); - const diff_hours: number = DateTime.now().diff(checkin_date, "hours").hours; - - if (diff_hours >= Number(interval) && !param.sent) { - const action_id = param.key.replace("checkin", ""); - const action_info = await Resources.actions.info(action_id); - if (!action_info.tags) { - throw "Action not found"; - } - - const send_to = action_info.tags - .find((x) => x.key === "send_to") - ?.value?.replace(/;/g, ",") - .split(","); - const type = action_info.tags - .find((x) => x.key === "action_type") - ?.value?.replace(/;/g, ",") - .split(","); - const device = action_info.tags.find((x) => x.key === "device")?.value as string; - - if (!send_to || !type || !device) { - throw "Action not found"; - } - - const mockData = { - variable: "Inactivity", - value: diff_hours, - device: device_id, - time: new Date(), - }; - - const alert: IAlertTrigger = { - action_id, - device, - send_to, - type, - data: mockData as any, - }; - - await sendAlert(context, org_id, alert); - } else if (diff_hours < Number(interval) && param.sent) { - await Resources.devices.paramSet(device_id, { ...param, sent: true }); - await Resources.devices.paramSet(device_id, { ...param, sent: false }); - } - } -} - -/** - * Add this function to alert Handler in order to add the needed variable for Checkin events - * @param devToStoreAlert Organization/Group/Etc device that will have the event stored - * @param action_id Id of the action - * @param structure structure of the action - */ -async function checkInAlertSet(action_id: string, interval: number, device_ids: string[]) { - for (const device_id of device_ids) { - const paramList = await Resources.devices.paramList(device_id); - const getParam = (key: string) => paramList.find((param) => param.key === key) || { key, value: "", sent: false }; - const actionParam = getParam(`checkin${action_id}`); - - actionParam.value = `${interval},${new Date().toISOString()}`; - await Resources.devices.paramSet(device_id, actionParam); - } -} - -export { checkInAlertSet, checkInTrigger }; diff --git a/src/services/alerts/edit.ts b/src/services/alerts/edit.ts deleted file mode 100644 index 4e37737..0000000 --- a/src/services/alerts/edit.ts +++ /dev/null @@ -1,182 +0,0 @@ -import { Resources } from "@tago-io/sdk"; -import { ActionQuery, Data } from "@tago-io/sdk/lib/types"; - -import { sendNotificationFeedback } from "../../lib/send-notification"; -import { RouterConstructorData } from "../../types"; -import { checkInAlertSet } from "./check-in-alerts"; -import { ActionStructureParams, generateActionStructure, getGroupDevices } from "./register"; - -interface ActionListParams { - device_id?: string; - action_id?: string; - group_id?: string; - organization_id?: string; -} - -/** - * Function to be used externally when need to add a device to an alert. - * @param org_id Id of the organization - * @param action_id Id of the action - * @param device_id Id of the device that will be sent the alert - */ -async function addDeviceToAlert(org_id: string, action_id: string, device_id: string) { - const [action_variable] = await Resources.devices.getDeviceData(org_id, { variables: ["action_list_variable", "action_group_variable"], qty: 1, groups: action_id }); - if (!action_variable) { - console.debug(`Couldnt find the action_variable for ${action_id}`); - return; - } - const action_info = await Resources.actions.info(action_id); - if (!action_info.tags) { - throw "Action not found"; - } - const device_list = [...new Set(action_info.tags.filter((tag) => tag.key === "device_id").map((tag) => tag.value))]; - device_list.push(device_id); - - const action_strcuture = generateActionStructure(action_variable.metadata as any, device_list); - await Resources.actions.edit(action_id, action_strcuture); -} - -/** - * List all actions based on a "and" filter - * @param device_id - * @param qty Number of devices that will be listed - */ -async function listDeviceAction({ device_id, action_id, group_id, organization_id }: ActionListParams, qty: number = 9999) { - if (!device_id && !action_id && !group_id) { - throw "Invalid filter"; - } - - const filter: ActionQuery["filter"] = { - tags: [], - }; - if (!filter.tags) { - filter.tags = []; - } - if (device_id) { - filter.tags.push({ key: "device_id", value: device_id }); - } - if (group_id) { - filter.tags.push({ key: "group_id", value: group_id }); - } - if (organization_id) { - filter.tags.push({ key: "organization_id", value: organization_id }); - } - - return Resources.actions.list({ amount: qty, fields: ["id", "tags"], filter }); -} - -async function undoChanges(organization_id: string, scope: Data[]) { - await Resources.devices.deleteDeviceData(organization_id, { variables: scope.map((data) => data.variable), groups: scope[0].device }); - await Resources.devices.sendDeviceData( - organization_id, - scope.map((data) => ({ ...data, value: data?.metadata?.old_value })) - ); -} -/** - * Main edit alert function - * @param environment Environment Variable is a resource to send variables values to the context of your script - * @param scope Number of devices that will be listed - */ -async function editAlert({ environment, scope }: RouterConstructorData) { - if (!scope) { - throw "Organization device not found"; - } - - const organization_id = scope[0].device; - if (!organization_id) { - throw "Organization device not found"; - } - - const { group: action_id } = scope[0]; - if (!action_id) { - throw "Action not found"; - } - - // Get the fields from the Dynamic Table widget. - // If the field was not edited, the value of the variable will be equal to null. - const action_devices = scope.find((x) => ["action_list_devices"].includes(x.variable)); - const action_group = scope.find((x) => ["action_group_group"].includes(x.variable)); - - let action_variable = scope.find((x) => ["action_list_variable", "action_group_variable"].includes(x.variable)); - const action_condition = scope.find((x) => ["action_list_condition", "action_group_condition"].includes(x.variable)); - const action_value = scope.find((x) => ["action_list_value", "action_group_value"].includes(x.variable)); - - const action_type = scope.find((x) => ["action_list_type", "action_group_type"].includes(x.variable)); - const action_sendto = scope.find((x) => ["action_list_sendto", "action_group_sendto"].includes(x.variable)); - - if (!action_variable) { - [action_variable] = await Resources.devices.getDeviceData(organization_id, { variables: ["action_list_variable", "action_group_variable"], qty: 1, groups: action_id }); - } - - if (!action_variable) { - console.debug("[Error] Update action: action_variable not found"); - void undoChanges(organization_id, scope); - return sendNotificationFeedback({ environment, title: "An error ocurred, please try again", message: "Error when editing alert" }); - } - - let device_list: string[] = []; - if (action_devices) { - device_list = (action_devices.value as string).split(";"); - } else if (action_group) { - device_list = await getGroupDevices(action_group.value as string); - } else { - const action_info = await Resources.actions.info(action_id); - if (!action_info.tags) { - throw "Action tags not found"; - } - const group_id = action_info.tags.find((tag) => tag.key === "group_id")?.value; - if (group_id) { - device_list = await getGroupDevices(group_id); - } else { - device_list = action_info.tags.filter((tag) => tag.key === "device_id").map((tag) => tag.value); - } - } - - const structure: ActionStructureParams = action_variable.metadata as any; - structure.variable = action_variable.value as string; - - if (action_condition) { - structure.condition = action_condition.value as string; - } - if (action_condition) { - structure.condition = action_condition.value as string; - } - if (action_value) { - const [value, value_2] = (action_value.value as string).split(";"); - if (value_2) { - structure.trigger_value2 = value_2; - } - structure.trigger_value = value; - } - - if (action_type) { - structure.type = action_type.value as string; - } - if (action_sendto) { - structure.send_to = action_sendto.value as string; - } - if (action_value && structure.condition === "><" && (action_value.value as string)?.split(";").length !== 2) { - void undoChanges(organization_id, scope); - void sendNotificationFeedback({ environment, message: "Invalid between condition, you must enter the value such as: 2;15" }); - throw `[Error] Invalid between value: ${action_value.value}`; - } - - const action_structure = generateActionStructure(structure, device_list); - - if (structure.variable === "checkin") { - await checkInAlertSet(action_id, structure.trigger_value as number, device_list); - } - - await Resources.actions.edit(action_id, action_structure).catch(async (error) => { - console.debug("[Error] ", error); - // Simple way to remove the edited fields and add it back again with the old value; - void undoChanges(organization_id, scope); - await sendNotificationFeedback({ environment, message: error }); - return error; - }); - - await Resources.devices.deleteDeviceData(organization_id, { variables: ["action_list_variable", "action_group_variable"], groups: action_id }); - await Resources.devices.sendDeviceData(organization_id, { ...action_variable, metadata: structure }); -} - -export { editAlert, listDeviceAction, addDeviceToAlert }; diff --git a/src/services/alerts/geofence-alert.ts b/src/services/alerts/geofence-alert.ts deleted file mode 100644 index dfbb0ad..0000000 --- a/src/services/alerts/geofence-alert.ts +++ /dev/null @@ -1,261 +0,0 @@ -import { isPointWithinRadius } from "geolib"; - -import { Resources } from "@tago-io/sdk"; -import { ActionInfo, ConfigurationParams, Data, TagoContext } from "@tago-io/sdk/lib/types"; - -import { ActionStructureParams } from "./register"; -import { IAlertTrigger, sendAlert } from "./send-alert"; - -/** - * The function checks if our device is inside a polygon geofence - * @param point Point on map, latitude and longitude - * @param geofence List of the geofences - */ -function insidePolygon(point: [number, number], geofence: Data["metadata"]) { - if (!geofence) { - throw "Invalid geofence"; - } - const x = point[1]; - const y = point[0]; - let inside = false; - for (let i = 0, j = geofence.length - 1; i < geofence.length; j = i++) { - const xi = geofence[i][0]; - const yi = geofence[i][1]; - const xj = geofence[j][0]; - const yj = geofence[j][1]; - const intersect = yi > y != yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi; - if (intersect) { - inside = !inside; - } - } - return inside; -} - -type ILatitude = number; -type ILongitude = number; -interface IGeofenceMetadata { - event: string; - geolocation: { - type: string; - radius: number; - coordinates: [ILongitude, ILatitude]; - }; - id: string; - eventColor: string; - eventDescription: string; -} -function getGeofenceResult(check_list: boolean[], geofence_list: Data["metadata"][]): IGeofenceMetadata[] { - return check_list - .map((x, index) => { - if (!x) { - return; - } - - return geofence_list[index]; - }) - .filter((x) => x) as any; -} - -/** - * The function checks if our device is inside any geofence - * @param point Point on map, latitude and longitude - * @param geofence_list List of the geofences - */ -function checkZones(point: [number, number], geofence_list: Data["metadata"][]): IGeofenceMetadata[] { - let geofences: IGeofenceMetadata[] = []; - - // The line below gets all Polygon geofences that we may have. - const polygons = geofence_list.filter((x) => x?.geolocation.type === "Polygon"); - if (polygons.length > 0) { - // Here we check if our device is inside any Polygon geofence using our function above. - const pass_check = polygons.map((x) => insidePolygon(point, x?.geolocation.coordinates[0])); - geofences = geofences.concat(getGeofenceResult(pass_check, polygons)); - } - - // The line below gets all Point (circle) geofences that we may have. - const circles = geofence_list.filter((x) => x?.geolocation.type === "Point"); - if (circles.length === 0) { - return geofences; - } - - // Here we check if our device is inside any Point geofence using a third party library called geolib. - const pass_check = circles.map((x) => - isPointWithinRadius( - { latitude: point[1], longitude: point[0] }, - { - latitude: x?.geolocation.coordinates[0], - longitude: x?.geolocation.coordinates[1], - }, - x?.geolocation.radius - ) - ); - - geofences = geofences.concat(getGeofenceResult(pass_check, circles)); - - return geofences; -} - -type IAlertToBeSent = Omit; -/** - * The function returns the list of alerts that are outside the geofence zone - * @param outsideZones Zones that are outside the geofence - * @param deviceParams Configuration parameter of the device - * @param device_id Device id - */ -async function getAlertList(outsideZones: IGeofenceMetadata[], deviceParams: ConfigurationParams[], device_id: string) { - const alerts: IAlertToBeSent[] = []; - for (const item of outsideZones) { - const action_info: ActionInfo = await Resources.actions.info(item.event); - if (!action_info) { - console.debug(`Action not found ${item.event}`); - continue; - } - if (!action_info.trigger || !action_info.tags) { - throw "Invalid action"; - } - - const devices = action_info.trigger.map((x: any) => x.device).filter((x) => x); - - if (!devices.includes(device_id)) { - continue; - } - - const alertParam = deviceParams.find((param) => param.key === item.event); - if (alertParam?.sent) { - continue; - } - - const send_to = action_info.tags - .find((x) => x.key === "send_to") - ?.value?.replace(/;/g, ",") - .split(","); - const action_type = action_info.tags - .find((x) => x.key === "action_type") - ?.value?.replace(/;/g, ",") - .split(","); - - if (!send_to || !action_type) { - throw "Invalid action type and send to"; - } - const action_device = action_info.tags.find((x) => x.key === "device")?.value as string; - - await Resources.devices.paramSet(device_id, { ...alertParam, key: item.event, value: "geofence", sent: true }); - alerts.push({ action_id: item.event, send_to, type: action_type, device: action_device }); - } - - return alerts; -} - -interface ILocationData { - lat: ILatitude; - lng: ILongitude; -} -interface IGeofenceAlert { - coordinates: ILocationData; - device_id: string; -} - -/** - * Add this function to the analysis that is receiving the location variable somehow. - * It can be used on statusUpdater or uplinkHandler, depending on how often you want to check the alert. - * @param context Context of the analysis, to retrieve the token - * @param locationData lat and lng of device current position - */ -async function geofenceAlertTrigger(context: TagoContext, locationData: IGeofenceAlert) { - const { coordinates, device_id } = locationData; - - const { tags } = await Resources.devices.info(device_id); - const org_id = tags.find((tag) => tag.key === "organization_id")?.value as string; - const group_id = tags.find((tag) => tag.key === "group_id")?.value as string; - const subgroup_id = tags.find((tag) => tag.key === "subgroup_id")?.value as string; - let geofences: Data[] = []; - - if (org_id) { - let geofence_list = await Resources.devices.getDeviceData(org_id, { variables: "geofence_alerts", qty: 100 }); - geofence_list = geofence_list.map((x) => ({ ...x, metadata: { ...x.metadata, device: org_id } })); - geofences = geofences.concat(geofence_list); - } - - // Support for group owners, if the application has it. - if (group_id) { - let geofence_list = await Resources.devices.getDeviceData(group_id, { variables: "geofence_alerts", qty: 100 }); - geofence_list = geofence_list.map((x) => ({ ...x, metadata: { ...x.metadata, device: group_id } })); - geofences = geofences.concat(geofence_list); - } - - // Support for subgroup owners, if the application has it. - if (subgroup_id) { - let geofence_list = await Resources.devices.getDeviceData(subgroup_id, { variables: "geofence_alerts", qty: 100 }); - geofence_list = geofence_list.map((x) => ({ ...x, metadata: { ...x.metadata, device: subgroup_id } })); - geofences = geofences.concat(geofence_list); - } - - // Filter the geofences and send the alerts if any - const geofenceMetadataList = geofences.map((geofences) => geofences.metadata) as IGeofenceMetadata[]; - const zones = checkZones([coordinates.lng, coordinates.lat], geofenceMetadataList); - - const outsideZones = geofenceMetadataList.filter((x) => x.eventColor === "green" && !zones.some((y) => y.event === x.event)); - const insideZones = geofenceMetadataList.filter((x) => x.eventColor === "red" && zones.find((y) => y.event === x.event)); - - const alertsToReset = geofenceMetadataList.filter((x) => !outsideZones.some((y) => y.event === x.event) && !insideZones.some((y) => y.event === x.event)); - - const deviceParams = await Resources.devices.paramList(device_id); - for (const alert of alertsToReset) { - const param = deviceParams.find((param) => param.key.includes(alert.event)); - if (!param) { - continue; - } - - void Resources.devices.paramSet(device_id, { ...param, sent: false }); - } - - let alerts: IAlertToBeSent[] = []; - if (outsideZones.length > 0) { - alerts = alerts.concat(await getAlertList(outsideZones, deviceParams, device_id)); - } - - if (insideZones.length > 0) { - alerts = alerts.concat(await getAlertList(insideZones, deviceParams, device_id)); - } - - for (const alert of alerts) { - const mockData: any = { - variable: "location", - value: `${coordinates.lat},${coordinates.lng}`, - device: device_id, - time: new Date(), - }; - - await sendAlert(context, org_id, { ...alert, data: mockData }); - } -} - -/** - * Add this function to alert Handler in order to add the needed variable for geofence events - * @param device_id Organization/Group/Etc device id that will have the event stored - * @param action_id Id of the action - * @param structure structure of the action - */ -async function geofenceAlertCreate(device_id: string, action_id: string, structure: ActionStructureParams) { - const condition = structure.trigger_value as string; - await Resources.devices.sendDeviceData(device_id, { - variable: "action_geofence", - value: structure.name, - metadata: { color: condition.includes("Sair") || condition.includes("Outside") ? "green" : "red" }, - group: action_id, - }); -} - -/** - * Add this function to alert Handler when editing geofence alerts. - * @param device_id Organization/Group/Etc device id that will have the event stored - * @param action_id Id of the action - * @param structure structure of the action - */ -async function geofenceAlertEdit(device_id: string, action_id: string, structure: ActionStructureParams) { - await Resources.devices.deleteDeviceData(device_id, { variables: "action_geofence", groups: action_id }); - - void geofenceAlertCreate(device_id, action_id, structure); -} - -export { IGeofenceAlert, geofenceAlertEdit, geofenceAlertCreate, geofenceAlertTrigger }; diff --git a/src/services/alerts/register.ts b/src/services/alerts/register.ts deleted file mode 100644 index a5fa3bd..0000000 --- a/src/services/alerts/register.ts +++ /dev/null @@ -1,285 +0,0 @@ -import { Resources } from "@tago-io/sdk"; -import { Data, DataToSend, DeviceListItem } from "@tago-io/sdk/lib/types"; - -import { parseTagoObject } from "../../lib/data.logic"; -import { fetchDeviceList } from "../../lib/fetch-device-list"; -import { getAnalysisByTagID } from "../../lib/find-resource"; -import { RouterConstructorData } from "../../types"; -import { checkInAlertSet } from "./check-in-alerts"; -import { geofenceAlertCreate } from "./geofence-alert"; - -/** - * The function returns the group that was configured in the alert - * @param group_id Id of the group - * @param groupKey Tag key that will be used in the device list - */ -async function getGroupDevices(group_id: string, groupKey: string = "group_id") { - const list: DeviceListItem[] = await fetchDeviceList({ - tags: [ - { key: groupKey, value: group_id }, - { key: "device_type", value: "device" }, //? - ], - }); - - return list.map((x) => x.id); -} -/** - * Function that reverses the logic of the alert so that we can use it in the trigger unlock of the action - * @param condition Condition of the action - */ -function reverseCondition(condition: string) { - switch (condition) { - case "=": - return "!"; - case "!": - return "="; - case ">": - return "<"; - case "<": - return ">"; - default: - break; - } -} - -interface ActionStructureParams { - group_id?: string; - org_id: string; - - send_to: string; - type: string; - - trigger_value: string | number; - trigger_value2?: string | number; - variable: string; - condition: string; - - script: string; - device: string; - name?: string; -} -/** - * Function that generate the structure of the action create - * @param structure Parameters used to create the structure - * @param device_ids Device id list - */ -function generateActionStructure(structure: ActionStructureParams, device_ids: string[]) { - const action_structure: any = { - active: true, - name: `Application alert trigger ${structure.group_id ? "GROUP" : "DEVICE"}`, - tags: [ - { key: "group_id", value: structure.group_id || "N/A" }, - { key: "organization_id", value: structure.org_id }, - { key: "device", value: structure.device }, - { key: "name", value: structure.name || "" }, - { key: "send_to", value: structure.send_to.replaceAll(" ", "") }, - { key: "action_type", value: structure.type.replaceAll(" ", "") }, - ], - type: "condition", - trigger: [], - action: { - type: "script", - script: [structure.script], - }, - }; - - const variables = structure.variable.split(","); - for (const device_id of device_ids) { - for (const variable of variables) { - action_structure.trigger.push({ - is: structure.condition, - value: String(structure.trigger_value), - value_type: "number", - variable, - device: device_id, - second_value: structure.trigger_value2, - }); - - if (structure.type !== "><") { - action_structure.trigger.push({ - is: reverseCondition(structure.condition), - unlock: true, - value: String(structure.trigger_value), - value_type: "number", - variable, - device: device_id, - second_value: structure.trigger_value2 || "", - }); - } else { - action_structure.trigger.push({ - is: "<", - unlock: true, - value: String(structure.trigger_value), - value_type: "number", - variable, - device: device_id, - second_value: "", - }); - - action_structure.trigger.push({ - is: ">", - unlock: true, - value: String(structure.trigger_value2), - value_type: "number", - variable, - device: device_id, - second_value: "", - }); - } - } - } - - // Add a random trigger, so the API can accept it. - if (action_structure.trigger.length === 0) { - action_structure.trigger.push({ - is: "=", - value: String(structure.trigger_value), - value_type: "number", - variable: "not_used_and_doesnt_exist", - tag_key: "tag_not_used_and_doesnt_exist", - tag_value: "temp_value", - second_value: "", - }); - } - - return action_structure; -} - -/** - * Main function of creating alerts - * @param environment Environment Variable is a resource to send variables values to the context of your script - * @param scope Number of devices that will be listed - */ -async function createAlert({ environment, scope }: RouterConstructorData) { - if (!environment || !scope) { - throw new Error("Missing parameters"); - } - const resources = new Resources({ token: environment.ACCOUNT_TOKEN }); - const organization_id = scope[0].device; - await Resources.devices.sendDeviceData(organization_id, { variable: "action_validation", value: "#VAL.CREATING_ALERT#", metadata: { type: "warning" } }); - - // Get the fields from the Input widget. - const action_group = scope.find((x) => x.variable === "action_group_list"); - const action_dev_list = scope.find((x) => x.variable === "action_device_list" && x.metadata?.sentValues); - const action_sendto = scope.find((x) => x.variable === "action_sendto"); - - const action_variable = scope.find((x) => x.variable === "action_variable"); - let action_condition: Data | DataToSend | undefined = scope.find((x) => x.variable === "action_condition"); - let action_value = scope.find((x) => x.variable === "action_value"); - const action_value2 = scope.find((x) => x.variable === "action_value2"); - - const action_type = scope.find((x) => x.variable === "action_type"); - const action_message = scope.find((x) => x.variable === "action_message"); - - const action_name = scope.find((x) => x.variable === "action_name")?.value as string; - - let groupKey = scope.find((x) => x.variable === "action_groupkey")?.value as string; - if (!groupKey) { - groupKey = "group_id"; - } - - // const action_unlock_variable = scope.find((x) => x.variable === "action_unlock_variable"); - // const action_unlock_condition = scope.find((x) => x.variable === "action_unlock_condition"); - // const action_unlock_value = scope.find((x) => x.variable === "action_unlock_value"); - - const action_value_unit = scope.find((x) => x.variable === "action_value_unit"); - - if (action_value_unit?.value === "F" && action_value?.value) { - action_value.value = (((Number(action_value?.value) - 32) * 5) / 9).toFixed(2); - if (action_value2?.value) { - action_value2.value = (((Number(action_value2?.value) - 32) * 5) / 9).toFixed(2); - } - } - - if (!action_condition?.value) { - action_value = scope.find((x) => x.variable === "action_binary_value"); - action_condition = { device: scope[0].device, time: new Date(), variable: "action_condition", value: "=" }; - } - - // Validate all the fields. This is just a double check so we don't run in unexpected behaviour. - if (!action_variable || !action_variable.value) { - throw 'Missing "action_variable" in the data scope.'; - } else if (!action_condition || !action_condition.value) { - throw 'Missing "action_condition" in the data scope.'; - } else if (!action_type || !action_type.value) { - throw 'Missing "action_type" in the data scope.'; - } else if (!action_message || !action_message.value) { - throw 'Missing "action_message" in the data scope.'; - } else if (!action_value || !action_value.value) { - throw 'Missing "action_value" in the data scope.'; - } - - let device_list: string[] = []; - - if (action_dev_list?.metadata?.sentValues) { - device_list = action_dev_list?.metadata?.sentValues.map((x) => x.value as string); - } else if (action_group) { - const group_id = action_group.value as string; - const group_exist = await Resources.devices.info(group_id); - if (!group_exist) { - throw `Group ${action_group?.metadata?.label} couldn't be found.`; - } - device_list = await getGroupDevices(group_id, groupKey); - } - - const script_id = await getAnalysisByTagID(resources, "alertTrigger"); - - if (!action_sendto?.value) { - throw "Missing action_sendto"; - } - - // Create the action structure. - const structure: ActionStructureParams = { - org_id: organization_id, - group_id: action_group?.value as string, - script: script_id, - - trigger_value: action_value?.value as string, - trigger_value2: action_value2?.value as string, - - send_to: action_sendto.value as string, - condition: action_condition.value as string, - type: action_type?.value as string, - variable: action_variable.value as string, - device: scope[0].device, - name: action_name, - }; - const action_structure = generateActionStructure(structure, device_list); - - const { action: action_id } = await Resources.actions.create(action_structure).catch(async (error) => { - await Resources.devices.sendDeviceData(organization_id, { variable: "action_validation", value: error, metadata: { type: "danger" } }); - throw error; - }); - // Store the data in the device, so we can see and edit it in the Dynamic Table. - // It's very important that the group is the action ID, so we can use it to edit/delete later. - let data_to_tago: DataToSend[] = parseTagoObject( - { - action_list_variable: { value: action_variable.value, metadata: structure }, - action_list_condition: action_condition.value, - action_list_value: !action_value2 ? action_value.value : `${action_value.value},${action_value2.value}`, - action_list_type: { value: action_type.value, metadata: action_type.metadata }, - action_list_sendto: { value: action_sendto.value, metadata: action_sendto.metadata }, - action_list_message: action_message.value, - }, - action_id - ); - - if (action_dev_list) { - data_to_tago.push({ variable: "action_list_devices", value: action_dev_list.value, metadata: action_dev_list.metadata, group: action_id }); - } else if (action_group) { - data_to_tago.push({ variable: "action_group_group", value: action_group.value, metadata: action_group.metadata, group: action_id }); - - // change action_list to action_group, in order to show up on alert group list. - data_to_tago = data_to_tago.map((x) => ({ ...x, variable: x.variable.replace("action_list", "action_group") })); - } - - await Resources.devices.sendDeviceData(organization_id, data_to_tago); - await Resources.devices.sendDeviceData(organization_id, { variable: "action_validation", value: "#ALC.CREATE_SUCCESS#", metadata: { type: "success" } }); - if (structure.variable === "geofence") { - await geofenceAlertCreate(organization_id, action_id, structure); - } else if (structure.variable === "checkin") { - await checkInAlertSet(action_id, structure.trigger_value as number, device_list); - } -} - -export { createAlert, getGroupDevices, generateActionStructure, ActionStructureParams }; diff --git a/src/services/alerts/remove.ts b/src/services/alerts/remove.ts deleted file mode 100644 index a4cb3b2..0000000 --- a/src/services/alerts/remove.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { Resources } from "@tago-io/sdk"; -import { ActionInfo } from "@tago-io/sdk/lib/types"; - -import { RouterConstructorData } from "../../types"; - -/** - * Function to be used externally when need to remove a device from an alert. - * @param action_id Id of the action that will be removed - * @param device_id Device id of the action that will be removed - */ -async function removeDeviceFromAlert(action_id: string, device_id: string) { - const action_info: Partial> & { trigger: any } = (await Resources.actions.info(action_id)) as any; - - if (!action_id || !device_id) { - throw new Error("Missing parameters"); - } - - if (!action_info.tags) { - throw new Error("Action not found"); - } - delete action_info.created_at; - delete action_info.updated_at; - delete action_info.last_triggered; - delete action_info.description; - delete action_info.id; - - action_info.tags = action_info.tags.filter((tag) => tag.value !== device_id); - action_info.trigger = action_info.trigger.filter((trigger: any) => trigger.device !== device_id); - - // Add a random trigger, so the API can accept it. - if (action_info.trigger.length === 0) { - action_info.trigger.push({ - is: "<", - unlock: true, - value: "0", - value_type: "number", - variable: "not_used_and_doesnt_exist", - tag_key: "tag_not_used_and_doesnt_exist", - tag_value: "temp_value", - second_value: "", - }); - } - - await Resources.actions.edit(action_id, action_info); -} - -/** - * Main delete alert function. - * @param environment Environment Variable is a resource to send variables values to the context of your script - * @param scope Number of devices that will be listed - */ -async function deleteAlert({ environment, scope }: RouterConstructorData) { - if (!environment || !scope) { - throw new Error("Missing parameters"); - } - - const { group } = scope[0]; - if (!group) { - throw new Error("Group not found"); - } - - const organization_id = scope[0].device; - - await Resources.devices.deleteDeviceData(organization_id, { groups: group }); - - const action_info = await Resources.actions.info(group); - if (!action_info.trigger) { - throw new Error("Action not found"); - } - - await Resources.actions.delete(group); - const devices = [...new Set(action_info.trigger.map((x: any) => x.device).filter((x) => x))]; - for (const device_id of devices) { - const params = await Resources.devices.paramList(device_id); - const paramToDelete = params.find((x) => x.key.includes(group)); - if (!paramToDelete?.id) { - throw new Error("Param not found"); - } - if (paramToDelete) { - await Resources.devices.paramRemove(device_id, paramToDelete?.id); - } - } -} - -export { deleteAlert, removeDeviceFromAlert }; diff --git a/src/services/alerts/send-alert.ts b/src/services/alerts/send-alert.ts deleted file mode 100644 index df8d410..0000000 --- a/src/services/alerts/send-alert.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { Resources, Services } from "@tago-io/sdk"; -import { Data, TagoContext, UserInfo } from "@tago-io/sdk/lib/types"; - -import { checkAndChargeUsage } from "../plan/check-and-charge-usage"; - -interface IMessageDetail { - device_name: string; - device_id: string; - sensor_type: string; - value: string; - variable: string; -} -/** - * Function that replace the message variables - * @param message Message that will be replaced - * @param replace_details Object with the variables that will be replaced - */ -function replaceMessage(message: string, replace_details: IMessageDetail) { - for (const key of Object.keys(replace_details)) { - message = message.replaceAll(new RegExp(`#${key}#`, "g"), (replace_details as any)[key]); - } - - return message; -} - -/** - * Function that get the users that will receive the alert - * @param send_to List of users that will receive the alert - */ -async function getUsers(send_to: string[]) { - const func_list = send_to.map((user_id) => Resources.run.userInfo(user_id).catch(() => null)); - - return (await Promise.all(func_list)).filter((x) => x) as UserInfo[]; -} - -interface IAlertTrigger { - action_id: string; - data: Data; - send_to: string[]; - type: string[]; - device: string; -} - -/** - * Function that send the alert to the users - * @param context Context is a variable sent by the analysis - * @param org_id Organization ID that will be used to charge the usage - * @param alert Alert that will be sent - */ -async function sendAlert(context: TagoContext, org_id: string, alert: IAlertTrigger) { - const { data, action_id: alert_id, send_to, type, device } = alert; - - // Get action message - const [message_var] = await Resources.devices.getDeviceData(device, { variables: ["action_list_message", "action_group_message"], groups: alert_id, qty: 1 }); - - const device_id = data.device; - const device_info = await Resources.devices.info(device_id); - if (!device_info.tags) { - throw new Error("Device tags not found"); - } - const sensor_type = device_info?.tags?.find((tag) => tag.key === "sensor")?.value; - if (!sensor_type) { - throw new Error("Sensor type not found"); - } - const replace_details: IMessageDetail = { - device_name: device_info?.name, - device_id: device_info?.id, - sensor_type: sensor_type, - value: String(data?.value), - variable: data?.variable, - }; - - const message = replaceMessage(message_var.value as string, replace_details); - - const users_info = await getUsers(send_to); - - const to_dispatch_qty = users_info.length; - - if (type.includes("notification_run")) { - const has_service_limit = await checkAndChargeUsage(context, org_id, to_dispatch_qty, "notification_run"); - - if (has_service_limit) { - for (const user of users_info) { - void Resources.run.notificationCreate(user.id, { - message, - title: "Alert Trigger", - }); - } - } else { - await Resources.devices.sendDeviceData(org_id, { - variable: "plan_status", - value: `Attempt to send ${to_dispatch_qty} alert(s) was not successful. No notification service limit available, check your service usage at "Info" to learn more about your plan status.`, - }); - } - } - - if (type.includes("email")) { - const has_service_limit = await checkAndChargeUsage(context, org_id, to_dispatch_qty, "email"); - - if (has_service_limit) { - const email = new Services({ token: context.token }).email; - - void email.send({ - to: users_info.map((x) => x.email).join(","), - template: { - name: "email_alert", - params: { - device_name: device_info.name, - alert_message: message, - }, - }, - }); - } else { - await Resources.devices.sendDeviceData(org_id, { - variable: "plan_status", - value: `Attempt to send ${to_dispatch_qty} alert(s) was not successful. No email service limit available, check your service usage at "Info" to learn more about your plan status.`, - }); - } - } - - if (type.includes("sms")) { - const has_service_limit = await checkAndChargeUsage(context, org_id, to_dispatch_qty, "sms"); - - if (has_service_limit) { - for (const user of users_info) { - const smsService = new Services({ token: context.token }).sms; - if (!user.phone) { - throw new Error("User phone not found"); - } - void smsService - .send({ - message, - to: user.phone, - }) - .then((msg) => console.debug(msg)); - } - } else { - await Resources.devices.sendDeviceData(org_id, { - variable: "plan_status", - value: `Attempt to send ${to_dispatch_qty} alert(s) was not successful. No SMS service limit available, check your service usage at "Info" to learn more about your plan status.`, - }); - } - } -} - -export { sendAlert, IAlertTrigger }; diff --git a/src/services/device/README.MD b/src/services/device/README.MD deleted file mode 100644 index 53b835e..0000000 --- a/src/services/device/README.MD +++ /dev/null @@ -1,38 +0,0 @@ -# Device Folder Overview -This folder is responsible for the Sensor device handling. Mostly actions are triggered by the "Groups" dashboard. - -## Files -The Device folder contains the following files: -* Register a sensor - register.ts -* Edit a sensor - edit.ts -* Remove a sensor - remove.ts -* Place a sensor (pin) on a group plan - placeSensor.ts -* Stores sensor's pin information - deviceInfo.ts - -### Diagram - -:::mermaid -sequenceDiagram - participant RUN Application - participant Handler Analysis - participant Device Script Folder - participant TagoIO - alt register.ts - RUN Application ->> Handler Analysis: "Create New" command trigger - Handler Analysis ->> Device Script Folder: Redirects - Device Script Folder -->> TagoIO: Create dev_id data - Device Script Folder -->> TagoIO: Creates new device on TagoIO - end - alt edit.ts - RUN Application ->> Handler Analysis: Controls "Edit" command trigger - Handler Analysis ->> Device Script Folder: Redirects - Device Script Folder -->> TagoIO: Edit dev_id data - Device Script Folder -->> TagoIO: Edit sensor's device parameters - end - alt delete.ts - RUN Application ->> Handler Analysis: Controls "Delete" command trigger - Handler Analysis ->> Device Script Folder: Redirects - Device Script Folder -->> TagoIO: Delete dev_id data - Device Script Folder -->> TagoIO: Delete sensor's device - end - \ No newline at end of file diff --git a/src/services/device/device-info.ts b/src/services/device/device-info.ts deleted file mode 100644 index 10de6bf..0000000 --- a/src/services/device/device-info.ts +++ /dev/null @@ -1,19 +0,0 @@ -interface SensorInfo { - [key: string]: { desc: string; icon: string; color: string }; -} - -export const sensor_status_true: SensorInfo = { - motion: { desc: "motion", icon: "https://api.tago.io/file/612e6ab5ce34b90011757801/person_motion.svg", color: "#E40C13" }, - leak: { desc: "leakage", icon: "https://api.tago.io/file/612e6ab5ce34b90011757801/water.svg", color: "#E40C13" }, - door: { desc: "open", icon: "https://api.tago.io/file/612e6ab5ce34b90011757801/door_open.svg", color: "" }, - window: { desc: "open", icon: "https://api.tago.io/file/612e6ab5ce34b90011757801/Opened%20Window.svg", color: "#E40C13" }, -}; - -export const sensor_status_false: SensorInfo = { - motion: { desc: "normal(no motion)", icon: "https://api.tago.io/file/612e6ab5ce34b90011757801/person_no_motion.svg", color: "#B8B8B8" }, - leak: { desc: "normal(no leakage)", icon: "https://api.tago.io/file/612e6ab5ce34b90011757801/No%20Water.svg", color: "#B8B8B8" }, - door: { desc: "close", icon: "https://api.tago.io/file/612e6ab5ce34b90011757801/door_close.svg", color: "#0E9A43" }, - window: { desc: "close", icon: "https://api.tago.io/file/612e6ab5ce34b90011757801/Closed%20Window.svg", color: "#0E9A43" }, - tracker: { desc: "-", icon: "https://api.tago.io/file/612e6ab5ce34b90011757801/wifi.svg", color: "#2F3065" }, - humidity_temp: { desc: "-", icon: "https://api.tago.io/file/612e6ab5ce34b90011757801/thermometer-snow.svg", color: "#2F3065" }, -}; diff --git a/src/services/device/edit.ts b/src/services/device/edit.ts deleted file mode 100644 index b681955..0000000 --- a/src/services/device/edit.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { Resources } from "@tago-io/sdk"; - -import { parseTagoObject } from "../../lib/data.logic"; -import { getDashboardByConnectorID } from "../../lib/find-resource"; -import { RouterConstructorDevice } from "../../types"; -import { sensor_status_false } from "./device-info"; - -/** - * Function that validate parameters - */ -async function validateParams({ scope, environment }: RouterConstructorDevice) { - if (!environment || !scope) { - throw new Error("Missing parameters"); - } - const dev_id = (scope[0] as any).device; - if (!dev_id) { - return; - } -} - -/** - * Function that handle device name change - */ -async function handleDeviceNameChange(org_id: string, dev_id: string, new_dev_name: string, current_group_id: string) { - if (new_dev_name && current_group_id) { - const [old_dev_id] = await Resources.devices.getDeviceData(current_group_id, { variables: "dev_id", groups: dev_id, qty: 1 }); - const old_group_data = await Resources.devices.getDeviceData(current_group_id, { variables: "dev_id", groups: dev_id }); - const old_org_data = await Resources.devices.getDeviceData(org_id, { variables: "dev_id", groups: dev_id }); - - await Resources.devices.editDeviceData(org_id, { ...old_group_data, ...old_dev_id }); - await Resources.devices.editDeviceData(current_group_id, { ...old_org_data, ...old_dev_id }); - } -} - -/** - * Function that handle group id change - */ -async function handleGroupIdChange(dev_id: string, new_group_id: string, current_group_id: string, org_id: string, type: string) { - if (new_group_id || new_group_id === "") { - const { name: current_device_name, tags: current_device_tags, connector } = await Resources.devices.info(dev_id); - - //removing data from the last group - if (current_group_id && current_group_id !== "") { - await Resources.devices.deleteDeviceData(current_group_id, { variables: "dev_id", groups: dev_id }); - } - - const new_device_tags = current_device_tags.filter((x) => x.key !== "group_id"); - new_device_tags.push({ key: "group_id", value: new_group_id }); - await Resources.devices.edit(dev_id, { tags: new_device_tags }); - - const device_params = await Resources.devices.paramList(dev_id); - const go_to_param = device_params.find((x) => x.key === "dashboard_url"); - - const { id: dash_id } = await getDashboardByConnectorID(connector); - - const new_url = `https://admin.tago.io/dashboards/info/${dash_id}?org_dev=${org_id}&sensor=${dev_id}`; - - await Resources.devices.paramSet(dev_id, { ...go_to_param, value: new_url }); - - const to_tago = parseTagoObject( - { - dev_id: { - value: dev_id, - metadata: { - label: current_device_name, - url: new_url, - icon: sensor_status_false[type]?.icon || "wifi", - status: "unknwon", - type, - }, - }, - }, - dev_id - ); - - await Resources.devices.sendDeviceData(new_group_id, to_tago); - } -} - -/** - * Main function of editing devices - * @param scope Scope is a variable sent by the analysis - * @param environment Environment Variable is a resource to send variables values to the context of your script - */ -async function sensorEdit({ scope, environment }: RouterConstructorDevice) { - await validateParams({ scope, environment }); - - const dev_id = (scope[0] as any).device; - const new_group_id = (scope[0] as any)["param.dev_group"]; - const new_dev_name = (scope[0] as any).name; - - const { tags: device_tags } = await Resources.devices.info(dev_id); - - const org_id = device_tags.find((x) => x.key === "organization_id")?.value; - if (!org_id) { - throw new Error("Organization not found"); - } - - const type = device_tags.find((x) => x.key === "sensor")?.value; - if (!type) { - throw new Error("Sensor type not found"); - } - const current_group_id = device_tags.find((x) => x.key === "group_id")?.value as string; - - await handleDeviceNameChange(org_id, dev_id, new_dev_name, current_group_id); - await handleGroupIdChange(dev_id, new_group_id, current_group_id, org_id, type); -} - -export { sensorEdit }; diff --git a/src/services/device/place-sensor.ts b/src/services/device/place-sensor.ts deleted file mode 100644 index ee303ab..0000000 --- a/src/services/device/place-sensor.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { Resources } from "@tago-io/sdk"; - -import { getDashboardByTagID } from "../../lib/find-resource"; -import { initializeValidation } from "../../lib/validation"; -import { RouterConstructorData } from "../../types"; - -/** - * Place Sensor on the map - * @param scope Scope is a variable sent by the analysis - * @param environment Environment Variable is a resource to send variables values to the context of your script - */ -async function sensorPlacement({ scope, environment }: RouterConstructorData) { - if (!environment || !scope) { - throw new Error("Missing parameters"); - } - const { device: group_id } = scope[0]; - - const sensor_id = scope.find((x) => x.variable === "set_dev_pin_id"); - const sensor_location = scope.find((x) => x.variable === "set_dev_pin_location"); - if (!sensor_id || !sensor_location) { - throw new Error("Missing parameters sensor_id or sensor_location"); - } - - const validate = initializeValidation("placepin_validation", group_id); - await validate("#VAL.PLACING_THE_PIN#", "warning").catch((error) => console.error(error)); - - const [dev_id] = await Resources.devices.getDeviceData(group_id, { variables: "dev_id", groups: sensor_id.value as string, qty: 1 }); - - await Resources.devices.editDeviceData(group_id, { ...dev_id, location: sensor_location.location }); - - const dash_id = await getDashboardByTagID("dash_groupview"); - - await Resources.dashboards.edit(dash_id, {}); - - await validate("#VAL.PIN_PLACED_SUCCESSFULLY#", "success").catch((error) => console.error(error)); -} - -export { sensorPlacement }; diff --git a/src/services/device/register.ts b/src/services/device/register.ts deleted file mode 100644 index 728c70c..0000000 --- a/src/services/device/register.ts +++ /dev/null @@ -1,186 +0,0 @@ -import { Device, Resources } from "@tago-io/sdk"; -import { DeviceCreateInfo } from "@tago-io/sdk/lib/types"; - -import { createDashURL } from "../../lib/create-dash-url"; -import { parseTagoObject } from "../../lib/data.logic"; -import { fetchDeviceList } from "../../lib/fetch-device-list"; -import { getDashboardByConnectorID } from "../../lib/find-resource"; -import { initializeValidation } from "../../lib/validation"; -import { DeviceCreated, RouterConstructorData } from "../../types"; - -interface installDeviceParam { - new_dev_name: string; - org_id: string; - network_id: string; - connector: string; - new_device_eui: string; - type: string; - group_id?: string; -} - -/** - * Function that create devices - * @param new_dev_name Name of the device - * @param org_id Organization id that devices will be created - * @param network_id Network id that devices will be created - * @param connector Connector id that devices will be created - * @param new_device_eui Device eui configured by the user - * @param type Sensor type of the device - * @param group_id Group id that devices will be created - */ -async function installDevice({ new_dev_name, org_id, network_id, connector, new_device_eui, type, group_id }: installDeviceParam) { - //data retention set to 1 month - const device_data: DeviceCreateInfo = { - name: new_dev_name, - network: network_id, - serie_number: new_device_eui, - connector, - type: "immutable", - chunk_period: "month", - chunk_retention: 1, - }; - - //creating new device - const new_dev = await Resources.devices.create(device_data); - - const new_tags = { - tags: [ - { key: "device_id", value: new_dev.device_id }, - { key: "organization_id", value: org_id }, - { key: "device_type", value: "device" }, - { key: "sensor", value: type }, - { key: "dev_eui", value: new_device_eui }, - ], - }; - - if (group_id) { - new_tags.tags.push({ key: "group_id", value: group_id }); - } - - await Resources.devices.edit(new_dev.device_id, new_tags); - - const new_org_dev = new Device({ token: new_dev.token }); - - return { ...new_dev, device: new_org_dev } as DeviceCreated; -} - -/** - * Main function of creating devices - * @param context Context is a variable sent by the analysis - * @param scope Number of devices that will be listed - * @param environment Environment Variable is a resource to send variables values to the context of your script - */ -async function sensorAdd({ context, scope, environment }: RouterConstructorData) { - if (!environment || !scope || !context) { - throw new Error("Missing parameters"); - } - - const org_id = scope[0].device; - - const validate = initializeValidation("dev_validation", org_id); - await validate("#VAL.REGISTERING#", "warning").catch((error) => console.error(error)); - - const sensor_qty = await fetchDeviceList({ - tags: [ - { key: "device_type", value: "device" }, - { key: "organization_id", value: org_id }, - ], - }); - - if (sensor_qty.length >= 5) { - return validate("#VAL.LIMIT_OF_5_DEVICES_REACHED#", "danger"); - } - //Collecting data - const new_dev_name = scope.find((x) => x.variable === "new_dev_name"); - const new_dev_eui = scope.find((x) => x.variable === "new_dev_eui"); - const new_dev_group = scope.find((x) => x.variable === "new_dev_group"); - const new_dev_type = scope.find((x) => x.variable === "new_dev_type"); - const new_dev_network = scope.find((x) => x.variable === "new_dev_network"); - - if (!new_dev_name || !new_dev_group || !new_dev_type || !new_dev_network) { - throw new Error("Missing variables"); - } - if ((new_dev_name?.value as string).length < 3) { - throw validate("#VAL.NAME_FIELD_IS_SMALLER_THAN_3_CHAR#", "danger"); - } - - if (!new_dev_type?.value) { - throw validate("#VAL.DEVICE_TYPE_NOT_FOUND_PLEASE_SELECT_AGAIN_THE_DEVICE_TYPE#", "danger"); - } - - //If choosing for the simulator, we generate a random EUI - const dev_eui = (new_dev_eui?.value as string)?.toUpperCase() || String(Math.ceil(Math.random() * 10_000_000)); - - const dev_exists = await fetchDeviceList({ tags: [{ key: "dev_eui", value: dev_eui }] }); - - if (dev_exists.length > 0) { - console.debug("Sensor EUI already in use."); - return validate("Sensor EUI already in use.", "danger"); - } - - const group_id = new_dev_group?.value as string; - - const connector_id = new_dev_type.value as string; - - let dash_id = ""; - try { - ({ id: dash_id } = await getDashboardByConnectorID(connector_id)); - } catch (_error) { - return validate("#VAL.ERROR__NO_DASHBOARD_FOUND#", "danger"); - } - - const dash_info = await Resources.dashboards.info(dash_id); - const type = dash_info.blueprint_devices.find((bp) => bp.conditions[0].key === "sensor"); - if (!type) { - return validate("#VAL.ERROR__DASHBOARD_IS_MISSING_THE_BLUEPRINT_DEVICE_SENSOR#", "danger"); - } - - const { device_id } = await installDevice({ - new_dev_name: new_dev_name.value as string, - org_id, - network_id: new_dev_network.value as string, - connector: connector_id, - new_device_eui: dev_eui, - type: type.conditions[0].value, - group_id, - }); - - const url = createDashURL(dash_id, { org_dev: org_id, sensor: device_id }); - - const dev_data = parseTagoObject( - { - dev_id: { - value: device_id, - metadata: { - label: new_dev_name.value, - url, - status: "unknwon", - type: dash_info.type, - }, - }, - }, - device_id - ); - - await Resources.devices.paramSet(device_id, { - key: "dashboard_url", - value: url, - sent: false, - }); - - await Resources.devices.paramSet(device_id, { key: "dev_eui", value: dev_eui, sent: false }); - await Resources.devices.paramSet(device_id, { key: "dev_group", value: (new_dev_group?.metadata?.label as string) || "", sent: false }); - await Resources.devices.paramSet(device_id, { key: "dev_lastcheckin", value: "-", sent: false }); - await Resources.devices.paramSet(device_id, { key: "dev_battery", value: "-", sent: false }); - - const add_to_dropdown_list = parseTagoObject({ asset_list: new_dev_name.value }, device_id); - await Resources.devices.sendDeviceData(org_id, dev_data.concat(add_to_dropdown_list)); - - if (group_id) { - await Resources.devices.sendDeviceData(new_dev_group.value as string, dev_data); - } - - return validate("#VAL.DEVICE_CREATED_SUCCESSFULLY#", "success"); -} - -export { sensorAdd }; diff --git a/src/services/device/remove.ts b/src/services/device/remove.ts deleted file mode 100644 index 8db1c42..0000000 --- a/src/services/device/remove.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { Resources } from "@tago-io/sdk"; -import { DeviceListScope } from "@tago-io/sdk/lib/modules/Utils/router/router.types"; - -import { fetchDeviceList } from "../../lib/fetch-device-list"; -import { RouterConstructorDevice } from "../../types"; - -/** - * Function that remove aggregation data from the organization - * @param org_id Organization id that devices will be created - */ -async function removeAggregationData(org_id: string) { - const soil_devices = await fetchDeviceList({ - tags: [ - { key: "device_type", value: "device" }, - { key: "sensor", value: "soil" }, - { key: "organization_id", value: org_id }, - ], - }); - - if (soil_devices.length === 0) { - await Resources.devices.deleteDeviceData(org_id, { variables: ["temperature_maximum", "temperature_average", "temperature_minimum"], qty: 9999 }); - } -} - -/** - * Main function of deleting devices - * @param scope Scope is a variable sent by the analysis - * @param environment Environment Variable is a resource to send variables values to the context of your script - */ -async function sensorDel({ scope, environment }: RouterConstructorDevice & { scope: DeviceListScope[] }) { - const config_id = environment.config_id; - if (!config_id) { - throw "[Error] No config device ID: config_id."; - } - - const dev_id = scope[0].device; - const device_info = await Resources.devices.info(dev_id); - if (!device_info?.tags) { - throw new Error("Device not found"); - } - - const group_id = device_info.tags.find((tag) => tag.key === "group_id")?.value; - const org_id = device_info.tags.find((tag) => tag.key === "organization_id")?.value; - - if (group_id) { - await Resources.devices.deleteDeviceData(group_id, { groups: dev_id, qty: 9999 }); - } - - await Resources.devices.deleteDeviceData(config_id, { groups: dev_id, qty: 9999 }); - - await Resources.devices.delete(dev_id); - - if (org_id) { - await Resources.devices.deleteDeviceData(org_id, { groups: dev_id, qty: 9999 }); - await removeAggregationData(org_id); - } - return console.debug("Device deleted!"); -} - -export { sensorDel }; diff --git a/src/services/group/README.MD b/src/services/group/README.MD deleted file mode 100644 index 5adefc4..0000000 --- a/src/services/group/README.MD +++ /dev/null @@ -1,42 +0,0 @@ -# Group Folder Overview -This folder is responsible for the Group device handling. Mostly actions are triggered by the "Groups" dashboard. - -## Files -The Group folder contains the following files: -* Register a plan - register.ts -* Edit a plan - edit.ts -* Remove a plan - remove.ts - -### Diagram - -:::mermaid -sequenceDiagram - participant RUN Application - participant Handler Analysis - participant Group Script Folder - participant TagoIO - alt register.ts - RUN Application ->> Handler Analysis: "Create New" command trigger - Handler Analysis ->> Group Script Folder: Redirects - Group Script Folder -->> TagoIO: Creates new group device (listed on "Group List" table) - Group Script Folder -->> TagoIO: Creates group_id data - end - alt edit.ts - RUN Application ->> Handler Analysis: Controls "Edit" command trigger - Handler Analysis ->> Group Script Folder: Redirects - Group Script Folder -->> TagoIO: Edit group_id data - Group Script Folder -->> TagoIO: Edit group's device parameters - loop All sensors that have the Group assigned - Group Script Folder -->> TagoIO: Edit sensor's group - end - end - alt delete.ts - RUN Application ->> Handler Analysis: Controls "Delete" command trigger - Handler Analysis ->> Group Script Folder: Redirects - Group Script Folder -->> TagoIO: Delete group_id data - Group Script Folder -->> TagoIO: Delete group's device - loop All sensors that have the Group assigned - Group Script Folder -->> TagoIO: Remove group from the sensor - end - end - \ No newline at end of file diff --git a/src/services/group/edit.ts b/src/services/group/edit.ts deleted file mode 100644 index a86d9cb..0000000 --- a/src/services/group/edit.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { Resources } from "@tago-io/sdk"; -import { DeviceListScope } from "@tago-io/sdk/lib/modules/Utils/router/router.types"; - -import { deviceNameExists } from "../../lib/device-name-exists"; -import { sendNotificationFeedback } from "../../lib/send-notification"; -import { undoDeviceChanges } from "../../lib/undo-device-changes"; -import { RouterConstructorDevice } from "../../types"; - -/** - * Main function of editing groups - * @param scope Scope is a variable sent by the analysis - * @param environment Environment Variable is a resource to send variables values to the context of your script - */ -async function groupEdit({ scope, environment }: RouterConstructorDevice & { scope: DeviceListScope[] }) { - if (!environment || !scope) { - throw new Error("Missing parameters"); - } - const group_id = scope[0].device; - const new_group_name = scope[0].name; - - if (new_group_name) { - const group_info = await Resources.devices.info(group_id); - const org_id = group_info.tags.find((x) => x.key === "organization_id")?.value as string; - const is_device_name_exists = await deviceNameExists({ - name: new_group_name, - tags: [ - { key: "device_type", value: "group" }, - { key: "organization_id", value: org_id }, - ], - isEdit: true, - }); - - if (is_device_name_exists) { - await undoDeviceChanges({ deviceInfo: group_info, scope }); - await sendNotificationFeedback({ environment, message: `The organization with name ${new_group_name} already exists.` }); - throw `The organization with name ${new_group_name} already exists.`; - } - } - - const { tags } = await Resources.devices.info(group_id); - if (!tags) { - throw new Error("Tags not found"); - } - const org_id = tags.find((tag) => tag.key === "organization_id")?.value; - if (!org_id) { - throw new Error("Organization id not found"); - } - - const [group_id_data] = await Resources.devices.getDeviceData(org_id, { variables: "group_id", groups: group_id, qty: 1 }); - if (group_id_data) { - await Resources.devices.deleteDeviceData(org_id, { variables: "group_id", groups: group_id }); - await Resources.devices.sendDeviceData(org_id, { ...group_id_data, metadata: { ...group_id_data.metadata, label: new_group_name } }); - } - - return console.debug("Group edited!"); -} - -export { groupEdit }; diff --git a/src/services/group/register.ts b/src/services/group/register.ts deleted file mode 100644 index 8b6da57..0000000 --- a/src/services/group/register.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { Device, Resources } from "@tago-io/sdk"; -import { DeviceCreateInfo } from "@tago-io/sdk/lib/types"; - -import { parseTagoObject } from "../../lib/data.logic"; -import { deviceNameExists } from "../../lib/device-name-exists"; -import { fetchDeviceList } from "../../lib/fetch-device-list"; -import { getDashboardByTagID } from "../../lib/find-resource"; -import { initializeValidation } from "../../lib/validation"; -import { DeviceCreated, RouterConstructorData } from "../../types"; - -interface installDeviceParam { - new_group_name: string; - org_id: string; -} -/** - * Function that create groups - * @param new_group_name Group name that will be created - * @param org_id Organization id that the group will be created - */ -async function installDevice({ new_group_name, org_id }: installDeviceParam) { - //structuring data - const device_data: DeviceCreateInfo = { - name: new_group_name, - network: "5bbd0d144051a50034cd19fb", - connector: "5f5a8f3351d4db99c40dece5", - type: "mutable", - }; - - //creating new device - const new_group = await Resources.devices.create(device_data); - - //inserting device id -> so we can reference this later - await Resources.devices.edit(new_group.device_id, { - tags: [ - { key: "group_id", value: new_group.device_id }, - { key: "organization_id", value: org_id }, - { key: "device_type", value: "group" }, - ], - }); - - //instantiating new device - const new_org_dev = new Device({ token: new_group.token }); - - //token, device_id, bucket_id - return { ...new_group, device: new_org_dev } as DeviceCreated; -} - -/** - * Main function of creating groups - * @param context Context is a variable sent by the analysis - * @param scope Number of devices that will be listed - * @param environment Environment Variable is a resource to send variables values to the context of your script - */ -async function groupAdd({ context, scope, environment }: RouterConstructorData) { - if (!environment || !scope || !context) { - throw new Error("Missing parameters"); - } - const org_id = scope[0].device; - - const validate = initializeValidation("group_validation", org_id); - await validate("#VAL.REGISTERING#", "warning").catch((error) => console.error(error)); - - const group_qty = await fetchDeviceList({ - tags: [ - { key: "device_type", value: "group" }, - { key: "organization_id", value: org_id }, - ], - }); - - if (group_qty.length >= 2) { - return await validate("#VAL.LIMIT_OF_2_GROUPS_REACHED#", "danger").catch((error) => console.error(error)); - } - - //Collecting data - // const new_group_org = scope.find((x) => x.variable === "new_group_org"); - const new_group_name = scope.find((x) => x.variable === "new_group_name"); - const new_group_address = scope.find((x) => x.variable === "new_group_address"); - - if (!new_group_name) { - throw new Error("new_group_name is missing"); - } - - if ((new_group_name.value as string).length < 3) { - throw await validate("#VAL.NAME_FIELD_IS_SMALLER_THAN_3_CHAR#", "danger").catch((error) => console.error(error)); - } - - const [group_exists] = await Resources.devices.getDeviceData(org_id, { variables: "group_name", values: new_group_name.value, qty: 1 }); /** */ - - const is_device_name_exists = await deviceNameExists({ - name: new_group_name.value as string, - tags: [ - { key: "device_type", value: "group" }, - { key: "organization_id", value: org_id }, - ], - }); - - if (is_device_name_exists) { - throw await validate("#VAL.GROUP_ALREADY_EXISTS#", "danger").catch((error) => console.error(error)); - } - - if (group_exists) { - throw await validate("#VAL.GROUP_ALREADY_EXISTS#", "danger").catch((error) => console.error(error)); - } - - const { device_id: group_id, device: group_dev } = await installDevice({ new_group_name: new_group_name.value as string, org_id }); - - const group_data = { - group_id: { - value: group_id, - metadata: { - label: new_group_name.value, - }, - }, - }; - - const dash_organization_id = await getDashboardByTagID("dash_groupview"); - - await Resources.devices.paramSet(group_id, { - key: "dashboard_url", - value: `https://admin.tago.io/dashboards/info/${dash_organization_id}?group_dev=${group_id}&org_dev=${org_id}`, - sent: false, - }); - await Resources.devices.paramSet(group_id, { key: "group_address", value: (new_group_address?.value as string) || "N/A", sent: false }); - - //send to organization device - await Resources.devices.sendDeviceData(org_id, parseTagoObject(group_data, group_id)); - - //uploading a default layer - await group_dev.sendData({ - value: "Layer #1", - variable: "layers", - metadata: { - file: { - path: "buckets/6127d8d10ceb400012b53fc3/layers/Floor Plan Right.png", - url: "https://api.tago.io/file/61b2f46e561da800197a9c43/Floor%20Plan%20with%20Watermark.png", - md5: "", - }, - }, - }); - - return validate("#VAL.GROUP_SUCCESSFULLY_CREATED#", "success"); -} - -export { groupAdd }; diff --git a/src/services/group/remove.ts b/src/services/group/remove.ts deleted file mode 100644 index 9795831..0000000 --- a/src/services/group/remove.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { Resources } from "@tago-io/sdk"; -import { DeviceListScope } from "@tago-io/sdk/lib/modules/Utils/router/router.types"; - -import { fetchDeviceList } from "../../lib/fetch-device-list"; -import { sendNotificationFeedback } from "../../lib/send-notification"; -import { RouterConstructorDevice } from "../../types"; - -/** - * Main function of deleting groups - * @param scope Scope is a variable sent by the analysis - * @param environment Environment Variable is a resource to send variables values to the context of your script - */ -async function groupDel({ scope, environment }: RouterConstructorDevice & { scope: DeviceListScope[] }) { - if (!environment || !scope) { - throw new Error("Missing parameters"); - } - - const config_id = environment.config_id; - if (!config_id) { - throw "[Error] No config device ID: config_id."; - } - - const group_id = scope[0].device; - if (!group_id) { - return; - } - - const group_info = await Resources.devices.info(group_id); - if (!group_info?.tags) { - throw new Error("Group not found"); - } - const org_id = group_info.tags.find((x) => x.key === "organization_id")?.value; - if (!org_id) { - throw new Error("Organization id not found"); - } - - const [is_used_by_sensor] = await fetchDeviceList({ - tags: [ - { key: "device_type", value: "device" }, - { key: "sensor", value: "soil" }, - { key: "group_id", value: group_id }, - ], - }); - - if (is_used_by_sensor) { - await sendNotificationFeedback({ environment, message: `Group is being used by a sensor - ${is_used_by_sensor.name}` }); - throw new Error("Group is being used by a sensor"); - } - - //delete from settings_device - await Resources.devices.deleteDeviceData(config_id, { groups: group_id, qty: 9999 }); - //delete from org_dev - await Resources.devices.deleteDeviceData(org_id, { groups: group_id, qty: 9999 }); - - //deleting users (site's user) - const user_accounts = await Resources.run.listUsers({ filter: { tags: [{ key: "group_id", value: group_id }] } }); - if (user_accounts) { - for (const user of user_accounts) { - if (!user.id) { - throw new Error("User not found"); - } - await Resources.run.userDelete(user.id); - await Resources.devices.deleteDeviceData(org_id, { groups: user.id, qty: 9999 }).then((msg) => console.debug(msg)); - await Resources.devices.deleteDeviceData(config_id, { groups: user.id, qty: 9999 }); - } - } - - //to comment ~ should not delete the sensors but remove the sensor's group name - //deleting site's device - - const devices = await fetchDeviceList({ tags: [{ key: "group_id", value: group_id }] }); - - if (devices) { - for (const device of devices) { - await Resources.devices.delete(device.id); /*passing the device id*/ - await Resources.devices.deleteDeviceData(org_id, { groups: device.id, qty: 9999 }).then((msg) => msg); - await Resources.devices.deleteDeviceData(config_id, { groups: device.id, qty: 9999 }); - } - } - - return console.debug("Group deleted!"); -} - -export { groupDel }; diff --git a/src/services/organization/README.MD b/src/services/organization/README.MD deleted file mode 100644 index 1c6a9e5..0000000 --- a/src/services/organization/README.MD +++ /dev/null @@ -1,39 +0,0 @@ -# Organization Folder Overview -This folder is responsible for the organization device handling. Mostly actions are triggered by the "Admin" dashboard. - -## Files -The Organization folder contains the following files: -* Register a organization - register.ts -* Edit a organization - edit.ts -* Remove a organization - remove.ts - -### Diagram - -:::mermaid -sequenceDiagram - participant RUN Application - participant Handler Analysis - participant Organization Script Folder - participant TagoIO - alt register.ts - RUN Application ->> Handler Analysis: "Create New" command trigger - Handler Analysis ->> Organization Script Folder: Redirects - Organization Script Folder -->> TagoIO: Creates new organization device (listed on "Organization List" table) - Organization Script Folder -->> TagoIO: Creates org_id data - end - alt edit.ts - RUN Application ->> Handler Analysis: Controls "Edit" command trigger - Handler Analysis ->> Organization Script Folder: Redirects - Organization Script Folder -->> TagoIO: Edit org_id data - Organization Script Folder -->> TagoIO: Edit organization parameters - end - alt delete.ts - RUN Application ->> Handler Analysis: Controls "Delete" command trigger - Handler Analysis ->> Organization Script Folder: Redirects - Organization Script Folder -->> TagoIO: Delete org_id data - Organization Script Folder -->> TagoIO: Delete organization's users - Organization Script Folder -->> TagoIO: Delete organization's sensor device - Organization Script Folder -->> TagoIO: Delete organization's group device - Organization Script Folder -->> TagoIO: Delete organization device - end - \ No newline at end of file diff --git a/src/services/organization/edit.ts b/src/services/organization/edit.ts deleted file mode 100644 index bcf274f..0000000 --- a/src/services/organization/edit.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { Resources } from "@tago-io/sdk"; -import { DeviceListScope } from "@tago-io/sdk/lib/modules/Utils/router/router.types"; -import { AnalysisEnvironment, ConfigurationParams } from "@tago-io/sdk/lib/types"; - -import { deviceNameExists } from "../../lib/device-name-exists"; -import { sendNotificationFeedback } from "../../lib/send-notification"; -import { undoDeviceChanges } from "../../lib/undo-device-changes"; -import { RouterConstructorDevice } from "../../types"; - -/** - * Function that handle organization name change - */ -async function handleOrgNameChange(config_id: string, scope: DeviceListScope[], environment: AnalysisEnvironment, org_id: string) { - const new_org_name = scope[0]["name"]; - if (new_org_name) { - const is_device_name_exists = await deviceNameExists({ name: new_org_name, tags: [{ key: "device_type", value: "organization" }], isEdit: true }); - - if (is_device_name_exists) { - const orgInfo = await Resources.devices.info(org_id); - await undoDeviceChanges({ deviceInfo: orgInfo, scope }); - await sendNotificationFeedback({ environment, message: `The organization with name ${new_org_name} already exists.` }); - throw `The organization with name ${new_org_name} already exists.`; - } - - await Resources.devices.deleteDeviceData(config_id, { variables: "org_id", groups: org_id }); - const [org_id_data] = await Resources.devices.getDeviceData(config_id, { variables: "org_id", groups: org_id, qty: 1 }); - await Resources.devices.sendDeviceData(config_id, { ...org_id_data, metadata: { ...org_id_data.metadata, label: new_org_name } }); - await Resources.devices.edit(org_id, { name: new_org_name }); - } -} - -/** - * Function that handle auth token change - */ -async function handleAuthTokenChange(config_id: string, org_id: string, org_auth_token_param: ConfigurationParams) { - if (org_auth_token_param) { - const [org_auth_token] = await Resources.devices.getDeviceData(config_id, { variables: "org_auth_token", qty: 1, groups: org_id }); - if (org_auth_token?.value) { - await Resources.serviceAuthorization.tokenEdit(org_auth_token.value as string, org_auth_token_param.value); - } - } -} - -/** - * Function that handle plan name change - */ -async function handlePlanNameChange(config_id: string, scope: DeviceListScope[], org_id: string) { - const new_plan_name = scope[0]["param.plan_name"]; - if (new_plan_name) { - const [plan_data] = await Resources.devices.getDeviceData(config_id, { variables: "plan_data", values: new_plan_name, qty: 1 }); - await Resources.devices.deleteDeviceData(org_id, { variables: "plan_data", qty: 9999 }); - await Resources.devices.sendDeviceData(org_id, { ...plan_data }); - - const org_params = await Resources.devices.paramList(org_id); - - const plan_name = org_params.find((x) => x.key === "plan_name") || { key: "plan_name", value: "", sent: false }; - const plan_email_limit = org_params.find((x) => x.key === "plan_email_limit") || { key: "plan_email_limit", value: "", sent: false }; - const plan_sms_limit = org_params.find((x) => x.key === "plan_sms_limit") || { key: "plan_sms_limit", value: "", sent: false }; - const plan_notif_limit = org_params.find((x) => x.key === "plan_notif_limit") || { key: "plan_notif_limit", value: "", sent: false }; - const plan_data_retention = org_params.find((x) => x.key === "plan_data_retention") || { key: "plan_data_retention", value: "", sent: false }; - - if (!plan_data.metadata) { - throw "Plan not found"; - } - - const org_info = await Resources.devices.info(org_id); - const org_tags = org_info.tags; - const new_org_tags = org_tags.filter((tag) => tag.key !== "plan_group"); - await Resources.devices.edit(org_id, { tags: [...new_org_tags, { key: "plan_group", value: plan_data.group as string }] }); - - await Resources.devices.paramSet(org_id, { ...plan_name, value: plan_data.value as string }); - await Resources.devices.paramSet(org_id, { ...plan_email_limit, value: String(plan_data.metadata.email_limit) }); - await Resources.devices.paramSet(org_id, { ...plan_sms_limit, value: String(plan_data.metadata.sms_limit) }); - await Resources.devices.paramSet(org_id, { ...plan_notif_limit, value: String(plan_data.metadata.notif_limit) }); - await Resources.devices.paramSet(org_id, { ...plan_data_retention, value: String(plan_data.metadata.data_retention) }); - } -} - -/** - * Function that handle org address change - */ -async function handleOrgAddressChange(config_id: string, scope: DeviceListScope[], org_id: string) { - const new_org_address = scope[0]["param.org_address"]; - if (new_org_address) { - const coordinates = (new_org_address as string).split(";")[0]; - const [org_id_data] = await Resources.devices.getDeviceData(config_id, { variables: "org_id", groups: org_id, qty: 1 }); - await Resources.devices.deleteDeviceData(config_id, { variables: "org_id", groups: org_id }); - await Resources.devices.sendDeviceData(config_id, { - ...org_id_data, - metadata: { ...org_id_data.metadata }, - location: { lat: Number(coordinates.split(",")[0]), lng: Number(coordinates.split(",")[1]) }, - }); - } -} - -/** - * Main function of editing organizations - * @param scope Scope is a variable sent by the analysis - * @param environment Environment Variable is a resource to send variables values to the context of your script - */ -async function orgEdit({ scope, environment }: RouterConstructorDevice & { scope: DeviceListScope[] }) { - if (!scope || !environment) { - throw "Missing Router parameter"; - } - - const config_id = environment.config_id; - if (!config_id) { - throw "[Error] No config device ID: config_id."; - } - - if (!scope[0]) { - return console.error("Not a valid TagoIO Data"); - } - - const org_id = scope[0].device; - const org_params = await Resources.devices.paramList(org_id); - const org_auth_token_param = org_params.find((x) => x.key === "org_auth_token") as ConfigurationParams; - - await handleOrgNameChange(config_id, scope, environment, org_id); - await handleAuthTokenChange(config_id, org_id, org_auth_token_param); - await handlePlanNameChange(config_id, scope, org_id); - await handleOrgAddressChange(config_id, scope, org_id); - - return console.debug("edited!"); -} - -export { orgEdit }; diff --git a/src/services/organization/register.ts b/src/services/organization/register.ts deleted file mode 100644 index 6d83660..0000000 --- a/src/services/organization/register.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { Resources } from "@tago-io/sdk"; -import { DeviceCreateInfo } from "@tago-io/sdk/lib/types"; - -import { parseTagoObject } from "../../lib/data.logic"; -import { deviceNameExists } from "../../lib/device-name-exists"; -import { getDashboardByTagID } from "../../lib/find-resource"; -import { initializeValidation } from "../../lib/validation"; -import { RouterConstructorData } from "../../types"; -import { userAdd } from "../user/register"; -import { orgDel } from "./remove"; - -interface installDeviceParam { - new_org_name: string; - new_org_plan_group: string; -} -/** - * Function that create organizations - * @param new_org_name Organization name configured by the user - * @param new_org_plan_group User configured plan - */ -async function installDevice({ new_org_name, new_org_plan_group }: installDeviceParam) { - //structuring data - const device_data: DeviceCreateInfo = { - name: new_org_name, - network: "5bbd0d144051a50034cd19fb", - connector: "5f5a8f3351d4db99c40dece5", - type: "mutable", - }; - - //creating new device - const new_org = await Resources.devices.create(device_data); - - //inserting device id -> so we can reference this later - await Resources.devices.edit(new_org.device_id, { - tags: [ - { key: "organization_id", value: new_org.device_id }, - { key: "user_org_id", value: new_org.device_id }, - { key: "device_type", value: "organization" }, - { key: "plan_group", value: new_org_plan_group }, - ], - }); - - return new_org.device_id; -} - -/** - * Main function of creating organizations - * @param context Context is a variable sent by the analysis - * @param scope Scope is a variable sent by the analysis - * @param environment Environment is a variable sent by the analysis - */ -async function orgAdd({ context, scope, environment }: RouterConstructorData) { - if (!("variable" in scope[0])) { - return console.error("Not a valid TagoIO Data"); - } - - const config_id = environment.config_id; - if (!config_id) { - throw "[Error] No config device ID: config_id."; - } - - const validate = initializeValidation("org_validation", config_id); - await validate("#VAL.RESGISTERING#", "warning").catch((error) => console.log(error)); - - //Collecting data - const new_org_name = scope.find((x) => x.variable === "new_org_name"); - const new_org_address = scope.find((x) => x.variable === "new_org_address"); - const new_org_plan = scope.find((x) => x.variable === "new_org_plan"); - - const new_org_plan_group = scope.find((x) => x.variable === "new_org_plan_group"); - - const new_user_name = scope.find((x) => x.variable === "new_orgadmin_name"); - const new_user_email = scope.find((x) => x.variable === "new_orgadmin_email"); - - if (!new_org_plan && !new_org_plan_group) { - throw await validate("Plan error, internal problem.", "danger").catch((error) => console.log(error)); - } - - if (new_user_email) { - const [user_exists] = await Resources.run.listUsers({ - page: 1, - amount: 1, - filter: { email: new_user_email.value as string }, - }); - - if (user_exists) { - throw await validate("#VAL.USER_ALREADY_EXISTS#", "danger").catch((error) => console.log(error)); - } - } - - if (!new_org_name) { - throw await validate("Name field is empty", "danger").catch((error) => console.log(error)); - } - - let [plan_data] = await Resources.devices.getDeviceData(config_id, { variables: "plan_data", values: new_org_plan?.value, qty: 1 }); - - //sign up route ~ place an environment variable "plan_group" on analysis [TagoIO] - User Signup - if (new_org_plan_group) { - [plan_data] = await Resources.devices.getDeviceData(config_id, { variables: "plan_data", groups: new_org_plan_group?.value as string, qty: 1 }); - } - if (!plan_data || !plan_data?.metadata) { - throw await validate("Plan error, internal problem.", "danger").catch((error) => console.log(error)); - } - const plan_name = plan_data.value as string; - - if ((new_org_name.value as string).length < 3) { - throw await validate("Name field is smaller than 3 character", "danger").catch((error) => console.log(error)); - } - - const is_device_name_exists = await deviceNameExists({ name: new_org_name.value as string, tags: [{ key: "device_type", value: "organization" }] }); - - if (is_device_name_exists) { - throw await validate(`The Organization with name ${new_org_name.value} already exists.`, "danger").catch(console.log); - } - - const service_authorization = new Resources({ token: environment.ACCOUNT_TOKEN }).serviceAuthorization; - const user_auth_token = await service_authorization.tokenCreate({ - name: `${new_org_name.value}_token`, - permission: "full", - }); - - //need device id to configure group in parseTagoObject - //creating new device - const device_id = await installDevice({ new_org_name: new_org_name.value as string, new_org_plan_group: plan_data.group as string }); - - const dash_organization_id = await getDashboardByTagID("dash_sensor_list"); - - await Resources.devices.paramSet(device_id, { - key: "dashboard_url", - value: `https://admin.tago.io/dashboards/info/${dash_organization_id}?settings=${config_id}&org_dev=${device_id}`, - }); - await Resources.devices.paramSet(device_id, { key: "org_address", value: (new_org_address?.value as string) || "N/A", sent: false }); - await Resources.devices.paramSet(device_id, { key: "org_auth_token", value: user_auth_token.token, sent: false }); - await Resources.devices.paramSet(device_id, { key: "_param", value: "", sent: false }); - await Resources.devices.paramSet(device_id, { key: "plan_name", value: plan_name, sent: false }); - await Resources.devices.paramSet(device_id, { key: "plan_email_limit", value: String(plan_data.metadata.email_limit), sent: false }); - await Resources.devices.paramSet(device_id, { key: "plan_sms_limit", value: String(plan_data.metadata.sms_limit), sent: false }); - await Resources.devices.paramSet(device_id, { key: "plan_notif_limit", value: String(plan_data.metadata.notif_limit), sent: false }); - await Resources.devices.paramSet(device_id, { key: "plan_data_retention", value: String(plan_data.metadata.data_retention), sent: false }); - await Resources.devices.paramSet(device_id, { key: "plan_email_limit_usage", value: "0", sent: false }); - await Resources.devices.paramSet(device_id, { key: "plan_sms_limit_usage", value: "0", sent: false }); - await Resources.devices.paramSet(device_id, { key: "plan_notif_limit_usage", value: "0", sent: false }); - - const org_data = { - org_id: { value: device_id, metadata: { label: new_org_name.value }, location: new_org_address?.location }, - }; - - await Resources.devices.sendDeviceData(config_id, parseTagoObject(org_data, device_id)); - - await Resources.devices.sendDeviceData(device_id, { ...plan_data }); - - if (new_user_name?.value) { - scope = scope.map((data) => ({ ...data, device: device_id })); - await userAdd({ context, scope, environment }).catch(async (error) => { - await orgDel({ scope: [{ device: device_id }], environment }).catch((error) => console.log(error)); - throw await validate(error, "danger").catch((error) => console.log(error)); - }); - } - - await validate("#VAL.ORGANIZATION_SUCCESSFULLY_CREATED#", "success").catch((error) => console.log(error)); - - return device_id; -} - -export { orgAdd }; diff --git a/src/services/organization/remove.ts b/src/services/organization/remove.ts deleted file mode 100644 index c97e9c9..0000000 --- a/src/services/organization/remove.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { Resources } from "@tago-io/sdk"; -import { DeviceListScope, RouterConstructor } from "@tago-io/sdk/lib/modules/Utils/router/router.types"; - -import { fetchDeviceList } from "../../lib/fetch-device-list"; -import { fetchUserList } from "../../lib/fetch-user-list"; - -/** - * Main function of deleting organizations - * @param scope Scope is a variable sent by the analysis - * @param environment Environment is a variable sent by the analysis - */ -async function orgDel({ scope, environment }: RouterConstructor & { scope: DeviceListScope[] }) { - if (!scope[0]) { - return console.error("Not a valid TagoIO Data"); - } - - const config_id = environment.config_id; - if (!config_id) { - throw "[Error] No config device ID: config_id."; - } - - //id of the org device - const org_id = scope[0].device; - - const params = await Resources.devices.paramList(org_id); - - const org_auth_token = params.find((x) => x.key === "org_auth_token"); - //deleting token - // const [org_auth_token] = await Resources.devices.getDeviceData(config_id, { variables: "org_auth_token", qty: 1, groups: org_id }); - if (org_auth_token?.value) { - // This should be made because the Acess Management doesn't have permission to delete tokens - const service_authorization = new Resources({ token: environment.ACCOUNT_TOKEN }).serviceAuthorization; - await service_authorization.tokenDelete(org_auth_token.value); - } - - //delete from settings_device - await Resources.devices.deleteDeviceData(config_id, { groups: org_id, qty: 9999 }); - - //deleting users (organization's user) - const user_accounts = await fetchUserList({ tags: [{ key: "organization_id", value: org_id }] }); - if (user_accounts) { - for (const user of user_accounts) { - await Resources.run.userDelete(user.id); - } - } - - //deleting organization's device - - const devices = await fetchDeviceList({ tags: [{ key: "organization_id", value: org_id }] }); - - if (devices) { - for (const x of devices) { - await Resources.devices.delete(x.id); /*passing the device id*/ - } - } - - return console.debug("Organization deleted"); -} - -export { orgDel }; diff --git a/src/services/plan/README.MD b/src/services/plan/README.MD deleted file mode 100644 index 0db66bf..0000000 --- a/src/services/plan/README.MD +++ /dev/null @@ -1,42 +0,0 @@ -# Plan Folder Overview -This folder is responsible for the plan system handling. Mostly actions are triggered by the "Plan Management" dashboard. - -## Files -The Plan folder contains the following files: -* Register a plan - register.ts -* Edit a plan - edit.ts -* Remove a plan - remove.ts -* Check and charge an organization service usage (email, sms and push notification) - checkAndChargeUsage.ts - -### Diagram - -:::mermaid -sequenceDiagram - participant RUN Application - participant Handler Analysis - participant Plan Script Folder - participant TagoIO - alt register.ts - RUN Application ->> Handler Analysis: "Create New" command trigger - Handler Analysis ->> Plan Script Folder: Redirects - Plan Script Folder -->> TagoIO: Sends new plan to "Plan List" table (create plan_data variable) - end - alt edit.ts - RUN Application ->> Handler Analysis: Controls "Edit" command trigger - Handler Analysis ->> Plan Script Folder: Redirects - Plan Script Folder -->> TagoIO: Edit plan_data - loop All organizations that have the plan assigned - Plan Script Folder -->> TagoIO: Edit organization's plan information - end - end - alt delete.ts - RUN Application ->> Handler Analysis: Controls "Delete" command trigger - Handler Analysis ->> Plan Script Folder: Redirects - Plan Script Folder -->> TagoIO: Delete plan_data - alt Plan is assigned to an organization - Plan Script Folder -->> TagoIO: Send the scope back (prevent to delete) - else Plan is not assigned to an organization - Plan Script Folder -->> TagoIO: Return - end - end - \ No newline at end of file diff --git a/src/services/plan/check-and-charge-usage.ts b/src/services/plan/check-and-charge-usage.ts deleted file mode 100644 index df65444..0000000 --- a/src/services/plan/check-and-charge-usage.ts +++ /dev/null @@ -1,182 +0,0 @@ -import { Resources, Utils } from "@tago-io/sdk"; -import { AnalysisEnvironment, ConfigurationParams, TagoContext } from "@tago-io/sdk/lib/types"; - -type CommunicationMean = "email" | "sms" | "notification_run"; -/** - * Function that check if the user has exceeded the limit of the plan - * @param type Type of communication mean that will be checked - * @param environment Environment of the analysis - */ -const checkTagoPlan = async (type: CommunicationMean, environment: AnalysisEnvironment) => { - // This should be made because the Access Management doesn't have permissions - const resources = new Resources({ token: environment.ACCOUNT_TOKEN }); - const { profile: profile_id } = await resources.run.info(); - - const tago_usage_statistic = await resources.profiles.usageStatisticList(profile_id, { date: String(new Date()), timezone: "UTC" }); - const tago_usage_limit = await resources.profiles.info(profile_id); - - if (!tago_usage_statistic || !tago_usage_limit) { - console.debug("INTERNAL ERROR - NO PROFILE INFO WAS CATCH"); - return true; - } - - if (type === "email") { - const email_tago_statistic = (tago_usage_statistic.at(-1) as any).email; - const email_tago_limit = (tago_usage_limit as any).allocation.email; - - if (email_tago_limit === email_tago_statistic) { - return false; - } - } else if (type === "sms") { - const sms_tago_statistic = (tago_usage_statistic.at(-1) as any).sms; - const sms_tago_limit = (tago_usage_limit as any).allocation.sms; - - if (sms_tago_limit === sms_tago_statistic) { - return false; - } - } else if (type === "notification_run") { - const notification_tago_statistic = (tago_usage_statistic.at(-1) as any).push_notification; - const notification_tago_limit = (tago_usage_limit as any).allocation.push_notification; - - if (notification_tago_limit === notification_tago_statistic) { - return false; - } - } - - return true; -}; -/** - * Function that send limit alert to the user - * @param context Context is a variable sent by the analysis - * @param org_id Organization ID that will be used to check the plan - * @param current_email_usage Current email usage of the organization - * @param current_sms_usage Current sms usage of the organization - * @param service_type Type of service that will be checked - */ -const sendLimitAlert = async (_context: TagoContext, org_id: string, _current_email_usage: string, _current_sms_usage: string, service_type: string) => { - //guest will not receive the notification/email - const users_list = await Resources.run.listUsers({ amount: 9999, fields: ["id", "name", "email"], filter: { tags: [{ key: "organization_id", value: org_id }] } }); - - // const [plan_data] = await Resources.devices.getDeviceData(org_id, { variables: "plan_data", qty: 1 }); - - // const email = new Services({ token: context.token }).email; - - const notif_string = `Your plan has exceed the ${service_type} service limit! Check your service usage at "Info" to learn more about your plan status.`; - - for (const user of users_list) { - //UNCOMENT THOSE LINES TO WARN USE THROUGH EMAIL AS WELL! - - // await email - // .send({ - // to: user.email, - // template: { - // name: "plan_alert", - // params: { - // name: user.name, - // plan: plan_data.value as string, - // email_limit: plan_data.metadata.email_limit, - // email_usage: current_email_usage, - // sms_limit: plan_data.metadata.sms_limit, - // sms_usage: current_sms_usage, - // service_type, - // }, - // }, - // }) - // .catch((msg) => console.debug(msg)); - - await Resources.run.notificationCreate(user.id, { title: "Service Limit", message: notif_string }).catch((error) => console.debug(error)); - } -}; - -/** - * Function that check if the organization has exceeded the limit of the plan - * @param plan_sms_limit The limit of the plan - * @param current_plan_count The current count of the plan - */ -const orgHasLimit = (plan_sms_limit: string, current_plan_count: number): boolean => { - if (Number(plan_sms_limit) >= current_plan_count) { - return true; - } else { - return false; - } -}; - -/** - * Function that check if the organization has exceeded the limit of the plan - * @param context Context is a variable sent by the analysis - * @param org_id Organization ID that will be used to check the plan - * @param to_dispatch_qty Quantity of the dispatches that will be sent - * @param type Type of communication mean that will be checked - */ -async function checkAndChargeUsage(context: TagoContext, org_id: string, to_dispatch_qty: number, type: CommunicationMean) { - //id of the org device - const org_params = await Resources.devices.paramList(org_id); - - const plan_email_limit = org_params.find((x) => x.key === "plan_email_limit") as ConfigurationParams; - const plan_sms_limit = org_params.find((x) => x.key === "plan_sms_limit") as ConfigurationParams; - const plan_notif_limit = org_params.find((x) => x.key === "plan_notif_limit") as ConfigurationParams; - const plan_sms_limit_usage = org_params.find((x) => x.key === "plan_sms_limit_usage") || { key: "plan_sms_limit_usage", value: "0", sent: false }; - const plan_email_limit_usage = org_params.find((x) => x.key === "plan_email_limit_usage") || { key: "plan_email_limit_usage", value: "0", sent: false }; - const plan_notif_limit_usage = org_params.find((x) => x.key === "plan_notif_limit_usage") || { key: "plan_notif_limit_usage", value: "0", sent: false }; - - const environment = Utils.envToJson(context.environment); - - //checking if the admin.tago.io profile has limit available - if (!(await checkTagoPlan(type, environment))) { - return false; - } - if (type === "email") { - const current_plan_count = Number(plan_email_limit_usage.value) + to_dispatch_qty; - - const org_has_limit = orgHasLimit(plan_email_limit.value, current_plan_count); - - if (org_has_limit) { - await Resources.devices.paramSet(org_id, { ...plan_email_limit_usage, value: String(current_plan_count), sent: false }); //SET NEW SERVICE USAGE - return true; - } else if (!org_has_limit && !plan_email_limit_usage.sent) { - await sendLimitAlert(context, org_id, plan_email_limit_usage.value, plan_sms_limit_usage.value, "email"); - - await Resources.devices.paramSet(org_id, { ...plan_email_limit_usage, sent: true }); - - return false; - } - - return false; - } else if (type === "sms") { - const current_plan_count = Number(plan_sms_limit_usage.value) + to_dispatch_qty; - - const org_has_limit = orgHasLimit(plan_sms_limit.value, current_plan_count); - - if (org_has_limit) { - await Resources.devices.paramSet(org_id, { ...plan_sms_limit_usage, value: String(current_plan_count), sent: false }); //SET NEW SERVICE USAGE - return true; - } else if (!org_has_limit && !plan_sms_limit_usage.sent) { - await sendLimitAlert(context, org_id, plan_sms_limit_usage.value, plan_sms_limit_usage.value, "SMS"); - - await Resources.devices.paramSet(org_id, { ...plan_sms_limit_usage, sent: true }); - - return false; - } - - return false; - } else if (type === "notification_run") { - const current_plan_count = Number(plan_notif_limit_usage.value) + to_dispatch_qty; - - const org_has_limit = orgHasLimit(plan_notif_limit.value, current_plan_count); - - if (org_has_limit) { - await Resources.devices.paramSet(org_id, { ...plan_notif_limit_usage, value: String(current_plan_count), sent: false }); //SET NEW SERVICE USAGE - return true; - } else if (!org_has_limit && !plan_notif_limit_usage.sent) { - await sendLimitAlert(context, org_id, plan_notif_limit_usage.value, plan_notif_limit_usage.value, "notification_run"); - - await Resources.devices.paramSet(org_id, { ...plan_notif_limit_usage, sent: true }); - - return false; - } - - return false; - } -} - -export { checkAndChargeUsage }; diff --git a/src/services/plan/edit.ts b/src/services/plan/edit.ts deleted file mode 100644 index fdcc34e..0000000 --- a/src/services/plan/edit.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { Resources } from "@tago-io/sdk"; -import { Data } from "@tago-io/sdk/lib/types"; - -import { fetchDeviceList, FetchDeviceResponse } from "../../lib/fetch-device-list"; -import { sendNotificationFeedback } from "../../lib/send-notification"; -import { RouterConstructorData } from "../../types"; - -/** - * Function that resolves the report of the organization and send it to the user - */ -const resolveOrg = async (org: FetchDeviceResponse, plan_data: Data) => { - if (!org || !plan_data || !plan_data.metadata) { - throw new Error("Missing parameters"); - } - //changing plan_data variable - const org_id = org.id; - - const old_plan_data = await Resources.devices.getDeviceData(org_id, { variables: "plan_data", query: "last_item" }); - - await Resources.devices.editDeviceData(org_id, { ...old_plan_data, ...plan_data }); - - //changing params - const org_params = await Resources.devices.paramList(org_id); - const plan_name = org_params.find((x) => x.key === "plan_name"); - const plan_email_limit = org_params.find((x) => x.key === "plan_email_limit") || { key: "plan_email_limit", value: "", sent: false }; - const plan_sms_limit = org_params.find((x) => x.key === "plan_sms_limit") || { key: "plan_sms_limit", value: "", sent: false }; - const plan_notif_limit = org_params.find((x) => x.key === "plan_notif_limit") || { key: "plan_notif_limit", value: "", sent: false }; - const plan_data_retention = org_params.find((x) => x.key === "plan_data_retention") || { key: "plan_data_retention", value: "", sent: false }; - - await Resources.devices.paramSet(org_id, { ...plan_name, value: plan_data.value as string }); - await Resources.devices.paramSet(org_id, { ...plan_email_limit, value: String(plan_data.metadata.email_limit) }); - await Resources.devices.paramSet(org_id, { ...plan_sms_limit, value: String(plan_data.metadata.sms_limit) }); - await Resources.devices.paramSet(org_id, { ...plan_notif_limit, value: String(plan_data.metadata.notif_limit) }); - await Resources.devices.paramSet(org_id, { ...plan_data_retention, value: String(plan_data.metadata.data_retention) }); -}; - -/** - * Function that validates the limit values of the plan variables and throws an error if the value is invalid or negative integer - */ -async function _validateLimitValues( - plan_email_limit: Data | undefined, - plan_sms_limit: Data | undefined, - plan_notif_limit: Data | undefined, - plan_data_retention: Data | undefined -) { - if (plan_email_limit && ((plan_email_limit.value as number) < 0 || !Number.isInteger(plan_email_limit.value))) { - return Promise.reject("Email Limit must be a non-negative integer value"); - } - if (plan_sms_limit && ((plan_sms_limit.value as number) < 0 || !Number.isInteger(plan_sms_limit.value))) { - return Promise.reject("SMS Limit must be a non-negative integer value"); - } - if (plan_notif_limit && ((plan_notif_limit.value as number) < 0 || !Number.isInteger(plan_notif_limit.value))) { - return Promise.reject("Push Notification Limit must be a non-negative integer value"); - } - if (plan_data_retention && ((plan_data_retention.value as number) < 0 || !Number.isInteger(plan_data_retention.value))) { - return Promise.reject("Data Retention must be a non-negative integer value"); - } -} - -/** - * Undo Plan changes in the settings device. - */ -async function _undoPlanChanges(scope: Data[], config_id: string) { - const groups = scope[0].group; - const variables = await Resources.devices.getDeviceData(config_id, { variables: ["plan_email_limit", "plan_sms_limit", "plan_notif_limit", "plan_data_retention"], groups }); - for (const variable of variables) { - const variableValue = variable.variable.replace(new RegExp("plan_" + "_?"), ""); - if (variable.variable === `plan_${variableValue}`) { - variable.value = scope.find((x) => x.variable === `plan_${variableValue}`)?.metadata?.old_value ?? variable.value; - } - } - await Resources.devices.editDeviceData(config_id, variables); -} - -/** - * Main function of editing plan by admin account - * @param context Context is a variable sent by the analysis - * @param scope Scope is a variable sent by the analysis - * @param environment Environment Variable is a resource to send variables values to the context of your script - */ -async function planEdit({ context, scope, environment }: RouterConstructorData) { - if (!environment || !scope || !context) { - throw new Error("Missing parameters"); - } - - const config_id = environment.config_id; - if (!config_id) { - throw "[Error] No config device ID: config_id."; - } - - const plan_name = scope.find((x) => x.variable === "plan_data"); - const plan_email_limit = scope.find((x) => x.variable === "plan_email_limit"); - const plan_sms_limit = scope.find((x) => x.variable === "plan_sms_limit"); - const plan_notif_limit = scope.find((x) => x.variable === "plan_notif_limit"); - const plan_data_retention = scope.find((x) => x.variable === "plan_data_retention"); - - await _validateLimitValues(plan_email_limit, plan_sms_limit, plan_notif_limit, plan_data_retention).catch(async (error) => { - await _undoPlanChanges(scope, config_id); - await sendNotificationFeedback({ environment, message: error }); - throw error; - }); - - const plan_group = scope[0].group; - - const [plan_data] = await Resources.devices.getDeviceData(config_id, { variables: "plan_data", groups: plan_group, qty: 1 }); - - if (!plan_data.value || !plan_data.metadata) { - throw new Error("Plan not found"); - } - - const org_dev_list = await fetchDeviceList({ - tags: [ - { key: "device_type", value: "organization" }, - { key: "plan_group", value: plan_data.group as string }, - ], - }); - - //change plan_data - if (plan_name) { - plan_data.value = plan_name.value; - } - if (plan_email_limit) { - plan_data.metadata.email_limit = plan_email_limit.value; - } - if (plan_sms_limit) { - plan_data.metadata.sms_limit = plan_sms_limit.value; - } - if (plan_notif_limit) { - plan_data.metadata.notif_limit = plan_notif_limit.value; - } - if (plan_data_retention) { - plan_data.metadata.data_retention = plan_data_retention.value; - } - - for (const org of org_dev_list) { - await resolveOrg(org, plan_data); - } - - //this line must always work synchronously - await Resources.devices.deleteDeviceData(config_id, { variables: "plan_data", groups: plan_group, qty: 1 }); - - await Resources.devices.sendDeviceData(config_id, plan_data); - - return console.debug("Plan edited!"); -} - -export { planEdit }; diff --git a/src/services/plan/register.ts b/src/services/plan/register.ts deleted file mode 100644 index 5d8ced6..0000000 --- a/src/services/plan/register.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { Resources } from "@tago-io/sdk"; - -import { parseTagoObject } from "../../lib/data.logic"; -import { initializeValidation } from "../../lib/validation"; -import { RouterConstructorData } from "../../types"; - -/** - * Main function of registered plan by admin account - * @param scope Scope is a variable sent by the analysis - * @param environment Environment Variable is a resource to send variables values to the context of your script - */ -async function planAdd({ scope, environment }: RouterConstructorData) { - if (!scope) { - throw new Error("Missing parameters"); - } - - const config_id = environment.config_id; - if (!config_id) { - throw "[Error] No config device ID: config_id."; - } - - //Collecting data - const new_plan_name = scope.find((x) => x.variable === "new_plan_name"); - const new_plan_email_limit = scope.find((x) => x.variable === "new_plan_email_limit"); - const new_plan_sms_limit = scope.find((x) => x.variable === "new_plan_sms_limit"); - const new_plan_notif_limit = scope.find((x) => x.variable === "new_plan_notif_limit"); - const new_plan_data_retention = scope.find((x) => x.variable === "new_plan_data_retention"); - - if (!new_plan_name || !new_plan_email_limit || !new_plan_sms_limit || !new_plan_notif_limit || !new_plan_data_retention) { - throw new Error("Missing variables in scope array to create new plan"); - } - - const validate = initializeValidation("plan_validation", config_id); - - const plan_exists = await Resources.devices.getDeviceData(config_id, { variables: "plan_data", values: new_plan_name.value }); - - if (plan_exists.length > 0) { - throw await validate("Plan name already in use!", "danger").catch((error) => console.log(error)); - } - - const to_tago = { - plan_data: { - value: new_plan_name.value, - metadata: { - email_limit: new_plan_email_limit.value, - sms_limit: new_plan_sms_limit.value, - notif_limit: new_plan_notif_limit.value, - data_retention: new_plan_data_retention.value, - }, - }, - plan_email_limit: new_plan_email_limit.value, - plan_sms_limit: new_plan_sms_limit.value, - plan_notif_limit: new_plan_notif_limit.value, - plan_data_retention: new_plan_data_retention.value, - }; - - await Resources.devices.sendDeviceData(config_id, parseTagoObject(to_tago)); - - return validate("New plan has been successfully created!", "success"); -} - -export { planAdd }; diff --git a/src/services/plan/remove.ts b/src/services/plan/remove.ts deleted file mode 100644 index abae01c..0000000 --- a/src/services/plan/remove.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { Resources } from "@tago-io/sdk"; - -import { fetchDeviceList } from "../../lib/fetch-device-list"; -import { RouterConstructorData } from "../../types"; - -/** - * Main function of deleting plan by admin account - * @param scope Scope is a variable sent by the analysis - * @param environment Environment Variable is a resource to send variables values to the context of your script - */ -async function planDel({ scope, environment }: RouterConstructorData) { - if (!scope || !environment) { - throw new Error("Missing parameters"); - } - - const config_id = environment.config_id; - if (!config_id) { - throw "[Error] No config device ID: config_id."; - } - - const plan_name = scope.find((x) => x.variable === "plan_data"); - if (!plan_name?.group) { - throw new Error("Missing plan name to delete plan data from settings device"); - } - - const org_dev_list = await fetchDeviceList({ - tags: [ - { key: "device_type", value: "organization" }, - { key: "plan_group", value: plan_name?.group }, - ], - }); - - //do not let the user delete the plan if there's an organization assigned to it. - if (org_dev_list.length > 0) { - await Resources.devices.sendDeviceData(config_id, scope); - } - - return console.debug("Plan deleted"); -} - -export { planDel }; diff --git a/src/services/reports/README.MD b/src/services/reports/README.MD deleted file mode 100644 index 957e998..0000000 --- a/src/services/reports/README.MD +++ /dev/null @@ -1,37 +0,0 @@ -# Reports Folder Overview -This folder is responsible for the report handling. Mostly actions are triggered by the "Report System" dashboard. - -## Files -The Report folder contains the following files: -* Register a report - register.ts -* Edit a report - edit.ts -* Remove a report - remove.ts -* TagoIO Action structure for scheduled reports - action.model.ts - -### Diagram - -:::mermaid -sequenceDiagram - participant RUN Application - participant Handler Analysis - participant Reports Script Folder - participant TagoIO - alt register.ts - RUN Application ->> Handler Analysis: "New Scheduled Report" command trigger - Handler Analysis ->> Reports Script Folder: Redirects - Reports Script Folder -->> TagoIO: Creates schedule TagoIO action - Reports Script Folder -->> TagoIO: Sends new report to "Scheduled Report List" table - end - alt edit.ts - RUN Application ->> Handler Analysis: Controls "Edit" command trigger - Handler Analysis ->> Reports Script Folder: Redirects - Reports Script Folder -->> TagoIO: Edit schedule TagoIO action - Reports Script Folder -->> TagoIO: Edit existing report on "Scheduled Report List" table - end - alt delete.ts - RUN Application ->> Handler Analysis: Controls "Delete" command trigger - Handler Analysis ->> Reports Script Folder: Redirects - Reports Script Folder -->> TagoIO: Delete schedule TagoIO action - Reports Script Folder -->> TagoIO: Delete existing report on "Scheduled Report List" table - end - \ No newline at end of file diff --git a/src/services/reports/action.model.ts b/src/services/reports/action.model.ts deleted file mode 100644 index adc3aa6..0000000 --- a/src/services/reports/action.model.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Resources } from "@tago-io/sdk"; -import { AnalysisEnvironment } from "@tago-io/sdk/lib/types"; - -import { getAnalysisByTagID } from "../../lib/find-resource"; -import { ReportActionStructure } from "./create"; - -/** - * Function that create the action model - * @param action_object Object with the action data - * @param timezone timezone string - * @param environment Environment Variable is a resource to send variables values to the context of your script - */ -async function actionModel(action_object: ReportActionStructure, timezone: string, environment: AnalysisEnvironment): Promise { - if (!action_object.tags) { - throw new Error("Missing parameters"); - } - - if (!environment.ACCOUNT_TOKEN) { - throw new Error("Missing secret environment ACCOUNT_TOKEN"); - } - - const resources = new Resources({ token: environment.ACCOUNT_TOKEN }); - const script_id = await getAnalysisByTagID(resources, "sendReport"); - - const action_model = { - name: `SENSOR REPORT ACTION / VAR. GROUP: ${action_object.group}`, - active: action_object.active, - type: "schedule", - tags: [{ key: "organization_id", value: action_object.org_id }, ...action_object.tags], - trigger: [ - { - cron: action_object.cron, //"00 08 */1 * Fri,Mon,Sat,Sun,Thu,Tue,Wed" - timezone, - }, - ], - action: { script: [script_id], type: "script" }, - }; - - return action_model; -} - -export { actionModel }; diff --git a/src/services/reports/create.ts b/src/services/reports/create.ts deleted file mode 100644 index e340c86..0000000 --- a/src/services/reports/create.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { Resources } from "@tago-io/sdk"; -import { TagsObj } from "@tago-io/sdk/lib/types"; - -import { initializeValidation } from "../../lib/validation"; -import { RouterConstructorData } from "../../types"; -import { actionModel } from "./action.model"; - -interface ReportActionStructure { - org_id: string; - group: string; - cron: string; //chronological string in the TagoIO structure e.g. "00 08 */1 * Fri,Mon,Sat,Sun,Thu,Tue,Wed" -> 8am once each day - active: boolean; - tags?: TagsObj[]; -} - -/** - * Function that create the chronological string - * @param report_time e.g. "08:00" - * @param report_days e.g. "Mon;Tue;Wed;Thu;Fri;Sat;Sun" - */ -function getCronString(report_time: string, report_days: string): string { - // 00 08 */1 * Fri,Mon,Sat,Sun,Thu,Tue,Wed - const time = `${report_time?.slice(3, 5)} ${report_time?.slice(0, 2)}`; - let week_days = report_days?.replace(/\s/g, ""); //removing white spaces - week_days = week_days.replaceAll(";", ","); //if existed replacing ";" for "," - - return `${time} */1 * ${week_days}`; -} - -/** - * Main function of creating reports - * @param context Context is a variable sent by the analysis - * @param scope Scope is a variable sent by the analysis - * @param environment Environment Variable is a resource to send variables values to the context of your script - */ -async function reportAdd({ context, scope, environment }: RouterConstructorData) { - if (!environment || !scope || !context) { - throw new Error("Missing parameters"); - } - - const org_id = scope[0].device; - - const action_group = scope[0].group; - - const validate = initializeValidation("report_validation", org_id); - await validate("Registering...", "warning").catch((error) => console.log(error)); - - if (!environment._user_id) { - throw await validate("This should be run in Tago RUN need a environment._user_id", "danger").catch((error) => console.log(error)); - } - - const report_active = scope.find((x) => x.variable === "new_report_active"); - const report_time = scope.find((x) => x.variable === "new_report_time"); - const report_days = scope.find((x) => x.variable === "new_report_days"); - const report_contact = scope.find((x) => x.variable === "new_report_contact"); - const report_sensors = scope.find((x) => x.variable === "new_report_sensors"); - const report_group = scope.find((x) => x.variable === "new_report_group"); - - if (!report_active || !report_time || !report_days || !report_contact || !action_group) { - throw new Error("Missing parameters report_active, report_time, report_days, report_contact or action_group"); - } - - const action_tags: TagsObj[] = [ - { key: "action_group", value: action_group }, - { key: "report_contact", value: report_contact.value as string }, - ]; - - //removing "new_" from each variable name of the scope - let new_scope = scope.map((data) => ({ ...data, variable: data?.variable?.replace("new_", "") })); - new_scope = new_scope.filter((x) => x.variable); - - if (report_sensors) { - //send new line to by sensor - report table - const table_data = new_scope; - await Resources.devices.sendDeviceData(org_id, table_data); - - action_tags.push({ key: "sensor_list", value: report_sensors?.value as string }); - } else if (report_group) { - //send new line to by group - report table, inserting bysite_ as a prefix for each variable name - const table_data = new_scope.map((data) => ({ ...data, variable: "bysite_" + data.variable })); - await Resources.devices.sendDeviceData(org_id, table_data); - - action_tags.push({ key: "group_list", value: report_group?.value as string }); - } - - const cron = getCronString(report_time.value as string, report_days.value as string); - - const action_object: ReportActionStructure = { - org_id, - group: action_group, - cron, - active: report_active?.value === "true" ? true : false, - tags: action_tags, - }; - - // action_tags.push({ key: "action_group", value: action_group }, { key: "report_contact", value: report_contact.value as string }); - const user_info = await Resources.run.userInfo(environment._user_id); - const timezone = user_info?.timezone || "America/Sao_Paulo"; - const action_model = await actionModel(action_object, timezone, environment); - console.debug(action_model); - await Resources.actions.create(action_model).catch(async (error) => { - throw await validate(error, "danger").catch((error) => console.log(error)); - }); - - return validate("Report schedule successfully set!", "success").catch((error) => console.log(error)); -} - -export { reportAdd, ReportActionStructure, getCronString }; diff --git a/src/services/reports/edit.ts b/src/services/reports/edit.ts deleted file mode 100644 index b86195c..0000000 --- a/src/services/reports/edit.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { Resources } from "@tago-io/sdk"; - -import { RouterConstructorData } from "../../types"; -import { actionModel } from "./action.model"; -import { getCronString, ReportActionStructure } from "./create"; - -/** - * Main function of editing reports - * @param context Context is a variable sent by the analysis - * @param scope Scope is a variable sent by the analysis - * @param environment Environment Variable is a resource to send variables values to the context of your script - */ -async function reportEdit({ context, scope, environment }: RouterConstructorData) { - if (!environment || !scope || !context) { - throw new Error("Missing parameters"); - } - const org_id = scope[0].device; - - const action_group = scope[0].group as string; - - const report_active = scope.find((x) => x.variable === "report_active" || x.variable === "bysite_report_active"); - const report_time = scope.find((x) => x.variable === "report_time" || x.variable === "bysite_report_time"); - const report_days = scope.find((x) => x.variable === "report_days" || x.variable === "bysite_report_days"); - const report_contact = scope.find((x) => x.variable === "report_contact" || x.variable === "bysite_report_contact"); - const report_sensors = scope.find((x) => x.variable === "report_sensors" || x.variable === "bysite_report_sensors"); - const report_group = scope.find((x) => x.variable === "report_group" || x.variable === "bysite_report_group"); - - const [action_registered] = await Resources.actions.list({ - page: 1, - fields: ["id", "name", "tags", "active"], - filter: { - tags: [{ key: "action_group", value: action_group }], - }, - amount: 1, - }); - - let action_info; - - if (action_registered) { - console.debug("Editting report action"); - action_info = await Resources.actions.info(action_registered?.id); - } - - const action_object: ReportActionStructure = { - org_id, - group: action_group, - cron: "", - active: false, - tags: [{ key: "action_group", value: action_group }], //sensor/group, contact, org_id - }; - if (!action_registered.tags) { - throw new Error("Action not found in Tago"); - } - - if (!action_object.tags) { - action_object.tags = [{ key: "action_group", value: action_group }]; - } - //if new sensor or new group - if (report_sensors) { - action_object.tags.push({ key: "sensor_list", value: (report_sensors?.value as string).replaceAll(";", ", ") }); - } else { - const sensor_list_tag = action_registered?.tags.find((x) => x.key === "sensor_list"); - //if it previously has a sensor_list tag - if (sensor_list_tag) { - action_object.tags.push(sensor_list_tag); - } - } - if (report_group) { - action_object.tags.push({ key: "group_list", value: (report_group?.value as string).replaceAll(";", ", ") }); - } else { - //if it previously has a sensor_list tag - const group_list_tag = action_registered.tags.find((x) => x.key === "group_list"); - if (group_list_tag) { - action_object.tags.push(group_list_tag); - } - } - - let old_time = ((action_info?.trigger as any)[0].cron as string).slice(0, 5); - //time from action comes inverted, so we need to invert back so we can re-use getCronString function e.g. "45 23" -> "23 45" - old_time = `${old_time.slice(3, 5)} ${old_time.slice(0, 2)}`; - const old_week_days = ((action_info?.trigger as any)[0].cron as string).slice(12); - - const new_time = report_time?.value as string; - const new_week_days = report_days?.value as string; - - action_object.cron = getCronString(new_time || old_time, new_week_days || old_week_days); - if (report_active?.value) { - action_object.active = report_active.value === "true" ? true : false; //type boolean only - } else { - action_object.active = action_registered.active as boolean; - } - - if (report_contact) { - action_object.tags.push({ key: "report_contact", value: (report_contact?.value as string).replaceAll(";", ", ") }); - } else { - const contact_tag = action_registered.tags.find((x) => x.key === "report_contact"); - if (!contact_tag) { - throw new Error("Missing report_contact tag"); - } - action_object.tags.push(contact_tag); - } - - const user_info = await Resources.run.userInfo(environment._user_id); - const timezone = user_info?.timezone || "America/Sao_Paulo"; - const action_model = await actionModel(action_object, timezone, environment); - - await Resources.actions - .edit(action_registered.id, action_model) - .then((msg) => console.debug(msg)) - .catch((error) => console.debug(error)); -} - -export { reportEdit }; diff --git a/src/services/reports/remove.ts b/src/services/reports/remove.ts deleted file mode 100644 index a8020f7..0000000 --- a/src/services/reports/remove.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { Resources } from "@tago-io/sdk"; - -import { RouterConstructorData } from "../../types"; - -/** - * Main function of deleting reports - * @param context Context is a variable sent by the analysis - * @param scope Scope is a variable sent by the analysis - * @param environment Environment Variable is a resource to send variables values to the context of your script - */ -async function reportDel({ context, scope, environment }: RouterConstructorData) { - if (!environment || !scope || !context) { - throw new Error("Missing parameters"); - } - const action_group = scope[0].group; - if (!action_group) { - throw new Error("Missing action group"); - } - - const [action_registered] = await Resources.actions.list({ - page: 1, - fields: ["id", "name", "tags"], - filter: { - tags: [{ key: "action_group", value: action_group }], - }, - amount: 1, - }); - - if (!action_registered.tags) { - throw new Error("Action not found in Tago"); - } - - const org_id = action_registered.tags.find((x) => x.key === "organization_id")?.value; - - if (!org_id) { - throw new Error("Organization not found in Tago"); - } - - await Resources.devices.deleteDeviceData(org_id, { groups: action_group, qty: 9999 }); - - if (!action_registered) { - return console.debug("ERROR - No action found."); - } - - await Resources.actions.delete(action_registered.id); - - return console.debug("Action deleted successfully!"); -} - -export { reportDel }; diff --git a/src/services/uplinks/README.MD b/src/services/uplinks/README.MD deleted file mode 100644 index 53f9e9b..0000000 --- a/src/services/uplinks/README.MD +++ /dev/null @@ -1,24 +0,0 @@ -# Uplink Folder Overview -This folder is responsible for the sensor's uplink handling. Everytime a sensor sends a variable which has an action ([TagoIO] - Sensor Uplink Status Trigger) created to listen to it, an uplink script will be triggered to process the information sent. - -## Files -The Report folder contains the following files: - -* sensorUplinkLocation.ts -* sensorUplinkStatus.ts -* sensorUplinkTempHum.ts - -### Diagram - -:::mermaid -sequenceDiagram - participant Sensor Uplink - participant Action (Sensor Uplink Status Trigger) - participant Uplink Handler Analysis - participant Uplink Script Folder - participant TagoIO - Sensor Uplink ->> Action (Sensor Uplink Status Trigger): Watched variable (e.g. status, temperature, humidity and location) - Action (Sensor Uplink Status Trigger) ->> Uplink Handler Analysis: Triggers - Uplink Handler Analysis -->> Uplink Script Folder: Redirects - Uplink Script Folder -->> TagoIO: Process the data and create new customized variables - \ No newline at end of file diff --git a/src/services/uplinks/sensor-uplink-location.ts b/src/services/uplinks/sensor-uplink-location.ts deleted file mode 100644 index adffebd..0000000 --- a/src/services/uplinks/sensor-uplink-location.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { Resources } from "@tago-io/sdk"; - -import { RouterConstructorData } from "../../types"; - -/** - * Main function of receiving the uplink location - * @param context Context is a variable sent by the analysis - * @param scope Scope is a variable sent by the analysis - * @param environment Environment Variable is a resource to send variables values to the context of your script - */ -async function sensorUplinkLocation({ context, scope, environment }: RouterConstructorData) { - if (!environment || !scope || !context) { - throw new Error("Missing parameters"); - } - const { device: sensor_id } = scope[0]; - - const sensor_info = await Resources.devices.info(sensor_id); - - const sensor_location = scope.find((x) => x.variable === "location"); - if (!sensor_location) { - throw new Error("Missing location"); - } - - await Resources.devices.sendDeviceData(sensor_id, { - variable: "status_history", - value: `Lat: ${(sensor_location.location as any).coordinates[1]} Lng: ${(sensor_location.location as any).coordinates[0]}`, - group: sensor_location.group, - }); - - const group_id = sensor_info.tags.find((x) => x.key === "group_id")?.value; - - if (!group_id) { - return; - } //"Skipped. No group addressed to the sensor." - - const [dev_id] = await Resources.devices.getDeviceData(group_id, { variables: "dev_id", groups: sensor_id, qty: 1 }); - - await Resources.devices.editDeviceData(group_id, { ...dev_id, location: sensor_location.location }); -} - -export { sensorUplinkLocation }; diff --git a/src/services/uplinks/sensor-uplink-status.ts b/src/services/uplinks/sensor-uplink-status.ts deleted file mode 100644 index 100ab92..0000000 --- a/src/services/uplinks/sensor-uplink-status.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { Resources } from "@tago-io/sdk"; - -import { RouterConstructorData } from "../../types"; - -/** - * Function that update the status history - * @param sensor_dev - * @param current_sensor_info Current information of the sensor - */ -const updateStatusHistory = async (sensor_id: string, current_sensor_info: any) => { - const status_history = `# - Sensor reported a new status.`; - - await Resources.devices.sendDeviceData(sensor_id, { variable: "status_history", value: status_history.replace("#", String(current_sensor_info.desc).toUpperCase()) }); -}; - -/** - * Main function of receiving the uplink status - * @param context Context is a variable sent by the analysis - * @param scope Scope is a variable sent by the analysis - * @param environment Environment Variable is a resource to send variables values to the context of your script - */ -async function sensorUplinkStatus({ context, scope, environment }: RouterConstructorData) { - if (!environment || !scope || !context) { - throw new Error("Missing parameters"); - } - const { device: sensor_id } = scope[0]; - - const sensor_info = await Resources.devices.info(sensor_id); - - const org_id = sensor_info.tags.find((x) => x.key === "organization_id")?.value; - if (!org_id) { - throw new Error("Organization not found in Tago"); - } - - const sensor_status = scope.find((x) => x.variable === "status"); - if (!sensor_status) { - throw new Error("Missing Sensor status"); - } - - let current_sensor_info; - - if (sensor_status.value === "1" || sensor_status.value === 1 || sensor_status.value === "true") { - current_sensor_info = { icon: "correct-symbol", color: "green" }; - } else if (sensor_status.value === "0" || sensor_status.value === 0 || sensor_status.value === "false") { - current_sensor_info = { icon: "ban-circle-symbol", color: "red" }; - } - - if (!current_sensor_info) { - return; - } //"Different uplink message"; - - await updateStatusHistory(sensor_id, current_sensor_info); - - const group_id = sensor_info.tags.find((x) => x.key === "group_id")?.value; - - if (!group_id) { - return; - } //"Skipped. No group addressed to the sensor." - - const layers = await Resources.devices.getDeviceData(group_id, { variables: "layers", qty: 9999 }); - - const [dev_id] = await Resources.devices.getDeviceData(group_id, { variables: "dev_id", groups: sensor_id, qty: 1 }); - if (!dev_id.metadata) { - throw new Error("dev_id.metadata not found in Tago"); - } - const fixed_position_key = `${group_id}${sensor_id}`; - const layer = layers.find((x) => (x?.metadata?.fixed_position as any)[fixed_position_key]); - if (!layer) { - return; - } //"Device has no pin in layer yet." - - dev_id.metadata.color = current_sensor_info.color; - dev_id.metadata.icon = current_sensor_info.icon; - - await Resources.devices.editDeviceData(group_id, { ...dev_id, metadata: dev_id.metadata }); -} - -export { sensorUplinkStatus }; diff --git a/src/services/uplinks/sensor-uplink-temp-hum.ts b/src/services/uplinks/sensor-uplink-temp-hum.ts deleted file mode 100644 index 61cc517..0000000 --- a/src/services/uplinks/sensor-uplink-temp-hum.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Resources } from "@tago-io/sdk"; - -import { RouterConstructorData } from "../../types"; - -/** - * Main function of receiving the uplink temperature and humidity - * @param context Context is a variable sent by the analysis - * @param scope Scope is a variable sent by the analysis - * @param environment Environment Variable is a resource to send variables values to the context of your script - */ -async function sensorUplinkTempHum({ context, scope, environment }: RouterConstructorData) { - if (!environment || !scope || !context) { - throw new Error("Missing parameters"); - } - const { device: sensor_id } = scope[0]; - - const sensor_temp = scope.find((x) => x.variable === "temperature"); - const sensor_hum = scope.find((x) => x.variable === "relative_humidity"); - if (!sensor_temp || !sensor_hum) { - throw new Error("Missing temperature or humidity"); - } - - const status_history_value = `Temp: ${sensor_temp?.value ? sensor_temp?.value : "N/A"}${sensor_temp?.unit ? sensor_temp?.unit : ""} Hum: ${ - sensor_hum?.value ? sensor_hum?.value : "N/A" - }${sensor_hum?.unit ? sensor_hum?.unit : ""}`; - - await Resources.devices.sendDeviceData(sensor_id, { - variable: "status_history", - value: status_history_value, - group: sensor_temp.group, - }); -} - -export { sensorUplinkTempHum }; diff --git a/src/services/user/README.MD b/src/services/user/README.MD deleted file mode 100644 index 3b3a426..0000000 --- a/src/services/user/README.MD +++ /dev/null @@ -1,36 +0,0 @@ -# Device Folder Overview -This folder is responsible for the User handling. Mostly actions are triggered by the "Users" dashboard. - -## Files -The User folder contains the following files: -* Register a user - register.ts -* Edit a user - edit.ts -* Remove a user - remove.ts - -### Diagram - -:::mermaid -sequenceDiagram - participant RUN Application - participant Handler Analysis - participant User Script Folder - participant TagoIO - alt register.ts - RUN Application ->> Handler Analysis: "Create New" command trigger - Handler Analysis ->> User Script Folder: Redirects - User Script Folder -->> TagoIO: Send new user to "Users List" table (create user_id variable) - User Script Folder -->> TagoIO: Creates new User on TagoIO - end - alt edit.ts - RUN Application ->> Handler Analysis: Controls "Edit" command trigger - Handler Analysis ->> User Script Folder: Redirects - User Script Folder -->> TagoIO: Edit user_id variable - User Script Folder -->> TagoIO: Edit TagoIO User information - end - alt delete.ts - RUN Application ->> Handler Analysis: Controls "Delete" command trigger - Handler Analysis ->> User Script Folder: Redirects - User Script Folder -->> TagoIO: Delete user_id data - User Script Folder -->> TagoIO: Delete TagoIO User - end - \ No newline at end of file diff --git a/src/services/user/edit.ts b/src/services/user/edit.ts deleted file mode 100644 index 2dc842f..0000000 --- a/src/services/user/edit.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { Resources } from "@tago-io/sdk"; -import { UserListScope } from "@tago-io/sdk/lib/modules/Utils/router/router.types"; - -import { RouterConstructorData } from "../../types"; - -/** - * Function that edit user information - * @param scope Scope is a variable sent by the analysis - * @param environment Environment Variable is a resource to send variables values to the context of your script - */ -async function userEdit({ scope, environment }: RouterConstructorData & { scope: UserListScope[] }) { - if (!environment || !scope) { - throw new Error("Missing parameters"); - } - - const config_id = environment.config_id; - if (!config_id) { - throw "[Error] No config device ID: config_id."; - } - - const user_id = scope[0].user; - - const user_active = scope[0]?.["tags.active"]; - const user_name = scope[0]?.name as string; - const user_phone = scope[0]?.phone as string; - - const user_exists = await Resources.run.userInfo(user_id); - if (!user_exists) { - throw "User does not exist"; - } - - const new_user_info: any = {}; - - if (user_active) { - await Resources.run.userEdit(user_id, { active: JSON.parse(user_active) }); - } - - if (user_name) { - //fetching prev data - const [user_name_config_dev] = await Resources.devices.getDeviceData(config_id, { variables: "user_name", qty: 1, groups: user_id }); - - await Resources.devices.editDeviceData(config_id, { ...user_name_config_dev, value: user_name }); - - new_user_info.name = user_name; - await Resources.run.userEdit(user_id, new_user_info); - } - if (user_phone) { - //fetching prev data - const [user_phone_config_dev] = await Resources.devices.getDeviceData(config_id, { variables: "user_phone", qty: 1, groups: user_id }); - - await Resources.devices.editDeviceData(config_id, { ...user_phone_config_dev, value: user_phone }); - - new_user_info.phone = user_phone; - await Resources.run.userEdit(user_id, new_user_info); - } - return console.debug("User edited!"); -} - -export { userEdit }; diff --git a/src/services/user/register.ts b/src/services/user/register.ts deleted file mode 100644 index 7771f42..0000000 --- a/src/services/user/register.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { Resources } from "@tago-io/sdk"; -import { TagsObj } from "@tago-io/sdk/lib/types"; - -import { parseTagoObject } from "../../lib/data.logic"; -import { inviteUser } from "../../lib/register-user"; -import { initializeValidation } from "../../lib/validation"; -import { RouterConstructorData } from "../../types"; - -interface UserData { - name: string; - email: string; - phone?: string | number | boolean | void; - timezone: string; - tags?: TagsObj[]; - password?: string; - id?: string; -} - -/** - * Function that handle phone number - * @param phone_number Phone number - */ -function phoneNumberHandler(phone_number: string) { - //US as default - let country_code = "+1"; - let resulted_phone_number: string; - - if (phone_number.slice(0, 1).includes("+")) { - country_code = phone_number.slice(0, 3); - phone_number = phone_number.slice(3); - } - //removing special characters - resulted_phone_number = phone_number.replaceAll(/[^\w\s]/gi, ""); - - resulted_phone_number = `${country_code}${resulted_phone_number}`; - - return resulted_phone_number; -} - -//registered by admin Resources. - -/** - * Function that register new user - * @param context Context is a variable sent by the analysis - * @param scope Scope is a variable sent by the analysis - * @param environment Environment Variable is a resource to send variables values to the context of your script - */ -async function userAdd({ context, scope, environment }: RouterConstructorData) { - if (!environment || !scope || !context) { - throw new Error("Missing parameters"); - } - - const config_id = environment.config_id; - if (!config_id) { - throw "[Error] No config device ID: config_id."; - } - - const org_id = scope[0].device; - - //Collecting data - const new_user_name = scope.find((x) => x.variable === "new_user_name" || x.variable === "new_orgadmin_name"); - const new_user_email = scope.find((x) => x.variable === "new_user_email" || x.variable === "new_orgadmin_email"); - const new_user_access = scope.find((x) => x.variable === "new_user_access" || x.variable === "new_orgadmin_access"); - const new_user_phone = scope.find((x) => x.variable === "new_user_phone" || x.variable === "new_orgadmin_phone"); - - //validation - const validate = initializeValidation("user_validation", org_id); - - if (!new_user_name?.value) { - throw await validate("Name field is empty", "danger").catch((error) => console.log(error)); - } - if ((new_user_name.value as string).length < 3) { - throw await validate("Name field is smaller than 3 character", "danger").catch((error) => console.log(error)); - } - if (!new_user_email?.value) { - throw await validate("Email field is empty", "danger").catch((error) => console.log(error)); - } - if (!new_user_access?.value) { - throw await validate("Access field is empty", "danger").catch((error) => console.log(error)); - } - if (new_user_phone?.value) { - new_user_phone.value = phoneNumberHandler(new_user_phone.value as string); - } - - const [user_exists] = await Resources.run.listUsers({ - page: 1, - amount: 1, - filter: { email: new_user_email.value as string }, - }); - - if (user_exists) { - throw await validate("#VAL.USER_ALREADY_EXISTS#", "danger").catch((error) => console.log(error)); - } - - //creating user - const resources_with_account_token = new Resources({ token: environment.ACCOUNT_TOKEN }); - const { timezone } = await resources_with_account_token.account.info(); - - const new_user_data: UserData = { - name: new_user_name.value as string, - email: (new_user_email.value as string).trim(), - phone: (new_user_phone?.value as string) || "", - timezone: timezone, - tags: [ - { - key: "organization_id", - value: org_id, - }, - { - key: "access", - value: new_user_access.value as string, - }, - { - key: "active", - value: "true", - }, - ], - }; - - const { url: run_url } = await resources_with_account_token.run.info(); - - //registering user - const new_user_id = await inviteUser(resources_with_account_token, context, new_user_data, run_url).catch(async (error) => { - throw await validate(error, "danger").catch((error) => console.log(error)); - }); - - let user_access_label: string | undefined = ""; - - if (new_user_access.value === "admin") { - user_access_label = "Administrator"; - } else if (new_user_access.value === "orgadmin") { - user_access_label = "Organization Admin"; - } else if (new_user_access.value === "guest") { - user_access_label = "Guest"; - } else { - user_access_label = new_user_access?.metadata?.label; - } - - let user_data = parseTagoObject( - { - user_id: { value: new_user_id, metadata: { label: `${new_user_name.value} (${new_user_email.value})` } }, - user_name: new_user_name.value as string, - user_email: (new_user_email.value as string).trim(), - user_phone: (new_user_phone?.value as string) || "", - user_access: { value: new_user_access.value as string, metadata: { label: user_access_label } }, - }, - new_user_id - ); - - if (new_user_access.value === "admin") { - user_data = user_data.concat([{ variable: "user_admin", value: new_user_id, group: new_user_id, metadata: { label: new_user_name.value as string } }]); - } - - //sending to org device - await Resources.devices.sendDeviceData(org_id, user_data); - - //sending to admin device (settings_device) - await Resources.devices.sendDeviceData(config_id, user_data); - - return validate("#VAL.USER_SUCCESSFULLY_INVITED_AN_EMAIL_WILL_BE_SENT_WITH_THE_CREDENTIALS_TO_THE_NEW_USER#", "success"); -} - -export { userAdd }; diff --git a/src/services/user/remove.ts b/src/services/user/remove.ts deleted file mode 100644 index d2db75e..0000000 --- a/src/services/user/remove.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { Resources } from "@tago-io/sdk"; -import { UserListScope } from "@tago-io/sdk/lib/modules/Utils/router/router.types"; - -import { RouterConstructorData } from "../../types"; - -/** - * Function that remove user from organization - * @param scope Scope is a variable sent by the analysis - * @param environment Environment Variable is a resource to send variables values to the context of your script - */ -async function userDel({ scope, environment }: RouterConstructorData & { scope: UserListScope[] }) { - if (!environment || !scope) { - throw new Error("Missing parameters"); - } - - const config_id = environment.config_id; - if (!config_id) { - throw "[Error] No config device ID: config_id."; - } - - const user_id = scope[0].user; - if (!user_id) { - throw new Error("User id not found"); - } - //checking if user exists - const user_exists = await Resources.run.userInfo(user_id); - if (!user_exists) { - throw "User does not exist"; - } - - const org_id = user_exists.tags.find((x) => ["user_org_id", "organization_id"].includes(x.key))?.value; - if (!org_id) { - throw "Organization id not found"; - } - - //collecting org id - const group_id = user_exists.tags.find((x) => x.key === "group_id")?.value; - - // block the user from deleting himself - if (environment._user_id === user_id) { - throw "User tried to delete himself"; - } - - if (group_id) { - await Resources.devices.deleteDeviceData(group_id, { groups: user_id, qty: 9999 }); - } - - await Resources.devices.deleteDeviceData(config_id, { groups: user_id, qty: 9999 }); - await Resources.devices.deleteDeviceData(org_id, { groups: user_id, qty: 9999 }); - //deleting user - await Resources.run.userDelete(user_id).then((msg) => console.debug(msg)); - return; -} - -export { userDel }; diff --git a/src/types.ts b/src/types.ts deleted file mode 100644 index a6927d8..0000000 --- a/src/types.ts +++ /dev/null @@ -1,24 +0,0 @@ -// ? ==================================== (c) TagoIO ==================================== -// ? What is this file? -// * This file is global types, it's used to remove "implicitly has an 'any' type" errors. -// ? ==================================================================================== - -import { Device } from "@tago-io/sdk"; -import { RouterConstructor } from "@tago-io/sdk/lib/modules/Utils/router/router.types"; -import { Data } from "@tago-io/sdk/lib/types"; - -interface DeviceCreated { - bucket_id: string; - device_id: string; - device: Device; -} - -interface RouterConstructorData extends Omit { - scope: Data[]; -} - -interface RouterConstructorDevice extends Omit { - scope: { device: string; [key: string]: string }[]; -} - -export { DeviceCreated, RouterConstructorData, RouterConstructorDevice }; diff --git a/tagoconfig.json b/tagoconfig.json deleted file mode 100644 index 778b1c2..0000000 --- a/tagoconfig.json +++ /dev/null @@ -1,67 +0,0 @@ -{ - "$schema": "https://github.com/tago-io/tagoio-cli/blob/master/docs/schema.json", - "analysisPath": "./src/analysis", - "buildPath": "./build", - "prod": { - "analysisList": [ - { - "fileName": "alert-handler.ts", - "name": "[TagoIO] - Alert Handler", - "id": "61b2f60d26edc70018bda3bf" - }, - { - "fileName": "alert-trigger.ts", - "name": "[TagoIO] - Alert Trigger", - "id": "61b2f610a14c040018c6672f" - }, - { - "fileName": "battery-updater.ts", - "name": "[TagoIO] - Battery Updater", - "id": "62700cd16d0a8000129b2011" - }, - { - "fileName": "clear-buckets.ts", - "name": "[TagoIO] - Clear Buckets", - "id": "6477404717f4110009e5c4dc" - }, - { - "fileName": "data-retention.ts", - "name": "[TagoIO] - Data Retention Updater", - "id": "61c310d6d6df77001acb54a4" - }, - { - "fileName": "handler.ts", - "name": "[TagoIO] - Handler", - "id": "61b2f617e3f46d00191d997c" - }, - { - "fileName": "monthly-usage-reset.ts", - "name": "[TagoIO] - Monthly Usage Reset", - "id": "61b2f614561da800197abed4" - }, - { - "fileName": "send-report.ts", - "name": "[TagoIO] - Send Report ", - "id": "61b2f6199e269200196d4344" - }, - { - "fileName": "status-updater.ts", - "name": "[TagoIO] - Status Updater", - "id": "61b2f6124edcc00019b44f0b" - }, - { - "fileName": "uplink-handler.ts", - "name": "[TagoIO] - Uplink Handler", - "id": "61c1c1346aec8f001844ea3b" - }, - { - "fileName": "user-sign-up.ts", - "name": "[TagoIO] - User Signup", - "id": "61b327b8e3f46d00192153b7" - } - ], - "id": "61b2f46e561da800197a9c43", - "profileName": "Kickstarter Application [TEMPLATE]", - "email": "mateus.silva@tago.io" - } -} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json deleted file mode 100644 index 91141db..0000000 --- a/tsconfig.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "compilerOptions": { - "types": [ - "vitest/importMeta", - "vitest/globals" - ], - "target": "ES2022", - "module": "CommonJS", - "lib": [ - "ES2023" - ], - "allowJs": true, - "outDir": "./build", - "noImplicitAny": true, - "resolveJsonModule": true, - "esModuleInterop": true, - "moduleResolution": "node", - "allowSyntheticDefaultImports": true, - "forceConsistentCasingInFileNames": true, - "rootDir": "./src", - "tsBuildInfoFile": "./build/.tsbuildinfo", - "noUnusedParameters": true, - "noUnusedLocals": true, - "removeComments": true, - "strictNullChecks": true, - "skipLibCheck": true, - "inlineSourceMap": false, - "sourceMap": true - }, - "exclude": [ - "node_modules", - "packages/payload-parser-runtime/**/*" - ], - "include": [ - "src/**/*" - ], - "rules": { - "no-unused-declaration": true - }, - "ts-node": { - "transpileOnly": true - }, -} diff --git a/vitest.config.js b/vitest.config.js deleted file mode 100644 index 2f7b80d..0000000 --- a/vitest.config.js +++ /dev/null @@ -1,16 +0,0 @@ -import swc from 'unplugin-swc'; -import { defineConfig } from 'vitest/config'; - -export default defineConfig({ - test: { - globals: true, - root: './src', - }, - plugins: [ - // This is required to build the test files with SWC - swc.vite({ - // Explicitly set the module type to avoid inheriting this value from a `.swcrc` config file - module: { type: 'es6' }, - }), - ], -}); diff --git a/widgets/README.md b/widgets/README.md new file mode 100644 index 0000000..68faa1a --- /dev/null +++ b/widgets/README.md @@ -0,0 +1,143 @@ +# `widgets/` — Custom Dashboard Widgets + +Each widget is a self-contained React app (TypeScript + Vite + Tailwind v4) that TagoIO loads inside a dashboard iframe. Every widget is built independently so its `_dist//` +folder is fully self-contained and can be uploaded as a single bundle. + +## Layout + +``` +widgets/ +├── / # One folder per widget — each is its own Vite app +│ ├── index.html # Vite entry;
only +│ ├── main.tsx # createRoot + +│ ├── App.tsx # widget root component +│ ├── styles.css # @import "tailwindcss"; +│ ├── components/ # Only when code actually moves out of App.tsx +│ └── deno.json # Per-widget imports + Deno LSP compilerOptions +├── shared/ # Cross-widget helpers (imported as @/shared/... from each widget) +├── _dist/ # Build output, one folder per widget (git-ignored) +├── build.ts # Builds every widget in one pass +├── vite.config.ts # Shared Vite config — picks a widget via WIDGET env +└── deno.json # Workspace-level tasks + shared npm imports +``` + +Current widgets: + +- **sensor-status** — Sensors dashboard / Overview tab. Three KPI cards (registered / active / inactive) fed by `device_connectivity_summary`. +- **cold-room-card-data** — Groups dashboard / Cold Rooms tab. One card per sensor (temperature, compressor, door, last seen) grouped by Group. +- **cold-room-monitor** — Sensor Detail dashboard / Overview tab. Temperature gauge plus compressor and door state for a single sensor. + +## The iframe model (why some rules are absolute) + +The TagoIO dashboard renders the widget title, `⋮` menu, and background **outside** the iframe. Two consequences every widget must respect: + +- **No widget title or page background inside the component** — you'll get visible duplicates. +- **No fixed pixel sizes on the root** — the iframe is resized live when the user drags the widget edges. Use `h-dvh w-dvw` plus container queries and `ResizeObserver` for + canvases. + +## The four root files + +These four files live at the widget's root — no exceptions: + +| File | Purpose | +| ------------ | -------------------------------------------------------- | +| `index.html` | Vite entry point. One `
`, no body styles. | +| `main.tsx` | `createRoot` + `` wrapping ``. | +| `App.tsx` | Widget root. Uses SDK hooks to read config/data. | +| `styles.css` | `@import "tailwindcss";` only. | + +Add `components/`, `hooks/`, `lib/`, or `features/` **only** when code actually needs to move out of `App.tsx`. Cross-widget helpers go in [`widgets/shared/`](./shared) (imported +as `@/shared/...`), never inside another widget. + +## Data access: hooks only + +All TagoIO interaction goes through hooks from `@tago-io/custom-widget-react` — never call `window.TagoIO.*` in components. `` in `main.tsx` wires them up. + +| Need | Hook | +| -------------------------------- | ----------------------------------------------------- | +| Widget config + live data (most) | `useWidgetData()` | +| Config only | `useWidget()` | +| Live data only | `useRealtimeData()` | +| Send / edit / delete data | `useSendData()` / `useEditData()` / `useDeleteData()` | +| Edit account resources | `useEditResourceData()` | +| Trigger an analysis | `useRunAnalysis()` | +| Open a dashboard / close a modal | `useNavigation()` | +| User locale, token, preferences | `useUserInformation()` | +| Blueprint device selection | `useBlueprintDevices()` | +| i18n | `useDictionary()` | +| Surface SDK errors | `useWidgetErrors()` | + +`records` from `useWidgetData` / `useRealtimeData` is a **flat array mixing all variables together** — group by `record.variable` for per-variable handling. + +Import TagoIO types (`TDataRecord`, `TUserInformation`, etc.) from the SDK — don't redeclare them. + +## Tasks + +Run from `widgets/` (or as `deno task :widgets` from the repo root): + +| Task | What it does | +| --------------------- | -------------------------------------------------------------------- | +| `deno task dev` | Start Vite dev server on port 1234. Requires `WIDGET=`. | +| `deno task build` | Run `build.ts` — builds every widget into its own `_dist//`. | +| `deno task build:one` | Build one widget (requires `WIDGET=`). | +| `deno task preview` | Preview the last build. | + +Example: + +```bash +WIDGET=sensor-status deno task dev # Dev server for sensor-status +WIDGET=sensor-status deno task build:one # Build just sensor-status +deno task build # Build every widget +``` + +## Testing against a real TagoIO dashboard + +TagoIO only loads widgets from **HTTPS** endpoints, so pointing a dashboard at `http://localhost:1234` won't work — the iframe will refuse to load. For dev you need an HTTPS tunnel +in front of the Vite server. + +The dev server is pre-configured for this: it binds to all interfaces (`host: true`) and trusts any `Host` header (`allowedHosts: true`), so tunnels route to it without extra +config. + +Typical flow with [ngrok](https://ngrok.com/): + +```bash +# Terminal 1 — dev server +WIDGET=sensor-status deno task dev + +# Terminal 2 — expose it over HTTPS +ngrok http 1234 +``` + +Copy the `https://.ngrok-free.app` URL ngrok prints and paste it into the widget's **URL** field in the TagoIO dashboard. Cloudflare Tunnel +(`cloudflared tunnel --url http://localhost:1234`) works the same way. Keep in mind: free ngrok tunnel URLs change on every restart — you'll need to re-paste after each session. + +## Adding a new widget + +1. Create `widgets//` with the four root files (copy from `sensor-status/`). +2. Add a `deno.json` with the widget's npm imports and `compilerOptions` for the Deno LSP (Deno doesn't inherit these from the parent). +3. Register the widget path in the root `deno.json` workspace array. +4. `WIDGET= deno task dev` — iterate. + +## Build output + +`build.ts` discovers every folder under `widgets/` that contains an `index.html`, then invokes Vite once per widget with `WIDGET=`. Each build writes to +`widgets/_dist//` with a flat layout (`base: "./"`, `assetsDir: ""`) so the whole folder can be uploaded to TagoIO file storage or any static host. + +## Stack + +- React 19 — declared at the workspace `deno.json` and inherited by every widget +- TypeScript +- Vite (via `@vitejs/plugin-react`) +- Tailwind v4 (via `@tailwindcss/vite`) +- `@tago-io/custom-widget-react` — `` and all hooks + +Per-widget extras are declared in each widget's own `deno.json` when needed (e.g. charting or date libraries). + +## What never to do + +- No widget title or page background inside the iframe — the dashboard chrome already shows them. +- No fixed pixel sizes on the root. +- No router. Each widget is its own `index.html`; routing inside one means you actually have two widgets. +- No `window.TagoIO.*` in components when a hook exists. +- No cross-widget global state — each iframe is a separate runtime. Coordinate via TagoIO data. +- No `window.parent` navigation. Use `useNavigation().openLink(url)`. diff --git a/widgets/build.ts b/widgets/build.ts new file mode 100644 index 0000000..98e518e --- /dev/null +++ b/widgets/build.ts @@ -0,0 +1,41 @@ +#!/usr/bin/env -S deno run -A +// Build every widget in this workspace as an independent bundle under _dist//. +// Each widget is a separate vite invocation so its output is fully self-contained +// (no shared chunks), which keeps per-widget uploads to TagoIO simple. + +import { existsSync, readdirSync, statSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +const widgets = readdirSync(__dirname).filter((name) => { + if (name.startsWith("_") || name.startsWith(".")) return false; + const full = resolve(__dirname, name); + try { + return statSync(full).isDirectory() && existsSync(resolve(full, "index.html")); + } catch { + return false; + } +}); + +if (widgets.length === 0) { + console.error("No widgets found (expected widgets//index.html)."); + Deno.exit(1); +} + +console.log(`Building ${widgets.length} widget(s): ${widgets.join(", ")}`); + +for (const widget of widgets) { + console.log(`\n→ ${widget}`); + const { code } = await new Deno.Command("deno", { + args: ["run", "-A", "npm:vite", "build"], + env: { WIDGET: widget }, + stdout: "inherit", + stderr: "inherit", + }).output(); + if (code !== 0) { + console.error(`\nBuild failed for ${widget}`); + Deno.exit(code); + } +} diff --git a/widgets/cold-room-card-data/App.tsx b/widgets/cold-room-card-data/App.tsx new file mode 100644 index 0000000..dc2849c --- /dev/null +++ b/widgets/cold-room-card-data/App.tsx @@ -0,0 +1,214 @@ +import { type TDataRecord, useUserInformation, useWidgetData } from "@tago-io/custom-widget-react"; +import { useMemo, useState } from "react"; +import { GroupSection } from "./components/GroupSection.tsx"; +import { SearchBar } from "./components/SearchBar.tsx"; +import { SensorCard } from "./components/SensorCard.tsx"; +import { normalizeTemperature, resolveTempUnit, tempTone } from "../shared/temperature.ts"; +import { relativeTime } from "../shared/relative-time.ts"; +import { useNow } from "../shared/use-now.ts"; + +const SENSOR_VARIABLE = "cold_room_card_data"; + +function asString(value: unknown): string { + return value === undefined || value === null ? "" : String(value).trim().toLowerCase(); +} + +function asNumber(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + if ( + typeof value === "string" && + value.trim() !== "" && + Number.isFinite(Number(value)) + ) { + return Number(value); + } + return null; +} + +function sensorName(record: TDataRecord): string { + const metaName = record.metadata && typeof record.metadata.sensor_name === "string" ? record.metadata.sensor_name.trim() : ""; + if (metaName) { + return metaName; + } + return record.group ?? record.device ?? "Unknown sensor"; +} + +function groupID(record: TDataRecord): string | null { + const raw = record.metadata && typeof record.metadata.group_id === "string" ? record.metadata.group_id.trim() : ""; + return raw === "" ? null : raw; +} + +function groupName(record: TDataRecord): string { + const raw = record.metadata && typeof record.metadata.group_name === "string" ? record.metadata.group_name.trim() : ""; + return raw === "" ? "Unknown group" : raw; +} + +/** + * Returns `true` when the sensor reading is in an alert state. + * + * Alerts are: temperature in the orange/red tone bands, or door open. + * Used to sort flagged sensors to the top inside each group section. + */ +function hasAlert(record: TDataRecord): boolean { + const rawTempF = asNumber(record.metadata?.temperature_fahrenheit); + if (rawTempF !== null) { + const tone = tempTone(rawTempF); + if (tone === "red" || tone === "orange") { + return true; + } + } + return asString(record.metadata?.door_status) === "open"; +} + +interface GroupBucket { + groupID: string; + groupName: string; + records: TDataRecord[]; +} + +/** + * Buckets the visible records by `group_id`. Records without a `group_id` + * in metadata are skipped — they are legacy points that pre-date the + * uplink-handler update and will be replaced on the next uplink. + * + * When the same `group_id` appears with two different `group_name` values + * (group was renamed between writes), the last name we see wins — the + * next uplink reconciles everything anyway. + */ +function bucketByGroup(records: TDataRecord[]): GroupBucket[] { + const buckets = new Map(); + + for (const record of records) { + const id = groupID(record); + if (!id) { + continue; + } + + const existing = buckets.get(id); + if (existing) { + existing.records.push(record); + existing.groupName = groupName(record); + continue; + } + + buckets.set(id, { + groupID: id, + groupName: groupName(record), + records: [record], + }); + } + + return Array.from(buckets.values()); +} + +export default function App() { + const { isLoading, records } = useWidgetData(); + const { customPreferences } = useUserInformation(); + const [query, setQuery] = useState(""); + const now = useNow(30_000); + + const tempUnit = resolveTempUnit(customPreferences); + const sensorRecords = useMemo( + () => records.filter((r) => r.variable === SENSOR_VARIABLE), + [records], + ); + + // Step 1 — apply the search filter. The search matches both the sensor + // name AND the group name so users can narrow to a single group quickly. + const matchingRecords = useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) { + return sensorRecords; + } + return sensorRecords.filter((record) => { + const inSensor = sensorName(record).toLowerCase().includes(q); + const inGroup = groupName(record).toLowerCase().includes(q); + return inSensor || inGroup; + }); + }, [sensorRecords, query]); + + // Step 2 — bucket by group_id and sort: groups alphabetically by name, + // sensors inside a group with alerts first, then alphabetical by name. + const sortedGroups = useMemo(() => { + const buckets = bucketByGroup(matchingRecords); + + for (const bucket of buckets) { + bucket.records.sort((a, b) => { + const alertA = hasAlert(a); + const alertB = hasAlert(b); + if (alertA !== alertB) { + return alertA ? -1 : 1; + } + return sensorName(a).localeCompare(sensorName(b)); + }); + } + + buckets.sort((a, b) => a.groupName.localeCompare(b.groupName)); + return buckets; + }, [matchingRecords]); + + return ( +
+
+ +
+ + {!isLoading && sensorRecords.length === 0 + ? ( +
+
+
+ No data +
+
+
+ ) + : !isLoading && sortedGroups.length === 0 + ? ( +
+
+ No sensors match "{query}". +
+
+ ) + : ( +
+ {sortedGroups.map((group) => ( + + {group.records.map((record) => { + const rawTempF = asNumber(record.metadata?.temperature_fahrenheit); + const temp = rawTempF === null ? null : normalizeTemperature(rawTempF, "°F", tempUnit); + const compressorOn = asString(record.metadata?.compressor_status) === "on"; + const doorOpen = asString(record.metadata?.door_status) === "open"; + + return ( + + ); + })} + + ))} +
+ )} +
+ ); +} diff --git a/widgets/cold-room-card-data/components/CardHeader.tsx b/widgets/cold-room-card-data/components/CardHeader.tsx new file mode 100644 index 0000000..bb3884a --- /dev/null +++ b/widgets/cold-room-card-data/components/CardHeader.tsx @@ -0,0 +1,26 @@ +type DotColor = "blue" | "green" | "red" | "orange"; + +const dotClass: Record = { + blue: "bg-[#5b8dee] so-dot-blue", + green: "bg-[#34c47c] so-dot-green", + red: "bg-[#e74c3c] so-dot-red", + orange: "bg-[#f5a623] so-dot-orange", +}; + +interface CardHeaderProps { + title: string; + dotColor: DotColor; + time: string; +} + +export function CardHeader({ title, dotColor, time }: CardHeaderProps) { + return ( + <> +
+ + {title} +
+
{time}
+ + ); +} diff --git a/widgets/cold-room-card-data/components/GroupSection.tsx b/widgets/cold-room-card-data/components/GroupSection.tsx new file mode 100644 index 0000000..bb462e5 --- /dev/null +++ b/widgets/cold-room-card-data/components/GroupSection.tsx @@ -0,0 +1,33 @@ +import type { ReactNode } from "react"; + +interface GroupSectionProps { + groupName: string; + sensorCount: number; + children: ReactNode; +} + +/** + * Visual container that wraps every sensor card belonging to the same parent + * group. The header shows the group name on the left and a discreet sensor + * count on the right; the body is a generic slot so the caller keeps full + * control over the cards it renders. + */ +export function GroupSection({ groupName, sensorCount, children }: GroupSectionProps) { + const sensorLabel = sensorCount === 1 ? "1 sensor" : `${sensorCount} sensors`; + + return ( +
+
+

+ {groupName} +

+ + {sensorLabel} + +
+
+ {children} +
+
+ ); +} diff --git a/widgets/cold-room-card-data/components/SearchBar.tsx b/widgets/cold-room-card-data/components/SearchBar.tsx new file mode 100644 index 0000000..07a4cc6 --- /dev/null +++ b/widgets/cold-room-card-data/components/SearchBar.tsx @@ -0,0 +1,107 @@ +import { useEffect, useRef, useState } from "react"; + +interface SearchBarProps { + value: string; + onChange: (value: string) => void; + placeholder?: string; +} + +export function SearchBar({ value, onChange, placeholder = "Search sensors or groups..." }: SearchBarProps) { + const [expanded, setExpanded] = useState(false); + const inputRef = useRef(null); + + useEffect(() => { + if (expanded && inputRef.current) { + inputRef.current.focus(); + } + }, [expanded]); + + function close() { + onChange(""); + setExpanded(false); + } + + if (!expanded) { + return ( + + ); + } + + return ( +
+ + onChange(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Escape") { + close(); + } + }} + placeholder={placeholder} + className="flex-1 bg-transparent text-[13px] text-[#e0e0e0] placeholder:text-[#62627a] focus:outline-none" + aria-label="Search sensors or groups by name" + /> + +
+ ); +} + +function SearchIcon() { + return ( + + Search + + + + ); +} + +function CloseIcon() { + return ( + + Close + + + + ); +} diff --git a/widgets/cold-room-card-data/components/SensorCard.tsx b/widgets/cold-room-card-data/components/SensorCard.tsx new file mode 100644 index 0000000..dca9076 --- /dev/null +++ b/widgets/cold-room-card-data/components/SensorCard.tsx @@ -0,0 +1,128 @@ +import { CardHeader } from "./CardHeader.tsx"; +import { DoorIcon, PowerIcon } from "./icons.tsx"; + +type Tone = "green" | "red" | "orange" | "blue"; + +const accentBar: Record = { + green: "bg-[#34c47c]", + red: "bg-[#e74c3c]", + orange: "bg-[#f5a623]", + blue: "bg-gradient-to-r from-[#5b8dee] to-[#a78bfa]", +}; + +const iconBg: Record = { + green: "bg-[rgba(52,196,124,0.15)] text-[#34c47c]", + red: "bg-[rgba(231,76,60,0.15)] text-[#e74c3c]", + orange: "bg-[rgba(245,166,35,0.15)] text-[#f5a623]", + blue: "bg-[rgba(91,141,238,0.15)] text-[#5b8dee]", +}; + +const tempColor: Record = { + green: "text-[#34c47c]", + red: "text-[#e74c3c]", + orange: "text-[#f5a623]", + blue: "text-[#5b8dee]", +}; + +const pillBadge: Record = { + green: "bg-[rgba(52,196,124,0.12)] text-[#34c47c]", + red: "bg-[rgba(231,76,60,0.12)] text-[#e74c3c]", + orange: "bg-[rgba(245,166,35,0.12)] text-[#f5a623]", + blue: "bg-[rgba(91,141,238,0.12)] text-[#5b8dee]", +}; + +interface SensorCardProps { + name: string; + tempValue: number | null; + tempUnit: "°F" | "°C"; + tempTone: Tone; + compressorOn: boolean; + doorOpen: boolean; + time: string; + isLoading: boolean; +} + +export function SensorCard({ + name, + tempValue, + tempUnit, + tempTone, + compressorOn, + doorOpen, + time, + isLoading, +}: SensorCardProps) { + const compressorTone: Tone = compressorOn ? "green" : "red"; + const doorTone: Tone = doorOpen ? "orange" : "green"; + const accentTone = worstTone([tempTone, compressorTone, doorTone]); + const headerDot: Tone = accentTone; + + return ( +
+ + + + +
+
+
+ {tempValue === null ? "—" : tempValue.toFixed(1)} + + {tempUnit} + +
+
+ Temperature +
+
+ +
+ } + label={compressorOn ? "ON" : "OFF"} + tone={compressorTone} + isLoading={isLoading} + /> + } + label={doorOpen ? "OPEN" : "CLOSED"} + tone={doorTone} + isLoading={isLoading} + /> +
+
+
+ ); +} + +interface StatusPillProps { + icon: React.ReactNode; + label: string; + tone: Tone; + isLoading: boolean; +} + +function StatusPill({ icon, label, tone, isLoading }: StatusPillProps) { + return ( +
+
+ {icon} +
+ + {label} + +
+ ); +} + +const tonePriority: Record = { red: 3, orange: 2, blue: 1, green: 0 }; + +function worstTone(tones: Tone[]): Tone { + return tones.reduce((worst, t) => (tonePriority[t] > tonePriority[worst] ? t : worst), "green" as Tone); +} diff --git a/widgets/cold-room-card-data/components/icons.tsx b/widgets/cold-room-card-data/components/icons.tsx new file mode 100644 index 0000000..2aad459 --- /dev/null +++ b/widgets/cold-room-card-data/components/icons.tsx @@ -0,0 +1,36 @@ +export function PowerIcon() { + return ( + + Power + + + + ); +} + +export function DoorIcon() { + return ( + + Door + + + ); +} diff --git a/widgets/cold-room-card-data/deno.json b/widgets/cold-room-card-data/deno.json new file mode 100644 index 0000000..61333ff --- /dev/null +++ b/widgets/cold-room-card-data/deno.json @@ -0,0 +1,17 @@ +{ + "name": "@widgets/cold-room-card-data", + "version": "1.0.0", + "exports": "./main.tsx", + "imports": { + "@/": "./", + "@/components/": "./components/", + "@/lib/": "./lib/" + }, + "_compilerOptions_note": "Required for Deno LSP (IDE), deno check, and deno lint. Vite ignores this. Deno doesn't inherit compilerOptions from parent deno.json, only imports.", + "compilerOptions": { + "jsx": "react-jsx", + "jsxImportSource": "react", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "jsxImportSourceTypes": "@types/react" + } +} diff --git a/widgets/cold-room-card-data/index.html b/widgets/cold-room-card-data/index.html new file mode 100644 index 0000000..c067800 --- /dev/null +++ b/widgets/cold-room-card-data/index.html @@ -0,0 +1,12 @@ + + + + + + Sensors Overview Widget + + +
+ + + diff --git a/widgets/cold-room-card-data/main.tsx b/widgets/cold-room-card-data/main.tsx new file mode 100644 index 0000000..2792afe --- /dev/null +++ b/widgets/cold-room-card-data/main.tsx @@ -0,0 +1,13 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { TagoIOProvider } from "@tago-io/custom-widget-react"; +import App from "./App.tsx"; +import "./styles.css"; + +createRoot(document.getElementById("root")!).render( + + + + + , +); diff --git a/widgets/cold-room-card-data/styles.css b/widgets/cold-room-card-data/styles.css new file mode 100644 index 0000000..184f189 --- /dev/null +++ b/widgets/cold-room-card-data/styles.css @@ -0,0 +1,63 @@ +@import "tailwindcss"; + +@keyframes so-pulse-blue { + 0%, 100% { + box-shadow: 0 0 0 0 rgba(91, 141, 238, 0.7); + } + 50% { + box-shadow: 0 0 0 8px rgba(91, 141, 238, 0); + } +} + +@keyframes so-pulse-green { + 0%, 100% { + box-shadow: 0 0 0 0 rgba(52, 196, 124, 0.75); + } + 50% { + box-shadow: 0 0 0 8px rgba(52, 196, 124, 0); + } +} + +@keyframes so-pulse-red { + 0%, 100% { + box-shadow: 0 0 0 0 rgba(231, 76, 60, 0.75); + } + 50% { + box-shadow: 0 0 0 8px rgba(231, 76, 60, 0); + } +} + +@keyframes so-pulse-orange { + 0%, 100% { + box-shadow: 0 0 0 0 rgba(245, 166, 35, 0.75); + } + 50% { + box-shadow: 0 0 0 8px rgba(245, 166, 35, 0); + } +} + +@keyframes so-shimmer { + 0%, 100% { + opacity: 0.35; + } + 50% { + opacity: 0.85; + } +} + +.so-dot-blue { + animation: so-pulse-blue 1.8s ease-out infinite; +} +.so-dot-green { + animation: so-pulse-green 1.8s ease-out infinite; +} +.so-dot-red { + animation: so-pulse-red 1.8s ease-out infinite; +} +.so-dot-orange { + animation: so-pulse-orange 1.8s ease-out infinite; +} + +.so-shimmer { + animation: so-shimmer 1.4s ease-in-out infinite; +} diff --git a/widgets/cold-room-monitor/App.tsx b/widgets/cold-room-monitor/App.tsx new file mode 100644 index 0000000..279eb75 --- /dev/null +++ b/widgets/cold-room-monitor/App.tsx @@ -0,0 +1,73 @@ +import { type TDataRecord, useUserInformation, useWidgetData } from "@tago-io/custom-widget-react"; +import { StatusCard } from "./components/StatusCard.tsx"; +import { TemperatureGauge } from "./components/TemperatureGauge.tsx"; +import { DoorIcon, PowerIcon } from "./components/icons.tsx"; +import { gaugeRange, normalizeTemperature, resolveTempUnit } from "../shared/temperature.ts"; +import { relativeTime } from "../shared/relative-time.ts"; +import { useNow } from "../shared/use-now.ts"; + +function findRecord(records: TDataRecord[], variable: string): TDataRecord | undefined { + return records.find((r) => r.variable === variable); +} + +function asString(value: unknown): string { + return value === undefined || value === null ? "" : String(value).trim().toLowerCase(); +} + +function asNumber(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + if (typeof value === "string" && value.trim() !== "" && Number.isFinite(Number(value))) { + return Number(value); + } + return null; +} + +export default function App() { + const { isLoading, records } = useWidgetData(); + const { customPreferences } = useUserInformation(); + const now = useNow(30_000); + + const tempUnit = resolveTempUnit(customPreferences); + const range = gaugeRange(tempUnit); + + const tempRec = findRecord(records, "temperature"); + const compRec = findRecord(records, "compressor"); + const doorRec = findRecord(records, "door"); + + const tempRaw = asNumber(tempRec?.value); + const temp = tempRaw === null ? null : normalizeTemperature(tempRaw, tempRec?.unit, tempUnit); + + const compOn = asString(compRec?.value) === "on"; + const doorOpen = asString(doorRec?.value) === "open"; + + return ( +
+ + } + isLoading={isLoading} + /> + } + isLoading={isLoading} + /> +
+ ); +} diff --git a/widgets/cold-room-monitor/components/CardHeader.tsx b/widgets/cold-room-monitor/components/CardHeader.tsx new file mode 100644 index 0000000..b6b4c7e --- /dev/null +++ b/widgets/cold-room-monitor/components/CardHeader.tsx @@ -0,0 +1,26 @@ +type DotColor = "blue" | "green" | "red" | "orange"; + +const dotClass: Record = { + blue: "bg-[#5b8dee] crm-dot-blue", + green: "bg-[#34c47c] crm-dot-green", + red: "bg-[#e74c3c] crm-dot-red", + orange: "bg-[#f5a623] crm-dot-orange", +}; + +interface CardHeaderProps { + title: string; + dotColor: DotColor; + time: string; +} + +export function CardHeader({ title, dotColor, time }: CardHeaderProps) { + return ( + <> +
+ + {title} +
+
{time}
+ + ); +} diff --git a/widgets/cold-room-monitor/components/StatusCard.tsx b/widgets/cold-room-monitor/components/StatusCard.tsx new file mode 100644 index 0000000..6ad147a --- /dev/null +++ b/widgets/cold-room-monitor/components/StatusCard.tsx @@ -0,0 +1,56 @@ +import type { ReactNode } from "react"; +import { CardHeader } from "./CardHeader.tsx"; + +type StatusTone = "green" | "red" | "orange"; + +const accentBar: Record = { + green: "bg-[#34c47c]", + red: "bg-[#e74c3c]", + orange: "bg-[#f5a623]", +}; + +const iconBg: Record = { + green: "bg-[rgba(52,196,124,0.15)] text-[#34c47c]", + red: "bg-[rgba(231,76,60,0.15)] text-[#e74c3c]", + orange: "bg-[rgba(245,166,35,0.15)] text-[#f5a623]", +}; + +const badge: Record = { + green: "bg-[rgba(52,196,124,0.12)] text-[#34c47c]", + red: "bg-[rgba(231,76,60,0.12)] text-[#e74c3c]", + orange: "bg-[rgba(245,166,35,0.12)] text-[#f5a623]", +}; + +interface StatusCardProps { + title: string; + time: string; + tone: StatusTone; + badgeText: string; + icon: ReactNode; + isLoading: boolean; +} + +export function StatusCard({ title, time, tone, badgeText, icon, isLoading }: StatusCardProps) { + return ( +
+ + + + +
+
+ {icon} +
+ + {badgeText} + +
+
+ ); +} diff --git a/widgets/cold-room-monitor/components/TemperatureGauge.tsx b/widgets/cold-room-monitor/components/TemperatureGauge.tsx new file mode 100644 index 0000000..b6f1cc0 --- /dev/null +++ b/widgets/cold-room-monitor/components/TemperatureGauge.tsx @@ -0,0 +1,109 @@ +import { CardHeader } from "./CardHeader.tsx"; + +const ARC_LENGTH = 251; +const ARC_START_DEG = 200; +const ARC_END_DEG = 340; + +interface TemperatureGaugeProps { + value: number | null; + unit: "°F" | "°C"; + min: number; + max: number; + time: string; + isLoading: boolean; +} + +function angleToXY(angleDeg: number, cx: number, cy: number, r: number) { + const rad = ((angleDeg - 180) * Math.PI) / 180; + return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) }; +} + +function dotColor(valueF: number): string { + if (valueF <= 0) { + return "#5b8dee"; + } + if (valueF <= 20) { + return "#34c47c"; + } + if (valueF <= 40) { + return "#f5a623"; + } + return "#e74c3c"; +} + +export function TemperatureGauge({ value, unit, min, max, time, isLoading }: TemperatureGaugeProps) { + const pct = value === null ? 0 : Math.max(0, Math.min(1, (value - min) / (max - min))); + const filled = pct * ARC_LENGTH; + const angle = ARC_START_DEG + pct * (ARC_END_DEG - ARC_START_DEG); + const dot = angleToXY(angle, 100, 110, 80); + const valueInF = unit === "°C" && value !== null ? value * (9 / 5) + 32 : (value ?? 0); + + return ( +
+ + + + +
+ + Temperature gauge + + + + + + + + + + + {min} + {max} + + +
+
+ {value === null ? "—" : value.toFixed(2)} + + {unit} + +
+
+ Temperature +
+
+
+
+ ); +} diff --git a/widgets/cold-room-monitor/components/icons.tsx b/widgets/cold-room-monitor/components/icons.tsx new file mode 100644 index 0000000..2aad459 --- /dev/null +++ b/widgets/cold-room-monitor/components/icons.tsx @@ -0,0 +1,36 @@ +export function PowerIcon() { + return ( + + Power + + + + ); +} + +export function DoorIcon() { + return ( + + Door + + + ); +} diff --git a/widgets/cold-room-monitor/deno.json b/widgets/cold-room-monitor/deno.json new file mode 100644 index 0000000..e16bc07 --- /dev/null +++ b/widgets/cold-room-monitor/deno.json @@ -0,0 +1,17 @@ +{ + "name": "@widgets/cold-room-monitor", + "version": "1.0.0", + "exports": "./main.tsx", + "imports": { + "@/": "./", + "@/components/": "./components/", + "@/lib/": "./lib/" + }, + "_compilerOptions_note": "Required for Deno LSP (IDE), deno check, and deno lint. Vite ignores this. Deno doesn't inherit compilerOptions from parent deno.json, only imports.", + "compilerOptions": { + "jsx": "react-jsx", + "jsxImportSource": "react", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "jsxImportSourceTypes": "@types/react" + } +} diff --git a/widgets/cold-room-monitor/index.html b/widgets/cold-room-monitor/index.html new file mode 100644 index 0000000..f9587f8 --- /dev/null +++ b/widgets/cold-room-monitor/index.html @@ -0,0 +1,12 @@ + + + + + + Cold Room Monitor Widget + + +
+ + + diff --git a/widgets/cold-room-monitor/main.tsx b/widgets/cold-room-monitor/main.tsx new file mode 100644 index 0000000..2792afe --- /dev/null +++ b/widgets/cold-room-monitor/main.tsx @@ -0,0 +1,13 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { TagoIOProvider } from "@tago-io/custom-widget-react"; +import App from "./App.tsx"; +import "./styles.css"; + +createRoot(document.getElementById("root")!).render( + + + + + , +); diff --git a/widgets/cold-room-monitor/styles.css b/widgets/cold-room-monitor/styles.css new file mode 100644 index 0000000..361db89 --- /dev/null +++ b/widgets/cold-room-monitor/styles.css @@ -0,0 +1,74 @@ +@import "tailwindcss"; + +/* Widget-local CSS only. + Do NOT set background on html or body — the TagoIO dashboard + supplies the iframe background. */ + +@keyframes crm-pulse-blue { + 0%, 100% { + box-shadow: 0 0 0 0 rgba(91, 141, 238, 0.7); + } + 50% { + box-shadow: 0 0 0 8px rgba(91, 141, 238, 0); + } +} + +@keyframes crm-pulse-green { + 0%, 100% { + box-shadow: 0 0 0 0 rgba(52, 196, 124, 0.75); + } + 50% { + box-shadow: 0 0 0 8px rgba(52, 196, 124, 0); + } +} + +@keyframes crm-pulse-red { + 0%, 100% { + box-shadow: 0 0 0 0 rgba(231, 76, 60, 0.75); + } + 50% { + box-shadow: 0 0 0 8px rgba(231, 76, 60, 0); + } +} + +@keyframes crm-pulse-orange { + 0%, 100% { + box-shadow: 0 0 0 0 rgba(245, 166, 35, 0.75); + } + 50% { + box-shadow: 0 0 0 8px rgba(245, 166, 35, 0); + } +} + +@keyframes crm-shimmer { + 0%, 100% { + opacity: 0.35; + } + 50% { + opacity: 0.85; + } +} + +.crm-dot-blue { + animation: crm-pulse-blue 1.8s ease-out infinite; +} +.crm-dot-green { + animation: crm-pulse-green 1.8s ease-out infinite; +} +.crm-dot-red { + animation: crm-pulse-red 1.8s ease-out infinite; +} +.crm-dot-orange { + animation: crm-pulse-orange 1.8s ease-out infinite; +} + +.crm-shimmer { + animation: crm-shimmer 1.4s ease-in-out infinite; +} + +.crm-arc-value { + transition: stroke-dasharray 0.8s cubic-bezier(0.4, 0, 0.2, 1); +} +.crm-arc-dot { + transition: cx 0.8s cubic-bezier(0.4, 0, 0.2, 1), cy 0.8s cubic-bezier(0.4, 0, 0.2, 1), fill 0.4s ease; +} diff --git a/widgets/deno.json b/widgets/deno.json new file mode 100644 index 0000000..b5b4bbf --- /dev/null +++ b/widgets/deno.json @@ -0,0 +1,23 @@ +{ + "name": "@app/custom-widgets", + "version": "1.0.0", + "exports": {}, + "tasks": { + "dev": "deno run -A npm:vite", + "build": "deno run -A ./build.ts", + "build:one": "deno run -A npm:vite build", + "preview": "deno run -A npm:vite preview" + }, + "imports": { + "@tago-io/custom-widget-react": "npm:@tago-io/custom-widget-react@^2.0.1", + "@vitejs/plugin-react": "npm:@vitejs/plugin-react@^6.0.1", + "@tailwindcss/vite": "npm:@tailwindcss/vite@^4.2.4", + "tailwindcss": "npm:tailwindcss@^4.2.4", + "vite": "npm:vite@^8.0.10", + "react": "npm:react@^19.2.6", + "react-dom": "npm:react-dom@^19.2.6", + "react/jsx-runtime": "npm:react@19.2.6/jsx-runtime", + "@types/react": "npm:@types/react@^19.2.14", + "@types/react-dom": "npm:@types/react-dom@^19.2.3" + } +} diff --git a/widgets/sensor-status/App.tsx b/widgets/sensor-status/App.tsx new file mode 100644 index 0000000..ec921d1 --- /dev/null +++ b/widgets/sensor-status/App.tsx @@ -0,0 +1,67 @@ +import { type TDataRecord, useWidgetData } from "@tago-io/custom-widget-react"; +import { SensorCard } from "./components/SensorCard.tsx"; +import { ActiveIcon, InactiveIcon, RegisteredIcon } from "./components/icons.tsx"; + +const SUMMARY_VARIABLE = "device_connectivity_summary"; + +interface ConnectivityCounts { + registered: number | null; + active: number | null; + inactive: number | null; +} + +function toNumber(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + if (typeof value === "string" && value.trim() !== "" && Number.isFinite(Number(value))) { + return Number(value); + } + return null; +} + +function readCounts(records: TDataRecord[]): ConnectivityCounts { + const summary = records.find((r) => r.variable === SUMMARY_VARIABLE); + const meta = (summary?.metadata ?? {}) as Record; + return { + registered: toNumber(meta.total_registered), + active: toNumber(meta.online), + inactive: toNumber(meta.offline), + }; +} + +export default function App() { + const { isLoading, records } = useWidgetData(); + const { registered, active, inactive } = readCounts(records); + + return ( +
+
+ } + /> + } + /> + } + /> +
+
+ ); +} diff --git a/widgets/sensor-status/components/SensorCard.tsx b/widgets/sensor-status/components/SensorCard.tsx new file mode 100644 index 0000000..a39d96b --- /dev/null +++ b/widgets/sensor-status/components/SensorCard.tsx @@ -0,0 +1,67 @@ +type Variant = "registered" | "active" | "inactive"; + +interface SensorCardProps { + variant: Variant; + label: string; + subtitle: string; + value: number | string | null; + isLoading: boolean; + icon: React.ReactNode; +} + +const accent: Record = { + registered: { + bar: "bg-[#5b8dee]", + text: "text-[#5b8dee]", + iconBg: "bg-[rgba(91,141,238,0.15)]", + dot: "bg-[#5b8dee]", + dotPulse: false, + }, + active: { + bar: "bg-[#34c47c]", + text: "text-[#34c47c]", + iconBg: "bg-[rgba(52,196,124,0.15)]", + dot: "bg-[#34c47c]", + dotPulse: true, + }, + inactive: { + bar: "bg-[#f5a623]", + text: "text-[#f5a623]", + iconBg: "bg-[rgba(245,166,35,0.15)]", + dot: "bg-[#f5a623]", + dotPulse: false, + }, +}; + +export function SensorCard({ variant, label, subtitle, value, isLoading, icon }: SensorCardProps) { + const a = accent[variant]; + const display = value ?? "—"; + + return ( +
+ + +
+ + {label} + +
+ {icon} +
+
+ +
+ {isLoading ? "—" : display} +
+ +
+ + {subtitle} +
+
+ ); +} diff --git a/widgets/sensor-status/components/icons.tsx b/widgets/sensor-status/components/icons.tsx new file mode 100644 index 0000000..020f30f --- /dev/null +++ b/widgets/sensor-status/components/icons.tsx @@ -0,0 +1,48 @@ +export function RegisteredIcon() { + return ( + + + + + ); +} + +export function ActiveIcon() { + return ( + + + + + + ); +} + +export function InactiveIcon() { + return ( + + + + + ); +} diff --git a/widgets/sensor-status/deno.json b/widgets/sensor-status/deno.json new file mode 100644 index 0000000..cba5665 --- /dev/null +++ b/widgets/sensor-status/deno.json @@ -0,0 +1,16 @@ +{ + "name": "@widgets/sensor-status", + "version": "1.0.0", + "exports": "./main.tsx", + "imports": { + "@/": "./", + "@/components/": "./components/" + }, + "_compilerOptions_note": "Required for Deno LSP (IDE), deno check, and deno lint. Vite ignores this. Deno doesn't inherit compilerOptions from parent deno.json, only imports.", + "compilerOptions": { + "jsx": "react-jsx", + "jsxImportSource": "react", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "jsxImportSourceTypes": "@types/react" + } +} diff --git a/widgets/sensor-status/index.html b/widgets/sensor-status/index.html new file mode 100644 index 0000000..88b96ab --- /dev/null +++ b/widgets/sensor-status/index.html @@ -0,0 +1,12 @@ + + + + + + Sensor Status Widget + + +
+ + + diff --git a/widgets/sensor-status/main.tsx b/widgets/sensor-status/main.tsx new file mode 100644 index 0000000..2792afe --- /dev/null +++ b/widgets/sensor-status/main.tsx @@ -0,0 +1,13 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { TagoIOProvider } from "@tago-io/custom-widget-react"; +import App from "./App.tsx"; +import "./styles.css"; + +createRoot(document.getElementById("root")!).render( + + + + + , +); diff --git a/widgets/sensor-status/styles.css b/widgets/sensor-status/styles.css new file mode 100644 index 0000000..e505344 --- /dev/null +++ b/widgets/sensor-status/styles.css @@ -0,0 +1,33 @@ +@import "tailwindcss"; + +/* Widget-local CSS only. + Do NOT set background on html or body — the TagoIO dashboard + supplies the iframe background. */ + +@keyframes sensor-pulse { + 0%, 100% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.5; + transform: scale(1.4); + } +} + +@keyframes sensor-shimmer { + 0%, 100% { + opacity: 0.4; + } + 50% { + opacity: 1; + } +} + +.animate-sensor-pulse { + animation: sensor-pulse 2s infinite; +} + +.animate-sensor-shimmer { + animation: sensor-shimmer 1.5s infinite; +} diff --git a/widgets/shared/relative-time.test.ts b/widgets/shared/relative-time.test.ts new file mode 100644 index 0000000..29adf38 --- /dev/null +++ b/widgets/shared/relative-time.test.ts @@ -0,0 +1,80 @@ +import { assertEquals } from "@std/assert"; +import { describe, it } from "@std/testing/bdd"; +import { relativeTime } from "./relative-time.ts"; + +describe("relativeTime function", () => { + const NOW = new Date("2026-05-20T12:00:00.000Z").getTime(); + + it("should return placeholder for null input", () => { + assertEquals(relativeTime(null, NOW), "— a few seconds ago"); + }); + + it("should return placeholder for undefined input", () => { + assertEquals(relativeTime(undefined, NOW), "— a few seconds ago"); + }); + + it("should return placeholder for empty string", () => { + assertEquals(relativeTime("", NOW), "— a few seconds ago"); + }); + + it("should return placeholder for invalid ISO string", () => { + assertEquals(relativeTime("not-a-date", NOW), "— a few seconds ago"); + }); + + it("should return placeholder for diff under 10 seconds", () => { + const iso = new Date(NOW - 5_000).toISOString(); + assertEquals(relativeTime(iso, NOW), "— a few seconds ago"); + }); + + it("should return placeholder exactly at 9 seconds", () => { + const iso = new Date(NOW - 9_000).toISOString(); + assertEquals(relativeTime(iso, NOW), "— a few seconds ago"); + }); + + it("should return seconds-ago label between 10s and 59s", () => { + const iso = new Date(NOW - 30_000).toISOString(); + assertEquals(relativeTime(iso, NOW), "— 30s ago"); + }); + + it("should return seconds-ago label at 10s boundary", () => { + const iso = new Date(NOW - 10_000).toISOString(); + assertEquals(relativeTime(iso, NOW), "— 10s ago"); + }); + + it("should return minutes-ago label between 60s and 3599s", () => { + const iso = new Date(NOW - 5 * 60_000).toISOString(); + assertEquals(relativeTime(iso, NOW), "— 5m ago"); + }); + + it("should return minutes-ago label at 60s boundary", () => { + const iso = new Date(NOW - 60_000).toISOString(); + assertEquals(relativeTime(iso, NOW), "— 1m ago"); + }); + + it("should floor minutes (90s becomes 1m)", () => { + const iso = new Date(NOW - 90_000).toISOString(); + assertEquals(relativeTime(iso, NOW), "— 1m ago"); + }); + + it("should return hours-ago label at 1h", () => { + const iso = new Date(NOW - 3_600_000).toISOString(); + assertEquals(relativeTime(iso, NOW), "— 1h ago"); + }); + + it("should return hours-ago label for multi-hour diffs", () => { + const iso = new Date(NOW - 5 * 3_600_000).toISOString(); + assertEquals(relativeTime(iso, NOW), "— 5h ago"); + }); + + it("should floor hours (90 minutes becomes 1h)", () => { + const iso = new Date(NOW - 90 * 60_000).toISOString(); + assertEquals(relativeTime(iso, NOW), "— 1h ago"); + }); + + it("should default `now` to Date.now() when omitted", () => { + const iso = new Date(Date.now() - 30_000).toISOString(); + const result = relativeTime(iso); + assertEquals(result.startsWith("— "), true); + assertEquals(result.endsWith("s ago") || result.endsWith("a few seconds ago"), true); + }); +}); diff --git a/widgets/shared/relative-time.ts b/widgets/shared/relative-time.ts new file mode 100644 index 0000000..3828eef --- /dev/null +++ b/widgets/shared/relative-time.ts @@ -0,0 +1,36 @@ +/** + * Formats an ISO timestamp as a short relative-time label (e.g. "— 5s ago", "— 12m ago", "— 3h ago"). + * + * Buckets: + * - Missing/invalid input or diff < 10s → "— a few seconds ago" + * - < 60s → seconds ago + * - < 1h → minutes ago + * - >= 1h → hours ago + * + * Pair with `useNow()` to make the label refresh on a fixed interval (the widget + * re-renders when `now` changes, otherwise the string would stay frozen). + * + * @param iso - ISO-8601 timestamp from a TagoIO data record (`record.time`). Accepts null/undefined for empty records. + * @param now - Reference "current time" in ms since epoch. Defaults to `Date.now()`; pass the value from `useNow()` for auto-refreshing UIs. + * @returns Human-readable relative time prefixed with an em-dash separator. + */ +export function relativeTime(iso: string | undefined | null, now: number = Date.now()): string { + if (!iso) { + return "— a few seconds ago"; + } + const t = new Date(iso).getTime(); + if (!Number.isFinite(t)) { + return "— a few seconds ago"; + } + const diff = Math.floor((now - t) / 1000); + if (diff < 10) { + return "— a few seconds ago"; + } + if (diff < 60) { + return `— ${diff}s ago`; + } + if (diff < 3600) { + return `— ${Math.floor(diff / 60)}m ago`; + } + return `— ${Math.floor(diff / 3600)}h ago`; +} diff --git a/widgets/shared/temperature.test.ts b/widgets/shared/temperature.test.ts new file mode 100644 index 0000000..c80e125 --- /dev/null +++ b/widgets/shared/temperature.test.ts @@ -0,0 +1,115 @@ +import { assertEquals } from "@std/assert"; +import { describe, it } from "@std/testing/bdd"; +import { gaugeRange, normalizeTemperature, resolveTempUnit, tempTone } from "./temperature.ts"; + +describe("resolveTempUnit function", () => { + it("should default to Fahrenheit when preferences are empty", () => { + assertEquals(resolveTempUnit({}), "F"); + }); + + it("should default to Fahrenheit when no temp-related key exists", () => { + assertEquals(resolveTempUnit({ language: "en", theme: "dark" }), "F"); + }); + + it("should resolve Celsius from value 'c'", () => { + assertEquals(resolveTempUnit({ temperature: "c" }), "C"); + }); + + it("should resolve Celsius from value '°C' (case-insensitive)", () => { + assertEquals(resolveTempUnit({ temperature: "°C" }), "C"); + }); + + it("should resolve Celsius from value 'Celsius'", () => { + assertEquals(resolveTempUnit({ temperature: "Celsius" }), "C"); + }); + + it("should resolve Fahrenheit from value 'f'", () => { + assertEquals(resolveTempUnit({ temperature: "f" }), "F"); + }); + + it("should resolve Fahrenheit from value 'Fahrenheit'", () => { + assertEquals(resolveTempUnit({ temp_unit: "Fahrenheit" }), "F"); + }); + + it("should match any key containing 'temp' (case-insensitive)", () => { + assertEquals(resolveTempUnit({ TempUnit: "c" }), "C"); + }); + + it("should ignore non-temp keys even when value would match", () => { + assertEquals(resolveTempUnit({ language: "celsius" }), "F"); + }); + + it("should default to Fahrenheit when temp value is unrecognized", () => { + assertEquals(resolveTempUnit({ temperature: "kelvin" }), "F"); + }); +}); + +describe("normalizeTemperature function", () => { + it("should keep value when source and target are both Fahrenheit", () => { + assertEquals(normalizeTemperature(70, "°F", "F"), { value: 70, unit: "°F" }); + }); + + it("should convert Fahrenheit to Celsius", () => { + assertEquals(normalizeTemperature(32, "°F", "C"), { value: 0, unit: "°C" }); + }); + + it("should convert Celsius to Fahrenheit", () => { + assertEquals(normalizeTemperature(0, "°C", "F"), { value: 32, unit: "°F" }); + }); + + it("should keep value when source and target are both Celsius", () => { + assertEquals(normalizeTemperature(20, "°C", "C"), { value: 20, unit: "°C" }); + }); + + it("should treat missing rawUnit as Fahrenheit (payload default)", () => { + assertEquals(normalizeTemperature(50, undefined, "F"), { value: 50, unit: "°F" }); + }); + + it("should detect Celsius source from any unit string containing 'c'", () => { + assertEquals(normalizeTemperature(100, "celsius", "F"), { value: 212, unit: "°F" }); + }); + + it("should be case-insensitive when detecting source unit", () => { + assertEquals(normalizeTemperature(0, "C", "F"), { value: 32, unit: "°F" }); + }); +}); + +describe("gaugeRange function", () => { + it("should return Fahrenheit range for unit 'F'", () => { + assertEquals(gaugeRange("F"), { min: -40, max: 60 }); + }); + + it("should return Celsius range for unit 'C'", () => { + assertEquals(gaugeRange("C"), { min: -40, max: 16 }); + }); +}); + +describe("tempTone function", () => { + it("should return 'blue' for sub-zero Fahrenheit", () => { + assertEquals(tempTone(-10), "blue"); + }); + + it("should return 'blue' at the 0°F boundary", () => { + assertEquals(tempTone(0), "blue"); + }); + + it("should return 'green' between 0°F and 20°F", () => { + assertEquals(tempTone(15), "green"); + }); + + it("should return 'green' at the 20°F boundary", () => { + assertEquals(tempTone(20), "green"); + }); + + it("should return 'orange' between 20°F and 40°F", () => { + assertEquals(tempTone(30), "orange"); + }); + + it("should return 'orange' at the 40°F boundary", () => { + assertEquals(tempTone(40), "orange"); + }); + + it("should return 'red' above 40°F", () => { + assertEquals(tempTone(50), "red"); + }); +}); diff --git a/widgets/shared/temperature.ts b/widgets/shared/temperature.ts new file mode 100644 index 0000000..191e86c --- /dev/null +++ b/widgets/shared/temperature.ts @@ -0,0 +1,124 @@ +/** + * Temperature unit identifier used across widgets. + * - `"F"` — Fahrenheit (native unit of the incoming TagoIO payload) + * - `"C"` — Celsius + */ +type TempUnit = "F" | "C"; + +/** + * Converts a Fahrenheit value to Celsius. + * + * @param f - Temperature in Fahrenheit. + * @returns Equivalent temperature in Celsius. + */ +function _fahrenheitToCelsius(f: number): number { + return (f - 32) * (5 / 9); +} + +/** + * Resolves the temperature unit configured for the current TagoIO Run user. + * + * The unit key inside `customPreferences` is admin-defined (it can be named + * `temperature`, `temp_unit`, `tempUnit`, etc.), so we scan every entry whose + * key matches `/temp/i` and look at the value. Recognized values: + * - Celsius: `"c"`, `"°c"`, `"celsius"` + * - Fahrenheit: `"f"`, `"°f"`, `"fahrenheit"` + * + * Defaults to `"F"` when no recognizable preference is found — that matches + * the native unit of the incoming payload. + * + * @param customPreferences - `customPreferences` map from `useUserInformation()`. + * @returns The resolved {@link TempUnit}. + */ +export function resolveTempUnit(customPreferences: Record): TempUnit { + for (const [key, value] of Object.entries(customPreferences)) { + if (!/temp/i.test(key)) { + continue; + } + const v = String(value).trim().toLowerCase(); + if (v === "c" || v === "°c" || v === "celsius") { + return "C"; + } + if (v === "f" || v === "°f" || v === "fahrenheit") { + return "F"; + } + } + return "F"; +} + +/** + * A temperature value paired with its display unit symbol. + */ +export interface NormalizedTemp { + value: number; + unit: "°F" | "°C"; +} + +/** + * Converts a raw temperature reading to the target unit, regardless of the + * unit the device originally reported. + * + * The source unit is inferred from `rawUnit` (case-insensitive — any string + * containing "c" is treated as Celsius; everything else as Fahrenheit). The + * result includes the unit symbol (`"°C"` or `"°F"`) suitable for direct + * rendering. + * + * @param rawValue - Raw numeric value from the TagoIO record. + * @param rawUnit - Unit string from the TagoIO record (`record.unit`). Optional. + * @param target - Desired output unit. + * @returns Normalized value plus its display unit symbol. + */ +export function normalizeTemperature(rawValue: number, rawUnit: string | undefined, target: TempUnit): NormalizedTemp { + const sourceIsCelsius = (rawUnit ?? "").trim().toLowerCase().includes("c"); + const sourceInF = sourceIsCelsius ? rawValue * (9 / 5) + 32 : rawValue; + if (target === "C") { + return { value: _fahrenheitToCelsius(sourceInF), unit: "°C" }; + } + return { value: sourceInF, unit: "°F" }; +} + +/** + * Returns the `{ min, max }` range used to size a temperature gauge axis, + * adjusted to the requested unit. + * + * Ranges are tuned for cold-room monitoring: + * - Fahrenheit: `-40` to `60` + * - Celsius: `-40` to `16` + * + * @param unit - Unit of the value that will be rendered on the gauge. + * @returns Gauge range with `min` and `max` in the requested unit. + */ +export function gaugeRange(unit: TempUnit): { min: number; max: number } { + if (unit === "C") { + return { min: -40, max: 16 }; + } + return { min: -40, max: 60 }; +} + +/** + * Maps a Fahrenheit temperature to a semantic color tone for status UI. + * + * Thresholds (inclusive upper bounds, in °F): + * - `<= 0` → `"blue"` (frozen) + * - `<= 20` → `"green"` (cold-room target) + * - `<= 40` → `"orange"` (warming) + * - `> 40` → `"red"` (out of range) + * + * Always pass the value in Fahrenheit — convert with {@link normalizeTemperature} + * first if your source data is in Celsius. + * + * @param valueInF - Temperature in Fahrenheit. + * @returns A color tone token to drive UI styling. + */ +export function tempTone(valueInF: number): "blue" | "green" | "orange" | "red" { + if (valueInF <= 0) { + return "blue"; + } + if (valueInF <= 20) { + return "green"; + } + if (valueInF <= 40) { + return "orange"; + } + return "red"; +} diff --git a/widgets/shared/use-now.ts b/widgets/shared/use-now.ts new file mode 100644 index 0000000..f5a3900 --- /dev/null +++ b/widgets/shared/use-now.ts @@ -0,0 +1,25 @@ +import { useEffect, useState } from "react"; + +/** + * React hook that returns the current epoch time (ms) and re-renders the caller + * on a fixed interval, so time-derived UI (like relative-time labels) stays fresh + * without waiting for new widget data to arrive. + * + * The initial value is captured once on mount via `Date.now()`. A `setInterval` + * then updates it every `intervalMs`, and the interval is cleared on unmount or + * when `intervalMs` changes. + * + * Typical use: combine with `relativeTime(record.time, now)` to refresh + * "— 12m ago" style labels every 30 seconds. + * + * @param intervalMs - Refresh interval in milliseconds. Defaults to 30_000 (30s). + * @returns Current timestamp in ms since epoch, updated on each interval tick. + */ +export function useNow(intervalMs: number = 30_000): number { + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + const id = setInterval(() => setNow(Date.now()), intervalMs); + return () => clearInterval(id); + }, [intervalMs]); + return now; +} diff --git a/widgets/vite.config.ts b/widgets/vite.config.ts new file mode 100644 index 0000000..67deebf --- /dev/null +++ b/widgets/vite.config.ts @@ -0,0 +1,48 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import tailwindcss from "@tailwindcss/vite"; +import { dirname, resolve } from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +// Each widget is built independently so its _dist// folder is fully +// self-contained (no shared assets/ dir to track). Pick the widget via the +// WIDGET env var; build.ts loops over all widgets for `deno task build`. +const widget = process.env.WIDGET; +if (!widget) { + throw new Error( + "Set WIDGET= to target a widget (e.g. `WIDGET=line-chart deno task dev`).", + ); +} + +export default defineConfig({ + root: resolve(__dirname, widget), + // Relative base so a widget works wherever it's hosted (TagoIO file storage, CDN, etc.). + base: "./", + plugins: [react(), tailwindcss()], + build: { + outDir: resolve(__dirname, "_dist", widget), + emptyOutDir: true, + chunkSizeWarningLimit: 5000, + // Flat output inside the widget's folder — easier to upload as a single bundle. + assetsDir: "", + rollupOptions: { + output: { + entryFileNames: "[name]-[hash].js", + chunkFileNames: "[name]-[hash].js", + assetFileNames: "[name]-[hash][extname]", + }, + }, + }, + server: { + port: 1234, + strictPort: true, + // Listen on all interfaces so tunnels (ngrok, Cloudflare, etc.) can reach the dev server. + host: true, + // Vite returns 403 for any Host header not in its allow-list (DNS-rebind guard). + // `true` trusts any host — fine for local dev over a tunnel; do not ship to prod. + allowedHosts: true, + }, +});