diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 0589cec9..b23f61b5 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,4 +1,4 @@ -name: "🐞 Bug report" +name: " Bug report" description: "File a reproducible problem with the software." title: "[Bug]: " labels: ["bug", "triage"] @@ -62,6 +62,8 @@ body: - Windows - macOS - Linux + - iPad + - Surface Tablet - Other (specify below) - type: input id: platform_other @@ -69,4 +71,4 @@ body: label: Other environment details - type: markdown attributes: - value: "Thank you for helping us improve! πŸ™" + value: "Thank you for helping us improve! " diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 74a75f3e..0f31e25b 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,7 +1,7 @@ blank_issues_enabled: false contact_links: - - name: πŸ’¬ General question / discussion - url: https://github.com/uw-ssec/project-template/discussions + - name: General question / discussion + url: https://github.com/SSDALab/respondent-driven-sampling/discussions about: Please ask questions in Discussions instead of opening an issue. issue_templates: - name: Bug report diff --git a/.github/ISSUE_TEMPLATE/documentation.yml b/.github/ISSUE_TEMPLATE/documentation.yml index ac89f281..2ee3baca 100644 --- a/.github/ISSUE_TEMPLATE/documentation.yml +++ b/.github/ISSUE_TEMPLATE/documentation.yml @@ -1,4 +1,4 @@ -name: "πŸ“š Documentation gap" +name: " Documentation gap" description: "Report missing or confusing docs." title: "[Docs]: " labels: ["documentation"] diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 60a64d82..1263d230 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -1,4 +1,4 @@ -name: "✨ Feature request" +name: " Feature request" description: "Suggest an idea or enhancement." title: "[Feature]: " labels: ["enhancement", "triage"] diff --git a/.github/ISSUE_TEMPLATE/performance_issue.yml b/.github/ISSUE_TEMPLATE/performance_issue.yml index a1c79d38..9950fa7e 100644 --- a/.github/ISSUE_TEMPLATE/performance_issue.yml +++ b/.github/ISSUE_TEMPLATE/performance_issue.yml @@ -1,4 +1,4 @@ -name: "🐒 Performance issue" +name: " Performance issue" description: "Report slowness, excessive memory, etc." title: "[Perf]: " labels: ["performance", "triage"] diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index cee260be..ee1aa199 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -5,35 +5,35 @@ PRs that follow this template are easier to review and merge. --> -## πŸ“„ Description +## Description -## βœ… Checklist +## Checklist - [ ] Tests added/updated where needed - [ ] Docs added/updated if applicable - [ ] I have linked the issue this PR closes (if any) -## πŸ”— Related Issues +## Related Issues Resolves #\ -## πŸ’‘ Type of change +## Type of change -| Type | Checked? | -| ---------------- | -------- | -| 🐞 Bug fix | [ ] | -| ✨ New feature | [ ] | -| πŸ“ Documentation | [ ] | -| ♻️ Refactor | [ ] | -| πŸ› οΈ Build/CI | [ ] | -| Other (explain) | [ ] | +| Type | Checked? | +| --------------- | -------- | +| Bug fix | [ ] | +| New feature | [ ] | +| Documentation | [ ] | +| Refactor | [ ] | +| Build/CI | [ ] | +| Other (explain) | [ ] | -## πŸ§ͺ How to test +## How to test -## πŸ“ Notes to reviewers +## Notes to reviewers diff --git a/.github/workflows/azure-webapp-deploy.yml b/.github/workflows/azure-webapp-deploy-rds-app-kc-prod.yml similarity index 83% rename from .github/workflows/azure-webapp-deploy.yml rename to .github/workflows/azure-webapp-deploy-rds-app-kc-prod.yml index 899bd18d..26f97110 100644 --- a/.github/workflows/azure-webapp-deploy.yml +++ b/.github/workflows/azure-webapp-deploy-rds-app-kc-prod.yml @@ -1,9 +1,9 @@ -name: RDS MAIN Build and Deploy Monolithic React + Node app to Azure +name: Deploying kc-pit-2026 branch to rds-app-kc prod slot on: push: branches: - - main # or your deployment branch + - kc-pit-2026 # or your deployment branch jobs: build-and-deploy: @@ -57,7 +57,7 @@ jobs: - name: Deploy to Azure Web App uses: azure/webapps-deploy@v3 with: - app-name: rds-main # Replace with your Azure App Service name + app-name: rds-app-kc # Replace with your Azure App Service name slot-name: "Production" - publish-profile: ${{ secrets.AZURE_PUBLISH_PROFILE }} + publish-profile: ${{ secrets.AZURE_PUBLISH_PROFILE_rds_app_kc}} package: ./server diff --git a/.github/workflows/azure-webapp-deploy-test.yml b/.github/workflows/azure-webapp-deploy-rds-app-kc-test.yml similarity index 81% rename from .github/workflows/azure-webapp-deploy-test.yml rename to .github/workflows/azure-webapp-deploy-rds-app-kc-test.yml index f652f190..bd88219b 100644 --- a/.github/workflows/azure-webapp-deploy-test.yml +++ b/.github/workflows/azure-webapp-deploy-rds-app-kc-test.yml @@ -1,9 +1,9 @@ -name: RDS TEST Build and Deploy Monolithic React + Node app to Azure +name: Deploying kc-pit-2026-test branch to rds-app-kc test slot on: push: branches: - - test # or your deployment branch + - kc-pit-2026-test # or your deployment branch jobs: build-and-deploy: @@ -57,7 +57,7 @@ jobs: - name: Deploy to Azure Web App uses: azure/webapps-deploy@v3 with: - app-name: rds-test # Replace with your Azure App Service name - slot-name: "Production" - publish-profile: ${{ secrets.AZURE_WEBAPP_PUBLISH_PROFILE_RDS_TEST_DEPLOY }} + app-name: rds-app-kc # Replace with your Azure App Service name + slot-name: "test" + publish-profile: ${{ secrets.AZURE_PUBLISH_PROFILE_rds_app_kc_test }} package: ./server diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 00000000..f806533a --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,23 @@ +name: Deploy MkDocs to GitHub Pages + +on: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: write + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-python@v5 + with: + python-version: '3.x' + - run: pip install -r docs/requirements.txt + - run: mkdocs gh-deploy --force diff --git a/.gitignore b/.gitignore index b9f401de..4dfcdcb7 100644 --- a/.gitignore +++ b/.gitignore @@ -66,4 +66,8 @@ desktop.ini # Generated seed PDFs scripts/seeds/ -scripts/coupons/ \ No newline at end of file +scripts/coupons/ + +# Pulumi +infra/node_modules/ +infra/Pulumi.*.yaml \ No newline at end of file diff --git a/.zenodo.json b/.zenodo.json new file mode 100644 index 00000000..3f4db28c --- /dev/null +++ b/.zenodo.json @@ -0,0 +1,16 @@ +{ + "upload_type": "software", + "communities": [ + { "identifier": "uw-ssec" } + ], + "grants": [ + { "id": "10.13039/100000001::2142964" } + ], + "related_identifiers": [ + { + "identifier": "https://ssdlab.github.io/respondent-driven-sampling/", + "relation": "isDocumentedBy", + "resource_type": "publication-softwaredocumentation" + } + ] +} diff --git a/CITATION.cff b/CITATION.cff new file mode 100644 index 00000000..849a3879 --- /dev/null +++ b/CITATION.cff @@ -0,0 +1,64 @@ +cff-version: 1.2.0 +title: "Respondent-Driven Sampling (RDS) Application" +message: "If you use this software, please cite it using the metadata from this file." +type: software +license: BSD-3-Clause +repository-code: "https://github.com/SSDALab/respondent-driven-sampling" +url: "https://ssdalab.github.io/respondent-driven-sampling/" +abstract: > + An open-source web application for conducting Respondent-Driven Sampling (RDS) + surveys of unsheltered populations. Built for the King County Regional Homelessness + Authority's Point-in-Time count; designed for reuse by other cities and localities. +authors: + - name: "University of Washington Scientific Software Engineering Center (SSEC)" + - given-names: Ihsan + family-names: Kahveci + - given-names: June + family-names: Yang + - given-names: Emily + family-names: Porter + - given-names: Zack + family-names: Almquist + - given-names: Elizabeth + family-names: Deng + - given-names: KelliAnn + family-names: Ramirez + - given-names: Jasmine + family-names: Vuong + - given-names: Hannah + family-names: Lam + - given-names: Ella + family-names: Weinberg + - given-names: Arushi + family-names: Agarwal + - given-names: Devanshi + family-names: Desai + - given-names: Aryan + family-names: Palave + - given-names: Kaden + family-names: Kapadia + - given-names: Hrudhai + family-names: Umashankar + - given-names: Liya + family-names: Finley Hutchison + - given-names: Hana + family-names: Amos + - given-names: Zack + family-names: Crouse + - given-names: Kristen L + family-names: Gustafson + - given-names: Anant + family-names: Mittal + - given-names: Natalie + family-names: Robbins + - given-names: Anshul + family-names: Tambay +keywords: + - respondent-driven sampling + - RDS + - homelessness + - point-in-time count + - survey + - open-source +version: "1.0.0" +date-released: "2026-02-20" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..3aa07a6c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,41 @@ +# Contributing to RDS App + +Thank you for your interest in contributing. This document explains how to run the app, run tests, open issues, and submit pull requests. + +## How to run the app + +Follow the [Setup Instructions](README.md#setup-instructions) in the main README. You will need: + +- Node.js (see repo or CI for version) +- MongoDB connection string and Twilio credentials in `server/.env` + +## How to run tests + +- **Server:** From the repo root, run `cd server && npm test`. For coverage: `cd server && npm run test:coverage`. +- **Client:** Run lint with `cd client && npm run lint`. The client does not currently define a test script in package.json. +- **Lint:** Run `npm run lint` in both `client/` and `server/` before pushing. + +## Pre-commit + +We use [pre-commit](https://pre-commit.com/) for consistent formatting and checks. Before pushing, run: + +```bash +pre-commit run --all-files +``` + +CI also runs these hooks on push/PR to `main`. + +## How to open issues + +Use the [issue templates](.github/ISSUE_TEMPLATE/) when opening a new issue (bug report, feature request, documentation gap, or performance issue). Search existing issues first to avoid duplicates. + +## How to submit pull requests + +1. Open a new issue or comment on an existing one to discuss the change if it’s substantial. +2. Create a branch from `main` (or the target branch). +3. Make your changes and run tests and pre-commit locally. +4. Open a pull request using the [PR template](.github/pull_request_template.md). Link any related issue in the description. + +## Code of Conduct + +This project adheres to the [Contributor Covenant Code of Conduct](CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. diff --git a/README.md b/README.md index 4e7b2579..b6b5d44d 100644 --- a/README.md +++ b/README.md @@ -1,220 +1,107 @@ + +[![DOI](https://zenodo.org/badge/DOI/TODO_ZENODO_DOI.svg)](https://doi.org/TODO_ZENODO_DOI) +[![License](https://img.shields.io/badge/License-BSD%203--Clause-blue.svg)](https://opensource.org/licenses/BSD-3-Clause) + ## Overview -The RDS App is a secure, accessible, and open-source web application that streamlines data collection for homelessness research using **Respondent-Driven Sampling (RDS)**. Developed in collaboration with the University of Washington iSchool and the King County Regional Homelessness Authority (KCRHA), this app enables volunteers and administrators to collect accurate survey data, track referrals, and generate population estimates more effectively than traditional Point-In-Time (PIT) counts. - - - -> **Research-Driven:** Based on field-tested RDS methodologies - -> **Secure & Compliant:** Built with HIPAA and HUD compliance in mind - -## Tech Stack - -| Layer | Technology | -| ----------- | -------------------------------- | -| Frontend | React, HTML/CSS, JavaScript | -| Backend | Node.js, Express.js | -| Database | MongoDB | -| Auth | Twilio | -| Hosting | Azure Web Service | -| QR Scanning | Html5QrcodeScanner, QRCodeCanvas | - -## Directory (old) - -```plaintext -client/ # Client-facing React application -β”œβ”€β”€ build/ # Production build of the React app -β”œβ”€β”€ static/ # Static assets (JS, CSS, media) -β”‚ β”œβ”€β”€ js/ # Compiled JS chunks -β”‚ β”‚ β”œβ”€β”€ 488.db91e947.chunk.js # Bundled JS code for part of the React app -β”‚ β”‚ └── 488.db91e947.chunk.js.map # Source map for debugging that chunk -β”‚ β”œβ”€β”€ asset-manifest.json # Maps file names to generated names (used by backend) -β”‚ β”œβ”€β”€ favicon.ico # Icon shown in browser tab -β”‚ β”œβ”€β”€ index.html # Root HTML file for the React app -β”‚ β”œβ”€β”€ manifest.json # Metadata for PWA features (name, icons, theme color) -β”‚ └── robots.txt # Tells search engines what to crawl or not -β”œβ”€β”€ public/ # Files accessible to anyone on the internet -β”‚ β”œβ”€β”€ favicon.ico # Icon file for the application -β”‚ β”œβ”€β”€ index.html # The main HTML file that serves as the entry point -β”‚ β”œβ”€β”€ manifest.json # Metadata about the web application -β”‚ └── robots.txt # Instructs crawlers on access rules -β”œβ”€β”€ src/ # Source code for the app -β”‚ β”œβ”€β”€ components/ -β”‚ β”‚ └── survey/ -β”‚ β”‚ └── SurveyComponent.tsx # Survey component logic -β”‚ β”œβ”€β”€ pages/ -β”‚ β”‚ β”œβ”€β”€ AdminDashboard/ # Admin dashboard code. Shows staff -β”‚ β”‚ β”‚ β”œβ”€β”€ NewUser.tsx # Admin new user creation -β”‚ β”‚ β”‚ └── StaffDashboard.tsx # Admin dashboard UI -β”‚ β”‚ β”œβ”€β”€ CompletedSurvey/ -β”‚ β”‚ β”‚ β”œβ”€β”€ CompletedSurvey.tsx # End of survey functionality code -β”‚ β”‚ β”‚ └── QrPage.tsx # Displays generated QR code -β”‚ β”‚ β”œβ”€β”€ Header/ -β”‚ β”‚ β”‚ └── Header.tsx # Header functionality code -β”‚ β”‚ β”œβ”€β”€ LandingPage/ -β”‚ β”‚ β”‚ └── LandingPage.tsx # App landing page functionality code -β”‚ β”‚ β”œβ”€β”€ Login/ -β”‚ β”‚ β”‚ └── Login.tsx # Login functionality code -β”‚ β”‚ β”œβ”€β”€ PastEntries/ -β”‚ β”‚ β”‚ β”œβ”€β”€ PastEntries.tsx # Past survey entries dashboard functionality -β”‚ β”‚ β”‚ β”œβ”€β”€ SurveyDetails.tsx # Displays individual survey details -β”‚ β”‚ β”œβ”€β”€ Profile/ -β”‚ β”‚ β”‚ β”œβ”€β”€ AdminEditProfile.tsx # Edit profile functionalities -β”‚ β”‚ β”‚ └── ViewProfile.tsx -β”‚ β”‚ β”œβ”€β”€ QRCodeScanAndReferral/ -β”‚ β”‚ β”‚ └── ApplyReferral.tsx # Functionality to apply a referral code -β”‚ β”‚ β”œβ”€β”€ Signup/ -β”‚ β”‚ β”‚ └── Signup.tsx # Sign up functionality -β”‚ β”‚ └── SurveyEntryDashboard/ -β”‚ β”‚ └── SurveyEntryDashboard.tsx # Displays all surveys as a dashboard -β”‚ β”œβ”€β”€ App.tsx # Main component of the application -β”‚ β”œβ”€β”€ App.test.js # Contains tests for the App component -β”‚ β”œβ”€β”€ index.tsx # JS entry point; renders root React component -β”‚ β”œβ”€β”€ index.css # Global styles for the application -β”‚ β”œβ”€β”€ logo.svg # The React logo -β”‚ β”œβ”€β”€ setupTests.js # Sets up the testing environment -β”‚ β”œβ”€β”€ assets/ # Image assets for UI -β”‚ β”‚ β”œβ”€β”€ filter.png -β”‚ β”‚ β”œβ”€β”€ magnifyingGlass.png -β”‚ β”‚ β”œβ”€β”€ pencil.png -β”‚ β”‚ β”œβ”€β”€ trash.png -β”‚ β”‚ └── up-down.png -β”‚ β”œβ”€β”€ styles/ # Styling files by page/component -β”‚ β”‚ β”œβ”€β”€ ApplyReferral.css -β”‚ β”‚ β”œβ”€β”€ LandingPage.css -β”‚ β”‚ β”œβ”€β”€ PastEntriesCss.css -β”‚ β”‚ β”œβ”€β”€ StaffDashboard.css -β”‚ β”‚ β”œβ”€β”€ SurveyDashboard.css -β”‚ β”‚ β”œβ”€β”€ SurveyDetailsCss.css -β”‚ β”‚ β”œβ”€β”€ complete.css -β”‚ β”‚ β”œβ”€β”€ header.css -β”‚ β”‚ β”œβ”€β”€ login.css -β”‚ β”‚ β”œβ”€β”€ profile.css -β”‚ β”‚ └── signup.css -β”‚ β”œβ”€β”€ types/ # TypeScript type definitions -β”‚ β”‚ β”œβ”€β”€ AuthProps.ts -β”‚ β”‚ β”œβ”€β”€ ReferralCode.ts -β”‚ β”‚ β”œβ”€β”€ Survey.ts -β”‚ β”‚ └── User.ts -β”‚ └── vite-env.d.ts # Vite's auto-imported type definitions -β”œβ”€β”€ .gitignore # Specifies files to ignore in Git -β”œβ”€β”€ README.md # Description of the project, usage, etc. -β”œβ”€β”€ README.old.md # Old version of the project description -β”œβ”€β”€ package.json # Frontend dependencies and scripts -β”œβ”€β”€ package-lock.json # Lockfile for frontend dependencies -β”œβ”€β”€ prettier.config.js # Code formatting configuration -β”œβ”€β”€ tsconfig.json # TypeScript config file -└── vite.config.ts # Vite bundler configuration -server/ # Backend code -β”œβ”€β”€ __tests__/ # Backend tests -β”‚ β”œβ”€β”€ database.test.js -β”‚ └── server.test.js -β”œβ”€β”€ database/ # Database layer -β”‚ β”œβ”€β”€ __tests__/ # Database tests -β”‚ β”‚ └── index.test.ts -β”‚ β”œβ”€β”€ survey/ # Survey domain module -β”‚ β”‚ β”œβ”€β”€ mongoose/ # Mongoose models and hooks -β”‚ β”‚ β”‚ β”œβ”€β”€ __tests__/ -β”‚ β”‚ β”‚ β”œβ”€β”€ survey.hooks.ts -β”‚ β”‚ β”‚ └── survey.model.ts -β”‚ β”‚ β”œβ”€β”€ zod/ # Zod validation schemas -β”‚ β”‚ β”‚ β”œβ”€β”€ __tests__/ -β”‚ β”‚ β”‚ β”œβ”€β”€ survey.base.ts -β”‚ β”‚ β”‚ └── survey.validator.ts -β”‚ β”‚ β”œβ”€β”€ survey.controller.ts # Route operations -β”‚ β”‚ └── survey.utils.ts # Utility functions -β”‚ β”œβ”€β”€ user/ # User domain module (same structure as survey) -β”‚ β”‚ └── ... -β”‚ β”œβ”€β”€ seed/ # Seed domain module (same structure as survey) -β”‚ β”‚ └── ... -β”‚ β”œβ”€β”€ utils/ # Database utilities -β”‚ β”‚ β”œβ”€β”€ constants.ts -β”‚ β”‚ └── errors.ts -β”‚ └── index.ts # Database module exports -β”œβ”€β”€ models/ # Mongoose schemas -β”‚ └── __tests__/ # Models tests -β”‚ β”œβ”€β”€ Survey.test.js -β”‚ └── Users.test.js -β”‚ β”œβ”€β”€ Survey.js # Survey entries with responses and geolocation -β”‚ └── Users.js # User accounts, roles, and hashed passwords -β”œβ”€β”€ routes/ # API routes -β”‚ β”œβ”€β”€ __tests__/ # Routes tests -β”‚ β”‚ β”œβ”€β”€ auth.test.js -β”‚ β”‚ β”œβ”€β”€ pages.test.js -β”‚ β”‚ └── surveys.test.js -β”‚ β”œβ”€β”€ auth.js # Handles login, registration, and approvals -β”‚ β”œβ”€β”€ pages.js # Future page-level routing logic -β”‚ └── surveys.js # Routes to submit, validate, and fetch surveys -β”œβ”€β”€ utils/ -β”‚ β”œβ”€β”€ __tests__/ # Utils tests -β”‚ β”‚ └── generateReferralCode.test.js -β”‚ └── generateReferralCode.js # Utility to generate unique referral codes -β”œβ”€β”€ index.ts # Main entry point for Express backend -β”œβ”€β”€ .gitignore # Specifies files to ignore in Git -β”œβ”€β”€ package.json # Backend dependencies and scripts -└── package-lock.json # Lockfile for backend dependencies -``` +> **Documentation:** [ssdlab.github.io/respondent-driven-sampling](https://ssdlab.github.io/respondent-driven-sampling/) -## Setup Instructions +The RDS App is an open-source web application for conducting **Respondent-Driven Sampling (RDS)** surveys of unsheltered populations. Developed in collaboration with the University of Washington County Regional Homelessness Authority (KCRHA), the app enables volunteers and administrators to collect survey data, track referral chains, and develop population estimates for Point-in-Time (PIT) counts. -### πŸ”§ Local Development +The codebase is a TypeScript monorepo: React frontend (`client/`), Node.js/Express backend (`server/`), and MongoDB for data storage. Authentication is handled via Twilio Verify (OTP). The present deployment is through Azure App Service. -1. **Clone Repo** +## Local Development + +1. **Clone the repo** ```bash -git clone -cd +git clone https://github.com/SSDALab/respondent-driven-sampling.git +cd respondent-driven-sampling ``` -2. **Set Environment Variables** - Copy paste `.env.example` as `.env` in the `server` directory, and paste the neccessary environment values. +2. **Set environment variables** + + Copy the template to create your local env file: + + ```bash + cp server/.env.example server/.env + ``` + + Open `server/.env` and fill in your values: + + ```dotenv + NODE_ENV=development + MONGO_URI=mongodb+srv://YOUR_USER:YOUR_PASSWORD@YOUR_CLUSTER.mongodb.net/ + MONGO_DB_NAME=rds-your-db-name + TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + TWILIO_AUTH_TOKEN=your_twilio_auth_token + TWILIO_VERIFY_SID=VAxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + TWILIO_PHONE_NUMBER=+12065551234 + AUTH_SECRET=paste_output_of_openssl_rand_-hex_32 + TIMEZONE=America/Los_Angeles + ``` + + > **Where do I get these values?** + > These credentials are **not** included in the repository. Contact a project maintainer to obtain shared development values, or set up your own [MongoDB Atlas](https://www.mongodb.com/atlas) and [Twilio](https://console.twilio.com) accounts. Generate `AUTH_SECRET` locally with `openssl rand -hex 32`. See the full [Environment Variables](https://ssdlab.github.io/respondent-driven-sampling/reference/environment-variables/) reference for details on each variable. -3. **Install Packages** + **Important:** The file must be `server/.env` (next to `server/package.json`), not inside `server/src/` or the repository root. + +3. **Install packages** ```bash -npm install +cd client && npm install +cd ../server && npm install ``` -4. **Start Backend Server** +4. **Start the backend server** (with hot reload) ```bash -npm start +cd server +npm run dev ``` -5. **Start Frontend Dev Server** (In seperate terminal) + For production-style run: `npm run build` then `npm start`. + +5. **Start the frontend dev server** (in a separate terminal) ```bash cd client npm run dev ``` -6. **Visit App** at http://localhost:3000. +6. **Visit the app** at [http://localhost:3000](http://localhost:3000). + +The login page will load, but authentication requires the database to be initialised with locations, a super admin account, and seeds. See [Getting Started](https://ssdlab.github.io/respondent-driven-sampling/getting-started/getting-started/) for the full setup walkthrough. ## Future Directions -The items listed below are features our team has identified out of scope for the duration of our project. These items are still considered high importance for the project as a whole, and are highly recommended as a jumping off point for teams taking over the project in the future. +The following features have been identified as high-priority candidates for future development: **App Features** -- Auto-populate location using GPS location coordinates -- Widget for staff to comment on survey responses -- Integration with Homeless Management Information System (HMIS) database system -- Volunteer scheduling dashboard for administrators -- Automated SMS gift card distribution -- Resume unfinished survey feature -- Admin ability to edit survey questions -- Volunteer ability to edit survey responses -- Survey analytics dashboard +- Auto-populate location from last survey entry +- Widget for staff to comment on survey responses +- Integration with Homeless Management Information System (HMIS) +- Volunteer scheduling dashboard for administrators +- Resume unfinished survey feature +- Admin ability to edit survey questions +- Volunteer ability to edit survey responses +- Survey analytics dashboard **Testing** -- Dynamic Application Security Testing (DAST) +- Dynamic Application Security Testing (DAST) -**User Experience** --Step-by-step user training guide +## Funding Support -- Setup wizard +This project is supported by: -## Contributors +- NSF CAREER Grant [#SES-2142964](https://www.nsf.gov/awardsearch/showAward?AWD_ID=2142964) to Zack Almquist (PI) +- UW Population Health Grant Tier 3 + +## Citation -Thanks to the following people for their work on this project: Ihsan Kahveci, June Yang, Emily Porter, Zack Almquist, Elizabeth Deng, KelliAnn Ramirez, Jasmine Vuong, Hannah Lam, Ella Weinberg, Arushi Agarwal, Devanshi Desai, Aryan Palave, Kaden Kapadia, Hrudhai Umashankar, Liya Finley Hutchison, Hana Amos, Zack Crouse, Kristen L Gustafson. +If you use this software, please cite it via the **"Cite this repository"** button on GitHub or see [`CITATION.cff`](CITATION.cff). + +## Contributors +[![Contributors](https://contrib.rocks/image?repo=SSDALab/respondent-driven-sampling)](https://github.com/SSDALab/respondent-driven-sampling/graphs/contributors) diff --git a/client/package-lock.json b/client/package-lock.json index 2abb4b53..3af7080f 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -27,8 +27,8 @@ "react-hot-toast": "^2.6.0", "react-icons": "^5.5.0", "react-router-dom": "^7.1.5", - "survey-core": "^2.3.15", - "survey-react-ui": "^2.3.15", + "survey-core": "^2.5.7", + "survey-react-ui": "^2.5.7", "swr": "^2.3.6", "typescript": "^5.8.3", "web-vitals": "^4.2.4", @@ -83,6 +83,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -366,6 +367,7 @@ "resolved": "https://registry.npmjs.org/@casl/ability/-/ability-6.7.3.tgz", "integrity": "sha512-A4L28Ko+phJAsTDhRjzCOZWECQWN2jzZnJPnROWWHjJpyMq1h7h9ZqjwS2WbIUa3Z474X1ZPSgW0f1PboZGC0A==", "license": "MIT", + "peer": true, "dependencies": { "@ucast/mongo2js": "^1.3.0" }, @@ -475,6 +477,7 @@ "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -518,6 +521,7 @@ "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -1454,6 +1458,7 @@ "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.5.tgz", "integrity": "sha512-8VVxFmp1GIm9PpmnQoCoYo0UWHoOrdA57tDL62vkpzEgvb/d71Wsbv4FRg7r1Gyx7PuSo0tflH34cdl/NvfHNQ==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.28.4", "@mui/core-downloads-tracker": "^7.3.5", @@ -2171,6 +2176,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.6.tgz", "integrity": "sha512-p/jUvulfgU7oKtj6Xpk8cA2Y1xKTtICGpJYeJXz2YVO2UcvjQgeRMLDGfDeqeRW2Ta+0QNFwcc8X3GH8SxZz6w==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2257,6 +2263,7 @@ "integrity": "sha512-lJi3PfxVmo0AkEY93ecfN+r8SofEqZNGByvHAI3GBLrvt1Cw6H5k1IM02nSzu0RfUafr2EvFSw0wAsZgubNplQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.47.0", "@typescript-eslint/types": "8.47.0", @@ -2800,6 +2807,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3117,6 +3125,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -3402,7 +3411,8 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/data-view-buffer": { "version": "1.0.2", @@ -3838,6 +3848,7 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -6026,6 +6037,7 @@ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -6140,6 +6152,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -6152,6 +6165,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -6928,20 +6942,21 @@ } }, "node_modules/survey-core": { - "version": "2.3.15", - "resolved": "https://registry.npmjs.org/survey-core/-/survey-core-2.3.15.tgz", - "integrity": "sha512-faBxYYSDgCoAT3Cc4oJgM8RPalJk8+ujyW/Xwf/FzzN5Pi8HVvfqwBzkkGlOHQT2l2eGMxEzNmUjXWHKayE70w==", - "license": "MIT" + "version": "2.5.7", + "resolved": "https://registry.npmjs.org/survey-core/-/survey-core-2.5.7.tgz", + "integrity": "sha512-gH8+ZTo4MX7i/y3VQvdrK5cIMbS2dFhISDk4qDSo0eh2lvJVnslA5m87I0233vLzu8r3YVF0ZnEyorebvPQzQQ==", + "license": "MIT", + "peer": true }, "node_modules/survey-react-ui": { - "version": "2.3.15", - "resolved": "https://registry.npmjs.org/survey-react-ui/-/survey-react-ui-2.3.15.tgz", - "integrity": "sha512-dRTapHLmGklBf3nuXpXt3c8xv9hGKlDbKjIXXUQNnryb/uGIyrtjLZOlZyzIgyugP7lJj1r8+QtM/mlzBvqeGA==", + "version": "2.5.7", + "resolved": "https://registry.npmjs.org/survey-react-ui/-/survey-react-ui-2.5.7.tgz", + "integrity": "sha512-V4JAOsysVjKN0InuC1HXHIQwPF5s7qti8WP88aD6wqURoHd/1gHLQctQtCbDnkCAXGifo+iatcRCLoIhbdnCzA==", "license": "MIT", "peerDependencies": { "react": "^16.5.0 || ^17.0.1 || ^18.1.0 || ^19.0.0", "react-dom": "^16.5.0 || ^17.0.1 || ^18.1.0 || ^19.0.0", - "survey-core": "2.3.15" + "survey-core": "2.5.7" } }, "node_modules/svg-pathdata": { @@ -7130,6 +7145,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7183,6 +7199,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "napi-postinstall": "^0.3.0" }, @@ -7277,6 +7294,7 @@ "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -7543,21 +7561,6 @@ "dev": true, "license": "ISC" }, - "node_modules/yaml": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", - "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/client/package.json b/client/package.json index 8a11bc5c..1e8ddc99 100644 --- a/client/package.json +++ b/client/package.json @@ -24,8 +24,8 @@ "react-hot-toast": "^2.6.0", "react-icons": "^5.5.0", "react-router-dom": "^7.1.5", - "survey-core": "^2.3.15", - "survey-react-ui": "^2.3.15", + "survey-core": "^2.5.7", + "survey-react-ui": "^2.5.7", "swr": "^2.3.6", "typescript": "^5.8.3", "web-vitals": "^4.2.4", diff --git a/client/src/components/Header.tsx b/client/src/components/Header.tsx index 76848c41..84495b83 100644 --- a/client/src/components/Header.tsx +++ b/client/src/components/Header.tsx @@ -12,7 +12,7 @@ export function Header() { const [isProfileMenuOpen, setIsProfileMenuOpen] = useState(false); const navigate = useNavigate(); const menuRef = useRef(null); // Ref to track the menu - const { userObjectId } = useAuthContext(); + const { userObjectId, userRole } = useAuthContext(); // Function to toggle the profile menu const toggleProfileMenu = () => { setIsProfileMenuOpen(!isProfileMenuOpen); @@ -26,6 +26,84 @@ export function Header() { navigate('/dashboard'); }; + const goToPreviousPage = () => { + const path = window.location.pathname; + + // Define the navigation hierarchy + // /survey/{id}/edit -> /survey/{id} + if (path.includes('/edit')) { + const editMatch = path.match(/^\/survey\/([^/]+)\/edit$/); + if (editMatch) { + const surveyId = editMatch[1]; + navigate(`/survey/${surveyId}`); + return; + } + } + + // /survey/{id}/continue -> /survey-entries + if (path.includes('/continue')) { + navigate('/survey-entries'); + return; + } + + // /survey/{id} -> /survey-entries + const surveyDetailsMatch = path.match(/^\/survey\/([^/]+)$/); + if (surveyDetailsMatch) { + navigate('/survey-entries'); + return; + } + + // /survey (new survey) -> /apply-referral + if (path === '/survey') { + navigate('/apply-referral'); + return; + } + + // /survey-entries -> /dashboard + if (path === '/survey-entries') { + navigate('/dashboard'); + return; + } + + // /apply-referral -> /dashboard + if (path === '/apply-referral') { + navigate('/dashboard'); + return; + } + + // /profile/{userId} -> /admin-dashboard (staff) or /dashboard (volunteers) + const profileMatch = path.match(/^\/profile\/([^/]+)$/); + if (profileMatch) { + const isStaff = + userRole === 'MANAGER' || + userRole === 'ADMIN' || + userRole === 'SUPER_ADMIN'; + navigate(isStaff ? '/admin-dashboard' : '/dashboard'); + return; + } + + // /admin-dashboard -> /dashboard + if (path === '/admin-dashboard') { + navigate('/dashboard'); + return; + } + + // /add-new-user -> /admin-dashboard + if (path === '/add-new-user') { + navigate('/admin-dashboard'); + return; + } + + // /qrcode -> /survey-entries + if (path === '/qrcode') { + navigate('/survey-entries'); + return; + } + + // Default: go to dashboard if no specific rule matches + navigate('/dashboard'); + }; + // Function to handle new entry navigation const handleNewEntry = () => { navigate('/apply-referral'); @@ -59,13 +137,32 @@ export function Header() { // Close menu when navigating return (
- {/* Logo with Home Icon */} -
-
+ {/* Left Icons Container */} +
+ {/* Previous Page Icon */} +
+ + + + +
+ + {/* Home Icon */} +
-

RDS Mobile

+ {/* Centered Title */} +

Point-in-Time Count 2026

+ {/* Navigation Icons */}
{/* New Entry/Plus Circle Outline Icon */} diff --git a/client/src/pages/ApplyReferral/ApplyReferral.tsx b/client/src/pages/ApplyReferral/ApplyReferral.tsx index 7aaeb8e6..dc74e455 100644 --- a/client/src/pages/ApplyReferral/ApplyReferral.tsx +++ b/client/src/pages/ApplyReferral/ApplyReferral.tsx @@ -38,29 +38,29 @@ export default function ApplyReferral() { } setIsScanning(false); - // Extract referral code from scanned text + // Extract coupon code from scanned text const code = decodedText.trim(); if (!code) { - toast.error('Invalid QR Code. Could not extract referral code.'); + toast.error('Invalid QR Code. Could not extract coupon code.'); return; } - // Clear any existing survey data and navigate to survey with the referral code + // Clear any existing survey data and navigate to survey with the coupon code clearSurvey(); try { const data = await surveyService.fetchReferralCodeValidation(code); if (!data?.isValid) { - setErrorMessage(data?.message ?? 'Invalid referral code.'); + setErrorMessage(data?.message ?? 'Invalid coupon code.'); setLoading(false); return; } - // If valid, navigate to the survey page with the referral code + // If valid, navigate to the survey page with the coupon code navigate(`/survey?ref=${code}`); } catch (error) { - console.error('Error validating referral code:', error); + console.error('Error validating coupon code:', error); setErrorMessage( error instanceof Error ? error.message @@ -125,16 +125,16 @@ export default function ApplyReferral() { }; }, [isScanning, onScanSuccess, onScanFailure]); - // Function to handle referral code submission + // Function to handle coupon code submission const handleStartSurvey = async () => { if (!referralCode.trim()) { - setErrorMessage('Please enter a referral code.'); + setErrorMessage('Please enter a coupon code.'); return; } if (referralCode.length !== SURVEY_CODE_LENGTH) { setErrorMessage( - `Please enter a valid ${SURVEY_CODE_LENGTH}-character referral code.` + `Please enter a valid ${SURVEY_CODE_LENGTH}-character coupon code.` ); return; } @@ -149,15 +149,15 @@ export default function ApplyReferral() { await surveyService.fetchReferralCodeValidation(referralCode); if (!data?.isValid) { - setErrorMessage(data?.message ?? 'Invalid referral code.'); + setErrorMessage(data?.message ?? 'Invalid coupon code.'); setLoading(false); return; } - // If valid, navigate to the survey page with the referral code + // If valid, navigate to the survey page with the coupon code navigate(`/survey?ref=${referralCode}`); } catch (error) { - console.error('Error validating referral code:', error); + console.error('Error validating coupon code:', error); setErrorMessage( error instanceof Error ? error.message @@ -171,12 +171,12 @@ export default function ApplyReferral() { return (
-

Apply Referral Code

+

Start a New Survey

Enter or Scan a QR code to start a new survey.

{/* setReferralCode(e.target.value.toUpperCase()) @@ -185,8 +185,8 @@ export default function ApplyReferral() { /> */} setReferralCode(e.target.value.toUpperCase()) @@ -202,7 +202,7 @@ export default function ApplyReferral() { onClick={handleStartSurvey} disabled={loading} > - {loading ? 'Checking...' : 'Start Survey with Referral'} + {loading ? 'Checking...' : 'Enter the Coupon Code'} {/* QR Code Scanner Button */} @@ -225,7 +225,7 @@ export default function ApplyReferral() { }} className="new-seed-btn" > - No referral code? Start new survey + No coupon code? Start new survey
)} diff --git a/client/src/pages/Login/Login.tsx b/client/src/pages/Login/Login.tsx index a97436bd..ec064a45 100644 --- a/client/src/pages/Login/Login.tsx +++ b/client/src/pages/Login/Login.tsx @@ -153,7 +153,9 @@ export default function Login() { color="primary.main" sx={{ mb: 3 }} > - {otpSent ? 'Verify OTP' : 'Hi, Welcome to RDS Mobile!'} + {otpSent + ? 'Verify OTP' + : 'Hi, Welcome to Point-in-Time Count 2026!'} {/* Initial login page */} diff --git a/client/src/pages/QrPage/QrPage.tsx b/client/src/pages/QrPage/QrPage.tsx index bf20ae3d..e0285328 100644 --- a/client/src/pages/QrPage/QrPage.tsx +++ b/client/src/pages/QrPage/QrPage.tsx @@ -24,7 +24,7 @@ export default function QrPage() { return (
-

Referral QR Codes

+

Coupon Codes

Provide these QR codes to referred individuals.

{/* Display QR Codes */} @@ -48,8 +48,7 @@ export default function QrPage() { level="M" />

- {index + 1}. Referral Code:{' '} - {code} + {index + 1}. Coupon Code: {code}

); @@ -63,13 +62,13 @@ export default function QrPage() {
diff --git a/client/src/pages/Survey/Survey.tsx b/client/src/pages/Survey/Survey.tsx index e4060b40..6ce0898d 100644 --- a/client/src/pages/Survey/Survey.tsx +++ b/client/src/pages/Survey/Survey.tsx @@ -20,7 +20,7 @@ import { initializeSurvey } from './utils/surveyUtils'; // This component is responsible for rendering the survey and handling its logic // It uses the SurveyJS library to create and manage the survey -// It also handles referral code validation and geolocation +// It also handles coupon code validation and geolocation // It uses React Router for navigation and URL parameter handling // It uses Zustand (with persist) & localstorage to manage and persist data across sessions // It uses the useEffect hook to manage side effects, such as fetching data and updating state @@ -29,6 +29,7 @@ const Survey = () => { const { surveyService, seedService } = useApi(); const [searchParams] = useSearchParams(); const surveyCodeInUrl = searchParams.get('ref'); + const editMode = searchParams.get('mode'); // 'details', 'giftcard', or 'feedback' const { id: surveyObjectIdInUrl } = useParams(); const isEditMode = window.location.pathname.includes('/edit'); @@ -43,7 +44,7 @@ const Survey = () => { // Add a ref to store the original full survey data in edit mode const originalSurveyData = useRef(null); - // Conditionally fetch survey by referral code (only when surveyCodeInUrl exists) + // Conditionally fetch survey by coupon code (only when surveyCodeInUrl exists) const { data: surveyByRefCode, isLoading: surveyByRefLoading } = surveyCodeInUrl ? surveyService.useSurveyBySurveyCode(surveyCodeInUrl) @@ -235,7 +236,7 @@ const Survey = () => { ) ) { toast.error( - 'You do not have permission to create a survey without a referral code.' + 'You do not have permission to create a survey without a coupon code.' ); navigate('/apply-referral'); return; @@ -246,7 +247,8 @@ const Survey = () => { surveyByRefCode as SurveyDocument | null, surveyByObjectId as SurveyDocument | null, parentSurvey as SurveyDocument | null, - isEditMode + isEditMode, + editMode as string | null ); surveyRef.current = survey; @@ -284,6 +286,13 @@ const Survey = () => { const handlePopState = (event: { state: { pageNo: any } }) => { const survey = surveyRef.current; if (!survey) return; + + // In edit mode, navigate to survey entries on back button + if (isEditMode) { + navigate('/survey-entries'); + return; + } + const currentPageNo = survey.currentPageNo; const targetPageNo = event.state?.pageNo; if (typeof targetPageNo !== 'number') return; @@ -292,7 +301,7 @@ const Survey = () => { }; window.addEventListener('popstate', handlePopState); return () => window.removeEventListener('popstate', handlePopState); - }, []); + }, [isEditMode, navigate]); // Loading state (need to fetch all data) const isLoading = @@ -310,67 +319,6 @@ const Survey = () => { {surveyRef.current && ( )} -
-
{ - if ( - surveyRef.current && - surveyRef.current.currentPageNo > 0 - ) { - surveyRef.current.prevPage(); - } - }} - style={{ cursor: 'pointer' }} - > - - - - -
-
{ - if ( - surveyRef.current && - !surveyRef.current.isLastPage - ) { - surveyRef.current.nextPage(); - } - }} - style={{ cursor: 'pointer' }} - > - - - - -
-
); diff --git a/client/src/pages/Survey/utils/survey.json b/client/src/pages/Survey/utils/survey.json index da87d270..406206fa 100644 --- a/client/src/pages/Survey/utils/survey.json +++ b/client/src/pages/Survey/utils/survey.json @@ -7,7 +7,7 @@ "elements": [ { "type": "html", - "name": "question3", + "name": "intro_text", "html": "
Instructions: These questions are for volunteers only. Please do not ask the respondent.
" }, { @@ -65,7 +65,7 @@ { "type": "html", "name": "youth_consent", - "enableIf": "{is_adult} = 'No'", + "visibleIf": "{is_adult} = 'No'", "html": "
\nInstructions: Please read the following consent information out loud to the respondent and have them orally give their consent to you.\n
\n\n
\n

Unsheltered Point-in-Time Count 2026

\n\n

UW Researchers:

\n
    \n\t
  • \n\t\tDr. Zack W. Almquist
    \n\t\tAssociate Professor, Department of Sociology
    \n\t\tzalmquist@uw.edu\n\t
  • \n\t
  • \n\t\tDr. Amy Hagopian
    \n\t\tProfessor Emeritus, Global Health
    \n\t\tProfessor Emeritus, Health Systems and Population Health
    \n\t\tDirector, Community Oriented Public Health Practice
    \n\t\thagopian@uw.edu\n\t
  • \n\t
  • \n\t\tDr. June Yang
    \n\t\tResearch Scientist, Center for Studies in Demography and Ecology (CSDE) & eScience Institute
    \n\t\tjyang32@uw.edu\n\t
  • \n
\n\n

Why are we talking to you?

\n

We are doing a research study to learn more about young people who are experiencing homelessness or unstable housing. We want to understand how many youth are in this situation, what their lives are like, and how their friendships and social networks work.

\n

We are asking you to be in this study because you are a young person who is currently experiencing homelessness or unstable housing.

\n

Taking part is your choice.

\n\n
\n

Key things to know:

\n
    \n\t
  • If you join, we will ask you to do a survey that takes about 10–15 minutes.
  • \n\t
  • The survey will ask about:\n\t\t
      \n\t\t\t
    • Your housing situation
    • \n\t\t\t
    • Your health
    • \n\t\t\t
    • People you know who are also experiencing homelessness or unstable housing
    • \n\t\t
    \n\t
  • \n\t
  • You will get a $20 prepaid card for doing the survey.
  • \n\t
  • You can also receive $5 for each person you successfully recruit (up to three people) who is also experiencing homelessness or unstable housing and who chooses to do the survey.
  • \n\t
  • Some questions may feel personal (for example, about health, substance use, or your housing history).
  • \n\t
  • You can skip any question you do not want to answer.
  • \n\t
  • You can stop at any time, and you will still get your payment for the survey.
  • \n\t
  • We will keep your information private as much as the law allows.
  • \n\t
  • We will not share your answers with parents/guardians, teachers, case workers, or shelter staff unless we are worried that you or someone else is in serious danger.
  • \n
\n
\n\n

What is the purpose of this study?

\n

The purpose of this study is to:

\n
    \n\t
  • Learn more about how many people are experiencing homelessness.
  • \n\t
  • Understand what life is like for youth who are unhoused or unstably housed.
  • \n\t
  • Learn how social networks (the people you know) are connected to homelessness.
  • \n
\n

This information may help community organizations and local leaders plan better services and support for youth.

\n\n

What will happen if you join?

\n

If you decide to be in the study:

\n
    \n\t
  • A researcher will explain the study and answer any questions you have.
  • \n\t
  • You will do a survey (on a tablet, phone, or paper, or read aloud if you prefer).
  • \n\t
  • The survey will ask you about:\n\t\t
      \n\t\t\t
    • Your age and basic background
    • \n\t\t\t
    • Your current and past housing situation
    • \n\t\t\t
    • Your health and well-being
    • \n\t\t\t
    • People you know who are also experiencing homelessness or unstable housing
    • \n\t\t
    \n\t
  • \n\t
  • The survey will take about 10–15 minutes.
  • \n\t
  • You can take breaks, skip any questions you don't like, or stop at any time.
  • \n
\n\n

Are there any risks or discomforts?

\n

Some questions may feel personal, sad, or uncomfortable (for example, questions about your housing situation, substance use, or hard life experiences).

\n

You might feel upset when thinking or talking about these topics.

\n
    \n\t
  • You do not have to answer any question you do not want to answer.
  • \n\t
  • You can stop the survey at any time, for any reason.
  • \n\t
  • If you become upset, we can pause, stop, or help connect you with local resources if available.
  • \n
\n\n

Will you be paid?

\n

Yes.

\n
    \n\t
  • You will get a $20 prepaid visa card or electronic payment after you complete the survey.
  • \n\t
  • You can also receive $5 for each person (up to three people) you recruit who is also experiencing homelessness or unstable housing and who decides to do the survey.
  • \n\t
  • Even if you decide to stop the survey early, you will still receive the $20 for participating.
  • \n
\n\n

Will your answers be kept private?

\n

We will do our best to keep your information private:

\n
    \n\t
  • We will give you a study ID number instead of using your name on the survey.
  • \n\t
  • Your name and any contact information (like phone or email) will be stored separately from your survey answers.
  • \n\t
  • Only the research team, individuals from the UW, or other agencies that may need to audit study records will have access to the link between your name and your survey ID, and this will be kept in secure, password-protected files.
  • \n
\n

However, there are a few important exceptions:

\n
    \n\t
  • If you tell us that you plan to seriously hurt yourself or hurt someone else, we must tell someone who can help keep you or others safe.
  • \n\t
  • Because you are a young person, if you tell us that an adult is hurting you, abusing you, or neglecting you, we are required by law to report this to a person or agency who can help keep you safe.
  • \n\t
  • Sometimes government or university staff check studies like this to make sure they are being done safely and legally. If they review this study, they may look at study records, but they will protect your privacy, and your information will not be used to get you in trouble with the law.
  • \n
\n\n

What happens to your information after the study?

\n

Because this is a multi-year project, we need to keep your data for some time.

\n

We will keep your survey answers and the link between your name/contact information and your study ID for as long as needed to:

\n
    \n\t
  • Finish this study
  • \n\t
  • Do closely related studies about homelessness
  • \n\t
  • Meet University of Washington and state/federal rules for how long we must keep research records
  • \n
\n

We may use your de-identified survey data (data with your name and contact information removed) in future research about homelessness and housing. We may share de-identified data with other researchers, but your name and direct contact information will not be shared without additional approval and/or your additional consent.

\n\n

What if you change your mind?

\n

Being in this study is voluntary.

\n
    \n\t
  • You can say no to being in the study.
  • \n\t
  • If you start and then change your mind, you can stop at any time.
  • \n\t
  • If you stop, we will not collect new information from you or contact you again for this study (unless you later tell us you want to re-join).
  • \n
\n

However:

\n
    \n\t
  • We will keep and continue to use information already collected about you, including any data that have already been de-identified and combined with other participants. Once your data are combined and analyzed, they cannot be separated out.
  • \n\t
  • We may keep certain basic identifiers (such as your date of birth and initials) and the code that links those to your study ID, as allowed by UW policy and law.
  • \n\t
  • You can decide if you are comfortable with this before you agree to participate.
  • \n
\n\n

What will be shared with others?

\n

We may share group-level results (for example, \"X% of youth reported Y\") with:

\n
    \n\t
  • King County Regional Homelessness Authority (KCRHA)
  • \n\t
  • Local governments
  • \n\t
  • HUD (the U.S. Department of Housing and Urban Development)
  • \n\t
  • Community organizations
  • \n\t
  • In academic papers or presentations
  • \n
\n

Your name or direct contact information will never appear in any reports or publications.

\n\n

Whom can you talk to?

\n

If you have questions or concerns about the study, you can contact:

\n\n

If you have questions about your rights as someone in a research study or complaints about the research, you can contact:

\n

Human Subjects Division (HSD) – University of Washington
\nPhone: (206) 543-0098
\nEmail: hsdinfo@uw.edu

\n\n

Your choice (Assent)

\n

Taking part in this research is your choice. You may refuse to participate or stop at any time without penalty or loss of benefits to which you are entitled.

\n

By agreeing, you are saying:

\n
    \n\t
  • Someone explained this study to you in a way you understand.
  • \n\t
  • You had a chance to ask questions.
  • \n\t
  • Your questions were answered.
  • \n\t
  • You understand that you can stop at any time.
  • \n\t
  • You want to be in this study.
  • \n
\n
\n" }, { @@ -113,8 +113,8 @@ "name": "date_of_birth", "title": "Enter the respondent's date of birth", "inputType": "date", - "minValueExpression": "today(-36525)", - "maxValueExpression": "today(-6574)" + "min": "1926-02-01", + "max": "2014-02-28" } ] }, @@ -137,6 +137,7 @@ { "type": "radiogroup", "name": "survey_churn_seed", + "visibleIf": "{survey_churn_prioryr} = 'Yes'", "title": "In 2024, did you receive a coupon from a care worker or navigator (this was referred to as a \"seed coupon\")?", "choices": [ "Yes", @@ -149,6 +150,7 @@ { "type": "radiogroup", "name": "survey_churn_referral", + "visibleIf": "{survey_churn_prioryr} = 'Yes'", "title": "In 2024, did you receive a coupon from a friend or other person experiencing homelessness?", "choices": [ "Yes", @@ -257,7 +259,6 @@ { "type": "checkbox", "name": "personal_amenities", - "visibleIf": "{sleeping_situation} noneof ['small_vehicle', 'large_vehicle'] and {sleeping_situation} <> 'none'", "title": "Do you generally have access to any of the following needs?", "choices": [ "Drinking water", @@ -480,6 +481,48 @@ "name": "shelter_services_panel", "title": "Housing Assistance Services", "elements": [ + { + "type": "radiogroup", + "name": "winter_shelter", + "title": "In the last week, did you stay in a Winter / Severe Weather Shelter? \n", + "choices": [ + "Yes", + "No", + "Do not know" + ], + "showNoneItem": true, + "noneText": "Choose not to answer" + }, + { + "type": "tagbox", + "name": "winter_shelter_dates", + "visibleIf": "{winter_shelter} = 'Yes'", + "title": "Which days did you stay in a Winter / Severe Weather Shelter? \n", + "description": "Please check all dates the respondent used a winter shelter.", + "choices": [ + "January 17th, 2026", + "January 18th, 2026", + "January 19th, 2026", + "January 20th, 2026", + "January 21st, 2026", + "January 22nd, 2026", + "January 23rd, 2026", + "January 24th, 2026", + "January 25th, 2026", + "January 26th, 2026", + "January 27th, 2026", + "January 28th, 2026", + "January 29th, 2026", + "January 30th, 2026", + "January 31st, 2026", + "February 1st, 2026", + "February 2nd, 2026", + "February 3rd, 2026", + "February 4th, 2026", + "February 5th, 2026", + "February 6th, 2026" + ] + }, { "type": "radiogroup", "name": "shelter_services", @@ -497,7 +540,7 @@ "name": "shelter_services_details", "visibleIf": "{shelter_services} = 'Yes'", "title": "Housing Assistance Services", - "description": "Fill out the table below as much as possible.\\nIn case a respondent received a service multiple times, only include the last service received.", + "description": "Fill out the table below as much as possible. If a respondent received a service multiple times, include only the last service.", "validators": [ { "type": "expression" @@ -528,8 +571,8 @@ "maskType": "datetime", "maskSettings": { "pattern": "mm/yyyy", - "min": "1950-01-01", - "max": "2025-12-15" + "min": "2023-01-01", + "max": "2026-02-15" } } ], @@ -557,14 +600,15 @@ { "type": "html", "name": "demographics_instruction", - "html": "
INTERVIEWER: Because the respondent has already indicated they are under 18, you may select 0-17 without asking this question.
" + "visibleIf": "{is_adult} = 'No'", + "html": "
INTERVIEWER: Because the respondent has already indicated they are under 18, you may select 12-17 without asking this question.
" }, { "type": "radiogroup", "name": "age_group", "title": "How old are you?", "choices": [ - "0-17", + "12-17", "18-24", "25-34", "35-44", @@ -615,7 +659,7 @@ "American Indian or Alaska Native", "Asian", "Black or African American", - "Hispanic or Latino", + "Hispanic/Latina/e/o", "Middle Eastern or North African", "Native Hawaiian or Pacific Islander", "White", @@ -654,6 +698,7 @@ { "type": "radiogroup", "name": "va_health_eligible", + "visibleIf": "{veteran_status} anyof ['Yes, I am a veteran', 'Yes, a member of my immediate family is a veteran', 'Yes, I am a veteran AND a member of my immediate family is a veteran']", "title": "Have you ever received health care of other benefits from a Veterans Administration (VA) center?", "choices": [ "Yes", @@ -799,7 +844,7 @@ { "type": "radiogroup", "name": "hh_member_ethnicity", - "title": "Is {panel.hh_member_initials} Hispanic/Latina/e/o??\n", + "title": "Is {panel.hh_member_initials} Hispanic/Latina/e/o?\n", "choices": [ "Yes", "No", @@ -816,7 +861,7 @@ "American Indian or Alaska Native", "Asian", "Black or African American", - "Hispanic or Latino", + "Hispanic/Latina/e/o", "Middle Eastern or North African", "Native Hawaiian or Pacific Islander", "White", @@ -833,8 +878,8 @@ { "type": "radiogroup", "name": "hh_member_vet_status", + "visibleIf": "{household_members[0].hh_member_age} anyof ['18 - 24 years old', '25 –34 years old', '35- 44 years old', '45-54 years old', '55-64 years old', '65 or older']", "title": "Is {panel.hh_member_initials} a veteran?", - "enableIf": "{is_adult} = 'No' and {is_over12} = 'Yes'", "choices": [ "Yes", "No", @@ -858,7 +903,7 @@ { "type": "radiogroup", "name": "hh_member_smi", - "visibleIf": "{is_adult} = 'Yes'", + "visibleIf": "{is_adult} = 'Yes' and {household_members[0].hh_member_age} anyof ['18 - 24 years old', '25 –34 years old', '35- 44 years old', '45-54 years old', '55-64 years old', '65 or older']", "title": "Does {panel.hh_member_initials} identify as having a severe mental illness?", "choices": [ "Yes", @@ -871,7 +916,7 @@ { "type": "radiogroup", "name": "hh_member_sud", - "visibleIf": "{is_adult} = 'Yes'", + "visibleIf": "{is_adult} = 'Yes' and {household_members[0].hh_member_age} anyof ['18 - 24 years old', '25 –34 years old', '35- 44 years old', '45-54 years old', '55-64 years old', '65 or older']", "title": "Does {panel.hh_member_initials} identify as having a substance use disorder?", "choices": [ "Yes", @@ -895,24 +940,160 @@ "title": "Special Questions", "elements": [ { - "type": "dropdown", + "type": "html", + "name": "travel_questions_instruction", + "visibleIf": "{location} notcontains 'Phone'", + "html": "
INTERVIEWER NOTE: Please do not ask the travel-related questions below when surveying at Mary's Place Phone Line.
" + }, + { + "type": "radiogroup", "name": "travel_location", + "visibleIf": "{location} notcontains 'Phone'", "title": "Where did you travel from today? (Town or City)", "choices": [ - "Tukwila", - "Burien", - "Renton", - "Kent", - "Auburn", - "SeaTac", - "Federal Way", - "Pacific", - "Algona", - "Normandy Park", - "Des Moines", - "Newcastle", - "Milton", - "Seattle" + { + "value": "Snoqualmie Valley", + "elements": [ + { + "type": "dropdown", + "name": "question1", + "title": "Which city?", + "choices": [ + "Snoqualmie", + "North Bend", + "Carnation", + "Duvall", + "Preston", + "Riverpoint", + "Skykomish" + ] + } + ] + }, + { + "value": "North King County", + "elements": [ + { + "type": "dropdown", + "name": "question1", + "title": "Which city?", + "choices": [ + "Shoreline", + "Lake Forest Park", + "Bothell", + "Kenmore", + "Lake City", + "Woodinville" + ] + } + ] + }, + { + "value": "East King County", + "elements": [ + { + "type": "dropdown", + "name": "question1", + "title": "Which city?", + "choices": [ + "Kirkland", + "Redmond", + "Bellevue", + "Mercer Island", + "Sammamish", + "Beaux Arts Village", + "Issaquah", + "Clyde Hill", + "Yarrow Point", + "Medina" + ] + } + ] + }, + { + "value": "South King County", + "elements": [ + { + "type": "dropdown", + "name": "question1", + "title": "Which city?", + "choices": [ + "Tukwila", + "Burien", + "Renton", + "Kent", + "Auburn", + "SeaTac", + "Federal Way", + "Pacific", + "Algona", + "Normandy Park", + "Des Moines", + "Newcastle", + "Milton" + ] + } + ] + }, + { + "value": "South East King County", + "elements": [ + { + "type": "dropdown", + "name": "question1", + "title": "Which city?", + "choices": [ + "Maple Valley", + "Black Diamond", + "Enumclaw", + "Covington" + ] + } + ] + }, + { + "value": "Unincorporated King County", + "elements": [ + { + "type": "dropdown", + "name": "question1", + "title": "Which city?", + "choices": [ + "Bryn Mawr Skyway", + "White Center", + "South Park", + "Fairwood", + "East Renton Highlands", + "Cottage Lake", + "Fall City", + "Hobart", + "Union Hill" + ] + } + ] + }, + { + "value": "Seattle Metro", + "elements": [ + { + "type": "dropdown", + "name": "question1", + "title": "Which city?", + "choices": [ + "Seattle Neighborhood A", + "Seattle Neighborhood B", + "Seattle Neighborhood C", + "Seattle Neighborhood D", + "Seattle Neighborhood E", + "Seattle Neighborhood F", + "Seattle Neighborhood G" + ] + } + ] + }, + { + "value": "Vashon-Maury Island" + } ], "showOtherItem": true, "showNoneItem": true, @@ -922,6 +1103,7 @@ { "type": "checkbox", "name": "travel_transport", + "visibleIf": "{location} notcontains 'Phone'", "title": "What transportation did you use to come to {location}?", "choices": [ "Bus", @@ -938,6 +1120,7 @@ "type": "panel", "name": "travel_time", "title": "How long did it take you to travel to get to {location}?", + "visibleIf": "{location} notcontains 'Phone'", "elements": [ { "type": "slider", @@ -957,6 +1140,7 @@ { "type": "checkbox", "name": "travel_distance", + "visibleIf": "{location} notcontains 'Phone'", "title": "About how many miles did you travel to {location}?", "choices": [ "Less than half a mile", @@ -1159,7 +1343,8 @@ { "type": "html", "name": "age_first_hmlss_instruction", - "html": "
INTERVIEWER: Because the respondent has indicated they are under 18, you may select 0-17 without asking the question.
" + "visibleIf": "{is_adult} = 'No'", + "html": "
INTERVIEWER: Because the respondent has indicated they are under 18, you may select 12-17 without asking the question.
" }, { "type": "dropdown", @@ -1187,25 +1372,45 @@ "Foreclosure", "Incarceration/detention", "Illness/medical problem", - "Substance Use Disorder", - "Mental health issues", - "Hospitalization/treatment", + { + "value": "Substance Use Disorder", + "visibleIf": "{is_adult} = 'Yes'" + }, + { + "value": "Mental health issues", + "visibleIf": "{is_adult} = 'Yes'" + }, + { + "value": "Hospitalization/treatment", + "visibleIf": "{is_adult} = 'Yes'" + }, "Divorce/separation/breakup", "Could not afford rent increase", - "Argument with family/friend/roommate", - "Family domestic violence", + { + "value": "Argument with family/friend/roommate", + "visibleIf": "{is_adult} = 'Yes'" + }, + { + "value": "Family domestic violence", + "visibleIf": "{is_adult} = 'Yes'" + }, "Family/friend's housing wouldn't let me stay", "Family/friend couldn't afford to let me stay", - "Safety", + { + "value": "Safety", + "visibleIf": "{is_adult} = 'Yes'" + }, "Resettlement transition", "Aging out of foster care", "Death of a parent/spouse/child", "Do not know" ], + "choicesOrder": "random", "showOtherItem": true, "showNoneItem": true, "noneText": "Choose not to answer", - "otherText": "Other (please specify)" + "otherText": "Other (please specify)", + "searchMode": "startsWith" }, { "type": "tagbox", @@ -1226,10 +1431,17 @@ "A private room", "Ability to return if I don't stay there one night", "Support to find permanent housing", - "Support for decreasing substance use", - "Support for mental health conditions", + { + "value": "Support for decreasing substance use", + "visibleIf": "{is_adult} = 'Yes'" + }, + { + "value": "Support for mental health conditions", + "visibleIf": "{is_adult} = 'Yes'" + }, "On-site health services such as a nurse" ], + "choicesOrder": "random", "showOtherItem": true, "otherText": "Other (please specify)", "maxSelectedChoices": 5 @@ -1348,7 +1560,7 @@ { "type": "html", "name": "referral_instruction", - "html": "
\n\t\t\t\tInstructions: Please read the following information out loud to the respondent.\n\t\t\t
\n\t\t\t
\n\t\t\t

\n\t\t\t\tAt the end of this survey, we will give you referral coupons.\n\t\t\t\tPlease give them to other people experiencing homelessness.\n\t\t\t\tIf they come and take our survey using your coupon, we can send you $5 gift cards for each completed referral.\n\t\t\t\tTo receive these gift cards, we need either a phone number or email address.\n\t\t\t

" + "html": "
\n\t\t\t\tInstructions: Please read the following information out loud to the respondent.\n\t\t\t
\n\t\t\t
\n\t\t\t

\n\t\t\t\tAt the end of this survey, we will give you referral coupons.\n\t\t\t\tPlease give them to other people experiencing homelessness.\n\t\t\t\tIf they come and take our survey using your coupon, we can send you $5 gift cards for each completed referral.\n\t\t\t\tTo receive these gift cards, we need either a phone number or email address. We will begin issuing the additional funds at the end of February.\n\t\t\t

" }, { "type": "checkbox", @@ -1383,7 +1595,7 @@ "requiredIf": "{email_phone_consent} contains 'phone'", "maskType": "pattern", "maskSettings": { - "pattern": "+1(999)-999-99-99" + "pattern": "(999) 999-9999" } } ] @@ -1422,7 +1634,7 @@ { "type": "text", "name": "gift_card_2", - "visibleIf": "{gift_card_check} = 'Yes' and {household_members[0].hh_member_age} anyof ['0-6 years old', '7-12 years old', '13-17 years old']", + "visibleIf": "{gift_card_check} = 'Yes' and {has_minor_child} = true", "title": "Please enter the last 4 digits of the 2nd Gift Card:", "description": "Note: Families with minor children receive two gift cards.", "inputType": "number", @@ -1430,6 +1642,22 @@ "max": 9999 } ] + }, + { + "name": "end_page", + "title": "End Page", + "elements": [ + { + "type": "html", + "name": "question2", + "html": "
Instructions: These questions are for volunteers only. Please do not ask the respondent.
" + }, + { + "type": "comment", + "name": "survey_comments", + "title": "Please document any issues, exceptions, or important context about this survey (e.g., respondent left early, alternate contact information provided, questions skipped due to circumstances, etc.)" + } + ] } ], "triggers": [ @@ -1442,6 +1670,12 @@ "expression": "{is_adult} = 'No' and {is_over12} = 'No'" } ], + "calculatedValues": [ + { + "name": "has_minor_child", + "expression": "countInArray({household_members}, 'hh_member_age', {hh_member_age} anyof ['0-6 years old', '7-12 years old', '13-17 years old']) > 0" + } + ], "partialSendEnabled": true, "showPageTitles": false, "showProgressBar": true, diff --git a/client/src/pages/Survey/utils/surveyUtils.tsx b/client/src/pages/Survey/utils/surveyUtils.tsx index ac87d48a..2180d1e9 100644 --- a/client/src/pages/Survey/utils/surveyUtils.tsx +++ b/client/src/pages/Survey/utils/surveyUtils.tsx @@ -10,15 +10,52 @@ export const initializeSurvey = ( surveyByRefCode: SurveyDocument | null, surveyByObjectId: SurveyDocument | null, parentSurvey: SurveyDocument | null, - isEditMode: boolean = false + isEditMode: boolean = false, + editMode: string | null = null ) => { // Clone the survey JSON to avoid mutating the original const surveyJson = JSON.parse(JSON.stringify(surveyJsonData)); if (isEditMode) { - // Edit mode only uses first 3 pages: volunteer-pre-screen, consent, survey-validation - surveyJson.title = 'Homelessness Experience Survey (Edit Mode)'; - surveyJson.pages = surveyJson.pages.slice(0, 3); + let pageNames: string[] = []; + let title = 'Homelessness Experience Survey (Edit Mode)'; + + // Determine which pages to show based on edit mode + if (editMode === 'details') { + // Edit Survey Details + title = 'Edit Survey Details'; + pageNames = [ + 'volunteer-pre-screen', + 'age_check', + 'consent', + 'survey-validation', + ]; + } else if (editMode === 'giftcard') { + // Edit Gift Card Information + title = 'Edit Gift Card Information'; + pageNames = ['giftCards', 'giftCards2']; + } else if (editMode === 'feedback') { + // Leave Feedback + title = 'Leave Feedback'; + pageNames = ['end_page']; + } else { + // Default: all edit pages + pageNames = [ + 'volunteer-pre-screen', + 'age_check', + 'consent', + 'survey-validation', + 'giftCards', + 'giftCards2', + 'end_page', + ]; + } + + surveyJson.title = title; + surveyJson.pages = surveyJson.pages.filter((page: any) => + pageNames.includes(page.name) + ); + // Remove any early stop triggers to allow full editing of survey // Without this, the survey will stop early if consent is revoked, not allowing any edits to consecutive pages if (surveyJson.triggers) { @@ -27,7 +64,7 @@ export const initializeSurvey = ( } const survey = new Model(surveyJson); - + // Apply custom theme survey.applyTheme(themeJson); @@ -43,7 +80,7 @@ export const initializeSurvey = ( } }; } - // Populate with existing data from referral code if found + // Populate with existing data from coupon code if found else if (surveyByRefCode) { survey.data = surveyByRefCode.responses; return { diff --git a/client/src/pages/SurveyDetails/SurveyDetails.tsx b/client/src/pages/SurveyDetails/SurveyDetails.tsx index 9ecd274c..d6ea44a9 100644 --- a/client/src/pages/SurveyDetails/SurveyDetails.tsx +++ b/client/src/pages/SurveyDetails/SurveyDetails.tsx @@ -16,13 +16,28 @@ import { useApi } from '@/hooks/useApi'; export default function SurveyDetails() { const { id } = useParams(); - const { surveyService } = useApi(); + const navigate = useNavigate(); + const { surveyService, locationService } = useApi(); const { data: survey, - isLoading: loading, - error + isLoading: surveyLoading, + error: surveyError } = surveyService.useSurveyWithUser(id ?? '') || {}; - const navigate = useNavigate(); + const { + data: locations, + isLoading: locationsLoading, + error: locationsError + } = locationService.useLocations() || {}; + + const loading = surveyLoading || locationsLoading; + const error = surveyError || locationsError || (!loading && !survey); + + // Find location name from locations + const locationName = + survey && locations + ? locations.find(loc => loc._id === survey.locationObjectId)?.hubName || + 'Unknown' + : 'Unknown'; const qrRefs = useRef<(HTMLDivElement | null)[]>([]); const ability = useAbility(); @@ -33,40 +48,32 @@ export default function SurveyDetails() { const canEdit = survey ? ability.can(ACTIONS.CASL.UPDATE, subject(SUBJECTS.SURVEY, survey)) : false; + // Fields to display in survey details (top box) + const surveyDetailsFields = [ + 'first_two_letters_fname', + 'first_two_letters_lname', + 'date_of_birth' + ]; + + // Fields to display in gift card information section + const giftCardFields = [ + 'email_phone_consent', + 'email', + 'phone', + 'gift_card_number', + 'gift_card_2' + ]; + const labelMap: Record = { - first_two_letters_fname: 'First two letters of first name', - first_two_letters_lname: 'First two letters of last name', - year_born: 'Year born', - month_born: 'Month born', - location: 'Location', - interpreter: 'Using interpreter?', - language: 'Language (if using interpreter)', - phone_number: 'Phone number', + first_two_letters_fname: 'First name initials', + first_two_letters_lname: 'Last name initials', + date_of_birth: 'Date of Birth', + survey_comments: 'Survey Comments', + email_phone_consent: 'Email/Phone Consent', email: 'Email', - email_consent: 'Consent to email', - age_for_consent: 'Age 18 or over?', - consent_given: 'Oral consent given?', - homeless_people_count: - 'Number of people experiencing homelessness you know', - people_you_know: 'People you know experiencing homelessness', - sleeping_location_last_night: 'Sleeping Location Last Night', - homeless_duration_since_housing: 'Homeless Duration Since Housing', - homeless_occurrences_past_3_years: 'Homeless Occurrences Past 3 Years', - months_homeless: 'Months Homeless', - age: 'Age', - hispanic_latino: 'Hispanic/Latino', - veteran: 'Veteran', - fleeing_dv: 'Fleeing Domestic Violence', - disability: 'Disability', - mental_illness: 'Mental Illness', - substance_abuse: 'Substance Abuse', - city_lasthoused: 'City Last Housed', - minutes_traveled: 'Minutes Traveled', - events_conditions: 'Events/Conditions', - shelter_preferences: 'Shelter Preferences', - person_name: 'Name', - relationship: 'Relationship', - current_sleeping_location: 'Current Sleeping Location' + phone: 'Phone', + gift_card_number: 'Gift Card Number', + gift_card_2: 'Gift Card 2' }; if (loading) return

Loading...

; @@ -117,143 +124,257 @@ export default function SurveyDetails() {

Survey Details

-
-

- Employee ID: {survey.employeeId} -

-

- Employee Name: {survey.employeeName} -

-

- Submitted At:{' '} - {new Date(survey.createdAt).toLocaleString()} -

+
+
+

Survey Information

+ {/* Edit Survey Details Button */} + + + + + +
+
+

+ Survey Code: {survey.surveyCode} +

+

+ Staff Name: {survey.employeeName} +

+

+ Location: {locationName} +

+

+ Date and Time:{' '} + {new Date(survey.createdAt).toLocaleString()} +

+ {survey.responses && + surveyDetailsFields + .filter(field => field in survey.responses) + .map((field, index) => { + const answer = survey.responses[field]; + const label = labelMap[field] || field; + return ( +

+ {label}:{' '} + {answer ?? 'N/A'} +

+ ); + })} +
- {/* Referral Code Information */} -
-

Referral Information

-

- Referred By Code:{' '} - {survey.parentSurveyCode ?? 'N/A'} -

+ {/* Gift Card Information */} +
+
+

Gift Card Information

+ {/* Edit Gift Card Information Button */} + + + + + +
+
+ {survey.responses && + giftCardFields + .filter(field => field in survey.responses) + .map((field, index) => { + const answer = survey.responses[field]; + const label = labelMap[field] || field; + return ( +

+ {label}:{' '} + {answer ?? 'N/A'} +

+ ); + })} +
+
+ {/* Feedback Section */} +
+
+

Feedback

+ {/* Leave Feedback Button */} + + + + + +
+
+ {survey.responses?.survey_comments ? ( +

+ Comments:{' '} + {survey.responses.survey_comments} +

+ ) : ( +

+ Comments: No feedback provided +

+ )} +
+
-

- Generated Referral Codes: -

- {survey.childSurveyCodes && - survey.childSurveyCodes.length > 0 ? ( -
    - {survey.childSurveyCodes.map( - (code: string, index: number) => ( -
  • - {code} -
  • - ) - )} -
- ) : ( -

N/A

- )} - {/* Display QR Codes */} -
-
+ {/* Coupon Code Information */} +
+
+

Referral Information

+ +
+
+

+ Referred By Code:{' '} + {survey.parentSurveyCode ?? 'N/A'} +

+ +
+

+ Generated Referral Codes: +

{survey.childSurveyCodes && survey.childSurveyCodes.length > 0 ? ( - survey.childSurveyCodes.map( - (code: string, index: number) => { - const qrSurveyCode = code; - return ( -
+ {survey.childSurveyCodes.map( + (code: string, index: number) => ( +
  • { - qrRefs.current[index] = el; - }} + className="referral-code-tag" > - -

    - {index + 1}. Referral Code:{' '} - {code} -

    -
  • - ); - } - ) + {code} + + ) + )} + ) : ( -

    No referral codes available.

    + N/A )}
    -
    -
    - + {/* Display QR Codes */} +
    +
    + {survey.childSurveyCodes && + survey.childSurveyCodes.length > 0 ? ( + survey.childSurveyCodes.map( + (code: string, index: number) => { + const qrSurveyCode = code; + return ( +
    { + qrRefs.current[index] = el; + }} + style={{ + display: 'inline-block', + textAlign: 'center', + padding: '5px' + }} + > + +

    + {index + 1}. {code} +

    +
    + ); + } + ) + ) : ( +

    No referral codes available.

    + )} +
    +
    - - {/* Survey Responses */} -
    -

    Survey Responses

    -
    -						{survey.responses &&
    -							Object.entries(survey.responses)
    -								.map(([question, answer]) => {
    -									const label =
    -										labelMap[question] || question;
    -
    -									if (
    -										question === 'people_you_know' &&
    -										Array.isArray(answer)
    -									) {
    -										return (
    -											`\n${label}:\n` +
    -											answer
    -												.map((person, index) => {
    -													return (
    -														`  Person ${index + 1}:\n` +
    -														Object.entries(person)
    -															.map(
    -																([key, val]) =>
    -																	`    ${key}: ${val}`
    -															)
    -															.join('\n')
    -													);
    -												})
    -												.join('\n\n')
    -										);
    -									} else {
    -										return `${label}: ${answer}`;
    -									}
    -								})
    -								.join('\n\n')}
    -					
    -
    - {/* Edit Pre-screen Questions Button */} - - - - -
    ); diff --git a/client/src/pages/SurveyEntryDashboard/SurveyEntryDashboard.tsx b/client/src/pages/SurveyEntryDashboard/SurveyEntryDashboard.tsx index 7fd9f8a5..e4c483cf 100644 --- a/client/src/pages/SurveyEntryDashboard/SurveyEntryDashboard.tsx +++ b/client/src/pages/SurveyEntryDashboard/SurveyEntryDashboard.tsx @@ -10,6 +10,7 @@ import { } from './components'; import { filterSurveysByDate, + filterSurveysByLocation, paginateSurveys, searchSurveys, sortSurveys, @@ -23,6 +24,7 @@ export default function SurveyEntryDashboard() { const [viewAll, setViewAll] = useState(false); const [filterMode, setFilterMode] = useState('byDate'); const [showFilterPopup, setShowFilterPopup] = useState(false); + const [selectedLocation, setSelectedLocation] = useState(null); const [sortConfig, setSortConfig] = useState<{ key: string | null; direction: 'asc' | 'desc'; @@ -38,7 +40,7 @@ export default function SurveyEntryDashboard() { useEffect(() => { setViewAll(filterMode === 'viewAll'); setCurrentPage(0); - }, [filterMode, selectedDate, searchTerm]); + }, [filterMode, selectedDate, searchTerm, selectedLocation]); const handleSort = (key: string) => { setSortConfig(prev => ({ @@ -48,12 +50,13 @@ export default function SurveyEntryDashboard() { })); }; - const filteredSurveys = filterSurveysByDate( + const filteredByDate = filterSurveysByDate( surveys ?? [], viewAll, selectedDate ); - const sortedSurveys = sortSurveys(filteredSurveys, sortConfig); + const filteredByLocation = filterSurveysByLocation(filteredByDate, selectedLocation); + const sortedSurveys = sortSurveys(filteredByLocation, sortConfig); const searchedSurveys = searchSurveys(sortedSurveys, searchTerm); const currentSurveys = paginateSurveys( searchedSurveys, @@ -65,15 +68,15 @@ export default function SurveyEntryDashboard() { { key: 'createdAt', label: 'Date & Time', sortable: true, width: 120 }, // { key: 'employeeId', label: 'Employee ID', sortable: true, width: 120 }, { - key: 'employeeName', - label: 'Employee Name', + key: 'surveyCode', + label: 'Survey Code', sortable: true, width: 120 }, { key: 'locationName', label: 'Location', sortable: true, width: 130 }, { - key: 'parentSurveyCode', - label: 'Referred By Code', + key: 'employeeName', + label: 'Staff Name', sortable: true, width: 140 }, @@ -95,13 +98,13 @@ export default function SurveyEntryDashboard() { sortable: true, width: 110 }, + { key: 'progress', label: 'Progress', sortable: false, width: 120 }, { key: 'actions', label: 'Survey Responses', sortable: false, width: 130 - }, - { key: 'progress', label: 'Progress', sortable: false, width: 120 } + } ]; return ( @@ -125,6 +128,7 @@ export default function SurveyEntryDashboard() { {viewAll ? 'Viewing All Survey Entries' : `Entries for: ${toPacificDateOnlyString(selectedDate)}`} + {selectedLocation && ` β€’ Location: ${selectedLocation}`} @@ -160,10 +164,21 @@ export default function SurveyEntryDashboard() { open={showFilterPopup} filterMode={filterMode} selectedDate={selectedDate} + selectedLocation={selectedLocation} + locations={ + Array.from( + new Set( + surveys + ?.map(s => s.locationName) + .filter(Boolean) + ) + ) ?? [] + } onClose={() => setShowFilterPopup(false)} - onApply={(mode, date) => { + onApply={(mode, date, location) => { setFilterMode(mode); setSelectedDate(date); + setSelectedLocation(location); }} /> diff --git a/client/src/pages/SurveyEntryDashboard/components/FilterDialog.tsx b/client/src/pages/SurveyEntryDashboard/components/FilterDialog.tsx index 56d6eb19..6eac3117 100644 --- a/client/src/pages/SurveyEntryDashboard/components/FilterDialog.tsx +++ b/client/src/pages/SurveyEntryDashboard/components/FilterDialog.tsx @@ -6,9 +6,13 @@ import { DialogActions, DialogContent, DialogTitle, + FormControl, FormControlLabel, + InputLabel, + MenuItem, Radio, RadioGroup, + Select, TextField } from '@mui/material'; @@ -18,30 +22,37 @@ interface FilterDialogProps { open: boolean; filterMode: string; selectedDate: Date; + selectedLocation: string | null; + locations: string[]; onClose: () => void; - onApply: (mode: string, date: Date) => void; + onApply: (mode: string, date: Date, location: string | null) => void; } export default function FilterDialog({ open, filterMode, selectedDate, + selectedLocation, + locations, onClose, onApply }: FilterDialogProps) { const [tempFilterMode, setTempFilterMode] = React.useState(filterMode); const [tempSelectedDate, setTempSelectedDate] = React.useState(selectedDate); + const [tempSelectedLocation, setTempSelectedLocation] = + React.useState(selectedLocation); React.useEffect(() => { if (open) { setTempFilterMode(filterMode); setTempSelectedDate(selectedDate); + setTempSelectedLocation(selectedLocation); } - }, [open, filterMode, selectedDate]); + }, [open, filterMode, selectedDate, selectedLocation]); const handleApply = () => { - onApply(tempFilterMode, tempSelectedDate); + onApply(tempFilterMode, tempSelectedDate, tempSelectedLocation); onClose(); }; @@ -78,6 +89,23 @@ export default function FilterDialog({ }} /> )} + + + Location + + diff --git a/client/src/pages/SurveyEntryDashboard/components/SurveyEntryDashboardControls.tsx b/client/src/pages/SurveyEntryDashboard/components/SurveyEntryDashboardControls.tsx index e48702b1..ca88ec0c 100644 --- a/client/src/pages/SurveyEntryDashboard/components/SurveyEntryDashboardControls.tsx +++ b/client/src/pages/SurveyEntryDashboard/components/SurveyEntryDashboardControls.tsx @@ -44,7 +44,7 @@ export default function SurveyEntryDashboardControls({ onSearchChange(e.target.value)} sx={{ flexGrow: 1, minWidth: 300 }} diff --git a/client/src/pages/SurveyEntryDashboard/components/SurveyEntryDashboardRow.tsx b/client/src/pages/SurveyEntryDashboard/components/SurveyEntryDashboardRow.tsx index 5f1faf12..da874731 100644 --- a/client/src/pages/SurveyEntryDashboard/components/SurveyEntryDashboardRow.tsx +++ b/client/src/pages/SurveyEntryDashboard/components/SurveyEntryDashboardRow.tsx @@ -33,25 +33,9 @@ export default function SurveyEntryDashboardRow({ return ( {toPacificDateTimeString(survey.createdAt)} - {/* - - - {survey.employeeId} - - - */} - {survey.employeeName} + {survey.surveyCode ?? 'N/A'} {survey.locationName ?? 'N/A'} - {survey.parentSurveyCode ?? 'N/A'} + {survey.employeeName} {survey.responses?.first_two_letters_fname ?? 'N/A'} @@ -59,20 +43,6 @@ export default function SurveyEntryDashboardRow({ {survey.responses?.first_two_letters_lname ?? 'N/A'} {survey.responses?.date_of_birth ?? 'N/A'} - - - {/* TODO: add some kind of permission check/unlocking functionality here for admin */} {survey.isCompleted ? ( @@ -108,6 +78,20 @@ export default function SurveyEntryDashboardRow({ )} + + + ); } diff --git a/client/src/pages/SurveyEntryDashboard/utils/SurveyEntryDashboardUtils.tsx b/client/src/pages/SurveyEntryDashboard/utils/SurveyEntryDashboardUtils.tsx index 17acf356..a16de038 100644 --- a/client/src/pages/SurveyEntryDashboard/utils/SurveyEntryDashboardUtils.tsx +++ b/client/src/pages/SurveyEntryDashboard/utils/SurveyEntryDashboardUtils.tsx @@ -77,6 +77,19 @@ export const sortSurveys = ( }); }; +/** + * Filter surveys by location name + */ +export const filterSurveysByLocation = ( + surveys: SurveyDocument[] | undefined, + locationName: string | null +): SurveyDocument[] => { + if (!surveys) return []; + if (!locationName) return surveys; + + return surveys.filter(s => s.locationName === locationName); +}; + /** * Search surveys by term across multiple fields */ @@ -90,10 +103,9 @@ export const searchSurveys = ( return surveys.filter(s => { const searchableText = [ - s.employeeId, + s.surveyCode, s.employeeName, s.locationName, - s.parentSurveyCode, s.responses?.first_two_letters_fname, s.responses?.first_two_letters_lname, s.responses?.date_of_birth diff --git a/client/src/styles/ApplyReferral.css b/client/src/styles/ApplyReferral.css index 8b541c25..f676019b 100644 --- a/client/src/styles/ApplyReferral.css +++ b/client/src/styles/ApplyReferral.css @@ -1,119 +1,121 @@ /* apply-referral-page */ .apply-referral-page { - font-family: 'Montserrat', sans-serif; - padding: 20px; - display: flex; - justify-content: center; /* Center horizontally */ - align-items: center; /* Center vertically */ - min-height: 100vh; /* Full viewport height */ + font-family: 'Montserrat', sans-serif; + padding: 20px; + display: flex; + justify-content: center; /* Center horizontally */ + align-items: center; /* Center vertically */ + min-height: 100vh; /* Full viewport height */ } /* Container for the entire referral application form */ .apply-referral-container { - max-width: 1000px; - background: white; - padding: 20px; - border-radius: 10px; - box-shadow: 0px 4px 10px rgba(0, 0, 0, 0.1); - text-align: center; - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; + max-width: 1000px; + background: white; + padding: 20px; + border-radius: 10px; + box-shadow: 0px 4px 10px rgba(0, 0, 0, 0.1); + text-align: center; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; } /* Title and description */ h2 { - color: #3E236E; - margin-bottom: 10px; + color: #3e236e; + margin-bottom: 10px; } /* Description text */ p { - font-size: 15px; - margin-top: 0; - margin-bottom: 30px; - color: #555; + font-size: 15px; + margin-top: 0; + margin-bottom: 30px; + color: #555; } -/* Input field for referral code */ +/* Input field for coupon code */ .referral-input { - width: 100%; - padding: 10px; - font-size: 16px; - margin-bottom: 30px; - border: 1px solid #ddd; - border-radius: 5px; - box-sizing: border-box; - transition: 0.3s; + width: 100%; + padding: 10px; + font-size: 16px; + margin-bottom: 30px; + border: 1px solid #ddd; + border-radius: 5px; + box-sizing: border-box; + transition: 0.3s; } /* Placeholder text styling */ .referral-input:focus { - border-color: #3E236E; - outline: none; + border-color: #3e236e; + outline: none; } /* Button styles */ .generate-btn { - padding: 15px 20px; - background-color: #3E236E; - color: white; - border: none; - border-radius: 10px; - cursor: pointer; - font-size: 14px; - transition: background-color 0.3s; - margin-bottom: 10px; + padding: 15px 20px; + background-color: #3e236e; + color: white; + border: none; + border-radius: 10px; + cursor: pointer; + font-size: 14px; + transition: background-color 0.3s; + margin-bottom: 10px; } /* Button hover effect */ .generate-btn:hover { - background-color: #5A2E8A; + background-color: #5a2e8a; } /* Disabled button styles */ .generate-btn:disabled { - background-color: #ddd; - cursor: not-allowed; + background-color: #ddd; + cursor: not-allowed; } /* Cancel button styles */ .cancel-btn { - background-color: white; - color: black; - border: 1px solid #969696; - padding: 10px 20px; - border-radius: 10px; - cursor: pointer; - font-size: 14px; - transition: background-color 0.3s, color 0.3s; + background-color: white; + color: black; + border: 1px solid #969696; + padding: 10px 20px; + border-radius: 10px; + cursor: pointer; + font-size: 14px; + transition: + background-color 0.3s, + color 0.3s; } /* Cancel button hover effect */ .cancel-btn:hover { - background-color: #969696; - color: white; /* White text on hover */ + background-color: #969696; + color: white; /* White text on hover */ } /* Cancel button disabled styles */ .cancel-btn:disabled { - background-color: #ddd; - color: #888; - cursor: not-allowed; - border-color: #ddd; + background-color: #ddd; + color: #888; + cursor: not-allowed; + border-color: #ddd; } .new-seed-btn { - font-family: 'Montserrat', sans-serif; - color: #3E236E; /* Purple color from your palette */ - text-decoration: underline; - cursor: pointer; - text-align: center; - padding-bottom: 20px; /* Space under the button */ - font-weight: 500; /* Medium weight, similar to your styles */ - font-size: 14px; - user-select: none; /* Optional: prevent text selection on click */ - width: 100%; /* Make it take full width to center text */ - display: block; -} \ No newline at end of file + font-family: 'Montserrat', sans-serif; + color: #3e236e; /* Purple color from your palette */ + text-decoration: underline; + cursor: pointer; + text-align: center; + padding-bottom: 20px; /* Space under the button */ + font-weight: 500; /* Medium weight, similar to your styles */ + font-size: 14px; + user-select: none; /* Optional: prevent text selection on click */ + width: 100%; /* Make it take full width to center text */ + display: block; +} diff --git a/client/src/styles/SurveyDetailsCss.css b/client/src/styles/SurveyDetailsCss.css index ef8c937e..aef2238e 100644 --- a/client/src/styles/SurveyDetailsCss.css +++ b/client/src/styles/SurveyDetailsCss.css @@ -1,63 +1,63 @@ /* CSS FOR SURVEYDETAILS.JS */ .survey-details-container { - max-width: 800px; - margin: auto; - padding: 20px; - background-color: #f7f7f7; - border-radius: 10px; - box-shadow: 0px 4px 10px rgba(0, 0, 0, 0.1); + max-width: 800px; + margin: auto; + padding: 20px; + background-color: #f7f7f7; + border-radius: 10px; + box-shadow: 0px 4px 10px rgba(0, 0, 0, 0.1); } /* General styles for headings and paragraphs */ -.survey-info, .referral-info { - margin-bottom: 20px; - padding: 15px; - background: white; - border-radius: 8px; - box-shadow: 0px 2px 8px rgba(0, 0, 0, 0.1); +.survey-info, +.referral-info { + margin-bottom: 20px; + padding: 15px; + background: white; + border-radius: 8px; + box-shadow: 0px 2px 8px rgba(0, 0, 0, 0.1); } /* Specific styles for headings */ h3 { - color: #3E236E; - margin-bottom: 10px; + color: #3e236e; + margin-bottom: 10px; } -/* Styles for the referral code input */ +/* Styles for the coupon code input */ .referral-list { - list-style: none; - padding: 0; + list-style: none; + padding: 0; } -/* Styles for the referral code input */ +/* Styles for the coupon code input */ .referral-code-tag { - display: inline-block; - background-color: #e0e0e0; - padding: 5px 10px; - border-radius: 5px; - margin: 5px; - font-weight: bold; + display: inline-block; + background-color: #e0e0e0; + padding: 5px 10px; + border-radius: 5px; + margin: 5px; + font-weight: bold; } /* Styles for the action buttons */ .responses-section pre { - background: #fff; - padding: 15px; - border-radius: 8px; - box-shadow: 0px 2px 8px rgba(0, 0, 0, 0.1); - white-space: pre-wrap; - word-wrap: break-word; + background: #fff; + padding: 15px; + border-radius: 8px; + box-shadow: 0px 2px 8px rgba(0, 0, 0, 0.1); + white-space: pre-wrap; + word-wrap: break-word; } /* Edit Pre-Screen Responses Button */ .edit-button { - width: 100%; - padding: 14px; - background-color: #3e236e; - color: white; - border: none; - font-size: 16px; - cursor: pointer; - border-radius: 8px; - margin-top: 10px; -} \ No newline at end of file + padding: 14px; + background-color: #3e236e; + color: white; + border: none; + font-size: 16px; + cursor: pointer; + border-radius: 8px; + margin-top: 10px; +} diff --git a/client/src/styles/complete.css b/client/src/styles/complete.css index 3db5f7e0..96754067 100644 --- a/client/src/styles/complete.css +++ b/client/src/styles/complete.css @@ -53,6 +53,11 @@ body { font-family: 'Montserrat', sans-serif; } +.qr-buttons { + display: flex; + gap: 1rem; +} + .qr-code-container { display: flex; flex-direction: row; /* βœ… horizontal layout */ diff --git a/client/src/styles/header.css b/client/src/styles/header.css index 0410e3ef..95491433 100644 --- a/client/src/styles/header.css +++ b/client/src/styles/header.css @@ -14,24 +14,35 @@ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); } -/* Logo styles */ -.logo { +/* Centered header title */ +.header-title { + position: absolute; + left: 50%; + transform: translateX(-50%); + font-size: 1.8rem; + margin: 0; + color: #3E236E; +} + +/* Left icons container */ +.left-icons { display: flex; align-items: center; - gap: 10px; + gap: 15px; } -/* Logo text */ -.logo h1 { - font-size: 1.8rem; - margin: 0; - color: #3E236E; +/* Back icon */ +.back-icon { + display: flex; + align-items: center; + cursor: pointer; } /* Home icon */ .home-icon { display: flex; align-items: center; + cursor: pointer; } /* Navigation Icons */ @@ -109,7 +120,7 @@ body { gap: 15px; } - .logo h1 { + .header-title { font-size: 1.5rem; } } \ No newline at end of file diff --git a/client/src/utils/qrCodeUtils.ts b/client/src/utils/qrCodeUtils.ts index 597bbc58..0b1faeab 100644 --- a/client/src/utils/qrCodeUtils.ts +++ b/client/src/utils/qrCodeUtils.ts @@ -49,11 +49,11 @@ const createQrCodePdf = ( // Add QR code image pdf.addImage(imgData, 'PNG', qrX, qrY, qrSizePt, qrSizePt); - // Add referral code text + // Add coupon code text const code = codes[i]; pdf.setFontSize(10); pdf.text( - `Referral Code: ${code}`, + `Coupon Code: ${code}`, pageWidthPt / 2, qrY + qrSizePt + spacingPt, { @@ -65,19 +65,52 @@ const createQrCodePdf = ( return pdf; }; -export const printQrCodePdf = ( +export const printQrCodePdf = async ( qrRefs: (HTMLDivElement | null)[], codes: string[] -): void => { +): Promise => { if (codes.length === 0) { return; } const pdf = createQrCodePdf(qrRefs, codes); const pdfBlob = pdf.output('blob'); - const blobUrl = URL.createObjectURL(pdfBlob); - // Open PDF in a new window + // Detect mobile devices (iOS/Android) where window.print() doesn't work well + const isMobile = + /iPhone|iPad|iPod|Android/i.test(navigator.userAgent) || + (navigator.maxTouchPoints > 0 && + /Mobile|Tablet/i.test(navigator.userAgent)); + + // Try Web Share API on mobile devices only (works best on iOS/iPad/Android) + if (isMobile) { + const file = new File([pdfBlob], 'coupon-qr-codes.pdf', { + type: 'application/pdf' + }); + + if (navigator.share && navigator.canShare?.({ files: [file] })) { + try { + await navigator.share({ + files: [file], + title: 'Coupon QR Codes' + }); + return; + } catch (err) { + // User cancelled or share failed - fall through to window.open approach + if ((err as Error).name === 'AbortError') { + // User cancelled the share - don't fall through + return; + } + console.warn( + 'Web Share failed, falling back to print window:', + err + ); + } + } + } + + // Fallback: Open PDF in a new window and trigger print (desktop browsers) + const blobUrl = URL.createObjectURL(pdfBlob); const printWindow = window.open(blobUrl, '_blank'); if (printWindow) { diff --git a/docs/about/license.md b/docs/about/license.md new file mode 100644 index 00000000..69d9e289 --- /dev/null +++ b/docs/about/license.md @@ -0,0 +1,47 @@ +# License + +The RDS App is released under the **BSD 3-Clause License**. + +``` +BSD 3-Clause License + +Copyright (c) 2024 University of Washington, eScience Institute, +Scientific Software Engineering Center +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the University of Washington nor the names of its + contributors may be used to endorse or promote products derived from this + software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE UNIVERSITY OF WASHINGTON AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE UNIVERSITY OF WASHINGTON OR CONTRIBUTORS +BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE +GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT +OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +``` + +The full license text is also available at [`LICENSE`](https://github.com/SSDALab/respondent-driven-sampling/blob/main/LICENSE) in the repository root. + +## Implications for Adopters + +The BSD 3-Clause license is a permissive open-source license: + +- The software may be forked, modified, and deployed for any city or organization, including commercial and government contexts. +- The original copyright notice and license text must be included in any redistribution of the source code or binaries. +- The University of Washington's name may not be used to endorse or promote derived products without written permission. +- Contributing improvements back to the project via pull request is encouraged but not required. diff --git a/docs/about/operations.md b/docs/about/operations.md new file mode 100644 index 00000000..be4f8e9a --- /dev/null +++ b/docs/about/operations.md @@ -0,0 +1,64 @@ +# Operations + +This page covers infrastructure and access management for maintainers of the King County deployment. Other cities deploying their own instance will have their own equivalent infrastructure. + +## Azure Access + +The King County production and test deployments run on Azure App Service. Access to the Azure subscription is granted by UW team. + +If you need access to: + +- **Azure Portal** β€” open an issue in the repository or contact the SSEC team directly +- **Azure App Service logs** β€” Log stream is available in the Azure Portal under the App Service β†’ **Log stream** +- **Production database (MongoDB Atlas)** β€” contact the UW SSEC team directly; access is restricted + +## GitHub Organization Permissions + +The repository lives under the [SSDALab](https://github.com/SSDALab) GitHub organization. Repository permissions are managed by org admins. + +To request: + +- **Write access to the repo** β€” open an issue +- **GitHub Actions secrets** (e.g. `AZURE_PUBLISH_PROFILE`) β€” requires admin access; request via an issue + +## Monitoring + +The production app does not currently have a dedicated monitoring service. To check on the app: + +- **Azure Portal β†’ App Service β†’ Log stream** β€” real-time Node.js server logs +- **Azure Portal β†’ App Service β†’ Metrics** β€” CPU, memory, request count +- **MongoDB Atlas β†’ Monitoring** β€” database query performance and connections + +## For Other Cities + +If you are running your own deployment, you are responsible for your own infrastructure. UW can provide guidance but does not manage other cities' Azure or MongoDB accounts. + +For questions, open an issue at [github.com/SSDALab/respondent-driven-sampling/issues](https://github.com/SSDALab/respondent-driven-sampling/issues). + +## Zenodo Setup + +### First-Time Setup (One-Time, Human-Required) + +Zenodo registration cannot be automated β€” it requires a repository maintainer with admin access to perform the following steps once: + +1. Go to [zenodo.org](https://zenodo.org) and sign in with GitHub. +2. Navigate to **Account β†’ Settings β†’ GitHub**. +3. Find `SSDALab/respondent-driven-sampling` in the repository list and toggle it **On**. +4. Publish a GitHub Release (tag: `v1.0.0` or appropriate). +5. Zenodo archives the release and issues a DOI within 1–2 minutes. +6. Copy the concept DOI (stable across all versions) from the Zenodo record page. +7. Update `CITATION.cff` with `doi: 10.5281/zenodo.XXXXXXX`, `version`, and `date-released`. +8. Update `README.md` with the real DOI. +10. Commit and push these updates to `main`. + +After first-time setup, future GitHub Releases are archived automatically. + +### Per-Release Verification + +After publishing each subsequent GitHub Release: + +1. Wait 1–2 minutes. +2. Check [zenodo.org/account/settings/github](https://zenodo.org/account/settings/github) to confirm the release was archived. +3. Update `CITATION.cff` with the new `version` and `date-released`. +4. Commit the update directly to `main`. + diff --git a/docs/about/project-overview.md b/docs/about/project-overview.md new file mode 100644 index 00000000..f8891214 --- /dev/null +++ b/docs/about/project-overview.md @@ -0,0 +1,46 @@ +# Project Overview + +## History + +The RDS App was developed through a collaboration between: + +- **University of Washington, Department of Sociology** β€” Zack Almquist, June Yang, and Ihsan Kahveci; primary academic home of the project. +- **University of Washington, Information School (iSchool)** β€” Emily Porter and capstone students who contributed to the application. +- **King County Regional Homelessness Authority (KCRHA)** β€” The primary partner and end user, using the app to run Point-in-Time (PIT) count surveys of unsheltered individuals in King County, Washington. +- **UW eScience Institute / Scientific Software Engineering Center (SSEC)** β€” Provided software engineering support, code quality, and open-source infrastructure. + +The first production deployment was used for the **2026 King County Unsheltered PIT Count**, collecting surveys from approximately 2,183 individuals experiencing unsheltered homelessness. + +## What the App Does + +The RDS App streamlines the implementation of respondent-driven sampling (RDS) for unsheltered point-in-time counts of people experiencing homelessness. The details of this method are described [here](https://academic.oup.com/aje/article/194/6/1524/7749332?login=true). + +Key capabilities: + +| Capability | Description | +|---|---| +| **Volunteer survey collection** | Volunteers log in, scan a referral QR code from a participant, then guide participants through the survey | +| **Referral chain tracking** | Each completed survey generates 3 child QR codes; referral relationships are stored in the database | +| **Admin oversight** | Admins can view, filter, and manage all survey entries in a dashboard | +| **User management** | Admins approve volunteer accounts; roles (volunteer, manager, admin, super-admin) control access | +| **SMS gift card distribution** | (Optional) Bulk SMS to survey participants with gift card information via Twilio | +| **Coupon / seed generation** | CLI scripts print QR code PDFs for distribution at outreach sites | + +## Tech Stack + +| Layer | Technology | +|---|---| +| Frontend | React, TypeScript, Material-UI, Vite | +| Backend | Node.js, Express.js, TypeScript | +| Database | MongoDB (via Mongoose) | +| Auth | Twilio Verify (OTP), JWT | +| Permissions | CASL (role + attribute-based) | +| Hosting | Azure App Service | +| Infrastructure | Pulumi (TypeScript), Azure | +| QR Scanning | Html5QrcodeScanner, QRCodeCanvas | + +## Open Source and Reuse + +The app is released under the **BSD 3-Clause License** (see [License](license.md)) and is designed to be forkable and adaptable. The survey questionnaire, locations, and campaign-specific content are all configurable without code changes. + +See [Getting Started](../getting-started/getting-started.md#7-customise) for how to adapt the app for a new locality. diff --git a/docs/about/rds-methodology.md b/docs/about/rds-methodology.md new file mode 100644 index 00000000..7f972b77 --- /dev/null +++ b/docs/about/rds-methodology.md @@ -0,0 +1,53 @@ +# Respondent-Driven Sampling Methodology + +## What Is Respondent-Driven Sampling? + +**Respondent-Driven Sampling (RDS)** is a network-based sampling method designed to reach "hidden" or hard-to-survey populations β€” groups that have no sampling frame (no list of members) and are unlikely to be reached through random-digit dialing or address-based sampling. + +Classic examples include unsheltered people experiencing homelessness, people who inject drugs, undocumented immigrants, and sex workers. For all of these groups, there is no census or registry to sample from, making traditional probability sampling impossible. + +Despite a well-established methodological literature RDS has remained difficult to deploy at scale. Field implementation has historically relied on paper coupons and manual tracking of referral chains β€” a process prone to linkage errors and dependent on significant operational expertise. This application was developed to address that gap: it replaces manual coupon tracking with a QR-code-based referral system. Includes one-time password authentication and device-agnostic data capture β€” reducing field error and lowering the barrier to adoption. The system was developed and validated through field deployment with the King County Regional Homelessness Authority's Point-in-Time (PIT) count. The detailed methodology can be found here: [https://doi.org/10.1093/aje/kwae342.](https://doi.org/10.1093/aje/kwae342) + +## How RDS Works + +1. **Seeds:** A small number of initial participants (called "seeds") are recruited directly β€” through outreach workers, shelters, or community contacts. These are not randomly selected; they are simply starting points. +2. **Referral coupons:** Each participant who completes a survey receives **3 referral coupons** (unique QR codes). They can share these with peers in their social network who meet eligibility criteria. +3. **Chain referral:** When a peer uses a coupon to participate, they also receive 3 new coupons to share β€” and so on, creating a chain of referrals that propagates through the social network. +4. **Population estimates:** Because each participant's social network size (degree) is recorded, and the probability of being recruited can be modeled from the network structure, RDS allows valid population-level estimates (e.g., proportion unhoused for more than a year) to be calculated from a non-random sample. + +## How the RDS App Implements This + +``` +Seed (starting QR code distributed by outreach worker) + β”‚ + β–Ό +Survey completed β†’ 3 child QR codes generated + β”‚ β”‚ + β”‚ └─ Printed / shared with referred peers + β–Ό +Peer scans QR code β†’ starts their own survey + β”‚ + β–Ό +Survey completed β†’ 3 more child QR codes generated + β”‚ + └─ ... (chain continues) +``` + +In the app: + +- Each survey record has a `surveyCode` (the code used to start it), a `parentSurveyCode` (the code that referred it), and `childSurveyCodes` (the 3 codes it generates). +- Scanning a QR code on the QR page after survey completion takes a peer directly to their survey, pre-filled with the referring code. +- All referral relationships are stored in MongoDB and can be exported for network analysis. + +## Population Estimation from RDS Data + +The RDS App collects the data; population estimation is done with external tools. The two most commonly used are: + +See [Post-Survey Analysis](../how-to/analysis.md) for how to export data from the app for use with these tools. + +## Further Reading + +- Heckathorn, D.D. (1997). Respondent-Driven Sampling: A New Approach to the Study of Hidden Populations. *Social Problems*, 44(2), 174–199. [doi:10.2307/3096941](https://doi.org/10.2307/3096941) +- Volz, E. & Heckathorn, D.D. (2008). Probability Based Estimation Theory for Respondent Driven Sampling. *Journal of Official Statistics*, 24(1), 79–97. +- [https://www.nsf.gov/awardsearch/show-award/?AWD_ID=2142964](https://www.nsf.gov/awardsearch/show-award/?AWD_ID=2142964) + diff --git a/docs/database-migration.md b/docs/database-migration.md deleted file mode 100644 index e5bca49c..00000000 --- a/docs/database-migration.md +++ /dev/null @@ -1,13 +0,0 @@ -# Database Export from MongoDB Atlas - -mongoexport --uri "mongodb+srv://:@/" --collection=surveys --out / --surveys.json - -mongoexport --uri "mongodb+srv://:@/" --collection=users --out / --users.json - -# Database Import to MongoDB Atlas - -mongoimport --uri "mongodb+srv://:@/" --collection=surveys --file -surveys.json - -mongoimport --uri "mongodb+srv://:@/" --collection=users --file -users.json diff --git a/docs/deployment.md b/docs/deployment.md deleted file mode 100644 index ebe6c3b2..00000000 --- a/docs/deployment.md +++ /dev/null @@ -1,123 +0,0 @@ -# Deployment - -There are two main workflows for deploying this codebase to Azure. In both cases the end result is having the codebase loaded onto an Azure Web App where it will install packages, build the client code and then run the Node.js server app (in `server/`). The two workflows of deploying the website are through GitHub Actions or manually through Azure's VSCode extension. - -## GitHub Actions (Continuous Deployment) - -To set up a monolithic deployment of your React (client) and Node/Express (server) app on Azure using GitHub Actions, you want a workflow that: - -- Trigger on pushes to your `main` branch. -- Builds the React app inside the client folder. -- Copies the React `dist` output into the server folder (assuming you serve React static files from Node). -- Installs server dependencies. -- Deploys the entire server folder (including the React dist) to Azure App Service as a single app. - -Here’s an example GitHub Actions workflow YAML that you can adapt. This assumes your repo has the `client` and `server` folders at the root, and the server serves the React build from `server/dist`. Create a file at `.github/workflows/azure-webapp-deploy.yml` in your repo root with the following content: - -```yaml -name: Build and Deploy Monolithic React + Node app to Azure - -on: - push: - branches: - - main # or your deployment branch - -jobs: - build-and-deploy: - runs-on: ubuntu-latest - - steps: - # Checkout the repo - - uses: actions/checkout@v3 - - # Setup Node.js (adjust version as needed) - - name: Setup Node.js - uses: actions/setup-node@v3 - with: - node-version: "22" - - # Install client dependencies and build React app - - name: Build React client - run: | - cd client - npm install - npm run build - - # Copy React dist into server folder - - name: Copy React dist to server build folder - run: | - rm -rf server/dist - cp -r client/dist server/dist - - # Install server dependencies - - name: Install server dependencies - run: | - cd server - npm install - - # Deploy to Azure App Service using Azure/webapps-deploy action - - name: Deploy to Azure Web App - uses: azure/webapps-deploy@v3 - with: - app-name: # Replace with your Azure App Service name - slot-name: "Production" - publish-profile: ${{ secrets.AZURE_PUBLISH_PROFILE }} - package: ./server -``` - -### Prepare Azure Credentials - -- Replace with the exact name of your Azure App Service. -- In the Azure Portal, go to your App Service > "Downloan publish profile" and download it. -- In your GitHub repo, go to **Settings > Secrets and variables > Actions > New repository secret**. -- Add a new secret named `AZURE_WEBAPP_PUBLISH_PROFILE` and paste the contents of the downloaded publish profile XML. - -This automation builds your React client, integrates it into the Node backend, and deploys the entire monolithic app to Azure seamlessly on every code push to the `main` branch. - -### 3. Commit and Push - -- Commit this workflow file and push to your main branch. -- On push, the workflow will run and deploy your server app to Azure. - -## Manual Test Deployment through VSCode - -If you are looking to test a deployment without having to push to the main branch this workflow will be useful. This workflow will: - -1. Only trigger on manual deployments via VSCode. -2. Set up Node.js, install dependencies, run tests (optional), and build (if needed). -3. Deploy to Azure App Service using the Azure Web App Publish action. - -### 1. Preliminary - -1. Install Azure App Services extension in VSCode. -2. Navigate to the Azure tab (left side panel) and sign into your Azure account with subscription. -3. In the Azure tab expand the subscription (rob5 main) and then App Services - -### 2. (Optional) Create a new App Service - -If you don't have a App Service to deploy to or want to create a new one follow this step. - -1. Right clicking App Service and selecting β€œCreate New Web App…” -2. Select β€œWest US” location -3. Give a name for the App Service -4. Select Node 22 LTS as Runstack -5. Select Pricing Tier - -### 3. Deploying your App - -1. Delete the `node_modules` folder in `server`. - - These folders take up a lot of space and will already be reinstalled on the server so this will save time when deploying -2. From the parent directory, run: - - ``` - npm run build - - ``` - - This will generate a `dist` folder in the `client` folder. Copy and paste this folder into the `server` folder. - -3. In App Services right click the App Service to deploy to, and click β€œDeploy to Web App” - - An output will open that shows the steps being taken to deploy the app (zip codebase and building the app) - - This will deploy the `server` code to the Azure App Service. Check out `.vscode/settings.json` to understand how VSCode knows which folder to deploy. -4. Visit your Web App - - Once the deployment finished a pop-up should appear in VSCode that will link you to your Web App. Otherwise you can log onto the Azure website and navigate to the App Service to find the Web App's link. diff --git a/docs/getting-started/getting-started.md b/docs/getting-started/getting-started.md new file mode 100644 index 00000000..9c0d872a --- /dev/null +++ b/docs/getting-started/getting-started.md @@ -0,0 +1,125 @@ +# Getting Started + +## Prerequisites + +**Accounts required:** + +- **GitHub** β€” to fork the repository and use GitHub Actions for CI/CD. [Fork the repository](https://github.com/SSDALab/respondent-driven-sampling/fork). +- **MongoDB** β€” all survey data is stored in MongoDB. [MongoDB Atlas](https://www.mongodb.com/atlas) (free M0 tier) is recommended. The connection string (`MONGO_URI`) and database name (`MONGO_DB_NAME`) come from the chosen provider. +- **Twilio** β€” OTP authentication for volunteer logins via Twilio Verify. Requires `TWILIO_ACCOUNT_SID`, `TWILIO_AUTH_TOKEN`, and a Verify service SID (`TWILIO_VERIFY_SID`). `TWILIO_PHONE_NUMBER` is only needed for outbound bulk SMS. +- **Azure App Service** (or any Node.js host) β€” King County uses Azure; see [Infrastructure](../how-to/infrastructure.md) for provisioning with Pulumi and [Deployment](../how-to/deployment.md) for deploying application code. + +**Local tools:** + +| Tool | Version | +|---|---| +| Node.js | 22.x | +| npm | bundled with Node | +| Git | any recent | +| Python 3.x | 3.9+ (for docs only) | + +--- + +## 1. Fork and clone + +Fork the repository on GitHub, then clone locally: + +```bash +git clone https://github.com/YOUR-ORG/respondent-driven-sampling.git +cd respondent-driven-sampling +``` + +--- + +## 2. Install dependencies + +```bash +cd client && npm install +cd ../server && npm install +``` + +--- + +## 3. Configure environment variables + +```bash +cp server/.env.example server/.env +``` + +The file must live at `server/.env` (next to `package.json`), not inside `server/src/`. + +Fill in `server/.env`: + +```dotenv +NODE_ENV=development +MONGO_URI= +MONGO_DB_NAME= +TWILIO_ACCOUNT_SID="" +TWILIO_AUTH_TOKEN="" +# TWILIO_VERIFY_SID +## Purpose: Uniquely identifies a Twilio Verify service; required for API operations like sending or checking verification codes. +## Format: Starts with "VA" followed by a string of alphanumeric characters. +## Location: Found in the Twilio Console under Verify services. +TWILIO_VERIFY_SID="" +# TWILIO_PHONE_NUMBER +## Purpose: The "from" phone number for sending SMS messages. Must be a Twilio-purchased number. +## Format: E.164 format, e.g., "+12065551234" +## Location: Found in the Twilio Console under Phone Numbers > Manage > Active Numbers +TWILIO_PHONE_NUMBER="" +AUTH_SECRET="" +# Timezone for date handling (e.g. "America/Los_Angeles", "America/New_York", "UTC") +# See full list: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones +TIMEZONE="America/Los_Angeles" +``` + +The MongoDB connection string is obtained from Atlas via **Connect > Drivers**. Full variable descriptions are in [Environment Variables](../reference/environment-variables.md). + +`server/.env` is listed in `.gitignore` and must never be committed to version control. + +--- + +## 4. Run locally + +Start backend and frontend in separate terminals: + +```bash +# Terminal 1 +cd server && npm run dev + +# Terminal 2 +cd client && npm run dev +``` + +The frontend is available at [http://localhost:3000](http://localhost:3000). The login page will load, but authentication requires the database to be initialised first. + +--- + +## 5. Initialise the database + +The database must be seeded with locations, at least one super admin account, and initial QR code seeds before the app is functional. Follow the step-by-step instructions in [Setting Up a Survey](../how-to/setting-up-a-survey.md). + +--- + +## 6. Verify + +1. Open [http://localhost:3000](http://localhost:3000). +2. Log in with the phone number used when creating the super admin. +3. Confirm the super admin dashboard is accessible. +4. Confirm the Users section shows the admin account as `APPROVED`. + +New volunteers who register via the app appear with status `PENDING` until approved by an admin. + +For a pre-deployment end-to-end test, see the checklist in [Setting Up a Survey](../how-to/setting-up-a-survey.md#pre-campaign-checklist). + +--- + +## 7. Customise + +- **Survey questions:** edit `client/src/pages/Survey/utils/survey.json`. Changes require a client rebuild (`cd client && npm run build`) and redeploy. +- **Theme:** `client/src/theme/muiTheme.ts` via MUI `createTheme`. +- **App title and favicon:** `client/index.html`. +- **Environment variables:** see [Environment Variables](../reference/environment-variables.md). + +## 8. Deploy + +To deploy to Azure App Service, first provision the required Azure resources (see [Infrastructure](../how-to/infrastructure.md)), then deploy the application code (see [Deployment](../how-to/deployment.md)). diff --git a/docs/how-to/adding-survey-locations.md b/docs/how-to/adding-survey-locations.md new file mode 100644 index 00000000..d70d0f4f --- /dev/null +++ b/docs/how-to/adding-survey-locations.md @@ -0,0 +1,88 @@ +# Adding Survey Locations + +Locations represent the physical survey sites where participants complete surveys. They must be created before seeds, users, or surveys can reference them. + +## Adding a Single Location + +```bash +cd server +npm run location -- create "" "
    " +``` + +**Example:** + +```bash +npm run location -- create "Central Library" ESTABLISHMENT ROOFTOP "1000 4th Ave, Seattle, WA 98104" +``` + +| Field | Options | Notes | +|---|---|---| +| `hubType` | `ESTABLISHMENT`, `STREET_ADDRESS`, `PREMISE`, `CHURCH`, `LOCALITY` | Use `ESTABLISHMENT` for most sites | +| `locationType` | `ROOFTOP`, `APPROXIMATE` | Use `ROOFTOP` for a specific address | + +## Bulk Import from YAML + +```bash +cd server +npm run location -- import path/to/locations.yaml +``` + +**YAML format:** + +```yaml +- hubName: "Burien Community Center" + hubType: ESTABLISHMENT + locationType: ROOFTOP + address: "14700 6th Ave SW, Burien, WA 98166" + +- hubName: "Kent Senior Activity Center" + hubType: ESTABLISHMENT + locationType: ROOFTOP + address: "600 E Smith St, Kent, WA 98030" +``` + +Each entry requires `hubName`, `hubType`, `locationType`, and `address`. + +**Verify:** + +```bash +npm run location -- list +``` + +## Viewing a Location + +```bash +npm run location -- get "Central Library" +# or by address: +npm run location -- get "1000 4th Ave, Seattle, WA 98104" +# or by MongoDB ObjectId: +npm run location -- get 507f1f77bcf86cd799439011 +``` + +## Updating a Location + +```bash +npm run location -- update "Central Library" --hubName "Seattle Central Library" --address "1000 4th Ave, Seattle, WA 98104" +``` + +Any combination of `--hubName`, `--hubType`, `--locationType`, and `--address` flags may be specified. + +## Removing a Location + +```bash +npm run location -- delete "Central Library" +``` + +!!! warning "Cascading effects" + Deleting a location does not automatically remove associated seeds or users. Remove those first, or verify they are no longer needed. Seeds referencing a deleted location will fail to load in the app. + +## How Locations Appear in the App + +- **Survey form:** The location name is displayed as a dropdown or pre-selected field when a volunteer scans a seed QR code. +- **Admin dashboard:** Surveys, users, and seeds can be filtered by location. +- **Seeds:** Each seed is tied to a specific location; seeds generated for a location define that site's referral starting point. +- **Auth middleware:** User permissions are scoped by location in the CASL permission model. + +## Per-Locality Survey Customization + +The survey questionnaire (`client/src/pages/Survey/utils/survey.json`) is shared across all locations. Per-location survey customization is not a built-in feature. To run different surveys at different sites, the current approach is to maintain separate deployments or implement feature-flag logic within the survey JSON. See [Getting Started](../getting-started/getting-started.md#7-customise) for how to edit the survey. diff --git a/docs/how-to/analysis.md b/docs/how-to/analysis.md new file mode 100644 index 00000000..938efafb --- /dev/null +++ b/docs/how-to/analysis.md @@ -0,0 +1,39 @@ +# Post-Survey Analysis + +## Current State + +The RDS App collects and stores survey data. **Population estimation and statistical analysis are performed outside the app** using external tools. The app is responsible for: + +- Collecting survey responses +- Maintaining referral chain relationships +- Storing raw data in MongoDB +- Providing an admin dashboard for tracking data collection in real time during the survey campaign + +Analysis (RDS estimators, network visualization, population projections) is a separate post-collection step. + +## Exporting Data + +Survey data can be exported via `mongoexport` (JSON or CSV). See [Database I/O](../reference/database-io.md) for full export commands and examples. + +Alternatively, the `mongolite` (R) and `PyMongo` (Python) packages can connect directly to MongoDB via the API and query data without exporting to a file first. + +## Survey Data Structure + +Each document in the `surveys` collection has the following key fields: + + +| Field | Type | Description | +| ------------------ | ------------- | ---------------------------------------------------------- | +| `surveyCode` | string | Unique code used to start this survey | +| `parentSurveyCode` | string | null | The code that referred this participant (`null` for seeds) | +| `childSurveyCodes` | string[] | The 3 referral codes generated after this survey | +| `responses` | object | The full survey response object (question IDs and answers) | +| `locationObjectId` | ObjectId | Reference to the survey site | +| `createdAt` | Date | Timestamp of survey submission | + + +The referral chain is reconstructed from `parentSurveyCode` and `childSurveyCodes`. Seeds (initial recruits) have `parentSurveyCode: null`. + +## Future Plans + +A dedicated analysis module (in-repo scripts or Jupyter notebook) for King County data is planned for a future release. Contributions are welcome β€” see [CONTRIBUTING.md](https://github.com/SSDALab/respondent-driven-sampling/blob/main/CONTRIBUTING.md). \ No newline at end of file diff --git a/docs/how-to/ci.md b/docs/how-to/ci.md new file mode 100644 index 00000000..7433cab5 --- /dev/null +++ b/docs/how-to/ci.md @@ -0,0 +1,48 @@ +# CI / Workflows + +The RDS App uses GitHub Actions for continuous integration and deployment. Workflow files are in `.github/workflows/`. + +## Workflow Overview + + +| Workflow file | Trigger | Purpose | +| ----------------------------------------- | -------------------------- | --------------------------------------------------------------- | +| `quality-checks.yml` | Push / PR to `main` | ESLint + TypeScript type check for client and server | +| `pre-commit-simple.yml` | Push / PR to `main` | Runs all pre-commit hooks | +| `azure-webapp-deploy-rds-app-kc-test.yml` | Push to `kc-pit-2026-test` (**test**) | Deploys to the Azure test slot (`rds-app-kc`, test slot) | +| `azure-webapp-deploy-rds-app-kc-prod.yml` | Push to `kc-pit-2026` (**prod**) | Deploys to the Azure production slot (`rds-app-kc`, Production) | +| `docs.yml` | Push to `main`, manual | Deploys MkDocs documentation to GitHub Pages | + + +## Required Checks + +The `quality-checks` and `pre-commit-simple` workflows must pass before a PR can be merged to `main`. + +!!! note + Quality checks are not currently enforced on the deployment branches (`kc-pit-2026`, `kc-pit-2026-test`) β€” deploys can proceed even if they fail. The goal is to clear existing lint errors and enforce these checks on all branches going forward. + +### `quality-checks.yml` + +Runs ESLint and TypeScript type checking (`tsc --noEmit`) for both client and server. Fix lint or type errors locally before pushing: + +```bash +cd client && npm run lint +cd server && npm run lint +``` + +### `pre-commit-simple.yml` + +Runs all hooks defined in `.pre-commit-config.yaml` (trailing whitespace, end-of-file fixes, YAML/JSON validity, and any project-specific hooks). To run the same checks locally, see [CONTRIBUTING.md](https://github.com/SSDALab/respondent-driven-sampling/blob/main/CONTRIBUTING.md). + +## Deployment Workflows + +Both deployment workflows follow the same steps: check out the code, install client and server dependencies, build the React client, copy `client/dist` into `server/dist`, build the server, and deploy the `server/` folder to Azure App Service using `azure/webapps-deploy`. + +- **Test:** pushing to `kc-pit-2026-test` deploys to the `test` slot of the `rds-app-kc` App Service. Uses the `AZURE_PUBLISH_PROFILE_rds_app_kc_test` GitHub secret. +- **Production:** pushing to `kc-pit-2026` deploys to the `Production` slot of the `rds-app-kc` App Service. Uses the `AZURE_PUBLISH_PROFILE_rds_app_kc` GitHub secret. + +See [Deployment](deployment.md) for manual deployment instructions. + +## Documentation Workflow + +`docs.yml` deploys the MkDocs documentation site to GitHub Pages on every push to `main` via `mkdocs gh-deploy --force`. It can also be triggered manually from the GitHub Actions tab (`workflow_dispatch`). \ No newline at end of file diff --git a/docs/how-to/database-and-security.md b/docs/how-to/database-and-security.md new file mode 100644 index 00000000..92f47918 --- /dev/null +++ b/docs/how-to/database-and-security.md @@ -0,0 +1,77 @@ +# Database and Security + +## Database Overview + +The RDS App uses **MongoDB** as its data store. All collections reside in a single database (configured via `MONGO_DB_NAME`). + +### Collections + +| Collection | Purpose | +|---|---| +| `users` | Volunteer, manager, and admin accounts. Fields: `firstName`, `lastName`, `phone`, `email`, `role`, `locationObjectId`, `approvalStatus`, `approvedByUserObjectId`, `permissions` (CASL action/subject/conditions array), `deletedAt` (soft-delete). | +| `locations` | Survey site records. Fields: `hubName`, `hubType`, `locationType`, `address`. Note: `hubType` and `locationType` are separate enum fields. | +| `surveys` | Individual survey submissions. Fields: `surveyCode`, `parentSurveyCode`, `childSurveyCodes`, `responses`, `locationObjectId`, `createdByUserObjectId`, `coordinates` (lat/lng), `isCompleted`, `deletedAt` (soft-delete), timestamps. | +| `seeds` | Initial QR codes distributed by outreach workers. Fields: `surveyCode`, `locationObjectId`, `isFallback`. | + +The codebase organises each collection into a domain folder under `server/src/database//` with co-located Mongoose models and Zod schemas. See [Architecture](../reference/architecture.md#backend-architecture) for the full layout. + +## Exporting and Importing Data + +See [Database I/O](../reference/database-io.md) for `mongoexport` / `mongoimport` commands. + +## Secrets and Environment Security + +### What Must Stay Secret + +All values in `server/.env` are sensitive. This file is in `.gitignore` and must never be committed. + +The most critical variables: + +| Variable | Risk if exposed | +|---|---| +| `AUTH_SECRET` | Allows forging JWT tokens and impersonating any user | +| `MONGO_URI` | Full database access including all survey data and PII | +| `TWILIO_ACCOUNT_SID` | Identifies the Twilio account; required for all Twilio API operations | +| `TWILIO_AUTH_TOKEN` | Enables sending SMS from the Twilio number and draining account balance | +| `TWILIO_VERIFY_SID` | Identifies the Twilio Verify service; allows initiating OTP verification flows | + +### Rotating Secrets + +If a secret is accidentally exposed: + +1. **`AUTH_SECRET`:** Change the value in `.env` and in the production environment config. All existing user sessions are immediately invalidated. +2. **`MONGO_URI` / database credentials:** Rotate the database user password in MongoDB Atlas, update `.env`, and redeploy. +3. **`TWILIO_AUTH_TOKEN`:** Regenerate the token in the Twilio Console and update `.env`. + +### Production Checklist + +- [ ] `NODE_ENV=production` is set +- [ ] `AUTH_SECRET` is a strong random string (at least 32 characters; generate with `openssl rand -hex 32`) +- [ ] `.env` is NOT committed to the repository +- [ ] MongoDB Atlas IP allowlist is restricted (not `0.0.0.0/0`) +- [ ] MongoDB Atlas user has only the required permissions (readWrite on the app database, not `atlasAdmin`) +- [ ] CORS `origin` is set to the specific frontend URL (not `*`); see [CORS](#cors) below +- [ ] `TWILIO_ACCOUNT_SID` and `TWILIO_VERIFY_SID` are configured + +### CORS + +The server currently sets `credentials: true` with `origin: '*'` in `server/src/index.ts`. Per the [Fetch specification](https://fetch.spec.whatwg.org/#http-access-control-allow-origin), browsers silently reject credentialed requests when `origin` is `*`, so this configuration is technically broken for any request that sends credentials. This should be tracked and fixed as a separate issue. + +For production, restrict the origin to the specific frontend URL: + +```typescript +app.use(cors({ origin: 'https://your-azure-app.azurewebsites.net', credentials: true })); +``` + +### Security Headers + +`server/src/index.ts` uses [Helmet.js](https://helmetjs.github.io/) to set standard security headers (CSP, HSTS, etc.). The Helmet middleware should not be modified or removed without reviewing the security implications. + +## HUD and HIPAA Compliance Notes + +The app collects survey data about people experiencing homelessness. This data may be subject to: + +- **HUD (U.S. Department of Housing and Urban Development)** data collection standards for Point-in-Time counts. +- **Local or state data privacy regulations** β€” consult with legal counsel before deploying in a new jurisdiction. + +The app does not currently implement encryption at rest for MongoDB collections. If a jurisdiction requires encrypted PII at rest, configure [MongoDB Atlas encryption at rest](https://www.mongodb.com/docs/atlas/security/encryption-at-rest/) in the Atlas project settings. diff --git a/docs/how-to/debugging.md b/docs/how-to/debugging.md new file mode 100644 index 00000000..9206dda7 --- /dev/null +++ b/docs/how-to/debugging.md @@ -0,0 +1,151 @@ +# Debugging + +## For Field Enumerators (Volunteers) + +Common issues encountered in the field during a campaign. + +### Cannot Log In + +**Problem:** The login page accepts the phone number but no OTP code arrives. + +- Confirm the device has cell signal or Wi-Fi. +- The phone number should be entered as a 10-digit number without a country code. +- New accounts start as **Pending** and require admin approval before the OTP flow works. The campaign coordinator can confirm approval status in the admin dashboard. +- If the issue persists, the coordinator should check the Twilio delivery logs. + +**Problem:** The OTP is received but the app shows "invalid code." + +- OTP codes expire after 10 minutes. Request a new code and enter it immediately. +- Ensure the most recent code is being entered, not a previously received one. + +**Problem:** After login, the screen shows "Access Denied" or is blank. + +- The account may not yet be approved. Contact the coordinator. +- Refresh the page. If the issue persists, log out and log back in. + +--- + +### Survey Not Loading + +**Problem:** A QR code was scanned but the survey page does not load. + +- An active internet connection is required to load and submit surveys. +- Confirm the scanned QR code is from a printed seed or referral coupon, not a generic URL. +- Try opening the URL from the QR code directly in the browser. + +**Problem:** Survey progress was lost after returning to the page. + +- Surveys are not saved locally. If the session expires or the browser is closed, progress is lost. +- Each survey should be completed in a single sitting without closing the browser. + +--- + +### QR Code Not Scanning + +- The browser must have camera permission (the app prompts on first use). +- The QR code should be flat, undamaged, and well-lit. +- Hold the camera steady at 15–30 cm from the code and allow it to focus. +- If scanning still fails, the coupon code can be typed manually if the printed coupon shows the text code below the QR image. + +--- + +### Referral Code Issues + +**Problem:** A participant's referral coupon is not working. + +- Each coupon code can only be used once. A previously used code will return an error. +- Codes are case-insensitive but must not contain extra spaces. +- If the code appears valid but returns an error, escalate to the campaign coordinator. + +--- + +### Escalation + +Issues not resolved by the steps above should be reported to the campaign coordinator or site manager. Technical issues affecting multiple devices or sites should be escalated to the development team. + +--- + +## For Developers and Maintainers + +### Running Locally + +See [Getting Started](../getting-started/getting-started.md) for initial setup. The main commands: + +```bash +# Backend (hot reload with tsx) +cd server && npm run dev + +# Frontend (Vite dev server, port 3000) +cd client && npm run dev +``` + +Server logs stream to the terminal. All route errors and middleware output appear here. + +--- + +### Common Environment Issues + +**`MongoServerError: bad auth`** + +- The `MONGO_URI` is incorrect or the database user credentials are wrong. +- Verify the connection string in Atlas and confirm the user has read/write access. + +**`TWILIO_VERIFY_SID` errors / OTP not sending** + +- Confirm `TWILIO_VERIFY_SID` starts with `VA`. +- Confirm the Verify service is active in the Twilio Console. +- Check Twilio logs at [console.twilio.com/us1/monitor/logs/messaging](https://console.twilio.com/us1/monitor/logs/messaging). + +**JWT errors / "unauthorized" after login** + +- Confirm `AUTH_SECRET` is set and non-empty. All existing sessions are invalidated if this value changes. +- JWT tokens expire; test by logging out and logging back in. + +**Client shows blank page or `Cannot GET /`** + +- In development, both the Vite dev server (port 3000) and the Express server (port 1234) must be running. Vite proxies `/api` requests to Express. +- In production, `client/dist` must be copied into `server/dist` before deploying. + +--- + +### Running Tests and Lint + +```bash +# Server tests +cd server && npm test +cd server && npm run test:coverage + +# Lint (both packages) +cd client && npm run lint +cd server && npm run lint +``` + +--- + +### Known Gotchas + +**Survey code uniqueness:** Survey codes are generated with a uniqueness check. Errors about duplicate codes during seed generation usually indicate the random space is exhausted for short codes β€” increase the code length in `generateUniqueSurveyCode`. + +**SWR cache in admin dashboard:** The admin dashboard uses SWR for data fetching with a short cache window. Data may not appear immediately; wait a few seconds or hard-refresh the page. + +**Soft-delete behaviour:** The `User` and `Survey` models use soft-delete via a `deletedAt` field (schema default: `null`, `select: false`). Standard queries exclude soft-deleted documents. If a user appears to have disappeared, check for soft-deletion with `npm run super-admin -- list --all`. + +**CORS setting:** `server/src/index.ts` currently sets `credentials: true` with `origin: '*'`. Per the Fetch specification, this combination is technically broken for credentialed requests. See [Database and Security](database-and-security.md#cors) for details and the production fix. + +--- + +### Pre-Commit Hooks + +If pre-commit checks fail: + +```bash +pre-commit run --all-files +``` + +To skip a check temporarily (use sparingly): + +```bash +SKIP=eslint git commit -m "message" +``` + +See [CI / Workflows](ci.md) for the full list of hooks and checks. diff --git a/docs/how-to/deployment.md b/docs/how-to/deployment.md new file mode 100644 index 00000000..bc9d67d9 --- /dev/null +++ b/docs/how-to/deployment.md @@ -0,0 +1,72 @@ +# Deployment + +This page covers deploying the RDS App to Azure App Service, the hosting platform used by King County. Complete the [Getting Started](../getting-started/getting-started.md) guide before proceeding. + +!!! tip "Need to provision Azure resources first?" + If the Azure Resource Group, MongoDB cluster, App Service Plan, and Web App do not exist yet, see [Infrastructure](infrastructure.md) to provision them with Pulumi before deploying application code. + +The app is a monorepo: the React frontend (`client/`) is built to static files, copied into the `server/` folder, and served by the Express backend as a single Node.js service. + +## Deployment via GitHub Actions (recommended) + +A GitHub Actions workflow automates the build-and-deploy process on every push to a designated branch. The workflow: + +1. Checks out the repository +2. Builds the React client (`cd client && npm run build`) +3. Copies `client/dist/` into `server/dist/` +4. Installs server dependencies +5. Deploys the `server/` folder to Azure App Service + +Reference workflow files are included in the repository at `.github/workflows/azure-webapp-deploy-*.yml`. See [CI / Workflows](ci.md#deployment-workflows-king-county-specific) for details on these files and how to adapt them for a new city. + +### Azure Credentials Setup + +1. In the Azure Portal, navigate to the App Service and select **Download publish profile**. +2. In the GitHub repository, navigate to **Settings > Secrets and variables > Actions > New repository secret**. +3. Create a secret named `AZURE_PUBLISH_PROFILE` and paste the contents of the downloaded publish profile XML. +4. In the workflow YAML, set `app-name` to the Azure App Service name. + +### Triggering a Deployment + +Pushing to `kc-pit-2026` triggers the production deployment. Pushing to `kc-pit-2026-test` triggers the test slot deployment. See [CI / Workflows](ci.md) for the full workflow details. + +--- + +## Manual Deployment via VS Code + +For testing a deployment without pushing to the main branch, the Azure App Service VS Code extension provides a manual deploy workflow. + +### Prerequisites + +1. Install the **Azure App Service** extension in VS Code. +2. Sign in to an Azure account with an active subscription via the Azure tab in the sidebar. + +### Creating an App Service (if needed) + +1. In the Azure tab, right-click **App Services** and select "Create New Web App." +2. Select region (e.g. "West US"), provide a name, select **Node 22 LTS** as the runtime, and choose a pricing tier. + +### Deploying + +1. Delete `server/node_modules/` to reduce upload size (dependencies are reinstalled on the server). +2. Build the client and copy the output into the server folder: + +```bash +cd client && npm run build +cp -r client/dist server/dist +``` + +3. In the Azure tab, right-click the target App Service and select **Deploy to Web App**. The deployment target directory is configured in `.vscode/settings.json`. +4. Once deployment completes, a pop-up in VS Code provides a link to the live app. The URL is also available in the Azure Portal under the App Service overview. + +--- + +## Environment Variables in Production + +On Azure App Service, environment variables are set as **Application settings** (not in a `.env` file). + +If the infrastructure was provisioned with Pulumi (see [Infrastructure](infrastructure.md)), these settings are configured automatically during `pulumi up`. Otherwise, set them manually: + +Azure Portal > App Service > **Configuration** > **Application settings** > add each variable as a key-value pair > Save > restart the service. + +See [Environment Variables](../reference/environment-variables.md) for the full list of required variables. diff --git a/docs/how-to/infrastructure.md b/docs/how-to/infrastructure.md new file mode 100644 index 00000000..db26bc2d --- /dev/null +++ b/docs/how-to/infrastructure.md @@ -0,0 +1,120 @@ +# Infrastructure Provisioning + +The `infra/` directory contains a [Pulumi](https://www.pulumi.com/) project (TypeScript) that provisions all Azure resources required to run the RDS App. No Pulumi Cloud account is required β€” state is stored locally. + +## What Gets Created + +Running `pulumi up` provisions four Azure resources: + +| Resource | Purpose | +|---|---| +| **Resource Group** | Logical container for all RDS resources | +| **MongoDB vCore Cluster** (Cosmos DB) | Application database | +| **App Service Plan** (Linux) | Compute plan for the web app | +| **Web App** (Node.js on Linux) | Hosts the Express server and React frontend | + +Pulumi also configures the Web App's **Application settings** (environment variables) automatically, including `MONGO_URI`, `AUTH_SECRET`, and the Twilio credentials. See [Environment Variables](../reference/environment-variables.md) for the meaning of each variable. + +## Prerequisites + +- [Node.js](https://nodejs.org/) (>=20.17.0) +- [Pulumi CLI](https://www.pulumi.com/docs/install/) +- [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli) +- An Azure subscription + +## Quick Start + +From the repository root: + +```bash +# 1. Install dependencies +cd infra +npm install + +# 2. Log in to Azure +az login + +# 3. Use local Pulumi state (no account needed) +pulumi login --local + +# 4. Create a stack +pulumi stack init prod + +# 5. Set required config +pulumi config set location westus2 +pulumi config set resourceGroupName my-rds-rg +pulumi config set mongoClusterName my-rds-db +pulumi config set mongoDbName main +pulumi config set appServicePlanName my-rds-plan +pulumi config set webAppName my-rds-app +pulumi config set skuName B1 + +# 6. Set secrets +pulumi config set --secret mongoAdminLogin +pulumi config set --secret mongoAdminPassword +pulumi config set --secret authSecret +pulumi config set --secret twilioAccountSid +pulumi config set --secret twilioAuthToken +pulumi config set --secret twilioVerifySid + +# 7. Deploy +pulumi up +``` + +!!! warning "Secrets" + `mongoAdminPassword`, `authSecret`, and the Twilio credentials contain sensitive values. Always use `pulumi config set --secret` so they are encrypted in the stack state file. + +## Config Reference + +| Key | Required | Default | Description | +|-----|----------|---------|-------------| +| `resourceGroupName` | Yes | β€” | Azure Resource Group name | +| `location` | Yes | β€” | Azure region (e.g. `westus2`) | +| `mongoClusterName` | Yes | β€” | MongoDB vCore cluster name | +| `mongoDbName` | Yes | β€” | MongoDB database name (e.g. `main`) | +| `appServicePlanName` | Yes | β€” | App Service Plan name | +| `webAppName` | Yes | β€” | Web App name (globally unique) | +| `skuName` | Yes | β€” | App Service Plan SKU (e.g. `B1`, `P1v2`) | +| `mongoAdminLogin` | Yes (secret) | β€” | MongoDB admin username | +| `mongoAdminPassword` | Yes (secret) | β€” | MongoDB admin password | +| `authSecret` | Yes (secret) | β€” | Auth secret for JWT signing | +| `twilioAccountSid` | Yes (secret) | β€” | Twilio Account SID | +| `twilioAuthToken` | Yes (secret) | β€” | Twilio Auth Token | +| `twilioVerifySid` | Yes (secret) | β€” | Twilio Verify Service SID | +| `nodeVersion` | No | `22-lts` | Node.js version for App Service | +| `nodeEnv` | No | `production` | `NODE_ENV` value | +| `mongoServerVersion` | No | `8.0` | MongoDB server version | +| `mongoSku` | No | `M20` | MongoDB vCore cluster SKU | +| `mongoDiskSizeGb` | No | `128` | MongoDB disk size in GB | + +!!! note "App settings managed by Pulumi" + When infrastructure is provisioned with Pulumi, the Web App's Application settings (`MONGO_URI`, `MONGO_DB_NAME`, `AUTH_SECRET`, Twilio credentials, etc.) are set automatically. There is no need to configure them manually in the Azure Portal. + +## Importing Existing Resources + +If Azure resources already exist (e.g. from a previous Terraform setup), they can be imported instead of recreated: + +1. Find each resource's Azure ID (from the Azure Portal or `az` CLI). +2. Add `{ import: "" }` as the third argument to each Pulumi resource constructor in `index.ts`. +3. Run `pulumi up` β€” Pulumi adopts the resources without modifying them. +4. Remove the `import` options and run `pulumi up` again to verify no changes are detected. + +Import order: Resource Group β†’ MongoDB Cluster β†’ App Service Plan β†’ Web App. + +## Tear Down + +```bash +pulumi destroy +pulumi stack rm prod +``` + +## Troubleshooting + +- **`az login` errors** β€” Verify `az account show` returns a valid subscription. Set the `ARM_SUBSCRIPTION_ID` environment variable if needed. +- **Name conflicts** β€” `mongoClusterName` and `webAppName` must be globally unique across Azure. +- **SKU not available** β€” Some SKUs are region-specific. Try `B1` or `P1v2` in the chosen region. +- **Node version format** β€” Use `22-lts` (not `v22` or `22.x`). This maps to `NODE|22-lts` in the App Service config. + +--- + +After provisioning, proceed to [Deployment](deployment.md) to push the application code to the new Azure App Service. diff --git a/docs/how-to/setting-up-a-survey.md b/docs/how-to/setting-up-a-survey.md new file mode 100644 index 00000000..782b687c --- /dev/null +++ b/docs/how-to/setting-up-a-survey.md @@ -0,0 +1,105 @@ +# Setting Up a Survey + +The following scripts must be run in sequence from the `server/` directory before a campaign begins or a new deployment is tested. + +```bash +cd server +``` + +Ensure `server/.env` is fully configured and MongoDB is reachable before proceeding. + +--- + +## 1. Import Locations + +Survey sites must exist in the database before seeds, users, or surveys can reference them. + +**Bulk import from YAML (recommended):** + +```bash +npm run location -- import path/to/locations.yaml +``` + +See [Adding Survey Locations](../how-to/adding-survey-locations.md) for the YAML format. + +**Single location:** + +```bash +npm run location -- create "Downtown Hub" ESTABLISHMENT ROOFTOP "123 Main St, City, ST 12345" +``` + +**Verify:** + +```bash +npm run location -- list +``` + +--- + +## 2. Create a Super Admin + +```bash +npm run super-admin -- create John Doe john@example.com +12065551234 +``` + +The `locationObjectId` is obtained from `npm run location -- get "Hub Name"`. At least one super admin is required before the app is usable. + +**Verify:** + +```bash +npm run super-admin -- list +``` + +The account should appear with status `APPROVED`. + +--- + +## 3. Generate Seeds + +Seeds are the initial QR codes distributed to participants to start referral chains. Each seed corresponds to one survey entry point. + +```bash +npm run generate-seeds -- "Location Hub Name" +``` + +This creates `` seed records in MongoDB and outputs a printable PDF to `server/src/scripts/seeds/seeds--.pdf`. A typical rule of thumb is to generate 1.5–2Γ— the expected number of first-session participants per site. + +--- + +## 4. Generate Blank Coupon Templates (Optional) + +If the campaign uses physical coupon cards with QR stickers affixed by staff: + +```bash +npm run generate-coupons -- 50 +``` + +This outputs blank PDF templates to `server/src/scripts/coupons/`. These templates are not connected to the database. + +--- + +Full options for all scripts are in [CLI Scripts](../reference/cli-scripts.md). + +--- + +## 5. Update Survey Module + +Customise the survey questionnaire for your campaign by editing `client/src/pages/Survey/utils/survey.json`. After any changes, rebuild and redeploy the client: + +```bash +cd client && npm run build +``` + +See [Getting Started β€” Customise](../getting-started/getting-started.md#7-customise) for full customisation options including theme, app title, and environment variables. + +--- + +## Pre-Campaign Checklist + +- [ ] All survey sites imported and verified (`npm run location -- list`) +- [ ] Super admin account created and confirmed as `APPROVED` +- [ ] Seeds generated for all sites and PDFs printed +- [ ] At least one full end-to-end test survey completed (seed QR β†’ survey submit β†’ admin dashboard β†’ 3 child codes generated) +- [ ] Volunteer onboarding flow tested: register β†’ admin approval β†’ log in β†’ scan code β†’ survey +- [ ] `NODE_ENV=production` set in the production environment +- [ ] Production deployment verified (see [Deployment](deployment.md)) diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 00000000..b6b81292 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,21 @@ +# RDS App Documentation + +The **RDS App** is an open-source web application for conducting Respondent-Driven Sampling (RDS) surveys of unsheltered populations. Developed in partnership with the University of Washington Department of Sociology and Information School (iSchool), and the King County Regional Homelessness Authority (KCRHA), the application is designed for reuse by other cities and localities. + +The codebase is a TypeScript monorepo (React frontend, Node.js/Express backend, MongoDB) requiring a developer familiar with Node.js, a MongoDB instance, and a Twilio account for OTP authentication. Deployment targets Azure App Service, though any Node.js host is compatible. + +- [Getting Started](getting-started/getting-started.md) β€” prerequisites, setup, and deployment +- [How-To Guides](how-to/deployment.md) β€” deployment, debugging, experimental setup, and operations +- [Reference](reference/architecture.md) β€” architecture, environment variables, CLI scripts, and API + +## Project Status + +The RDS App was used in the **2026 King County Unsheltered Point-in-Time Count** to collect surveys from approximately 2,183 unsheltered individuals. The codebase is actively maintained and open to community contributions. + +## Relevant Links + +- **License:** BSD 3-Clause β€” see [License](about/license.md) +- **Contributing:** [CONTRIBUTING.md](https://github.com/SSDALab/respondent-driven-sampling/blob/main/CONTRIBUTING.md) +- **Repository:** [github.com/SSDALab/respondent-driven-sampling](https://github.com/SSDALab/respondent-driven-sampling) +- **Issues:** [GitHub Issues](https://github.com/SSDALab/respondent-driven-sampling/issues) + diff --git a/docs/reference/api.md b/docs/reference/api.md new file mode 100644 index 00000000..e6028d01 --- /dev/null +++ b/docs/reference/api.md @@ -0,0 +1,132 @@ +# API Overview + +The RDS App exposes a REST API served by the Express backend. This page gives a high-level overview of the API structure, authentication, and main endpoints. + +!!! tip "Swagger UI" + An interactive Swagger UI is available at `/documentation` on any running instance of the app. + - Local: [http://localhost:1234/documentation](http://localhost:1234/documentation) + - Production: `https://your-app.azurewebsites.net/documentation` + +## Base URLs + +| Environment | Base URL | +|---|---| +| Local development | `http://localhost:1234` | +| Production (Azure) | `https://your-app.azurewebsites.net` | + +## Authentication + +All protected routes require a JWT Bearer token in the `Authorization` header: + +``` +Authorization: Bearer +``` + +Obtain a token by completing the OTP flow. For new users (signup): + +1. `POST /api/auth/send-otp-signup` β€” sends an OTP to the user's phone +2. `POST /api/auth/verify-otp-signup` β€” verifies the OTP, creates the account, returns a JWT + +For returning users (login): + +1. `POST /api/auth/send-otp-login` β€” sends an OTP to the user's phone +2. `POST /api/auth/verify-otp-login` β€” verifies the OTP, returns a JWT + +The JWT is valid until `AUTH_SECRET` is rotated or the token expires. + +## Endpoints + +All routes are mounted under `/api/*`. There is no API versioning. + +All protected routes use the same `auth` middleware, which verifies the JWT and injects a CASL `Ability` object into the request. Access control is determined by CASL ability checks, not role-specific middleware. The "Auth" column below indicates which roles are granted access by default; actual enforcement is attribute-based (see [CASL Permissions Summary](#casl-permissions-summary)). + +### Auth + +| Method | Path | Auth | Purpose | +|---|---|---|---| +| `POST` | `/api/auth/send-otp-signup` | None | Send OTP for new user registration | +| `POST` | `/api/auth/send-otp-login` | None | Send OTP for returning user login | +| `POST` | `/api/auth/verify-otp-signup` | None | Verify OTP, create account, return JWT | +| `POST` | `/api/auth/verify-otp-login` | None | Verify OTP, return JWT | + +### Users + +| Method | Path | Auth | Purpose | +|---|---|---|---| +| `GET` | `/api/users` | Admin, Manager | List users (filterable by location, status) | +| `GET` | `/api/users/:id` | Admin, Manager | Get a specific user | +| `POST` | `/api/users` | Admin, Manager | Create a user (admin/manager operation) | +| `PATCH` | `/api/users/:id` | Admin, Manager | Update user (approve/reject, change location) | +| `DELETE` | `/api/users/:id` | Admin | Soft-delete a user | + +### Surveys + +| Method | Path | Auth | Purpose | +|---|---|---|---| +| `GET` | `/api/surveys` | Admin, Manager, Volunteer | List surveys (scoped by CASL) | +| `GET` | `/api/surveys/:id` | Admin, Manager, Volunteer | Get a specific survey (scoped by CASL) | +| `POST` | `/api/surveys` | Admin, Manager, Volunteer | Submit a new survey | +| `PATCH` | `/api/surveys/:id` | Admin, Manager, Volunteer | Update a survey (scoped by CASL) | +| `DELETE` | `/api/surveys/:id` | Admin | Delete a survey | + +### Seeds + +| Method | Path | Auth | Purpose | +|---|---|---|---| +| `GET` | `/api/seeds` | Admin, Manager, Volunteer | List seeds | +| `GET` | `/api/seeds/:id` | Admin, Manager, Volunteer | Get a specific seed | +| `POST` | `/api/seeds` | Admin, Manager, Volunteer | Create a seed | +| `DELETE` | `/api/seeds/:id` | Admin | Delete a seed | + +Seeds are typically created via the `npm run generate-seeds` CLI script, not the API directly. + +### Locations + +| Method | Path | Auth | Purpose | +|---|---|---|---| +| `GET` | `/api/locations` | None | List all locations | +| `GET` | `/api/locations/:id` | Authenticated | Get a specific location | +| `POST` | `/api/locations` | Admin | Create a location | +| `PATCH` | `/api/locations/:id` | Admin | Update a location | +| `DELETE` | `/api/locations/:id` | Admin | Delete a location | + +### Other + +| Method | Path | Auth | Purpose | +|---|---|---|---| +| `GET` | `/api/validate-referral-code/:code` | Authenticated | Validate a referral code | + +## Error Responses + +Most error responses return only a `message` field: + +```json +{ + "message": "Human-readable error description" +} +``` + +Zod validation errors (HTTP 400) additionally include a `code` field with a machine-readable error constant. + +Common HTTP status codes: + +| Code | Meaning | +|---|---| +| `400` | Bad request β€” validation error (Zod) | +| `401` | Unauthorized β€” missing or invalid JWT | +| `403` | Forbidden β€” approval status check failed, or CASL permission denied | +| `404` | Not found | +| `409` | Conflict β€” duplicate resource (e.g. phone already registered) | +| `500` | Internal server error | + +## CASL Permissions Summary + +The API enforces permissions using [CASL](https://casl.js.org/). Key access rules: + +| Role | Can Do | +|---|---| +| **Super Admin** | Everything; manage all users across all locations | +| **Admin** | Read all users; approve/create volunteers, managers, and admins; manage surveys created today; create and read seeds | +| **Manager** | Read all users; approve volunteers at own location created today; create and read surveys at own location today; create and read seeds | +| **Volunteer** | Read and update own profile; create surveys; read and update own surveys created today at own location; create and read seeds | +| **Unauthenticated** | List locations; complete OTP signup/login flow | diff --git a/docs/reference/architecture.md b/docs/reference/architecture.md new file mode 100644 index 00000000..5c4630a1 --- /dev/null +++ b/docs/reference/architecture.md @@ -0,0 +1,168 @@ +# Architecture + +## High-Level Overview + +The RDS App is a **monolithic React + Node.js application**. The Express backend serves the React frontend as static files in production, so the entire application is deployed as a single Azure App Service. + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Browser (React SPA) β”‚ +β”‚ Vite + TypeScript + Material-UI β”‚ +β”‚ Port 3000 (dev) / served by Express β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ HTTP API (REST) + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Express API (Node.js) β”‚ +β”‚ TypeScript + Mongoose + CASL β”‚ +β”‚ Port 1234 (dev) / process.env.PORT β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ Mongoose ODM + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ MongoDB β”‚ +β”‚ Atlas (cloud) or Cosmos DB β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +**Monorepo layout:** + +``` +respondent-driven-sampling/ +β”œβ”€β”€ client/ # React SPA (Vite + TypeScript + MUI) +β”œβ”€β”€ server/ # Express API (TypeScript + MongoDB) +β”œβ”€β”€ docs/ # Documentation source (this site) +β”œβ”€β”€ infra/ # Azure infrastructure (Pulumi + TypeScript) +β”œβ”€β”€ mkdocs.yml # MkDocs configuration +└── .github/ # CI/CD workflows, issue templates +``` + +In development, the Vite dev server runs on port 3000 and proxies API requests to Express on port 1234. In production, Express serves the compiled React `dist/` directly. + +## Backend Architecture + +The backend uses a **domain-driven layered structure**, distinct from typical Express apps: + +``` +server/src/ +β”œβ”€β”€ index.ts # App entry point; security middleware, route registration +β”œβ”€β”€ middleware/ +β”‚ └── auth.ts # JWT verification, approval check, CASL ability injection +β”œβ”€β”€ routes/ +β”‚ β”œβ”€β”€ auth.ts +β”‚ β”œβ”€β”€ users.ts +β”‚ β”œβ”€β”€ surveys.ts +β”‚ β”œβ”€β”€ seeds.ts +β”‚ β”œβ”€β”€ locations.ts +β”‚ └── validateReferralCode.ts +β”œβ”€β”€ database/ +β”‚ β”œβ”€β”€ user/ +β”‚ β”‚ β”œβ”€β”€ mongoose/ # Mongoose model + hooks +β”‚ β”‚ β”œβ”€β”€ zod/ # Zod validation schemas +β”‚ β”‚ └── user.controller.ts # Business logic +β”‚ β”œβ”€β”€ survey/ +β”‚ β”œβ”€β”€ seed/ +β”‚ └── location/ +β”œβ”€β”€ permissions/ # CASL role and attribute definitions +β”œβ”€β”€ scripts/ # CLI management scripts +└── config/ # Swagger, constants +``` + +**Key pattern:** Route files (`routes/*.ts`) call controller functions (`database/{domain}/*.controller.ts`). Business logic lives in controllers, not routes. + +All routes are mounted under `/api/*` with no versioning. All routes that accept request bodies use Zod validation middleware. + +### Validation + +All API request bodies are validated with [Zod](https://zod.dev/). Schema files: + +- `zod/*.base.ts` β€” Full document schema (used for Mongoose typing) +- `zod/*.validator.ts` β€” API request validators (subset of the document schema) + +```typescript +// Example: route with Zod validation +router.post('/', validate(createSurveySchema), surveysController.create); +``` + +## Auth Flow + +``` +1. User enters phone number β†’ + New user: POST /api/auth/send-otp-signup β†’ Twilio sends SMS OTP + Returning user: POST /api/auth/send-otp-login β†’ Twilio sends SMS OTP +2. User enters OTP β†’ + New user: POST /api/auth/verify-otp-signup β†’ creates account, returns JWT + Returning user: POST /api/auth/verify-otp-login β†’ returns JWT +3. Server returns JWT β†’ Client stores in Zustand persistent store (localStorage) +4. All subsequent requests β†’ Authorization: Bearer header +5. Auth middleware: + a. Verifies JWT signature (AUTH_SECRET) + b. Checks approvalStatus === 'APPROVED' (403 if not) + c. Fetches latest survey to derive user's current location + d. Builds CASL Ability object and injects into req.authorization +``` + +### Approval Flow + +New users register with `approvalStatus: PENDING`. They can receive OTP codes but cannot access any protected routes until an admin or super-admin sets their status to `APPROVED` via the admin dashboard or the CLI. + +``` +PENDING β†’ APPROVED (admin approves in dashboard) +PENDING β†’ REJECTED (admin rejects) +APPROVED β†’ PENDING (admin can reset) +``` + +## Permissions (CASL) + +The app uses [CASL](https://casl.js.org/) for role and attribute-based access control. + +**Roles:** `SUPER_ADMIN`, `ADMIN`, `MANAGER`, `VOLUNTEER` + +**Key permission conditions:** + + +| Condition | Meaning | +| -------------------- | ---------------------------------------------------- | +| `IS_CREATED_BY_SELF` | The resource was created by the requesting user | +| `WAS_CREATED_TODAY` | The resource was created on the current calendar day | +| `HAS_SAME_LOCATION` | The resource belongs to the user's current location | + + +Example: volunteers can only update surveys they created today at their current location. Managers can read and update surveys at their location created today. Admins can update any survey created today across all locations. + +Permissions are defined in `server/src/permissions/abilityBuilder.ts` and the same constants are imported by the frontend (`client/src/hooks/useAbility.tsx`) to mirror permission checks in the UI. + +## Deployment Architecture + +Azure resources (Resource Group, MongoDB vCore cluster, App Service Plan, and Web App) are provisioned via Pulumi in the `infra/` directory. See [Infrastructure](../how-to/infrastructure.md) for setup instructions. + +In production (Azure App Service): + +``` +GitHub push to deployment branch + (kc-pit-2026 β†’ prod, kc-pit-2026-test β†’ test) + β”‚ + β–Ό +GitHub Actions workflow (azure-webapp-deploy-*.yml) + β”‚ + β”œβ”€ npm run build (client) β†’ client/dist/ + β”œβ”€ cp client/dist β†’ server/dist/ + β”œβ”€ npm install (server) + └─ Deploy server/ folder to Azure App Service + β”‚ + β–Ό + Azure App Service (Node 22 LTS) + node server/build/index.js + Serves: + /api/* β†’ Express routes + /* β†’ server/dist/ (React SPA) +``` + +## Path Aliases + +Both client and server use `@/*` imports: + +- **Server:** `tsconfig.json` + `tsc-alias` build step resolves `@/`* β†’ `src/*` +- **Client:** Vite resolves `@/`* β†’ `src/*`, plus `@/permissions/*` β†’ `../server/src/permissions/*` for shared constants + +This means the client can directly import server-side permission constants without duplicating them. \ No newline at end of file diff --git a/docs/reference/cli-generate-coupons.md b/docs/reference/cli-generate-coupons.md new file mode 100644 index 00000000..2ab1d447 --- /dev/null +++ b/docs/reference/cli-generate-coupons.md @@ -0,0 +1,35 @@ +# `npm run generate-coupons` + +**Source:** `server/src/scripts/generateCoupons.ts` + +Generates a blank coupon PDF template with a placeholder box for a QR code sticker. Does **not** connect to the database or create any records β€” it is purely a print-ready template. + +Run from the `server/` directory: + +```bash +cd server +``` + +## Usage + +```bash +npm run generate-coupons -- [count] +``` + +| Argument | Default | Description | +|---|---|---| +| `count` | 1 | Number of blank coupon pages to generate | + +**Examples:** + +```bash +npm run generate-coupons # 1 coupon +npm run generate-coupons -- 50 # 50 coupons +``` + +## Output + +Generates a PDF at `server/src/scripts/coupons/coupons--.pdf`. Each page has a dashed box labeled "Place QR Code Sticker Here" where staff can affix a sticker printed from the seed PDF. + +!!! note "When to use this vs. generate-seeds" + Use `generate-seeds` to create both the database records and the QR-printed pages for distribution. Use `generate-coupons` only when you need blank physical templates that staff will label manually. diff --git a/docs/reference/cli-generate-seeds.md b/docs/reference/cli-generate-seeds.md new file mode 100644 index 00000000..164e027a --- /dev/null +++ b/docs/reference/cli-generate-seeds.md @@ -0,0 +1,48 @@ +# `npm run generate-seeds` + +**Source:** `server/src/scripts/generateSeeds.ts` + +Generates seed records in MongoDB and outputs a PDF of printable QR code pages. + +Run from the `server/` directory: + +```bash +cd server +``` + +## Usage + +```bash +npm run generate-seeds -- "" +``` + +| Argument | Description | +|---|---| +| `hubName` or `objectId` | The location to generate seeds for | +| `count` | Number of seeds to generate (positive integer) | + +**Examples:** + +```bash +npm run generate-seeds -- "Downtown Hub" 10 +npm run generate-seeds -- 507f1f77bcf86cd799439011 25 +``` + +## Output + +- Creates `` seed records in MongoDB, each with a unique 8-character survey code +- Generates a PDF at `server/src/scripts/seeds/seeds--.pdf` +- Each page of the PDF contains one QR code and participant-facing instructions + +## PDF Contents + +The generated PDF pages include: + +- Campaign title (currently hardcoded for King County; customize in the script) +- Participant instructions +- QR code encoding the survey code +- Human-readable coupon code (for manual entry if QR scanning fails) +- Survey site locations and dates (hardcoded; customize in the script before your campaign) + +!!! note "Customize the PDF template" + The PDF content in `generateSeeds.ts` includes King County-specific text (site addresses, dates, contact number). Edit the `addQRCodePage` function in the script to match your campaign before generating seeds for production. diff --git a/docs/reference/cli-location.md b/docs/reference/cli-location.md new file mode 100644 index 00000000..6e0fe9fb --- /dev/null +++ b/docs/reference/cli-location.md @@ -0,0 +1,89 @@ +# `npm run location` + +**Source:** `server/src/scripts/locationCRUD.ts` + +Manages survey site (location) records. + +Run all commands from the `server/` directory: + +```bash +cd server +``` + +## Operations + +### `create` β€” Create a location + +```bash +npm run location -- create "" "
    " +``` + +| Argument | Options | Example | +|---|---|---| +| `hubName` | any string (unique) | `"Downtown Hub"` | +| `hubType` | `ESTABLISHMENT`, `STREET_ADDRESS`, `PREMISE`, `CHURCH`, `LOCALITY` | `ESTABLISHMENT` | +| `locationType` | `ROOFTOP`, `APPROXIMATE` | `ROOFTOP` | +| `address` | full street address | `"123 Main St, City, ST 12345"` | + +**Example:** + +```bash +npm run location -- create "Burien Community Center" ESTABLISHMENT ROOFTOP "14700 6th Ave SW, Burien, WA 98166" +``` + +--- + +### `import` β€” Bulk import from YAML + +```bash +npm run location -- import path/to/locations.yaml +``` + +See [Adding Survey Locations](../how-to/adding-survey-locations.md) for the YAML schema. + +--- + +### `list` β€” List all locations + +```bash +npm run location -- list +``` + +--- + +### `get` β€” Get a specific location + +```bash +npm run location -- get "Downtown Hub" +npm run location -- get "123 Main St, City, ST 12345" +npm run location -- get 507f1f77bcf86cd799439011 +``` + +Accepts hub name, address, or MongoDB ObjectId. + +--- + +### `update` β€” Update a location + +```bash +npm run location -- update [--hubName ] [--hubType ] [--locationType ] [--address
    ] +``` + +**Examples:** + +```bash +npm run location -- update "Downtown Hub" --hubName "Central Hub" --address "456 New St, City, ST 12345" +npm run location -- update 507f1f77bcf86cd799439011 --hubType PREMISE +``` + +--- + +### `delete` β€” Delete a location + +```bash +npm run location -- delete "Downtown Hub" +npm run location -- delete 507f1f77bcf86cd799439011 +``` + +!!! warning + Deleting a location that has associated seeds or users will not automatically clean up those records. Verify no active seeds or users reference the location before deleting. diff --git a/docs/reference/cli-scripts.md b/docs/reference/cli-scripts.md new file mode 100644 index 00000000..efceea96 --- /dev/null +++ b/docs/reference/cli-scripts.md @@ -0,0 +1,16 @@ +# CLI Scripts + +The `server/` package includes several management scripts accessible via `npm run`. Run all commands from the `server/` directory unless noted. + +```bash +cd server +``` + +## Available Scripts + +| Script | Description | +|---|---| +| [`npm run super-admin`](cli-super-admin.md) | Create, list, update, delete, and restore super-admin accounts | +| [`npm run location`](cli-location.md) | Create, import, list, update, and delete survey site records | +| [`npm run generate-seeds`](cli-generate-seeds.md) | Generate seed QR codes in MongoDB and export a printable PDF | +| [`npm run generate-coupons`](cli-generate-coupons.md) | Generate blank coupon PDF templates (no database connection) | diff --git a/docs/reference/cli-super-admin.md b/docs/reference/cli-super-admin.md new file mode 100644 index 00000000..f93f284e --- /dev/null +++ b/docs/reference/cli-super-admin.md @@ -0,0 +1,97 @@ +# `npm run super-admin` + +**Source:** `server/src/scripts/superAdminCRUD.ts` + +Manages super-admin user accounts. Super admins have full access to the admin dashboard and can approve volunteer accounts. + +Run all commands from the `server/` directory: + +```bash +cd server +``` + +## Operations + +### `create` β€” Create a super admin + +```bash +npm run super-admin -- create +``` + +| Argument | Format | Example | +|---|---|---| +| `firstName` | string | `John` | +| `lastName` | string | `Doe` | +| `email` | email address | `john@example.com` | +| `phone` | `+1XXXXXXXXXX` or `XXXXXXXXXX` | `+12065551234` | +| `locationId` | MongoDB ObjectId | `507f1f77bcf86cd799439011` | + +Get the `locationId` from `npm run location -- list` or `npm run location -- get "Hub Name"`. + +**Example:** + +```bash +npm run super-admin -- create Jane Smith jane@kcrha.org +12065551234 507f1f77bcf86cd799439011 +``` + +--- + +### `list` β€” List super admin accounts + +```bash +npm run super-admin -- list # Active accounts only +npm run super-admin -- list --all # Include soft-deleted +``` + +--- + +### `get` β€” Get a specific super admin + +```bash +npm run super-admin -- get john@example.com +npm run super-admin -- get +12065551234 +npm run super-admin -- get 507f1f77bcf86cd799439011 +``` + +Accepts email, phone number, or MongoDB ObjectId. + +--- + +### `update` β€” Update a super admin + +```bash +npm run super-admin -- update [--firstName ] [--lastName ] [--email ] [--phone ] [--location ] [--status ] +``` + +**Examples:** + +```bash +# Update email +npm run super-admin -- update john@example.com --email jane@example.com + +# Change location +npm run super-admin -- update john@example.com --location 507f1f77bcf86cd799439022 + +# Reactivate a pending account +npm run super-admin -- update john@example.com --status APPROVED +``` + +--- + +### `delete` β€” Delete a super admin + +```bash +npm run super-admin -- delete john@example.com # Soft delete (recoverable) +npm run super-admin -- delete john@example.com --hard # Permanent delete +``` + +Soft-deleted accounts are hidden from `list` but remain in the database. Use `list --all` to see them. Use `restore` to recover a soft-deleted account. + +--- + +### `restore` β€” Restore a soft-deleted super admin + +```bash +npm run super-admin -- restore john@example.com +npm run super-admin -- restore +12065551234 +``` diff --git a/docs/reference/database-io.md b/docs/reference/database-io.md new file mode 100644 index 00000000..67cb9398 --- /dev/null +++ b/docs/reference/database-io.md @@ -0,0 +1,109 @@ +# Database I/O (Under Development) + +This page covers exporting and importing MongoDB data using the `mongoexport` and `mongoimport` tools. These commands are useful for: + +- Migrating data from a test cluster to production +- Backing up survey data at the end of a campaign +- Transferring data between cities or deployments + +## Prerequisites + +Install [MongoDB Database Tools](https://www.mongodb.com/docs/database-tools/) on your local machine. These tools are separate from the MongoDB server. + +```bash +# macOS (Homebrew) +brew install mongodb-database-tools + +# Ubuntu/Debian +sudo apt install mongodb-database-tools +``` + +## Exporting Data + +### Export the Surveys Collection + +```bash +mongoexport \ + --uri "mongodb+srv://:@/" \ + --collection=surveys \ + --out /-surveys.json +``` + +### Export the Users Collection + +```bash +mongoexport \ + --uri "mongodb+srv://:@/" \ + --collection=users \ + --out /-users.json +``` + +### Export All Collections (Example Script) + +```bash +#!/bin/bash +URI="mongodb+srv://username:password@cluster0.xxxxx.mongodb.net/rds-your-city" +OUTPUT_DIR="./backup-$(date +%Y%m%d)" +mkdir -p "$OUTPUT_DIR" + +for collection in surveys users seeds locations; do + mongoexport --uri "$URI" --collection="$collection" --out "$OUTPUT_DIR/$collection.json" + echo "Exported $collection" +done +``` + +### Export as CSV (For Analysis) + +```bash +mongoexport \ + --uri "mongodb+srv://:@/" \ + --collection=surveys \ + --type=csv \ + --fields=surveyCode,parentSurveyCode,childSurveyCodes,createdAt \ + --out surveys-export.csv +``` + +See [Post-Survey Analysis](../how-to/analysis.md) for how to use the exported CSV for RDS population estimation. + +## Importing Data + +### Import the Surveys Collection + +```bash +mongoimport \ + --uri "mongodb+srv://:@/" \ + --collection=surveys \ + --file -surveys.json +``` + +### Import the Users Collection + +```bash +mongoimport \ + --uri "mongodb+srv://:@/" \ + --collection=users \ + --file -users.json +``` + +### Import with Upsert (Avoid Duplicates) + +If importing into a database that may already have some records, use `--mode=upsert` with `--upsertFields=_id`: + +```bash +mongoimport \ + --uri "mongodb+srv://:@/" \ + --collection=surveys \ + --mode=upsert \ + --upsertFields=_id \ + --file surveys.json +``` + +## Notes + +!!! warning "Sensitive data" + Exported files contain survey responses and user phone numbers. Handle them as PII and delete them when no longer needed. Do not commit export files to the repository. + +- The `users` collection includes phone numbers. Ensure exports are stored securely and deleted after use. +- The `surveys` collection includes all survey responses. Depending on your jurisdiction, this may be subject to data privacy regulations. +- When migrating to a new cluster, ensure `MONGO_URI` and `MONGO_DB_NAME` in `.env` are updated before restarting the app. + diff --git a/docs/reference/environment-variables.md b/docs/reference/environment-variables.md new file mode 100644 index 00000000..5e7d6457 --- /dev/null +++ b/docs/reference/environment-variables.md @@ -0,0 +1,42 @@ +# Environment Variables + +All server runtime configuration is supplied through `server/.env`: + +```bash +cp server/.env.example server/.env +``` + +`server/.env` is listed in `.gitignore` and must never be committed to version control. + +## Variables + + +| Variable | Required | Format | How to obtain | +| --------------------- | -------- | ----------------------------- | ------------------------------------------------------------------------------------ | +| `NODE_ENV` | Yes | `development` or `production` | Set manually | +| `MONGO_URI` | Yes | `mongodb+srv://...` | Atlas: Connect β†’ Drivers; Cosmos DB: Connection Strings | +| `MONGO_DB_NAME` | Yes | string | Chosen freely (e.g. `rds-seattle`) | +| `TWILIO_ACCOUNT_SID` | Yes | Starts with `AC` | Twilio Console dashboard | +| `TWILIO_AUTH_TOKEN` | Yes | 32-char hex | Twilio Console dashboard | +| `TWILIO_VERIFY_SID` | Yes | Starts with `VA` | Twilio Console β†’ Verify β†’ Services β†’ Service SID | +| `TWILIO_PHONE_NUMBER` | Optional | E.164 (e.g. `+12065551234`) | Twilio Console β†’ Phone Numbers (not in `.env.example`; add manually if needed) | +| `AUTH_SECRET` | Yes | Random string, min 32 chars | `openssl rand -hex 32` | +| `TIMEZONE` | Yes | tz database name | See [tz database list](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) | + + +`TWILIO_PHONE_NUMBER` is only required for outbound bulk SMS (e.g. gift card notifications). OTP authentication via Twilio Verify does not require it. + +Common timezone values: `America/Los_Angeles`, `America/Denver`, `America/Chicago`, `America/New_York`, `UTC`. + +!!! danger "Rotating AUTH_SECRET" + Changing `AUTH_SECRET` immediately invalidates all active user sessions. All logged-in users will be signed out. Coordinate with the team before applying to production. + +## Production Setup on Azure + +On Azure App Service, environment variables are set as **Application settings** rather than in a `.env` file. + +If the infrastructure was provisioned with Pulumi, these variables are configured automatically as App Service settings via `infra/index.ts`. See [Infrastructure](../how-to/infrastructure.md) for details. + +For manual configuration: + +Azure Portal β†’ App Service β†’ Configuration β†’ Application settings β†’ add each variable as a key-value pair β†’ Save β†’ restart the service. \ No newline at end of file diff --git a/docs/reference/glossary.md b/docs/reference/glossary.md new file mode 100644 index 00000000..adf50bed --- /dev/null +++ b/docs/reference/glossary.md @@ -0,0 +1,27 @@ +# Glossary + +Technical terms specific to the RDS App codebase and documentation. + +--- + +**RDS** β€” Respondent-Driven Sampling. A network-based sampling method for reaching hidden populations without a sampling frame. See [RDS Methodology](../about/rds-methodology.md). + +**CASL** β€” A JavaScript authorization library used for role and attribute-based access control. Defines what each user role can read, create, update, or delete. See [Architecture](architecture.md#permissions-casl). + +**Coupon** β€” A physical card with a space for a QR code sticker. Generated as blank PDF templates via `npm run generate-coupons`. Not connected to the database. + +**CSP** β€” Content Security Policy. An HTTP header restricting which sources browsers can load scripts, styles, and other resources from. Enforced by Helmet.js in the server. + +**JWT** β€” JSON Web Token. A signed token issued by the server after OTP verification, stored in the client and sent with every authenticated API request. + +**OTP** β€” One-Time Password. A short-lived numeric code sent via SMS through Twilio Verify that volunteers enter to authenticate. + +**Seed** β€” The initial survey code (and its corresponding QR code) distributed to start a referral chain. Seeds have no `parentSurveyCode`. See [CLI Scripts β€” generate-seeds](cli-generate-seeds.md). + +**SID** β€” Service Identifier. A Twilio-specific string identifying an account (`TWILIO_ACCOUNT_SID`, starts with `AC`) or a Verify service (`TWILIO_VERIFY_SID`, starts with `VA`). + +**Survey code** β€” An 8-character hex string that uniquely identifies a survey entry point. Encoded in QR codes and used to link parent and child surveys in the referral chain. + +**SWR** β€” Stale-While-Revalidate. A React data-fetching strategy used in the admin dashboard. Returns cached data immediately, then revalidates in the background. + +**Twilio Verify** β€” A Twilio service for phone number verification via SMS OTP. Used for all volunteer logins. \ No newline at end of file diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 00000000..372ffe2a --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,2 @@ +mkdocs>=1.6 +mkdocs-material>=9.5 diff --git a/docs/sms-tooling.md b/docs/sms-tooling.md new file mode 100644 index 00000000..e5697ea2 --- /dev/null +++ b/docs/sms-tooling.md @@ -0,0 +1,217 @@ +# SMS Tooling + +Automated SMS sending via Twilio, built for the 2026 KCRHA PIT Count bulk send (~3,700 messages). + +All commands are run from `server/`: + +```bash +npm run sms -- [options] +``` + +Requires a valid `server/.env` with Twilio credentials (see `.env.example`). + +--- + +## Commands + +### `send` β€” Single message + +```bash +# Using a YAML template +npm run sms -- send "(206) 555-1234" --template gift_card --var surveyCode=ABC123 + +# Using a raw message body +npm run sms -- send "+15551234567" --body "Hello from the PIT Count team!" +``` + +Sends a single SMS to one recipient. Phone numbers are normalized to E.164 format automatically (e.g. `(206) 555-1234` β†’ `+12065551234`). + +--- + +### `send-bulk` β€” Bulk send from database + +```bash +npm run sms -- send-bulk --template gift_card +npm run sms -- send-bulk --template gift_card --location 507f1f77bcf86cd799439011 +npm run sms -- send-bulk --template gift_card --dry-run +``` + +Sends to all survey respondents who provided a phone number. Template variables (`surveyCode`, etc.) are auto-populated from each respondent's survey record. Use `--location` to scope to a single site. Use `--dry-run` to preview recipients and message text without sending. + +**Requires a database connection.** + +Rate limit: 1.1 s between messages (Twilio long-code limit: 1 msg/sec). + +--- + +### `send-csv` β€” Bulk send from external CSV + +```bash +npm run sms -- send-csv --file ~/data/gift_cards.csv --template gift_card_redeem +npm run sms -- send-csv --file ~/data/gift_cards.csv --template gift_card_redeem --dry-run +``` + +Sends to recipients listed in an external CSV file. Designed for gift card redemption sends where the recipient list comes from an external system (e.g. Tango). + +**Does not require a database connection.** + +#### CSV format + +The CSV must have these columns (header names are **case-sensitive**): + +| Column | Required | Notes | +|---|---|---| +| `surveyCode` | Yes | Respondent's survey code (used in template variables) | +| `phone` | Yes | Any US phone format β€” normalized to E.164 automatically | +| `amount` | Yes | Numeric gift card value (e.g. `25`, `50.00`) | +| `Reward Code` | Yes | Gift card redemption code. Also accepts `RewardCode` or `rewardCode` | + +Example: + +```csv +surveyCode,phone,amount,Reward Code +ABC123,(206) 555-1234,25,TANGO-XYZ-001 +DEF456,2065559012,50,TANGO-XYZ-002 +``` + +Preprocessing: + +- Strips UTF-8 BOM (common in Excel exports) +- Deduplicates exact duplicate rows +- Filters out rows where `amount` is zero, empty, or non-numeric (`Number(amount) > 0`) +- Filters out rows with an empty Reward Code +- Formats `amount` as `$N` in the message (e.g. `25` β†’ `$25`) + +Rate limit: 1.1 s between messages. + +--- + +### `list-recipients` β€” Preview recipients + +```bash +npm run sms -- list-recipients +npm run sms -- list-recipients --location 507f1f77bcf86cd799439011 +``` + +Lists all survey respondents with a phone number. Useful for verifying the recipient list before a bulk send. + +**Requires a database connection.** + +--- + +### `logs` β€” List send logs + +```bash +npm run sms -- logs +``` + +Lists all CSV log files in `sms-logs/` with row counts. + +--- + +### `fetch-logs` β€” Recover a send log from Twilio + +```bash +npm run sms -- fetch-logs +npm run sms -- fetch-logs --date 2026-02-19 +``` + +Fetches all outbound messages from the Twilio API and writes a recovery CSV to `sms-logs/sms-log-recovered-DATE.csv`. Filters `direction === outbound-api` to exclude inbound replies. Use `--date YYYY-MM-DD` to scope to a single day (UTC). + +Useful when a send log was lost or the send was done from another machine. + +--- + +### `check-status` β€” Enrich logs with delivery status + +```bash +npm run sms -- check-status +npm run sms -- check-status --log-file sms-log-2026-02-19-gift_card_notice.csv +``` + +Fetches the latest delivery status from Twilio for every message SID in the log files. Writes a new enriched CSV alongside each source file (original is never modified): + +``` +sms-log-2026-02-19-gift_card_notice.csv ← original, untouched +sms-log-2026-02-19-gift_card_notice-updated-2026-02-19.csv ← enriched +``` + +Enriched columns added: `dateSent`, `price`, `priceUnit`, `errorCode`, `errorMessage`. + +Prints a delivery stats summary per file: + +``` +=== Delivery Statistics === +File: sms-log-2026-02-19-gift_card_notice.csv (3708 messages) + delivered 3200 (86.3%) + undelivered 358 (9.7%) + failed 150 (4.0%) +Updated log: sms-log-2026-02-19-gift_card_notice-updated-2026-02-19.csv +``` + +Progress is printed every 100 records. Use `--log-file` to limit to a single file. + +--- + +## SMS Templates + +Templates are YAML files in `server/src/scripts/sms-templates/`. Variables are written as `{variableName}`. + +### `gift_card.yaml` +Sent to respondents after completing the survey. Variable: `{surveyCode}`. + +### `gift_card_redeem.yaml` +Sent with gift card redemption codes. Variables: `{surveyCode}`, `{amount}`, `{rewardCode}`. + +--- + +## Logging + +All sends are logged to `server/src/scripts/sms-logs/` (gitignored β€” never committed). + +Log columns: + +| Column | Description | +|---|---| +| `surveyCode` | Respondent's survey code | +| `phone` | Normalized E.164 phone number | +| `templateName` | Template used (or `(raw)`) | +| `smsText` | Full message text sent | +| `datetime` | ISO timestamp of send | +| `status` | Twilio status at send time (e.g. `queued`) | +| `twilioSid` | Twilio message SID for later lookup | +| `numSegments` | Billing segments (160 chars = 1 segment) | + +--- + +## Environment Variables + +Add these to `server/.env` (see `server/.env.example`): + +``` +TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +TWILIO_AUTH_TOKEN=your_auth_token +TWILIO_PHONE_NUMBER=+12065550000 +``` + +--- + +## Design Decisions + +**Why a CLI script instead of an API endpoint?** +Bulk SMS sends are operational, one-off tasks run by staff β€” not triggered by app users. A CLI keeps it out of the web app's attack surface and avoids accidental triggers. + +**Why 1.1 s rate limiting?** +Twilio long-code numbers are limited to 1 message/second. Staying at 1.1 s gives a small buffer to avoid rate-limit errors under jitter. + +**Why `csv-parse` instead of a hand-rolled parser?** +The gift card redemption CSVs contain multi-line message bodies (embedded `\n`). The previous `split('\n')` approach silently dropped those records. `csv-parse` handles RFC 4180 quoted multi-line fields correctly. + +**Why capture `numSegments` at send time?** +Twilio billing is per segment (160 chars). Capturing it immediately from the send response makes cost analysis possible without a separate Twilio API call later. + +**Why never modify original log files?** +Log files are the audit trail. `check-status` writes a new `*-updated-DATE.csv` instead of overwriting, so the original send record is always preserved. + +**Why does `send-csv` not require a database connection?** +The gift card recipient list requires deduplication, removal of test entries, and an external list of Tango reward links β€” processing that happens outside the RDS database. In the future, this process should be automated for prompt delivery of gift cards. Keeping `send-csv` DB-free means it can be run from any machine with Twilio credentials, without needing a MongoDB connection. diff --git a/infra/Pulumi.yaml b/infra/Pulumi.yaml new file mode 100644 index 00000000..97fed71f --- /dev/null +++ b/infra/Pulumi.yaml @@ -0,0 +1,3 @@ +name: rds-infra +runtime: nodejs +description: Azure infrastructure for the Respondent-Driven Sampling app diff --git a/infra/getting-started.md b/infra/getting-started.md new file mode 100644 index 00000000..1c5739b8 --- /dev/null +++ b/infra/getting-started.md @@ -0,0 +1,97 @@ +> **Canonical docs:** [Infrastructure Provisioning](https://ssdlab.github.io/respondent-driven-sampling/how-to/infrastructure/) β€” this file is a quick-reference for the `infra/` directory; the full documentation lives on the docs site. + +# RDS Infrastructure (Pulumi + Azure) + +Deploy the full Azure stack for the Respondent-Driven Sampling app using **Pulumi with TypeScript**. + +## Prerequisites + +- [Node.js](https://nodejs.org/) (>=20.17.0) +- [Pulumi CLI](https://www.pulumi.com/docs/install/) +- [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli) +- An Azure subscription + +## Quick Start + +```bash +# 1. Install dependencies +cd infra +npm install + +# 2. Log in to Azure +az login + +# 3. Use local Pulumi state (no account needed) +pulumi login --local + +# 4. Create a stack +pulumi stack init prod + +# 5. Set required config +pulumi config set location westus2 +pulumi config set resourceGroupName my-rds-rg +pulumi config set mongoClusterName my-rds-db +pulumi config set mongoDbName main +pulumi config set appServicePlanName my-rds-plan +pulumi config set webAppName my-rds-app +pulumi config set skuName B1 + +# 6. Set secrets +pulumi config set --secret mongoAdminLogin +pulumi config set --secret mongoAdminPassword +pulumi config set --secret authSecret +pulumi config set --secret twilioAccountSid +pulumi config set --secret twilioAuthToken +pulumi config set --secret twilioVerifySid + +# 7. Deploy +pulumi up +``` + +## Config Reference + +| Key | Required | Default | Description | +|-----|----------|---------|-------------| +| `resourceGroupName` | Yes | β€” | Azure Resource Group name | +| `location` | Yes | β€” | Azure region (e.g. `westus2`) | +| `mongoClusterName` | Yes | β€” | MongoDB vCore cluster name | +| `mongoDbName` | Yes | β€” | MongoDB database name (e.g. `main`) | +| `appServicePlanName` | Yes | β€” | App Service Plan name | +| `webAppName` | Yes | β€” | Web App name (globally unique) | +| `skuName` | Yes | β€” | App Service Plan SKU (e.g. `B1`, `P1v2`) | +| `mongoAdminLogin` | Yes (secret) | β€” | MongoDB admin username | +| `mongoAdminPassword` | Yes (secret) | β€” | MongoDB admin password | +| `authSecret` | Yes (secret) | β€” | Auth secret for JWT signing | +| `twilioAccountSid` | Yes (secret) | β€” | Twilio Account SID | +| `twilioAuthToken` | Yes (secret) | β€” | Twilio Auth Token | +| `twilioVerifySid` | Yes (secret) | β€” | Twilio Verify Service SID | +| `nodeVersion` | No | `22-lts` | Node.js version for App Service | +| `nodeEnv` | No | `production` | `NODE_ENV` value | +| `mongoServerVersion` | No | `8.0` | MongoDB server version | +| `mongoSku` | No | `M20` | MongoDB vCore cluster SKU | +| `mongoDiskSizeGb` | No | `128` | MongoDB disk size in GB | + +## Importing Existing Resources + +If you already have Azure resources deployed (e.g. from the old Terraform setup), you can import them instead of creating new ones: + +1. Find each resource's Azure ID (from the Azure Portal or `az` CLI) +2. Add `{ import: "" }` as the third argument to each Pulumi resource constructor in `index.ts` +3. Run `pulumi up` β€” Pulumi adopts the resources without modifying them +4. Remove the `import` options and run `pulumi up` again to verify no changes + +Import order: Resource Group β†’ MongoDB Cluster β†’ App Service Plan β†’ Web App + +## Tear Down + +```bash +pulumi destroy +pulumi stack rm prod +``` + +## Troubleshooting + +- **"az login" errors**: Make sure `az account show` returns a valid subscription. Set `ARM_SUBSCRIPTION_ID` env var if needed. +- **Name conflicts**: `mongoClusterName` and `webAppName` must be globally unique across Azure. +- **SKU not available**: Some SKUs are region-specific. Try `B1` or `P1v2` in your chosen region. +- **Node version format**: Use `22-lts` (not `v22` or `22.x`). This maps to `NODE|22-lts` in the App Service config. diff --git a/infra/index.ts b/infra/index.ts new file mode 100644 index 00000000..70a99220 --- /dev/null +++ b/infra/index.ts @@ -0,0 +1,146 @@ +import * as pulumi from "@pulumi/pulumi"; +import * as resources from "@pulumi/azure-native/resources"; +import * as documentdb from "@pulumi/azure-native/documentdb"; +import * as web from "@pulumi/azure-native/web"; + +// --------------------------------------------------------------------------- +// Config +// --------------------------------------------------------------------------- +const config = new pulumi.Config(); + +// Required +const resourceGroupName = config.require("resourceGroupName"); +const location = config.require("location"); +const mongoClusterName = config.require("mongoClusterName"); +const mongoDbName = config.require("mongoDbName"); +const appServicePlanName = config.require("appServicePlanName"); +const webAppName = config.require("webAppName"); +const skuName = config.require("skuName"); + +// Secrets +const mongoAdminLogin = config.requireSecret("mongoAdminLogin"); +const mongoAdminPassword = config.requireSecret("mongoAdminPassword"); +const authSecret = config.requireSecret("authSecret"); +const twilioAccountSid = config.requireSecret("twilioAccountSid"); +const twilioAuthToken = config.requireSecret("twilioAuthToken"); +const twilioVerifySid = config.requireSecret("twilioVerifySid"); + +// Optional with defaults +const nodeVersion = config.get("nodeVersion") ?? "22-lts"; +const nodeEnv = config.get("nodeEnv") ?? "production"; +const mongoServerVersion = config.get("mongoServerVersion") ?? "8.0"; +const mongoSku = config.get("mongoSku") ?? "M20"; +const mongoDiskSizeGb = config.getNumber("mongoDiskSizeGb") ?? 128; + +// --------------------------------------------------------------------------- +// Resource Group +// --------------------------------------------------------------------------- +const resourceGroup = new resources.ResourceGroup("rds-resource-group", { + resourceGroupName: resourceGroupName, + location: location, +}); + +// --------------------------------------------------------------------------- +// MongoDB vCore Cluster (Cosmos DB) +// --------------------------------------------------------------------------- +const mongoCluster = new documentdb.MongoCluster("rds-mongo-cluster", { + mongoClusterName: mongoClusterName, + resourceGroupName: resourceGroup.name, + location: resourceGroup.location, + administratorLogin: mongoAdminLogin, + administratorLoginPassword: mongoAdminPassword, + serverVersion: mongoServerVersion, + nodeGroupSpecs: [ + { + kind: "Shard", + nodeCount: 1, + sku: mongoSku, + diskSizeGB: mongoDiskSizeGb, + enableHa: false, + }, + ], +}, { + // administratorLoginPassword is write-only and always shows as a diff; + // createMode is auto-populated by Azure + ignoreChanges: ["administratorLoginPassword", "createMode"], +}); + +// Construct the connection string from config values (not from the cluster +// output, since the template contains / placeholders) +const mongoUri = pulumi + .all([mongoAdminLogin, mongoAdminPassword]) + .apply(([login, password]) => + `mongodb+srv://${encodeURIComponent(login)}:${encodeURIComponent(password)}@${mongoClusterName}.mongocluster.cosmos.azure.com/?tls=true&authMechanism=SCRAM-SHA-256&retrywrites=false&maxIdleTimeMS=120000`, + ); + +// --------------------------------------------------------------------------- +// App Service Plan (Linux) +// --------------------------------------------------------------------------- +const appServicePlan = new web.AppServicePlan("rds-service-plan", { + name: appServicePlanName, + resourceGroupName: resourceGroup.name, + location: resourceGroup.location, + kind: "linux", + reserved: true, // Required for Linux plans + sku: { + name: skuName, + }, +}, { + // Azure auto-populates these fields; ignoring prevents spurious diffs + ignoreChanges: ["elasticScaleEnabled", "isSpot", "maximumElasticWorkerCount", "targetWorkerCount", "targetWorkerSizeId"], +}); + +// --------------------------------------------------------------------------- +// Web App (Node.js on Linux) +// --------------------------------------------------------------------------- +const webApp = new web.WebApp("rds-web-app", { + name: webAppName, + resourceGroupName: resourceGroup.name, + location: resourceGroup.location, + serverFarmId: appServicePlan.id, + httpsOnly: true, + clientAffinityEnabled: false, + siteConfig: { + linuxFxVersion: `NODE|${nodeVersion}`, + alwaysOn: true, + appSettings: [ + { name: "NODE_ENV", value: nodeEnv }, + { name: "PORT", value: "8080" }, + { name: "SCM_DO_BUILD_DURING_DEPLOYMENT", value: "true" }, + { name: "MONGO_URI", value: mongoUri }, + { name: "MONGO_DB_NAME", value: mongoDbName }, + { name: "AUTH_SECRET", value: authSecret }, + { name: "TWILIO_ACCOUNT_SID", value: twilioAccountSid }, + { name: "TWILIO_AUTH_TOKEN", value: twilioAuthToken }, + { name: "TWILIO_VERIFY_SID", value: twilioVerifySid }, + ], + }, +}, { + // Azure auto-populates these fields; ignoring prevents spurious diffs + ignoreChanges: [ + "clientCertEnabled", + "clientCertMode", + "containerSize", + "customDomainVerificationId", + "dailyMemoryTimeQuota", + "enabled", + "hostNameSslStates", + "hostNamesDisabled", + "keyVaultReferenceIdentity", + "kind", + "publicNetworkAccess", + "redundancyMode", + "reserved", + "storageAccountRequired", + "vnetContentShareEnabled", + "vnetImagePullEnabled", + "vnetRouteAllEnabled", + "tags", + ], +}); + +// --------------------------------------------------------------------------- +// Outputs +// --------------------------------------------------------------------------- +export const appServiceUrl = pulumi.interpolate`https://${webApp.defaultHostName}`; +export const mongoConnectionString = mongoUri; diff --git a/infra/package-lock.json b/infra/package-lock.json new file mode 100644 index 00000000..dd2a49b8 --- /dev/null +++ b/infra/package-lock.json @@ -0,0 +1,3318 @@ +{ + "name": "rds-infra", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "rds-infra", + "version": "1.0.0", + "dependencies": { + "@pulumi/azure-native": "^2.0.0", + "@pulumi/pulumi": "^3.0.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.0.0" + } + }, + "node_modules/@gar/promise-retry": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@gar/promise-retry/-/promise-retry-1.0.2.tgz", + "integrity": "sha512-Lm/ZLhDZcBECta3TmCQSngiQykFdfw+QtI1/GYMsZd4l3nG+P8WLB16XuS7WaBGLQ+9E+cOcWQsth9cayuGt8g==", + "license": "MIT", + "dependencies": { + "retry": "^0.13.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@grpc/grpc-js": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz", + "integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "engines": { + "node": ">=12.10.0" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.0.tgz", + "integrity": "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==", + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.3", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@isaacs/string-locale-compare": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@isaacs/string-locale-compare/-/string-locale-compare-1.1.0.tgz", + "integrity": "sha512-SQ7Kzhh9+D+ZW9MA0zkYv3VXhIDNx+LzM6EJ+/65I3QY+enU6Itte7E5XX7EWrqLW2FN4n06GWzBnPoC3th2aQ==", + "license": "ISC" + }, + "node_modules/@js-sdsl/ordered-map": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, + "node_modules/@logdna/tail-file": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@logdna/tail-file/-/tail-file-2.2.0.tgz", + "integrity": "sha512-XGSsWDweP80Fks16lwkAUIr54ICyBs6PsI4mpfTLQaWgEJRtY9xEV+PeyDpJ+sJEGZxqINlpmAwe/6tS1pP8Ng==", + "license": "SEE LICENSE IN LICENSE", + "engines": { + "node": ">=10.3.0" + } + }, + "node_modules/@npmcli/agent": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-4.0.0.tgz", + "integrity": "sha512-kAQTcEN9E8ERLVg5AsGwLNoFb+oEG6engbqAU2P43gD4JEIkNGMHdVQ096FsOAAYpZPB0RSt0zgInKIAS1l5QA==", + "license": "ISC", + "dependencies": { + "agent-base": "^7.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.1", + "lru-cache": "^11.2.1", + "socks-proxy-agent": "^8.0.3" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/arborist": { + "version": "9.4.1", + "resolved": "https://registry.npmjs.org/@npmcli/arborist/-/arborist-9.4.1.tgz", + "integrity": "sha512-SaXiFtYcAbzPI+VmuI+O6hii9fEVe36vm6XRAu0QcvCR9YphHfNF8PIDeDapVkE+LJ0c7BN7uPGd3plbh9zbrw==", + "license": "ISC", + "dependencies": { + "@gar/promise-retry": "^1.0.0", + "@isaacs/string-locale-compare": "^1.1.0", + "@npmcli/fs": "^5.0.0", + "@npmcli/installed-package-contents": "^4.0.0", + "@npmcli/map-workspaces": "^5.0.0", + "@npmcli/metavuln-calculator": "^9.0.2", + "@npmcli/name-from-folder": "^4.0.0", + "@npmcli/node-gyp": "^5.0.0", + "@npmcli/package-json": "^7.0.0", + "@npmcli/query": "^5.0.0", + "@npmcli/redact": "^4.0.0", + "@npmcli/run-script": "^10.0.0", + "bin-links": "^6.0.0", + "cacache": "^20.0.1", + "common-ancestor-path": "^2.0.0", + "hosted-git-info": "^9.0.0", + "json-stringify-nice": "^1.1.4", + "lru-cache": "^11.2.1", + "minimatch": "^10.0.3", + "nopt": "^9.0.0", + "npm-install-checks": "^8.0.0", + "npm-package-arg": "^13.0.0", + "npm-pick-manifest": "^11.0.1", + "npm-registry-fetch": "^19.0.0", + "pacote": "^21.0.2", + "parse-conflict-json": "^5.0.1", + "proc-log": "^6.0.0", + "proggy": "^4.0.0", + "promise-all-reject-late": "^1.0.0", + "promise-call-limit": "^3.0.1", + "semver": "^7.3.7", + "ssri": "^13.0.0", + "treeverse": "^3.0.0", + "walk-up-path": "^4.0.0" + }, + "bin": { + "arborist": "bin/index.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/fs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-5.0.0.tgz", + "integrity": "sha512-7OsC1gNORBEawOa5+j2pXN9vsicaIOH5cPXxoR6fJOmH6/EXpJB2CajXOu1fPRFun2m1lktEFX11+P89hqO/og==", + "license": "ISC", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/git": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@npmcli/git/-/git-7.0.2.tgz", + "integrity": "sha512-oeolHDjExNAJAnlYP2qzNjMX/Xi9bmu78C9dIGr4xjobrSKbuMYCph8lTzn4vnW3NjIqVmw/f8BCfouqyJXlRg==", + "license": "ISC", + "dependencies": { + "@gar/promise-retry": "^1.0.0", + "@npmcli/promise-spawn": "^9.0.0", + "ini": "^6.0.0", + "lru-cache": "^11.2.1", + "npm-pick-manifest": "^11.0.1", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "which": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/git/node_modules/ini": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz", + "integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/installed-package-contents": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/installed-package-contents/-/installed-package-contents-4.0.0.tgz", + "integrity": "sha512-yNyAdkBxB72gtZ4GrwXCM0ZUedo9nIbOMKfGjt6Cu6DXf0p8y1PViZAKDC8q8kv/fufx0WTjRBdSlyrvnP7hmA==", + "license": "ISC", + "dependencies": { + "npm-bundled": "^5.0.0", + "npm-normalize-package-bin": "^5.0.0" + }, + "bin": { + "installed-package-contents": "bin/index.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/map-workspaces": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@npmcli/map-workspaces/-/map-workspaces-5.0.3.tgz", + "integrity": "sha512-o2grssXo1e774E5OtEwwrgoszYRh0lqkJH+Pb9r78UcqdGJRDRfhpM8DvZPjzNLLNYeD/rNbjOKM3Ss5UABROw==", + "license": "ISC", + "dependencies": { + "@npmcli/name-from-folder": "^4.0.0", + "@npmcli/package-json": "^7.0.0", + "glob": "^13.0.0", + "minimatch": "^10.0.3" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/metavuln-calculator": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/@npmcli/metavuln-calculator/-/metavuln-calculator-9.0.3.tgz", + "integrity": "sha512-94GLSYhLXF2t2LAC7pDwLaM4uCARzxShyAQKsirmlNcpidH89VA4/+K1LbJmRMgz5gy65E/QBBWQdUvGLe2Frg==", + "license": "ISC", + "dependencies": { + "cacache": "^20.0.0", + "json-parse-even-better-errors": "^5.0.0", + "pacote": "^21.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/name-from-folder": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/name-from-folder/-/name-from-folder-4.0.0.tgz", + "integrity": "sha512-qfrhVlOSqmKM8i6rkNdZzABj8MKEITGFAY+4teqBziksCQAOLutiAxM1wY2BKEd8KjUSpWmWCYxvXr0y4VTlPg==", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/node-gyp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/node-gyp/-/node-gyp-5.0.0.tgz", + "integrity": "sha512-uuG5HZFXLfyFKqg8QypsmgLQW7smiRjVc45bqD/ofZZcR/uxEjgQU8qDPv0s9TEeMUiAAU/GC5bR6++UdTirIQ==", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/package-json": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/@npmcli/package-json/-/package-json-7.0.5.tgz", + "integrity": "sha512-iVuTlG3ORq2iaVa1IWUxAO/jIp77tUKBhoMjuzYW2kL4MLN1bi/ofqkZ7D7OOwh8coAx1/S2ge0rMdGv8sLSOQ==", + "license": "ISC", + "dependencies": { + "@npmcli/git": "^7.0.0", + "glob": "^13.0.0", + "hosted-git-info": "^9.0.0", + "json-parse-even-better-errors": "^5.0.0", + "proc-log": "^6.0.0", + "semver": "^7.5.3", + "spdx-expression-parse": "^4.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/promise-spawn": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/@npmcli/promise-spawn/-/promise-spawn-9.0.1.tgz", + "integrity": "sha512-OLUaoqBuyxeTqUvjA3FZFiXUfYC1alp3Sa99gW3EUDz3tZ3CbXDdcZ7qWKBzicrJleIgucoWamWH1saAmH/l2Q==", + "license": "ISC", + "dependencies": { + "which": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/query": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/query/-/query-5.0.0.tgz", + "integrity": "sha512-8TZWfTQOsODpLqo9SVhVjHovmKXNpevHU0gO9e+y4V4fRIOneiXy0u0sMP9LmS71XivrEWfZWg50ReH4WRT4aQ==", + "license": "ISC", + "dependencies": { + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/redact": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/redact/-/redact-4.0.0.tgz", + "integrity": "sha512-gOBg5YHMfZy+TfHArfVogwgfBeQnKbbGo3pSUyK/gSI0AVu+pEiDVcKlQb0D8Mg1LNRZILZ6XG8I5dJ4KuAd9Q==", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/run-script": { + "version": "10.0.4", + "resolved": "https://registry.npmjs.org/@npmcli/run-script/-/run-script-10.0.4.tgz", + "integrity": "sha512-mGUWr1uMnf0le2TwfOZY4SFxZGXGfm4Jtay/nwAa2FLNAKXUoUwaGwBMNH36UHPtinWfTSJ3nqFQr0091CxVGg==", + "license": "ISC", + "dependencies": { + "@npmcli/node-gyp": "^5.0.0", + "@npmcli/package-json": "^7.0.0", + "@npmcli/promise-spawn": "^9.0.0", + "node-gyp": "^12.1.0", + "proc-log": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/api-logs": { + "version": "0.55.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.55.0.tgz", + "integrity": "sha512-3cpa+qI45VHYcA5c0bHM6VHo9gicv3p5mlLHNG3rLyjQU8b7e0st1rWtrUn3JbZ3DwwCfhKop4eQ9UuYlC6Pkg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/context-async-hooks": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-1.30.1.tgz", + "integrity": "sha512-s5vvxXPVdjqS3kTLKMeBMvop9hbWkwzBpu+mUO2M7sZtlkyDJGwFe33wRKnbaYDo8ExRVBIIdwIGrqpxHuKttA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/core": { + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.28.0.tgz", + "integrity": "sha512-ZLwRMV+fNDpVmF2WYUdBHlq0eOWtEaUJSusrzjGnBt7iSRvfjFE3RXYUZJrqou/wIDWV0DwQ5KIfYe9WXg9Xqw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-grpc": { + "version": "0.55.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-grpc/-/exporter-trace-otlp-grpc-0.55.0.tgz", + "integrity": "sha512-ohIkCLn2Wc3vhhFuf1bH8kOXHMEdcWiD847x7f3Qfygc+CGiatGLzQYscTcEYsWGMV22gVwB/kVcNcx5a3o8gA==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/grpc-js": "^1.7.1", + "@opentelemetry/core": "1.28.0", + "@opentelemetry/otlp-grpc-exporter-base": "0.55.0", + "@opentelemetry/otlp-transformer": "0.55.0", + "@opentelemetry/resources": "1.28.0", + "@opentelemetry/sdk-trace-base": "1.28.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-grpc/node_modules/@opentelemetry/resources": { + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-1.28.0.tgz", + "integrity": "sha512-cIyXSVJjGeTICENN40YSvLDAq4Y2502hGK3iN7tfdynQLKWb3XWZQEkPc+eSx47kiy11YeFAlYkEfXwR1w8kfw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.28.0", + "@opentelemetry/semantic-conventions": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-grpc/node_modules/@opentelemetry/sdk-trace-base": { + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-1.28.0.tgz", + "integrity": "sha512-ceUVWuCpIao7Y5xE02Xs3nQi0tOGmMea17ecBdwtCvdo9ekmO+ijc9RFDgfifMl7XCBf41zne/1POM3LqSTZDA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.28.0", + "@opentelemetry/resources": "1.28.0", + "@opentelemetry/semantic-conventions": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-zipkin": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-zipkin/-/exporter-zipkin-1.30.1.tgz", + "integrity": "sha512-6S2QIMJahIquvFaaxmcwpvQQRD/YFaMTNoIxrfPIPOeITN+a8lfEcPDxNxn8JDAaxkg+4EnXhz8upVDYenoQjA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.30.1", + "@opentelemetry/resources": "1.30.1", + "@opentelemetry/sdk-trace-base": "1.30.1", + "@opentelemetry/semantic-conventions": "1.28.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0" + } + }, + "node_modules/@opentelemetry/exporter-zipkin/node_modules/@opentelemetry/core": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.30.1.tgz", + "integrity": "sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.28.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-zipkin/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz", + "integrity": "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/instrumentation": { + "version": "0.55.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.55.0.tgz", + "integrity": "sha512-YDCMlaQRZkziLL3t6TONRgmmGxDx6MyQDXRD0dknkkgUZtOK5+8MWft1OXzmNu6XfBOdT12MKN5rz+jHUkafKQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.55.0", + "@types/shimmer": "^1.2.0", + "import-in-the-middle": "^1.8.1", + "require-in-the-middle": "^7.1.1", + "semver": "^7.5.2", + "shimmer": "^1.2.1" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-grpc": { + "version": "0.55.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-grpc/-/instrumentation-grpc-0.55.0.tgz", + "integrity": "sha512-n2ZH4pRwOy0Vhag/3eKqiyDBwcpUnGgJI9iiIRX7vivE0FMncaLazWphNFezRRaM/LuKwq1TD8pVUvieP68mow==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "0.55.0", + "@opentelemetry/semantic-conventions": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/otlp-exporter-base": { + "version": "0.55.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.55.0.tgz", + "integrity": "sha512-iHQI0Zzq3h1T6xUJTVFwmFl5Dt5y1es+fl4kM+k5T/3YvmVyeYkSiF+wHCg6oKrlUAJfk+t55kaAu3sYmt7ZYA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.28.0", + "@opentelemetry/otlp-transformer": "0.55.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/otlp-grpc-exporter-base": { + "version": "0.55.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-grpc-exporter-base/-/otlp-grpc-exporter-base-0.55.0.tgz", + "integrity": "sha512-gebbjl9FiSp52igWXuGjcWQKfB6IBwFGt5z1VFwTcVZVeEZevB6bJIqoFrhH4A02m7OUlpJ7l4EfRi3UtkNANQ==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/grpc-js": "^1.7.1", + "@opentelemetry/core": "1.28.0", + "@opentelemetry/otlp-exporter-base": "0.55.0", + "@opentelemetry/otlp-transformer": "0.55.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/otlp-transformer": { + "version": "0.55.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.55.0.tgz", + "integrity": "sha512-kVqEfxtp6mSN2Dhpy0REo1ghP4PYhC1kMHQJ2qVlO99Pc+aigELjZDfg7/YKmL71gR6wVGIeJfiql/eXL7sQPA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.55.0", + "@opentelemetry/core": "1.28.0", + "@opentelemetry/resources": "1.28.0", + "@opentelemetry/sdk-logs": "0.55.0", + "@opentelemetry/sdk-metrics": "1.28.0", + "@opentelemetry/sdk-trace-base": "1.28.0", + "protobufjs": "^7.3.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/resources": { + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-1.28.0.tgz", + "integrity": "sha512-cIyXSVJjGeTICENN40YSvLDAq4Y2502hGK3iN7tfdynQLKWb3XWZQEkPc+eSx47kiy11YeFAlYkEfXwR1w8kfw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.28.0", + "@opentelemetry/semantic-conventions": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/sdk-trace-base": { + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-1.28.0.tgz", + "integrity": "sha512-ceUVWuCpIao7Y5xE02Xs3nQi0tOGmMea17ecBdwtCvdo9ekmO+ijc9RFDgfifMl7XCBf41zne/1POM3LqSTZDA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.28.0", + "@opentelemetry/resources": "1.28.0", + "@opentelemetry/semantic-conventions": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/propagator-b3": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/propagator-b3/-/propagator-b3-1.30.1.tgz", + "integrity": "sha512-oATwWWDIJzybAZ4pO76ATN5N6FFbOA1otibAVlS8v90B4S1wClnhRUk7K+2CHAwN1JKYuj4jh/lpCEG5BAqFuQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.30.1" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/propagator-b3/node_modules/@opentelemetry/core": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.30.1.tgz", + "integrity": "sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.28.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/propagator-b3/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz", + "integrity": "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/propagator-jaeger": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/propagator-jaeger/-/propagator-jaeger-1.30.1.tgz", + "integrity": "sha512-Pj/BfnYEKIOImirH76M4hDaBSx6HyZ2CXUqk+Kj02m6BB80c/yo4BdWkn/1gDFfU+YPY+bPR2U0DKBfdxCKwmg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.30.1" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/propagator-jaeger/node_modules/@opentelemetry/core": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.30.1.tgz", + "integrity": "sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.28.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/propagator-jaeger/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz", + "integrity": "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/resources": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-1.30.1.tgz", + "integrity": "sha512-5UxZqiAgLYGFjS4s9qm5mBVo433u+dSPUFWVWXmLAD4wB65oMCoXaJP1KJa9DIYYMeHu3z4BZcStG3LC593cWA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.30.1", + "@opentelemetry/semantic-conventions": "1.28.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/resources/node_modules/@opentelemetry/core": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.30.1.tgz", + "integrity": "sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.28.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/resources/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz", + "integrity": "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/sdk-logs": { + "version": "0.55.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.55.0.tgz", + "integrity": "sha512-TSx+Yg/d48uWW6HtjS1AD5x6WPfLhDWLl/WxC7I2fMevaiBuKCuraxTB8MDXieCNnBI24bw9ytyXrDCswFfWgA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.55.0", + "@opentelemetry/core": "1.28.0", + "@opentelemetry/resources": "1.28.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.4.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-logs/node_modules/@opentelemetry/resources": { + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-1.28.0.tgz", + "integrity": "sha512-cIyXSVJjGeTICENN40YSvLDAq4Y2502hGK3iN7tfdynQLKWb3XWZQEkPc+eSx47kiy11YeFAlYkEfXwR1w8kfw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.28.0", + "@opentelemetry/semantic-conventions": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-metrics": { + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-1.28.0.tgz", + "integrity": "sha512-43tqMK/0BcKTyOvm15/WQ3HLr0Vu/ucAl/D84NO7iSlv6O4eOprxSHa3sUtmYkaZWHqdDJV0AHVz/R6u4JALVQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.28.0", + "@opentelemetry/resources": "1.28.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-metrics/node_modules/@opentelemetry/resources": { + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-1.28.0.tgz", + "integrity": "sha512-cIyXSVJjGeTICENN40YSvLDAq4Y2502hGK3iN7tfdynQLKWb3XWZQEkPc+eSx47kiy11YeFAlYkEfXwR1w8kfw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.28.0", + "@opentelemetry/semantic-conventions": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-base": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-1.30.1.tgz", + "integrity": "sha512-jVPgBbH1gCy2Lb7X0AVQ8XAfgg0pJ4nvl8/IiQA6nxOsPvS+0zMJaFSs2ltXe0J6C8dqjcnpyqINDJmU30+uOg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.30.1", + "@opentelemetry/resources": "1.30.1", + "@opentelemetry/semantic-conventions": "1.28.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-base/node_modules/@opentelemetry/core": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.30.1.tgz", + "integrity": "sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.28.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-base/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz", + "integrity": "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/sdk-trace-node": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-node/-/sdk-trace-node-1.30.1.tgz", + "integrity": "sha512-cBjYOINt1JxXdpw1e5MlHmFRc5fgj4GW/86vsKFxJCJ8AL4PdVtYH41gWwl4qd4uQjqEL1oJVrXkSy5cnduAnQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/context-async-hooks": "1.30.1", + "@opentelemetry/core": "1.30.1", + "@opentelemetry/propagator-b3": "1.30.1", + "@opentelemetry/propagator-jaeger": "1.30.1", + "@opentelemetry/sdk-trace-base": "1.30.1", + "semver": "^7.5.2" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-node/node_modules/@opentelemetry/core": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.30.1.tgz", + "integrity": "sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.28.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-node/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz", + "integrity": "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.27.0.tgz", + "integrity": "sha512-sAay1RrB+ONOem0OZanAR1ZI/k7yDpnOQSQmTMuGImUQb2y8EbSaCJ94FQluM74xoU03vlb2d2U90hZluL6nQg==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "license": "BSD-3-Clause" + }, + "node_modules/@pulumi/azure-native": { + "version": "2.92.0", + "resolved": "https://registry.npmjs.org/@pulumi/azure-native/-/azure-native-2.92.0.tgz", + "integrity": "sha512-g59kLgaLrzOHxO1jG4jVjcPntSt6oq5klRai7p5lC8AO8+dyiOk6cselA4UmMMDgtf1UUXRGFknVxVCgl/rITg==", + "license": "Apache-2.0", + "dependencies": { + "@pulumi/pulumi": "^3.142.0" + } + }, + "node_modules/@pulumi/pulumi": { + "version": "3.226.0", + "resolved": "https://registry.npmjs.org/@pulumi/pulumi/-/pulumi-3.226.0.tgz", + "integrity": "sha512-GvBt9emcdUi4NjShXcH+nhd0yNUUAxydFhGl7pYCDHs2SRHMkhciGhbvlQwZniZZwff5SUGE+pR7DAlsTXdXuw==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/grpc-js": "^1.10.1", + "@logdna/tail-file": "^2.0.6", + "@npmcli/arborist": "^9.0.0", + "@opentelemetry/api": "^1.9", + "@opentelemetry/exporter-trace-otlp-grpc": "^0.55", + "@opentelemetry/exporter-zipkin": "^1.28", + "@opentelemetry/instrumentation": "^0.55", + "@opentelemetry/instrumentation-grpc": "^0.55", + "@opentelemetry/resources": "^1.28", + "@opentelemetry/sdk-trace-base": "^1.28", + "@opentelemetry/sdk-trace-node": "^1.28", + "@types/google-protobuf": "^3.15.5", + "@types/semver": "^7.5.6", + "@types/tmp": "^0.2.6", + "execa": "^5.1.0", + "fdir": "^6.5.0", + "google-protobuf": "^3.21.4", + "got": "^11.8.6", + "ini": "^2.0.0", + "js-yaml": "^3.14.2", + "minimist": "^1.2.6", + "normalize-package-data": "^6.0.0", + "package-directory": "^8.1.0", + "picomatch": "^3.0.1", + "require-from-string": "^2.0.1", + "semver": "^7.5.2", + "source-map-support": "^0.5.6", + "tmp": "^0.2.4", + "upath": "^1.1.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "ts-node": ">= 7.0.1 < 12", + "typescript": ">= 3.8.3 < 6" + }, + "peerDependenciesMeta": { + "ts-node": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@sigstore/bundle": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-4.0.0.tgz", + "integrity": "sha512-NwCl5Y0V6Di0NexvkTqdoVfmjTaQwoLM236r89KEojGmq/jMls8S+zb7yOwAPdXvbwfKDlP+lmXgAL4vKSQT+A==", + "license": "Apache-2.0", + "dependencies": { + "@sigstore/protobuf-specs": "^0.5.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@sigstore/core": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@sigstore/core/-/core-3.1.0.tgz", + "integrity": "sha512-o5cw1QYhNQ9IroioJxpzexmPjfCe7gzafd2RY3qnMpxr4ZEja+Jad/U8sgFpaue6bOaF+z7RVkyKVV44FN+N8A==", + "license": "Apache-2.0", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@sigstore/protobuf-specs": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@sigstore/protobuf-specs/-/protobuf-specs-0.5.0.tgz", + "integrity": "sha512-MM8XIwUjN2bwvCg1QvrMtbBmpcSHrkhFSCu1D11NyPvDQ25HEc4oG5/OcQfd/Tlf/OxmKWERDj0zGE23jQaMwA==", + "license": "Apache-2.0", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@sigstore/sign": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@sigstore/sign/-/sign-4.1.0.tgz", + "integrity": "sha512-Vx1RmLxLGnSUqx/o5/VsCjkuN5L7y+vxEEwawvc7u+6WtX2W4GNa7b9HEjmcRWohw/d6BpATXmvOwc78m+Swdg==", + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^4.0.0", + "@sigstore/core": "^3.1.0", + "@sigstore/protobuf-specs": "^0.5.0", + "make-fetch-happen": "^15.0.3", + "proc-log": "^6.1.0", + "promise-retry": "^2.0.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@sigstore/tuf": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@sigstore/tuf/-/tuf-4.0.1.tgz", + "integrity": "sha512-OPZBg8y5Vc9yZjmWCHrlWPMBqW5yd8+wFNl+thMdtcWz3vjVSoJQutF8YkrzI0SLGnkuFof4HSsWUhXrf219Lw==", + "license": "Apache-2.0", + "dependencies": { + "@sigstore/protobuf-specs": "^0.5.0", + "tuf-js": "^4.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@sigstore/verify": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@sigstore/verify/-/verify-3.1.0.tgz", + "integrity": "sha512-mNe0Iigql08YupSOGv197YdHpPPr+EzDZmfCgMc7RPNaZTw5aLN01nBl6CHJOh3BGtnMIj83EeN4butBchc8Ag==", + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^4.0.0", + "@sigstore/core": "^3.1.0", + "@sigstore/protobuf-specs": "^0.5.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@szmarczak/http-timer": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", + "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", + "license": "MIT", + "dependencies": { + "defer-to-connect": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@tufjs/canonical-json": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tufjs/canonical-json/-/canonical-json-2.0.0.tgz", + "integrity": "sha512-yVtV8zsdo8qFHe+/3kw81dSLyF7D576A5cCFCi4X7B39tWT7SekaEFUnvnWJHz+9qO7qJTah1JbrDjWKqFtdWA==", + "license": "MIT", + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/@tufjs/models": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@tufjs/models/-/models-4.1.0.tgz", + "integrity": "sha512-Y8cK9aggNRsqJVaKUlEYs4s7CvQ1b1ta2DVPyAimb0I2qhzjNk+A+mxvll/klL0RlfuIUei8BF7YWiua4kQqww==", + "license": "MIT", + "dependencies": { + "@tufjs/canonical-json": "2.0.0", + "minimatch": "^10.1.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@types/cacheable-request": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", + "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", + "license": "MIT", + "dependencies": { + "@types/http-cache-semantics": "*", + "@types/keyv": "^3.1.4", + "@types/node": "*", + "@types/responselike": "^1.0.0" + } + }, + "node_modules/@types/google-protobuf": { + "version": "3.15.12", + "resolved": "https://registry.npmjs.org/@types/google-protobuf/-/google-protobuf-3.15.12.tgz", + "integrity": "sha512-40um9QqwHjRS92qnOaDpL7RmDK15NuZYo9HihiJRbYkMQZlWnuH8AdvbMy8/o6lgLmKbDUKa+OALCltHdbOTpQ==", + "license": "MIT" + }, + "node_modules/@types/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==", + "license": "MIT" + }, + "node_modules/@types/keyv": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", + "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "22.19.15", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", + "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/responselike": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", + "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", + "license": "MIT" + }, + "node_modules/@types/shimmer": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@types/shimmer/-/shimmer-1.2.0.tgz", + "integrity": "sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg==", + "license": "MIT" + }, + "node_modules/@types/tmp": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/@types/tmp/-/tmp-0.2.6.tgz", + "integrity": "sha512-chhaNf2oKHlRkDGt+tiKE2Z5aJ6qalm7Z9rlLdBwmOiAAf09YQvvoLXjWK4HWPF1xU/fqvMgfNfpVoBscA/tKA==", + "license": "MIT" + }, + "node_modules/abbrev": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", + "integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "license": "MIT", + "peer": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-import-attributes": { + "version": "1.9.5", + "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", + "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", + "license": "MIT", + "peerDependencies": { + "acorn": "^8" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "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/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/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/bin-links": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bin-links/-/bin-links-6.0.0.tgz", + "integrity": "sha512-X4CiKlcV2GjnCMwnKAfbVWpHa++65th9TuzAEYtZoATiOE2DQKhSp4CJlyLoTqdhBKlXjpXjCTYPNNFS33Fi6w==", + "license": "ISC", + "dependencies": { + "cmd-shim": "^8.0.0", + "npm-normalize-package-bin": "^5.0.0", + "proc-log": "^6.0.0", + "read-cmd-shim": "^6.0.0", + "write-file-atomic": "^7.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "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==", + "license": "MIT" + }, + "node_modules/cacache": { + "version": "20.0.3", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-20.0.3.tgz", + "integrity": "sha512-3pUp4e8hv07k1QlijZu6Kn7c9+ZpWWk4j3F8N3xPuCExULobqJydKYOTj1FTq58srkJsXvO7LbGAH4C0ZU3WGw==", + "license": "ISC", + "dependencies": { + "@npmcli/fs": "^5.0.0", + "fs-minipass": "^3.0.0", + "glob": "^13.0.0", + "lru-cache": "^11.1.0", + "minipass": "^7.0.3", + "minipass-collect": "^2.0.1", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "p-map": "^7.0.2", + "ssri": "^13.0.0", + "unique-filename": "^5.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/cacheable-lookup": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", + "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", + "license": "MIT", + "engines": { + "node": ">=10.6.0" + } + }, + "node_modules/cacheable-request": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", + "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", + "license": "MIT", + "dependencies": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^4.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^6.0.1", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cacheable-request/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/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/cjs-module-lexer": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", + "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", + "license": "MIT" + }, + "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/clone-response": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", + "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", + "license": "MIT", + "dependencies": { + "mimic-response": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cmd-shim": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/cmd-shim/-/cmd-shim-8.0.0.tgz", + "integrity": "sha512-Jk/BK6NCapZ58BKUxlSI+ouKRbjH1NLZCgJkYoab+vEHUY3f6OzpNBN9u7HFSv9J6TRDGs4PLOHezoKGaFRSCA==", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "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/common-ancestor-path": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/common-ancestor-path/-/common-ancestor-path-2.0.0.tgz", + "integrity": "sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">= 18" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cross-spawn/node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/cross-spawn/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "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==", + "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==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "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/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.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/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "license": "MIT" + }, + "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/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/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/exponential-backoff": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", + "license": "Apache-2.0" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/find-up-simple": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/find-up-simple/-/find-up-simple-1.0.1.tgz", + "integrity": "sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/fs-minipass": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-3.0.3.tgz", + "integrity": "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.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/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-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/google-protobuf": { + "version": "3.21.4", + "resolved": "https://registry.npmjs.org/google-protobuf/-/google-protobuf-3.21.4.tgz", + "integrity": "sha512-MnG7N936zcKTco4Jd2PX2U96Kf9PxygAPKBug+74LHzmHXmceN16MmRcdgZv+DGef/S9YvQAfRsNCn4cjf9yyQ==", + "license": "(BSD-3-Clause AND Apache-2.0)" + }, + "node_modules/got": { + "version": "11.8.6", + "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", + "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^4.0.0", + "@szmarczak/http-timer": "^4.0.5", + "@types/cacheable-request": "^6.0.1", + "@types/responselike": "^1.0.0", + "cacheable-lookup": "^5.0.3", + "cacheable-request": "^7.0.2", + "decompress-response": "^6.0.0", + "http2-wrapper": "^1.0.0-beta.5.2", + "lowercase-keys": "^2.0.0", + "p-cancelable": "^2.0.0", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=10.19.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, + "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==", + "license": "ISC" + }, + "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/hosted-git-info": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.2.tgz", + "integrity": "sha512-M422h7o/BR3rmCQ8UHi7cyyMqKltdP9Uo+J2fXK+RSAY+wTcKOIRyhTuKv4qn+DJf3g+PL890AzId5KZpX+CBg==", + "license": "ISC", + "dependencies": { + "lru-cache": "^11.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "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": "1.0.3", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", + "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", + "license": "MIT", + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.0.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/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ignore-walk": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-8.0.0.tgz", + "integrity": "sha512-FCeMZT4NiRQGh+YkeKMtWrOmBgWjHjMJ26WQWrRQyoyzqevdaGSakUaJW5xQYmjLlUVk2qUnCjYVBax9EKKg8A==", + "license": "ISC", + "dependencies": { + "minimatch": "^10.0.3" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/import-in-the-middle": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-1.15.0.tgz", + "integrity": "sha512-bpQy+CrsRmYmoPMAE/0G33iwRqwW4ouqdRg8jgbH3aKuCtOc8lxgmYXg2dMM92CRiGP660EtBcymH/eVUpCSaA==", + "license": "Apache-2.0", + "dependencies": { + "acorn": "^8.14.0", + "acorn-import-attributes": "^1.9.5", + "cjs-module-lexer": "^1.2.2", + "module-details-from-path": "^1.0.3" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/ini": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-2.0.0.tgz", + "integrity": "sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "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==", + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-5.0.0.tgz", + "integrity": "sha512-ZF1nxZ28VhQouRWhUcVlUIN3qwSgPuswK05s/HIaoetAoE/9tngVmCHjSxmSQPav1nd+lPtTL0YZ/2AFdR/iYQ==", + "license": "MIT", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/json-stringify-nice": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/json-stringify-nice/-/json-stringify-nice-1.1.4.tgz", + "integrity": "sha512-5Z5RFW63yxReJ7vANgW6eZFGWaQvnPE3WNmZoOJrSkGju2etKA2L5rrOa1sm877TVTFt57A80BH1bArcmlLfPw==", + "license": "ISC", + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jsonparse": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", + "integrity": "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==", + "engines": [ + "node >= 0.2.0" + ], + "license": "MIT" + }, + "node_modules/just-diff": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/just-diff/-/just-diff-6.0.2.tgz", + "integrity": "sha512-S59eriX5u3/QhMNq3v/gm8Kd0w8OS6Tz2FS1NG4blv+z0MuQcBRJyFWjdovM0Rad4/P4aUPFtnkNjMjyMlMSYA==", + "license": "MIT" + }, + "node_modules/just-diff-apply": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/just-diff-apply/-/just-diff-apply-5.5.0.tgz", + "integrity": "sha512-OYTthRfSh55WOItVqwpefPtNt2VdKsq5AnAK6apdtR6yCH8pr0CmSr710J0Mf+WdQy7K/OzMy7K2MgAfdQURDw==", + "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==", + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/lru-cache": { + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/make-fetch-happen": { + "version": "15.0.4", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-15.0.4.tgz", + "integrity": "sha512-vM2sG+wbVeVGYcCm16mM3d5fuem9oC28n436HjsGO3LcxoTI8LNVa4rwZDn3f76+cWyT4GGJDxjTYU1I2nr6zw==", + "license": "ISC", + "dependencies": { + "@gar/promise-retry": "^1.0.0", + "@npmcli/agent": "^4.0.0", + "cacache": "^20.0.1", + "http-cache-semantics": "^4.1.1", + "minipass": "^7.0.2", + "minipass-fetch": "^5.0.0", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^1.0.0", + "proc-log": "^6.0.0", + "ssri": "^13.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "license": "MIT" + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/mimic-response": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minipass-collect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-2.0.1.tgz", + "integrity": "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minipass-fetch": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-5.0.2.tgz", + "integrity": "sha512-2d0q2a8eCi2IRg/IGubCNRJoYbA1+YPXAzQVRFmB45gdGZafyivnZ5YSEfo3JikbjGxOdntGFvBQGqaSMXlAFQ==", + "license": "MIT", + "dependencies": { + "minipass": "^7.0.3", + "minipass-sized": "^2.0.0", + "minizlib": "^3.0.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + }, + "optionalDependencies": { + "iconv-lite": "^0.7.2" + } + }, + "node_modules/minipass-flush": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", + "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-flush/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-flush/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "node_modules/minipass-pipeline": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", + "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-pipeline/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-pipeline/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "node_modules/minipass-sized": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-2.0.0.tgz", + "integrity": "sha512-zSsHhto5BcUVM2m1LurnXY6M//cGhVaegT71OfOXoprxT6o780GZd792ea6FfrQkuU4usHZIUczAQMRUE2plzA==", + "license": "ISC", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/module-details-from-path": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz", + "integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==", + "license": "MIT" + }, + "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/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-gyp": { + "version": "12.2.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.2.0.tgz", + "integrity": "sha512-q23WdzrQv48KozXlr0U1v9dwO/k59NHeSzn6loGcasyf0UnSrtzs8kRxM+mfwJSf0DkX0s43hcqgnSO4/VNthQ==", + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^15.0.0", + "nopt": "^9.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "tar": "^7.5.4", + "tinyglobby": "^0.2.12", + "which": "^6.0.0" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/nopt": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz", + "integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==", + "license": "ISC", + "dependencies": { + "abbrev": "^4.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/normalize-package-data": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.2.tgz", + "integrity": "sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==", + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^7.0.0", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/normalize-package-data/node_modules/hosted-git-info": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", + "integrity": "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==", + "license": "ISC", + "dependencies": { + "lru-cache": "^10.0.1" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/normalize-package-data/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-bundled": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-5.0.0.tgz", + "integrity": "sha512-JLSpbzh6UUXIEoqPsYBvVNVmyrjVZ1fzEFbqxKkTJQkWBO3xFzFT+KDnSKQWwOQNbuWRwt5LSD6HOTLGIWzfrw==", + "license": "ISC", + "dependencies": { + "npm-normalize-package-bin": "^5.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm-install-checks": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/npm-install-checks/-/npm-install-checks-8.0.0.tgz", + "integrity": "sha512-ScAUdMpyzkbpxoNekQ3tNRdFI8SJ86wgKZSQZdUxT+bj0wVFpsEMWnkXP0twVe1gJyNF5apBWDJhhIbgrIViRA==", + "license": "BSD-2-Clause", + "dependencies": { + "semver": "^7.1.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm-normalize-package-bin": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-5.0.0.tgz", + "integrity": "sha512-CJi3OS4JLsNMmr2u07OJlhcrPxCeOeP/4xq67aWNai6TNWWbTrlNDgl8NcFKVlcBKp18GPj+EzbNIgrBfZhsag==", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm-package-arg": { + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-13.0.2.tgz", + "integrity": "sha512-IciCE3SY3uE84Ld8WZU23gAPPV9rIYod4F+rc+vJ7h7cwAJt9Vk6TVsK60ry7Uj3SRS3bqRRIGuTp9YVlk6WNA==", + "license": "ISC", + "dependencies": { + "hosted-git-info": "^9.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "validate-npm-package-name": "^7.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm-packlist": { + "version": "10.0.4", + "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-10.0.4.tgz", + "integrity": "sha512-uMW73iajD8hiH4ZBxEV3HC+eTnppIqwakjOYuvgddnalIw2lJguKviK1pcUJDlIWm1wSJkchpDZDSVVsZEYRng==", + "license": "ISC", + "dependencies": { + "ignore-walk": "^8.0.0", + "proc-log": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm-pick-manifest": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/npm-pick-manifest/-/npm-pick-manifest-11.0.3.tgz", + "integrity": "sha512-buzyCfeoGY/PxKqmBqn1IUJrZnUi1VVJTdSSRPGI60tJdUhUoSQFhs0zycJokDdOznQentgrpf8LayEHyyYlqQ==", + "license": "ISC", + "dependencies": { + "npm-install-checks": "^8.0.0", + "npm-normalize-package-bin": "^5.0.0", + "npm-package-arg": "^13.0.0", + "semver": "^7.3.5" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm-registry-fetch": { + "version": "19.1.1", + "resolved": "https://registry.npmjs.org/npm-registry-fetch/-/npm-registry-fetch-19.1.1.tgz", + "integrity": "sha512-TakBap6OM1w0H73VZVDf44iFXsOS3h+L4wVMXmbWOQroZgFhMch0juN6XSzBNlD965yIKvWg2dfu7NSiaYLxtw==", + "license": "ISC", + "dependencies": { + "@npmcli/redact": "^4.0.0", + "jsonparse": "^1.3.1", + "make-fetch-happen": "^15.0.0", + "minipass": "^7.0.2", + "minipass-fetch": "^5.0.0", + "minizlib": "^3.0.1", + "npm-package-arg": "^13.0.0", + "proc-log": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "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/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-cancelable": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", + "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/p-map": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", + "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-directory": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/package-directory/-/package-directory-8.2.0.tgz", + "integrity": "sha512-qJSu5Mo6tHmRxCy2KCYYKYgcfBdUpy9dwReaZD/xwf608AUk/MoRtIOWzgDtUeGeC7n/55yC3MI1Q+MbSoektw==", + "license": "MIT", + "dependencies": { + "find-up-simple": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pacote": { + "version": "21.5.0", + "resolved": "https://registry.npmjs.org/pacote/-/pacote-21.5.0.tgz", + "integrity": "sha512-VtZ0SB8mb5Tzw3dXDfVAIjhyVKUHZkS/ZH9/5mpKenwC9sFOXNI0JI7kEF7IMkwOnsWMFrvAZHzx1T5fmrp9FQ==", + "license": "ISC", + "dependencies": { + "@gar/promise-retry": "^1.0.0", + "@npmcli/git": "^7.0.0", + "@npmcli/installed-package-contents": "^4.0.0", + "@npmcli/package-json": "^7.0.0", + "@npmcli/promise-spawn": "^9.0.0", + "@npmcli/run-script": "^10.0.0", + "cacache": "^20.0.0", + "fs-minipass": "^3.0.0", + "minipass": "^7.0.2", + "npm-package-arg": "^13.0.0", + "npm-packlist": "^10.0.1", + "npm-pick-manifest": "^11.0.1", + "npm-registry-fetch": "^19.0.0", + "proc-log": "^6.0.0", + "sigstore": "^4.0.0", + "ssri": "^13.0.0", + "tar": "^7.4.3" + }, + "bin": { + "pacote": "bin/index.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/parse-conflict-json": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/parse-conflict-json/-/parse-conflict-json-5.0.1.tgz", + "integrity": "sha512-ZHEmNKMq1wyJXNwLxyHnluPfRAFSIliBvbK/UiOceROt4Xh9Pz0fq49NytIaeaCUf5VR86hwQ/34FCcNU5/LKQ==", + "license": "ISC", + "dependencies": { + "json-parse-even-better-errors": "^5.0.0", + "just-diff": "^6.0.0", + "just-diff-apply": "^5.2.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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==", + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/picomatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-3.0.1.tgz", + "integrity": "sha512-I3EurrIQMlRc9IaAZnqRR044Phh2DXY+55o7uJ0V+hYZAcQYSuFWsc9q5PvyDHUSCe1Qxn/iBz+78s86zWnGag==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss-selector-parser": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", + "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/proc-log": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", + "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/proggy": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/proggy/-/proggy-4.0.0.tgz", + "integrity": "sha512-MbA4R+WQT76ZBm/5JUpV9yqcJt92175+Y0Bodg3HgiXzrmKu7Ggq+bpn6y6wHH+gN9NcyKn3yg1+d47VaKwNAQ==", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/promise-all-reject-late": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/promise-all-reject-late/-/promise-all-reject-late-1.0.1.tgz", + "integrity": "sha512-vuf0Lf0lOxyQREH7GDIOUMLS7kz+gs8i6B+Yi8dC68a2sychGrHTJYghMBD6k7eUcH0H5P73EckCA48xijWqXw==", + "license": "ISC", + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/promise-call-limit": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/promise-call-limit/-/promise-call-limit-3.0.2.tgz", + "integrity": "sha512-mRPQO2T1QQVw11E7+UdCJu7S61eJVWknzml9sC1heAdj1jxl0fWMBypIt9ZOcLFf8FkG995ZD7RnVk7HH72fZw==", + "license": "ISC", + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "license": "MIT", + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/promise-retry/node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/protobufjs": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", + "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "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==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-cmd-shim": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/read-cmd-shim/-/read-cmd-shim-6.0.0.tgz", + "integrity": "sha512-1zM5HuOfagXCBWMN83fuFI/x+T/UhZ7k+KIzhrHXcQoeX5+7gmaDYjELQHmmzIodumBHeByBJT4QYS7ufAgs7A==", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "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/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-in-the-middle": { + "version": "7.5.2", + "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-7.5.2.tgz", + "integrity": "sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.5", + "module-details-from-path": "^1.0.3", + "resolve": "^1.22.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "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==", + "license": "MIT" + }, + "node_modules/responselike": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", + "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", + "license": "MIT", + "dependencies": { + "lowercase-keys": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT", + "optional": true + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shimmer": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/shimmer/-/shimmer-1.2.1.tgz", + "integrity": "sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw==", + "license": "BSD-2-Clause" + }, + "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==", + "license": "ISC" + }, + "node_modules/sigstore": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/sigstore/-/sigstore-4.1.0.tgz", + "integrity": "sha512-/fUgUhYghuLzVT/gaJoeVehLCgZiUxPCPMcyVNY0lIf/cTCz58K/WTI7PefDarXxp9nUKpEwg1yyz3eSBMTtgA==", + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^4.0.0", + "@sigstore/core": "^3.1.0", + "@sigstore/protobuf-specs": "^0.5.0", + "@sigstore/sign": "^4.1.0", + "@sigstore/tuf": "^4.0.1", + "@sigstore/verify": "^3.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "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/socks": { + "version": "2.8.7", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", + "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.0.1", + "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==", + "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==", + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/spdx-correct": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "license": "Apache-2.0", + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-correct/node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "license": "CC-BY-3.0" + }, + "node_modules/spdx-expression-parse": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-4.0.0.tgz", + "integrity": "sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ==", + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", + "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", + "license": "CC0-1.0" + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "license": "BSD-3-Clause" + }, + "node_modules/ssri": { + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-13.0.1.tgz", + "integrity": "sha512-QUiRf1+u9wPTL/76GTYlKttDEBWV1ga9ZXW8BG6kfdeyyM8LGPix9gROyg9V2+P0xNyF3X2Go526xKFdMZrHSQ==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "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/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/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tar": { + "version": "7.5.11", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.11.tgz", + "integrity": "sha512-ChjMH33/KetonMTAtpYdgUFr0tbz69Fp2v7zWxQfYZX4g5ZN2nOBXm1R2xyA+lMIKrLKIoKAwFj93jE/avX9cQ==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tmp": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", + "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/treeverse": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/treeverse/-/treeverse-3.0.0.tgz", + "integrity": "sha512-gcANaAnd2QDZFmHFEOF4k7uc1J/6a6z3DJMd/QwEyxLoKGiptJRwid582r7QIsFlFMIZ3SnxfS52S4hm2DHkuQ==", + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/tuf-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/tuf-js/-/tuf-js-4.1.0.tgz", + "integrity": "sha512-50QV99kCKH5P/Vs4E2Gzp7BopNV+KzTXqWeaxrfu5IQJBOULRsTIS9seSsOVT8ZnGXzCyx55nYWAi4qJzpZKEQ==", + "license": "MIT", + "dependencies": { + "@tufjs/models": "4.1.0", + "debug": "^4.4.3", + "make-fetch-happen": "^15.0.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "peer": true, + "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==", + "license": "MIT" + }, + "node_modules/unique-filename": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-5.0.0.tgz", + "integrity": "sha512-2RaJTAvAb4owyjllTfXzFClJ7WsGxlykkPvCr9pA//LD9goVq+m4PPAeBgNodGZ7nSrntT/auWpJ6Y5IFXcfjg==", + "license": "ISC", + "dependencies": { + "unique-slug": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/unique-slug": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-6.0.0.tgz", + "integrity": "sha512-4Lup7Ezn8W3d52/xBhZBVdx323ckxa7DEvd9kPQHppTkLoJXw6ltrBCyj5pnrxj0qKDxYMJ56CoxNuFCscdTiw==", + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/upath": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/upath/-/upath-1.2.0.tgz", + "integrity": "sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==", + "license": "MIT", + "engines": { + "node": ">=4", + "yarn": "*" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "license": "Apache-2.0", + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "node_modules/validate-npm-package-license/node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/validate-npm-package-name": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-7.0.2.tgz", + "integrity": "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/walk-up-path": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/walk-up-path/-/walk-up-path-4.0.0.tgz", + "integrity": "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==", + "license": "ISC", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/which": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", + "license": "ISC", + "dependencies": { + "isexe": "^4.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "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/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": "7.0.1", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-7.0.1.tgz", + "integrity": "sha512-OTIk8iR8/aCRWBqvxrzxR0hgxWpnYBblY1S5hDWBQfk/VFmJwzmJgQFN3WsoUKHISv2eAwe+PpbUzyL1CKTLXg==", + "license": "ISC", + "dependencies": { + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/write-file-atomic/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "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" + } + } + } +} diff --git a/infra/package.json b/infra/package.json new file mode 100644 index 00000000..827addda --- /dev/null +++ b/infra/package.json @@ -0,0 +1,18 @@ +{ + "name": "rds-infra", + "version": "1.0.0", + "description": "Pulumi infrastructure for the RDS app", + "main": "index.ts", + "scripts": { + "build": "tsc" + }, + "dependencies": { + "@pulumi/azure-native": "^2.0.0", + "@pulumi/pulumi": "^3.0.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "tsx": "^4.19.2", + "typescript": "^5.0.0" + } +} diff --git a/infra/tsconfig.json b/infra/tsconfig.json new file mode 100644 index 00000000..1864d09c --- /dev/null +++ b/infra/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "strict": true, + "outDir": "bin", + "target": "es2020", + "module": "commonjs", + "moduleResolution": "node", + "sourceMap": true, + "experimentalDecorators": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true + }, + "files": ["index.ts"] +} diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 00000000..21ae876a --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,70 @@ +site_name: RDS App Documentation +site_url: https://ssdlab.github.io/respondent-driven-sampling/ +repo_url: https://github.com/SSDALab/respondent-driven-sampling +repo_name: SSDALab/respondent-driven-sampling +docs_dir: docs + +theme: + name: material + palette: + - scheme: default + primary: indigo + accent: indigo + toggle: + icon: material/brightness-7 + name: Switch to dark mode + - scheme: slate + primary: indigo + accent: indigo + toggle: + icon: material/brightness-4 + name: Switch to light mode + features: + - navigation.tabs + - navigation.sections + - navigation.top + - search.highlight + - content.code.copy + +markdown_extensions: + - admonition + - pymdownx.details + - pymdownx.superfences + - pymdownx.highlight: + anchor_linenums: true + - pymdownx.tabbed: + alternate_style: true + - toc: + permalink: true + +nav: + - Home: index.md + - About: + - Project Overview: about/project-overview.md + - RDS Methodology: about/rds-methodology.md + - Operations: about/operations.md + - License: about/license.md + - Getting Started: + - Getting Started: getting-started/getting-started.md + + - How-To: + - Deployment: how-to/deployment.md + - Infrastructure: how-to/infrastructure.md + - Setting Up a Survey: how-to/setting-up-a-survey.md + - Adding Survey Locations: how-to/adding-survey-locations.md + - Debugging: how-to/debugging.md + - Database & Security: how-to/database-and-security.md + - Post-Survey Analysis: how-to/analysis.md + - CI / Workflows: how-to/ci.md + - Reference: + - Architecture: reference/architecture.md + - Environment Variables: reference/environment-variables.md + - CLI Scripts: + - Overview: reference/cli-scripts.md + - super-admin: reference/cli-super-admin.md + - location: reference/cli-location.md + - generate-seeds: reference/cli-generate-seeds.md + - generate-coupons: reference/cli-generate-coupons.md + - API Overview: reference/api.md + - Database I/O: reference/database-io.md + - Glossary: reference/glossary.md diff --git a/server/.env.example b/server/.env.example index ca2574e2..f32ed2bf 100644 --- a/server/.env.example +++ b/server/.env.example @@ -1,3 +1,5 @@ +# Copy this file to server/.env (same directory as package.json) +# then fill in values from your MongoDB Atlas and Twilio accounts. NODE_ENV=development MONGO_URI= MONGO_DB_NAME= @@ -7,7 +9,12 @@ TWILIO_AUTH_TOKEN="" ## Purpose: Uniquely identifies a Twilio Verify service; required for API operations like sending or checking verification codes. ## Format: Starts with "VA" followed by a string of alphanumeric characters. ## Location: Found in the Twilio Console under Verify services. -TWILIO_VERIFY_SID="" +TWILIO_VERIFY_SID="" +# TWILIO_PHONE_NUMBER +## Purpose: The "from" phone number for sending SMS messages. Must be a Twilio-purchased number. +## Format: E.164 format, e.g., "+12065551234" +## Location: Found in the Twilio Console under Phone Numbers > Manage > Active Numbers +TWILIO_PHONE_NUMBER="" AUTH_SECRET="" # Timezone for date handling # Common options: diff --git a/server/package-lock.json b/server/package-lock.json index 81262b8c..8f63b5d0 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -17,6 +17,7 @@ "bcryptjs": "^3.0.1", "compression": "^1.8.0", "cors": "^2.8.5", + "csv-parse": "^6.1.0", "dotenv": "^16.4.7", "express": "^4.21.2", "helmet": "^8.1.0", @@ -4332,6 +4333,12 @@ "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==", "license": "MIT" }, + "node_modules/csv-parse": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-6.1.0.tgz", + "integrity": "sha512-CEE+jwpgLn+MmtCpVcPtiCZpVtB6Z2OKPTr34pycYYoL7sxdOkXDdQ4lRiw6ioC0q6BLqhc6cKweCVvral8yhw==", + "license": "MIT" + }, "node_modules/dayjs": { "version": "1.11.19", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", diff --git a/server/package.json b/server/package.json index c4b4084d..9fd86626 100644 --- a/server/package.json +++ b/server/package.json @@ -16,7 +16,8 @@ "super-admin": "tsx --env-file=.env src/scripts/superAdminCRUD.ts", "location": "tsx --env-file=.env src/scripts/locationCRUD.ts", "generate-seeds": "tsx --env-file=.env src/scripts/generateSeeds.ts", - "generate-coupons": "tsx --env-file=.env src/scripts/generateCoupons.ts" + "generate-coupons": "tsx --env-file=.env src/scripts/generateCoupons.ts", + "sms": "tsx --env-file=.env src/scripts/smsCRUD.ts" }, "keywords": [], "author": "", @@ -31,6 +32,7 @@ "bcryptjs": "^3.0.1", "compression": "^1.8.0", "cors": "^2.8.5", + "csv-parse": "^6.1.0", "dotenv": "^16.4.7", "express": "^4.21.2", "helmet": "^8.1.0", diff --git a/server/src/database/survey/survey.controller.ts b/server/src/database/survey/survey.controller.ts index ebfde384..f4c9ecf3 100644 --- a/server/src/database/survey/survey.controller.ts +++ b/server/src/database/survey/survey.controller.ts @@ -48,7 +48,7 @@ function isUniqueSurveyCodeArray(codes: Array): boolean { } /** - * Generates a random referral code consisting of 8 hexadecimal characters. + * Generates a random coupon code consisting of 8 hexadecimal characters. * The code is case-insensitive and can be used for tracking referrals in a system. * Guaranteed to be unique across all surveys within the database. * @returns string - A unique 8-character hexadecimal code in uppercase diff --git a/server/src/middleware/auth.ts b/server/src/middleware/auth.ts index 7ff4ded1..4b710d99 100644 --- a/server/src/middleware/auth.ts +++ b/server/src/middleware/auth.ts @@ -43,7 +43,8 @@ export async function auth( // This case means that the user has a valid JWT signed by our server but // the account it is linked to does not exist in our database. res.status(400).json({ - message: 'User account not found. Please contact your admin.' + message: + 'User account not found. Please contact Administration.' }); return; } @@ -52,7 +53,7 @@ export async function auth( if (user.approvalStatus !== ApprovalStatus.APPROVED) { res.status(403).json({ message: - 'User account not approved yet. Please contact your admin.' + 'User account not approved yet. Please contact Administration.' }); return; } diff --git a/server/src/routes/locations.ts b/server/src/routes/locations.ts index b1134add..110d23ce 100644 --- a/server/src/routes/locations.ts +++ b/server/src/routes/locations.ts @@ -35,7 +35,7 @@ router.get('/', async (req: Request, res: Response, next: NextFunction) => { try { const result = await Location.find({ $and: [req.query] - }); + }).sort({ hubName: 1 }); res.status(200).json({ message: 'Locations fetched successfully', data: result.map(item => item.toObject()) diff --git a/server/src/routes/surveys.ts b/server/src/routes/surveys.ts index a10ac247..a404ccce 100644 --- a/server/src/routes/surveys.ts +++ b/server/src/routes/surveys.ts @@ -229,8 +229,7 @@ router.post( ) ) { return res.status(403).json({ - message: - 'Please provide a referral code to create a survey.' + message: 'Please provide a coupon code to create a survey.' }); } } else { diff --git a/server/src/routes/users.ts b/server/src/routes/users.ts index 02e0092b..8cabb0b2 100644 --- a/server/src/routes/users.ts +++ b/server/src/routes/users.ts @@ -96,24 +96,28 @@ router.get( return res.status(403).json({ message: 'Forbidden' }); } try { - const result = await User.findOne({ - _id: req.params.objectId, - ...accessibleBy(req.authorization, ACTIONS.CASL.READ).ofType( - User.modelName - ) // Dynamic filter for handling custom permissions + const result = await User.findById(req.params.objectId); + if (!result) { + return res.status(404).json({ message: 'User not found' }); + } + const userObject = result.toObject(); + if ( + !req.authorization?.can( + ACTIONS.CASL.READ, + subject(SUBJECTS.USER, userObject) + ) + ) { + return res.status(404).json({ message: 'User not found' }); + } + res.status(200).json({ + message: 'User fetched successfully', + data: userObject, + serverTimezone: DEFAULT_TIMEZONE }); - if (!result) { - return res.status(404).json({ message: 'User not found' }); + } catch (err) { + next(err); } - res.status(200).json({ - message: 'User fetched successfully', - data: result.toObject(), - serverTimezone: DEFAULT_TIMEZONE - }); - } catch (err) { - next(err); } -} ); /** diff --git a/server/src/routes/validateReferralCode.ts b/server/src/routes/validateReferralCode.ts index d3a40704..75a4f380 100644 --- a/server/src/routes/validateReferralCode.ts +++ b/server/src/routes/validateReferralCode.ts @@ -15,7 +15,7 @@ const validateReferralCodeSchema = z .string() .length( SURVEY_CODE_LENGTH, - `Referral code must be exactly ${SURVEY_CODE_LENGTH} characters` + `Coupon code must be exactly ${SURVEY_CODE_LENGTH} characters` ) }) .strict() @@ -78,7 +78,7 @@ router.get( } res.status(200).json({ isValid: true, - message: 'Referral code is valid' + message: 'Coupon code is valid' }); } catch (error) { next(error); diff --git a/server/src/scripts/generateSeeds.ts b/server/src/scripts/generateSeeds.ts index 955843a5..8cdf5f88 100644 --- a/server/src/scripts/generateSeeds.ts +++ b/server/src/scripts/generateSeeds.ts @@ -58,7 +58,7 @@ async function generateQRCodeBuffer( surveyCode: string, qrSize: number ): Promise { - // Encode only the referral code (no URL) so QR codes work across any deployment + // Encode only the coupon code (no URL) so QR codes work across any deployment const qrDataUrl = await QRCode.toDataURL(surveyCode, { width: qrSize, margin: 1, @@ -142,7 +142,7 @@ async function addQRCodePage( currentY += 50; - // QR Code and Referral Code + // QR Code and Coupon Code const qrSize = 100; const qrX = (pageWidth - qrSize) / 2; @@ -156,7 +156,7 @@ async function addQRCodePage( doc.fontSize(16) .font('Helvetica-Bold') - .text(`Referral Code: ${surveyCode}`, margin, currentY, { + .text(`Coupon Code: ${surveyCode}`, margin, currentY, { align: 'center', width: contentWidth }); diff --git a/server/src/scripts/gift-cards/.gitignore b/server/src/scripts/gift-cards/.gitignore new file mode 100644 index 00000000..be342a9a --- /dev/null +++ b/server/src/scripts/gift-cards/.gitignore @@ -0,0 +1,4 @@ +# Ignore all files in this directory (gift card/recipient CSVs contain PII) +* +# Except this file +!.gitignore diff --git a/server/src/scripts/locations.yaml b/server/src/scripts/locations.yaml new file mode 100644 index 00000000..34139bf1 --- /dev/null +++ b/server/src/scripts/locations.yaml @@ -0,0 +1,139 @@ +# KCRHA Point-in-Time Count Survey Locations +# Import with: npm run location -- import src/scripts/locations.yaml + +# Seattle Locations +- hubName: 'Opportunity Center at North Seattle College' + hubType: ESTABLISHMENT + locationType: ROOFTOP + address: '9600 College Way N, Seattle, WA 98103' + +- hubName: 'YouthCare Orion Center (Use Entrance on Stewart St.)' + hubType: ESTABLISHMENT + locationType: ROOFTOP + address: '1828 Yale Ave, Seattle, WA 98101' + +- hubName: 'Lake City Library' + hubType: ESTABLISHMENT + locationType: ROOFTOP + address: '12501 28th Ave NE, Seattle, WA 98125' + +- hubName: 'Compass Day Center' + hubType: ESTABLISHMENT + locationType: ROOFTOP + address: '210 Alaskan Way , Seattle, WA 98104' + +- hubName: 'SVdP Georgetown Foodbank' + hubType: ESTABLISHMENT + locationType: ROOFTOP + address: '5972 4th Ave S, Seattle, WA 98108' + +- hubName: 'St. James Cathedral' + hubType: ESTABLISHMENT + locationType: ROOFTOP + address: '804 9th Ave, Seattle, WA 98104' + +- hubName: 'South Lucile Street VA Center' + hubType: ESTABLISHMENT + locationType: ROOFTOP + address: '305 S Lucile St, STE 103, Seattle, WA 98104' + +- hubName: 'Allen Family Center (Families with Children Only)' + hubType: ESTABLISHMENT + locationType: ROOFTOP + address: '3190 Martin Luther King Jr Way S, Seattle, WA 98144' + +- hubName: 'Southwest Library' + hubType: ESTABLISHMENT + locationType: ROOFTOP + address: '9010 35th Ave SW, Seattle, WA 98126' + +- hubName: 'South Park Library' + hubType: ESTABLISHMENT + locationType: ROOFTOP + address: '8604 8th Ave S, Seattle, WA 98108' + +# North King County Locations +- hubName: 'Shoreline Library' + hubType: ESTABLISHMENT + locationType: ROOFTOP + address: '345 NE 175th St, Shoreline, WA 98155' + +- hubName: 'Ronald United Methodist Church' + hubType: ESTABLISHMENT + locationType: ROOFTOP + address: '17839 Aurora Ave N, Shoreline, WA 98133' + +# East King County Locations +- hubName: 'Overlake Christian Church' + hubType: ESTABLISHMENT + locationType: ROOFTOP + address: '9900 Willows Rd. NE, Redmond, WA 98052' + +- hubName: 'Kirkland Library' + hubType: ESTABLISHMENT + locationType: ROOFTOP + address: '308 Kirkland Ave, Kirkland, WA 98033' + +- hubName: 'Issaquah Community Hall' + hubType: ESTABLISHMENT + locationType: ROOFTOP + address: '180 E Sunset Way, Issaquah, WA 98027' + +- hubName: 'Bellevue Library' + hubType: ESTABLISHMENT + locationType: ROOFTOP + address: '111 110th Ave NE, Bellevue, WA 98004' + +# Snoqualmie Valley Locations +- hubName: 'Reclaim' + hubType: ESTABLISHMENT + locationType: ROOFTOP + address: '8224 Railroad Ave, Snoqualmie, WA 98065' + +- hubName: 'North Bend Library' + hubType: ESTABLISHMENT + locationType: ROOFTOP + address: '115 E 4th St, North Bend, WA 98045' + +# Southeast King County Locations +- hubName: 'Plateau Outreach Ministries' + hubType: ESTABLISHMENT + locationType: ROOFTOP + address: '1806 Cole St, Enumclaw, WA 98022' + +- hubName: 'Maple Valley Food Bank' + hubType: ESTABLISHMENT + locationType: ROOFTOP + address: '21415 Renton-Maple Valley Rd, Maple Valley, WA 98038' + +# South King County Locations +- hubName: 'Renton Highlands Library' + hubType: ESTABLISHMENT + locationType: ROOFTOP + address: '2801 NE 10th St, Renton, WA 98056' + +- hubName: 'Kent Library' + hubType: ESTABLISHMENT + locationType: ROOFTOP + address: '212 2nd Ave N, Kent, WA 98032' + +- hubName: 'Highline United Methodist Church' + hubType: ESTABLISHMENT + locationType: ROOFTOP + address: '13015 1st Ave S, Burien, WA 98168' + +- hubName: 'Federal Way Library' + hubType: ESTABLISHMENT + locationType: ROOFTOP + address: '34200 1st Way S, Federal Way, WA 98003' + +# Vashon Locations +- hubName: 'Vashon Island Library' + hubType: ESTABLISHMENT + locationType: ROOFTOP + address: '17210 Vashon Hwy SW, Vashon, WA 98070' + +- hubName: 'Vashon Maury Community Food Bank' + hubType: ESTABLISHMENT + locationType: ROOFTOP + address: '10030 SW 210th St, Vashon, WA 98070' diff --git a/server/src/scripts/sms-logs/.gitignore b/server/src/scripts/sms-logs/.gitignore new file mode 100644 index 00000000..664d49e3 --- /dev/null +++ b/server/src/scripts/sms-logs/.gitignore @@ -0,0 +1,4 @@ +# Ignore all files in this directory (CSV logs contain PII) +* +# Except this file +!.gitignore diff --git a/server/src/scripts/sms-templates/gift_card.yaml b/server/src/scripts/sms-templates/gift_card.yaml new file mode 100644 index 00000000..a0b1a180 --- /dev/null +++ b/server/src/scripts/sms-templates/gift_card.yaml @@ -0,0 +1,6 @@ +# SMS template for gift card delivery notifications +# Variables: {surveyCode} is auto-populated from survey data +name: gift_card +description: Notify respondents about gift card delays +body: "Hi {surveyCode}, thank you for your participation in the PIT Count!\nWe are working on your gift cards and will send them via email and text this week.\nWe apologize for the delay and appreciate your patience!\n\nKing County Regional Homelessness Authority" + diff --git a/server/src/scripts/sms-templates/gift_card_redeem.yaml b/server/src/scripts/sms-templates/gift_card_redeem.yaml new file mode 100644 index 00000000..1716c33f --- /dev/null +++ b/server/src/scripts/sms-templates/gift_card_redeem.yaml @@ -0,0 +1,5 @@ +# SMS template for gift card redemption instructions +# Variables: {surveyCode}, {amount}, {rewardCode} +name: gift_card_redeem +description: Send gift card redemption code and instructions to respondents +body: "Dear Participant {surveyCode},\nThank you for taking the time to complete a survey as part of the 2026 Point in Time (PIT) count. Enclosed are instructions and your code to redeem your gift card. This card has a value of {amount}\n---\nGo to: MyPrepaidCenter.com/\nEnter the code below and follow the prompts.\n{rewardCode}" diff --git a/server/src/scripts/smsCRUD.ts b/server/src/scripts/smsCRUD.ts new file mode 100644 index 00000000..0d900095 --- /dev/null +++ b/server/src/scripts/smsCRUD.ts @@ -0,0 +1,1051 @@ +#!/usr/bin/env tsx + +/** + * Script to send automated SMS messages via Twilio + * Usage: npm run sms -- [args] + * + * Operations: + * list-recipients [--location ] + * List all survey respondents who provided a phone number. + * Example: npm run sms -- list-recipients + * Example: npm run sms -- list-recipients --location 507f1f77bcf86cd799439011 + * + * send --template [--var key=value ...] + * Send a single SMS using a YAML template with variable substitution. + * Example: npm run sms -- send "(206) 555-1234" --template gift_card --var surveyCode=ABC123 + * + * send --body "raw message text" + * Send a single SMS with a raw message body (no template). + * Example: npm run sms -- send "+15551234567" --body "Hello from the PIT Count team!" + * + * send-bulk --template [--location ] [--dry-run] + * Send SMS to all respondents who provided a phone number, using a template. + * Variables (surveyCode, etc.) are auto-populated from survey data. + * Example: npm run sms -- send-bulk --template gift_card + * Example: npm run sms -- send-bulk --template gift_card --dry-run + * Example: npm run sms -- send-bulk --template gift_card --location 507f1f77bcf86cd799439011 + * + * send-csv --file --template [--dry-run] + * Send SMS to recipients listed in an external CSV file. + * CSV must have columns: surveyCode, phone, amount, Reward Code. + * Deduplicates rows, skips rows with amount=0 or empty Reward Code. + * Does NOT require a database connection. + * Example: npm run sms -- send-csv --file ~/data/recipients.csv --template gift_card_redeem + * Example: npm run sms -- send-csv --file ~/data/recipients.csv --template gift_card_redeem --dry-run + * + * logs + * List all CSV log files in sms-logs/ directory with row counts. + * Example: npm run sms -- logs + * + * fetch-logs [--date YYYY-MM-DD] + * Recover a send log by fetching all outbound messages from Twilio API. + * Filters out inbound replies (direction === outbound-api only). + * Writes sms-log-recovered-DATE.csv. Use --date to limit to a single day. + * Example: npm run sms -- fetch-logs --date 2026-02-19 + * + * check-status [--log-file ] + * Fetch the latest delivery status from Twilio for every record in log files. + * Creates an enriched CSV per source log with updated status + dateSent, price, + * priceUnit, errorCode, errorMessage columns. Original log is preserved. + * Prints a delivery stats summary per file. + * --log-file limits the check to a single file. + * Example: npm run sms -- check-status + * Example: npm run sms -- check-status --log-file sms-log-2026-02-19-gift_card_notice.csv + */ +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { randomBytes } from 'crypto'; +import mongoose from 'mongoose'; +import { parse as parseYaml } from 'yaml'; +import { parse as parseCsv } from 'csv-parse/sync'; + +import connectDB from '@/database'; +import Survey from '@/database/survey/mongoose/survey.model'; +import { + sendSms, + normalizePhoneToE164, + interpolateTemplate, + fetchMessageStatus, + listOutboundMessages, + TWILIO_LIST_LIMIT +} from '@/services/twilio'; +import { + CsvSmsLogger, + UpdatedCsvSmsLogger, + listLogFiles +} from '@/services/smsLogger'; +// `import type` is a TypeScript-only import β€” it imports only the type +// definitions (interfaces) for compile-time type checking. These imports are +// completely removed from the compiled JavaScript output, so they add zero +// runtime overhead. +import type { SmsRecord, UpdatedSmsRecord } from '@/services/smsLogger'; + +// ===== Template Loading ===== + +/** Shape of a YAML SMS template file (e.g. sms-templates/gift_card.yaml). */ +interface SmsTemplate { + name: string; + description: string; + /** Message body with {varName} placeholders for variable substitution. */ + body: string; +} + +// ESM path resolution β€” see smsLogger.ts for detailed explanation. +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +/** Directory containing YAML SMS template files. */ +const TEMPLATES_DIR = path.join(__dirname, 'sms-templates'); + +/** + * Load and parse a YAML SMS template file by name. + * @param templateName - Template name without extension (e.g. "gift_card"). + * @returns Parsed template with name, description, and body fields. + * @throws Error if the template file doesn't exist or is missing required fields. + */ +function loadTemplate(templateName: string): SmsTemplate { + const filePath = path.join(TEMPLATES_DIR, `${templateName}.yaml`); + + if (!fs.existsSync(filePath)) { + const available = fs + .readdirSync(TEMPLATES_DIR) + .filter(f => f.endsWith('.yaml')) + .map(f => f.replace('.yaml', '')); + throw new Error( + `Template "${templateName}" not found at ${filePath}\n` + + `Available templates: ${available.length > 0 ? available.join(', ') : '(none)'}` + ); + } + + const content = fs.readFileSync(filePath, 'utf-8'); + const parsed = parseYaml(content) as SmsTemplate; + + if (!parsed.name || !parsed.body) { + throw new Error( + `Template "${templateName}" is missing required fields (name, body)` + ); + } + + return parsed; +} + +// ===== Recipient Queries ===== + +/** A survey respondent who provided a phone number. */ +interface Recipient { + phone: string; + surveyCode: string; + surveyObjectId: string; +} + +/** + * Query the database for survey respondents who provided a phone number. + * Optionally filter by location. + * + * @param locationObjectId - MongoDB ObjectId string to filter by location. Optional. + * @returns Array of recipients with phone, surveyCode, and surveyObjectId. + */ +async function getRecipients(locationObjectId?: string): Promise { + // Mongoose query object β€” uses MongoDB query operators: + // $exists: true β€” the field must be present in the document + // $ne: '' β€” the field must not equal empty string + // deletedAt: null β€” soft-delete filter (only include non-deleted surveys) + // Record is a TypeScript type meaning "object with string + // keys and values of any type" β€” needed because we dynamically add keys below. + const query: Record = { + 'responses.phone': { $exists: true, $ne: '' }, + deletedAt: null + }; + + if (locationObjectId) { + // Convert the string ID to a MongoDB ObjectId for the query + query.locationObjectId = new mongoose.Types.ObjectId(locationObjectId); + } + + // .select() limits which fields MongoDB returns (projection): + // 1 means "include this field", everything else is excluded (except _id + // which is always included unless explicitly set to 0). + const surveys = await Survey.find(query).select({ + 'responses.phone': 1, + surveyCode: 1, + _id: 1 + }); + + return surveys.map(s => ({ + // `as Record` is a type assertion β€” tells TypeScript to + // treat s.responses as a string-keyed object so we can access .phone + phone: (s.responses as Record).phone, + surveyCode: s.surveyCode, + surveyObjectId: s._id.toString() + })); +} + +// ===== Operations ===== + +async function listRecipients(locationObjectId?: string): Promise { + console.log('\nπŸ“± Listing survey respondents with phone numbers...\n'); + + const recipients = await getRecipients(locationObjectId); + + if (recipients.length === 0) { + console.log('No respondents with phone numbers found.'); + return; + } + + console.log(`Found ${recipients.length} respondent(s):\n`); + + for (const r of recipients) { + console.log(` ${r.phone} (surveyCode: ${r.surveyCode})`); + } + console.log(''); +} + +/** + * Send a single SMS to one phone number using either a template or raw body text. + * Logs the result to sms-log-single.csv. + * + * @param phone - Phone number in any common US format (will be normalized to E.164). + * @param options - Must include either `templateName` or `body` (not both). + * `vars` provides template variable substitutions (e.g. { surveyCode: "ABC123" }). + */ +async function sendSingle( + phone: string, + options: { + templateName?: string; + body?: string; + vars: Record; + } +): Promise { + console.log('\nπŸ“€ Sending single SMS...\n'); + + let messageBody: string; + let templateName: string; + + if (options.templateName) { + const template = loadTemplate(options.templateName); + templateName = template.name; + messageBody = interpolateTemplate(template.body, options.vars); + } else if (options.body) { + templateName = '(raw)'; + messageBody = options.body; + } else { + throw new Error('Either --template or --body must be provided'); + } + + const normalizedPhone = normalizePhoneToE164(phone); + console.log(` To: ${phone} β†’ ${normalizedPhone}`); + console.log(` Template: ${templateName}`); + console.log(` Message: ${messageBody}`); + console.log(''); + + const result = await sendSms(normalizedPhone, messageBody); + + console.log(` βœ“ SMS sent! Twilio SID: ${result.sid}`); + console.log(` Status: ${result.status}`); + if (result.errorCode) { + console.log(` Error: ${result.errorCode} - ${result.errorMessage}`); + } + + // Log to CSV + const logger = new CsvSmsLogger('sms-log-single.csv'); + const record: SmsRecord = { + surveyCode: options.vars.surveyCode ?? '', + phone: normalizedPhone, + templateName, + smsText: messageBody, + datetime: new Date().toISOString(), + status: result.status, + twilioSid: result.sid, + numSegments: result.numSegments + }; + await logger.log(record); + console.log(` Logged to: ${logger.getLogFilePath()}`); +} + +/** + * Send SMS to all survey respondents who provided a phone number. + * Supports --dry-run mode to preview messages without actually sending. + * + * Rate-limits sends to 1 message per 1.1 seconds because Twilio long-code + * numbers (standard 10-digit phone numbers) are limited to 1 SMS/second. + * Exceeding this rate causes Twilio to queue and potentially drop messages. + * + * @param options.templateName - Name of the YAML template to use. + * @param options.locationObjectId - Optional MongoDB ObjectId to filter recipients by location. + * @param options.dryRun - If true, preview messages without sending. + */ +async function sendBulk(options: { + templateName: string; + locationObjectId?: string; + dryRun: boolean; +}): Promise { + const mode = options.dryRun ? 'πŸ” DRY RUN' : 'πŸ“€ SENDING'; + console.log(`\n${mode} β€” Bulk SMS...\n`); + + const template = loadTemplate(options.templateName); + const recipients = await getRecipients(options.locationObjectId); + + if (recipients.length === 0) { + console.log('No respondents with phone numbers found. Nothing to send.'); + return; + } + + console.log(`Template: ${template.name}`); + console.log(`Recipients: ${recipients.length}`); + console.log(`Message template: ${template.body}`); + console.log(''); + + if (options.dryRun) { + console.log('--- Dry run preview ---\n'); + for (const r of recipients) { + try { + const normalizedPhone = normalizePhoneToE164(r.phone); + const variables: Record = { + surveyCode: r.surveyCode + }; + const renderedBody = interpolateTemplate( + template.body, + variables + ); + console.log( + ` βœ“ ${r.phone} β†’ ${normalizedPhone}: "${renderedBody}"` + ); + } catch (err) { + console.log( + ` βœ— ${r.phone}: ${err instanceof Error ? err.message : err}` + ); + } + } + console.log( + `\n--- Dry run complete. ${recipients.length} message(s) would be sent. ---` + ); + return; + } + + // Actual send + const batchId = randomBytes(8).toString('hex').toUpperCase(); + const date = new Date().toISOString().split('T')[0]; + const logFilename = `sms-log-${date}-${template.name}.csv`; + const logger = new CsvSmsLogger(logFilename); + + let successCount = 0; + let failCount = 0; + + for (let i = 0; i < recipients.length; i++) { + const r = recipients[i]; + const index = i + 1; + + try { + const normalizedPhone = normalizePhoneToE164(r.phone); + const variables: Record = { + surveyCode: r.surveyCode + }; + const renderedBody = interpolateTemplate( + template.body, + variables + ); + + const result = await sendSms(normalizedPhone, renderedBody); + + const record: SmsRecord = { + surveyCode: r.surveyCode, + phone: normalizedPhone, + templateName: template.name, + smsText: renderedBody, + datetime: new Date().toISOString(), + status: result.status, + twilioSid: result.sid, + numSegments: result.numSegments + }; + await logger.log(record); + + successCount++; + console.log( + ` βœ“ [${index}/${recipients.length}] ${r.phone} β†’ ${normalizedPhone} (SID: ${result.sid})` + ); + + if (result.errorCode) { + console.log( + ` Warning: ${result.errorCode} - ${result.errorMessage}` + ); + } + } catch (err) { + failCount++; + const errorMessage = + err instanceof Error ? err.message : String(err); + console.log( + ` βœ— [${index}/${recipients.length}] ${r.phone}: ${errorMessage}` + ); + + // Log failures too + const record: SmsRecord = { + surveyCode: r.surveyCode, + phone: r.phone, + templateName: template.name, + smsText: '', + datetime: new Date().toISOString(), + status: 'failed', + twilioSid: '', + numSegments: '' + }; + await logger.log(record); + } + + // Rate limiting: 1.1s delay between messages (Twilio long-code limit: 1 msg/sec) + if (i < recipients.length - 1) { + await new Promise(resolve => setTimeout(resolve, 1100)); + } + } + + console.log('\n' + '='.repeat(50)); + console.log('Bulk SMS Summary:'); + console.log(` Batch ID: ${batchId}`); + console.log(` βœ“ Sent: ${successCount}`); + console.log(` βœ— Failed: ${failCount}`); + console.log(` Log file: ${logger.getLogFilePath()}`); +} + +// ===== CSV-based Sending ===== +// This section handles sending SMS from an external CSV file (e.g. exported +// from a gift card vendor). Unlike send-bulk which queries the database, +// send-csv reads recipients from a CSV and does NOT require a DB connection. + +/** Shape of a single row in the external recipient CSV file. */ +interface CsvRecipient { + surveyCode: string; + phone: string; + /** Dollar amount as a string (e.g. "25"). Rows with amount=0 are skipped. */ + amount: string; + /** Gift card redemption code. Rows with empty codes are skipped. */ + rewardCode: string; +} + +/** + * Parse an external CSV file into CsvRecipient objects. + * Handles BOM stripping, column mapping, deduplication, and filtering. + * + * @param filePath - Absolute or relative path to the CSV file. + * @returns Deduplicated, filtered array of recipients. + */ +function parseCsvFile(filePath: string): CsvRecipient[] { + if (!fs.existsSync(filePath)) { + throw new Error(`CSV file not found: ${filePath}`); + } + + let content = fs.readFileSync(filePath, 'utf-8'); + // Strip UTF-8 BOM (Byte Order Mark) if present. + // BOM is an invisible character (U+FEFF) that Excel and some Windows tools + // prepend to CSV files. If not stripped, it becomes part of the first column + // header and breaks column-name lookups (e.g. "\uFEFFsurveyCode" !== "surveyCode"). + // 0xFEFF is the Unicode code point for the BOM character. + if (content.charCodeAt(0) === 0xfeff) { + content = content.slice(1); + } + // Parse the CSV content. See smsLogger.ts for explanation of csv-parse options. + // `trim: true` strips whitespace from each field value. + const rawRows = parseCsv(content, { + columns: true, + skip_empty_lines: true, + trim: true, + relax_column_count: true, + cast: false + }) as Record[]; + + // Map CSV column headers to our internal field names. + // The `??` (nullish coalescing) chains handle varying column header names + // across different CSV exports (e.g. "Reward Code" vs "RewardCode"). + const mapped = rawRows.map(row => ({ + surveyCode: row['surveyCode'] ?? '', + phone: row['phone'] ?? '', + amount: row['amount'] ?? '', + rewardCode: row['Reward Code'] ?? row['RewardCode'] ?? row['rewardCode'] ?? '' + })); + + // Deduplicate exact duplicate rows + const seen = new Set(); + const deduped = mapped.filter(r => { + const key = `${r.surveyCode}|${r.phone}|${r.amount}|${r.rewardCode}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + + // Filter out rows where amount is zero/invalid or Reward Code is empty + return deduped.filter(r => Number(r.amount) > 0 && r.rewardCode !== ''); +} + +/** + * Format a numeric amount string into a dollar display string (e.g. "25" β†’ "$25"). + * @throws Error if the amount is not a valid integer. + */ +function formatAmount(amount: string): string { + const num = parseInt(amount, 10); + if (isNaN(num)) { + throw new Error(`Invalid amount value: "${amount}"`); + } + return `$${num}`; +} + +/** + * Send SMS to recipients listed in an external CSV file. + * Similar to sendBulk but reads from CSV instead of the database, + * and supports additional template variables (amount, rewardCode). + * Does NOT require a database connection. + * + * @param options.filePath - Path to the CSV file with recipient data. + * @param options.templateName - Name of the YAML template to use. + * @param options.dryRun - If true, preview messages without sending. + */ +async function sendCsv(options: { + filePath: string; + templateName: string; + dryRun: boolean; +}): Promise { + const mode = options.dryRun ? 'πŸ” DRY RUN' : 'πŸ“€ SENDING'; + console.log(`\n${mode} β€” CSV Bulk SMS...\n`); + + const template = loadTemplate(options.templateName); + const recipients = parseCsvFile(options.filePath); + + if (recipients.length === 0) { + console.log('No valid recipients found in CSV. Nothing to send.'); + return; + } + + console.log(`Template: ${template.name}`); + console.log(`CSV file: ${options.filePath}`); + console.log(`Recipients: ${recipients.length} (after dedup & filtering)`); + console.log(`Message template: ${template.body}`); + console.log(''); + + if (options.dryRun) { + console.log('--- Dry run preview ---\n'); + for (const r of recipients) { + try { + const normalizedPhone = normalizePhoneToE164(r.phone); + const variables: Record = { + surveyCode: r.surveyCode, + amount: formatAmount(r.amount), + rewardCode: r.rewardCode + }; + const renderedBody = interpolateTemplate( + template.body, + variables + ); + console.log( + ` βœ“ ${r.phone} β†’ ${normalizedPhone}: "${renderedBody}"` + ); + } catch (err) { + console.log( + ` βœ— ${r.phone}: ${err instanceof Error ? err.message : err}` + ); + } + } + console.log( + `\n--- Dry run complete. ${recipients.length} message(s) would be sent. ---` + ); + return; + } + + // Actual send + const batchId = randomBytes(8).toString('hex').toUpperCase(); + const date = new Date().toISOString().split('T')[0]; + const logFilename = `sms-log-${date}-${template.name}.csv`; + const logger = new CsvSmsLogger(logFilename); + + let successCount = 0; + let failCount = 0; + + for (let i = 0; i < recipients.length; i++) { + const r = recipients[i]; + const index = i + 1; + + try { + const normalizedPhone = normalizePhoneToE164(r.phone); + const variables: Record = { + surveyCode: r.surveyCode, + amount: formatAmount(r.amount), + rewardCode: r.rewardCode + }; + const renderedBody = interpolateTemplate( + template.body, + variables + ); + + const result = await sendSms(normalizedPhone, renderedBody); + + const record: SmsRecord = { + surveyCode: r.surveyCode, + phone: normalizedPhone, + templateName: template.name, + smsText: renderedBody, + datetime: new Date().toISOString(), + status: result.status, + twilioSid: result.sid, + numSegments: result.numSegments + }; + await logger.log(record); + + successCount++; + console.log( + ` βœ“ [${index}/${recipients.length}] ${r.phone} β†’ ${normalizedPhone} (SID: ${result.sid})` + ); + + if (result.errorCode) { + console.log( + ` Warning: ${result.errorCode} - ${result.errorMessage}` + ); + } + } catch (err) { + failCount++; + const errorMessage = + err instanceof Error ? err.message : String(err); + console.log( + ` βœ— [${index}/${recipients.length}] ${r.phone}: ${errorMessage}` + ); + + // Log failures too + const record: SmsRecord = { + surveyCode: r.surveyCode, + phone: r.phone, + templateName: template.name, + smsText: '', + datetime: new Date().toISOString(), + status: 'failed', + twilioSid: '', + numSegments: '' + }; + await logger.log(record); + } + + // Rate limiting: 1.1s delay between messages (Twilio long-code limit: 1 msg/sec) + if (i < recipients.length - 1) { + await new Promise(resolve => setTimeout(resolve, 1100)); + } + } + + console.log('\n' + '='.repeat(50)); + console.log('CSV Bulk SMS Summary:'); + console.log(` Batch ID: ${batchId}`); + console.log(` βœ“ Sent: ${successCount}`); + console.log(` βœ— Failed: ${failCount}`); + console.log(` Log file: ${logger.getLogFilePath()}`); +} + +function showLogs(): void { + console.log('\nπŸ“‹ SMS Log Files\n'); + + const logFiles = listLogFiles(); + + if (logFiles.length === 0) { + console.log('No log files found.'); + return; + } + + for (const { filename, rows } of logFiles) { + console.log(` ${filename} (${rows} message${rows !== 1 ? 's' : ''})`); + } + console.log(''); +} + +/** + * Recover a send log by fetching all outbound messages from the Twilio API. + * Useful when the local CSV log was lost or incomplete. Writes a + * "recovered" CSV file with the fetched message data. + * + * @param options.date - Optional date filter (YYYY-MM-DD) to limit results to a single day. + */ +async function fetchLogs(options: { date?: string }): Promise { + console.log('\nπŸ“₯ Fetching outbound messages from Twilio API...\n'); + + let dateSentAfter: Date | undefined; + let dateSentBefore: Date | undefined; + + if (options.date) { + // Treat the date as UTC to match Twilio's dateSent field + dateSentAfter = new Date(`${options.date}T00:00:00Z`); + dateSentBefore = new Date(`${options.date}T23:59:59Z`); + console.log(` Filtering to date: ${options.date} (UTC)\n`); + } + + const messages = await listOutboundMessages({ dateSentAfter, dateSentBefore }); + + if (messages.length === 0) { + console.log('No outbound messages found.'); + return; + } + + // Warn if the result count hit our configured limit β€” some messages may have + // been truncated. Increase TWILIO_LIST_LIMIT in twilio.ts if this happens. + if (messages.length >= TWILIO_LIST_LIMIT) { + console.warn( + ` ⚠ WARNING: Retrieved ${messages.length} messages, which equals the configured limit (${TWILIO_LIST_LIMIT}).` + + '\n Some messages may have been truncated. Consider increasing TWILIO_LIST_LIMIT in twilio.ts.\n' + ); + } + + console.log(` Found ${messages.length} outbound message(s).\n`); + + const date = options.date ?? new Date().toISOString().split('T')[0]; + const logFilename = `sms-log-recovered-${date}.csv`; + const logger = new CsvSmsLogger(logFilename); + + for (let i = 0; i < messages.length; i++) { + const m = messages[i]; + const record: SmsRecord = { + surveyCode: '', + phone: m.to, + templateName: '', + smsText: m.body, + datetime: + m.dateSent?.toISOString() ?? + m.dateCreated?.toISOString() ?? + '', + status: m.status, + twilioSid: m.sid, + numSegments: m.numSegments + }; + await logger.log(record); + + if ((i + 1) % 100 === 0 || i + 1 === messages.length) { + const pct = (((i + 1) / messages.length) * 100).toFixed(1); + console.log(` [${i + 1}/${messages.length}] ${pct}%`); + } + } + + console.log(`\n Recovered log: ${logger.getLogFilePath()}`); + console.log( + ` Next step: npm run sms -- check-status --log-file ${logFilename}` + ); +} + +/** + * Fetch the latest delivery status from Twilio for every record in one or + * more log files. Creates an enriched CSV per source log with columns for + * dateSent, price, priceUnit, errorCode, and errorMessage. The original + * log file is preserved β€” a new "-updated-DATE.csv" file is written. + * + * @param logFilename - Optional: limit the check to a single log file. + */ +async function checkStatus(logFilename?: string): Promise { + console.log('\nπŸ”„ Checking SMS delivery statuses via Twilio API...\n'); + + const logFiles = listLogFiles(); + + if (logFiles.length === 0) { + console.log('No log files found. Send some messages first.'); + return; + } + + // Exclude previously enriched files (e.g. "*-updated-2026-03-01.csv") to avoid + // creating chains like "...-updated-...-updated-....csv" on repeated runs. + const filesToCheck = logFilename + ? logFiles.filter(f => f.filename === logFilename) + : logFiles.filter(f => !/-updated-\d{4}-\d{2}-\d{2}\.csv$/.test(f.filename)); + + if (filesToCheck.length === 0) { + console.log(`Log file "${logFilename}" not found.`); + return; + } + + const date = new Date().toISOString().split('T')[0]; + + for (const { filename } of filesToCheck) { + const sourceLogger = new CsvSmsLogger(filename); + const records = await sourceLogger.getLogs(); + + const outputFilename = + filename.replace('.csv', '') + `-updated-${date}.csv`; + const outputLogger = new UpdatedCsvSmsLogger(outputFilename); + + const statusCounts = new Map(); + const total = records.length; + + console.log(`\nProcessing: ${filename} (${total} records)`); + + for (let i = 0; i < records.length; i++) { + const record = records[i]; + let updatedRecord: UpdatedSmsRecord; + + if (!record.twilioSid) { + statusCounts.set( + record.status, + (statusCounts.get(record.status) ?? 0) + 1 + ); + updatedRecord = { + ...record, + dateSent: '', + price: '', + priceUnit: '', + errorCode: '', + errorMessage: '' + }; + } else { + try { + const msg = await fetchMessageStatus(record.twilioSid); + statusCounts.set( + msg.status, + (statusCounts.get(msg.status) ?? 0) + 1 + ); + updatedRecord = { + ...record, + status: msg.status, + dateSent: msg.dateSent?.toISOString() ?? '', + price: msg.price ?? '', + priceUnit: msg.priceUnit ?? '', + errorCode: + msg.errorCode != null ? String(msg.errorCode) : '', + errorMessage: msg.errorMessage ?? '' + }; + } catch (err) { + const errorMsg = + err instanceof Error ? err.message : String(err); + console.warn(` ⚠ ${record.twilioSid}: ${errorMsg}`); + statusCounts.set( + record.status, + (statusCounts.get(record.status) ?? 0) + 1 + ); + updatedRecord = { + ...record, + dateSent: '', + price: '', + priceUnit: '', + errorCode: '', + errorMessage: '' + }; + } + } + + await outputLogger.log(updatedRecord); + + if ((i + 1) % 100 === 0 || i + 1 === total) { + const pct = (((i + 1) / total) * 100).toFixed(1); + const lastSid = record.twilioSid || '(no SID)'; + console.log( + ` [${i + 1}/${total}] ${pct}% β€” last: ${lastSid} β†’ ${updatedRecord.status}` + ); + } + } + + console.log('\n=== Delivery Statistics ==='); + console.log(`File: ${filename} (${total} messages)`); + for (const [status, count] of [...statusCounts.entries()].sort( + (a, b) => b[1] - a[1] + )) { + const pct = ((count / total) * 100).toFixed(1); + console.log( + ` ${status.padEnd(14)} ${String(count).padStart(5)} (${pct}%)` + ); + } + console.log(`Updated log: ${outputLogger.getLogFilePath()}`); + } +} + +// ===== Argument Parsing Helpers ===== +// Simple hand-rolled argument parsing (no external library like yargs/commander). +// These helpers extract named flags and key=value pairs from process.argv. + +/** + * Get the value following a named flag (e.g. "--template gift_card" β†’ "gift_card"). + * @returns The value string, or undefined if the flag is not present. + */ +function getFlag(args: string[], flag: string): string | undefined { + const index = args.indexOf(flag); + if (index === -1 || index + 1 >= args.length) return undefined; + return args[index + 1]; +} + +/** Check if a boolean flag is present (e.g. "--dry-run"). */ +function hasFlag(args: string[], flag: string): boolean { + return args.includes(flag); +} + +/** + * Extract all `--var key=value` pairs from the argument list. + * Supports multiple --var flags (e.g. `--var surveyCode=ABC --var amount=25`). + * @returns Object mapping variable names to their values. + */ +function getVars(args: string[]): Record { + const vars: Record = {}; + for (let i = 0; i < args.length; i++) { + if (args[i] === '--var' && i + 1 < args.length) { + const pair = args[i + 1]; + const eqIndex = pair.indexOf('='); + if (eqIndex === -1) { + throw new Error( + `Invalid --var format: "${pair}". Expected key=value` + ); + } + vars[pair.slice(0, eqIndex)] = pair.slice(eqIndex + 1); + i++; + } + } + return vars; +} + +// ===== Main ===== +// Entry point: parses CLI arguments, routes to the appropriate operation, +// and handles database connection lifecycle. + +async function main(): Promise { + try { + // process.argv is [node, script, ...userArgs]. slice(2) drops the first + // two elements to get just the user-provided arguments. + const args = process.argv.slice(2); + + if (args.length === 0) { + printUsage(); + throw new Error('No operation specified'); + } + + const operation = args[0].toLowerCase(); + + // 'logs', 'fetch-logs', and 'check-status' don't need a DB connection + if (operation === 'logs') { + showLogs(); + return; + } + + if (operation === 'check-status') { + const logFile = getFlag(args, '--log-file'); + await checkStatus(logFile); + return; + } + + if (operation === 'fetch-logs') { + const date = getFlag(args, '--date'); + await fetchLogs({ date }); + return; + } + + if (operation === 'send-csv') { + const filePath = getFlag(args, '--file'); + if (!filePath) { + throw new Error('send-csv requires --file '); + } + const templateName = getFlag(args, '--template'); + if (!templateName) { + throw new Error('send-csv requires --template '); + } + const dryRun = hasFlag(args, '--dry-run'); + await sendCsv({ filePath, templateName, dryRun }); + return; + } + + console.log('Connecting to database...'); + await connectDB(); + console.log('Connected to database βœ“'); + + switch (operation) { + case 'list-recipients': { + const locationId = getFlag(args, '--location'); + await listRecipients(locationId); + break; + } + + case 'send': { + if (args.length < 2) { + throw new Error('send requires a phone number argument'); + } + const phone = args[1]; + const templateName = getFlag(args, '--template'); + const body = getFlag(args, '--body'); + const vars = getVars(args); + + await sendSingle(phone, { templateName, body, vars }); + break; + } + + case 'send-bulk': { + const templateName = getFlag(args, '--template'); + if (!templateName) { + throw new Error('send-bulk requires --template '); + } + const locationObjectId = getFlag(args, '--location'); + const dryRun = hasFlag(args, '--dry-run'); + + await sendBulk({ templateName, locationObjectId, dryRun }); + break; + } + + default: + printUsage(); + throw new Error(`Unknown operation "${operation}"`); + } + } catch (error) { + console.error( + '\nβœ— Error:', + error instanceof Error ? error.message : error + ); + // Set process.exitCode instead of calling process.exit() directly. + // process.exit() terminates immediately (skipping the `finally` block + // and any pending I/O), while setting exitCode lets the event loop + // drain naturally β€” so the `finally` block below still runs and the + // database connection is properly closed. + process.exitCode = 1; + } finally { + if (mongoose.connection.readyState === 1) { + await mongoose.connection.close(); + console.log('\nDatabase connection closed.'); + } + } +} + +function printUsage(): void { + console.log(` +Usage: npm run sms -- [args] + +Operations: + + list-recipients [--location ] + List all survey respondents who provided a phone number. + Example: npm run sms -- list-recipients + Example: npm run sms -- list-recipients --location 507f1f77bcf86cd799439011 + + send --template [--var key=value ...] + Send a single SMS using a YAML template with variable substitution. + Example: npm run sms -- send "(206) 555-1234" --template gift_card --var surveyCode=ABC123 + + send --body "raw message text" + Send a single SMS with a raw message body (no template). + Example: npm run sms -- send "+15551234567" --body "Hello from the PIT Count team!" + + send-bulk --template [--location ] [--dry-run] + Send SMS to all respondents who provided a phone number, using a template. + Variables (surveyCode, etc.) are auto-populated from survey data. + --dry-run shows what would be sent without actually sending. + Example: npm run sms -- send-bulk --template gift_card + Example: npm run sms -- send-bulk --template gift_card --dry-run + Example: npm run sms -- send-bulk --template gift_card --location 507f1f77bcf86cd799439011 + + send-csv --file --template [--dry-run] + Send SMS to recipients listed in an external CSV file. + CSV must have columns: surveyCode, phone, amount, Reward Code. + Deduplicates rows, skips rows with amount=0 or empty Reward Code. + Does NOT require a database connection. + --dry-run shows what would be sent without actually sending. + Example: npm run sms -- send-csv --file ~/data/recipients.csv --template gift_card_redeem + Example: npm run sms -- send-csv --file ~/data/recipients.csv --template gift_card_redeem --dry-run + + logs + List all CSV log files in sms-logs/ directory with row counts. + Example: npm run sms -- logs + + fetch-logs [--date YYYY-MM-DD] + Recover a send log by fetching all outbound messages from Twilio API. + Filters out inbound replies (direction === outbound-api only). + Writes sms-log-recovered-DATE.csv. Use --date to limit to a single day. + Example: npm run sms -- fetch-logs --date 2026-02-19 + + check-status [--log-file ] + Fetch the latest delivery status from Twilio for every record in log files. + Creates an enriched CSV per source log with updated status + dateSent, price, + priceUnit, errorCode, errorMessage columns. Original log is preserved. + Prints a delivery stats summary per file. + Example: npm run sms -- check-status + Example: npm run sms -- check-status --log-file sms-log-2026-02-19-gift_card_notice.csv + `); +} + +main(); diff --git a/server/src/services/smsLogger.ts b/server/src/services/smsLogger.ts new file mode 100644 index 00000000..0da4ddcd --- /dev/null +++ b/server/src/services/smsLogger.ts @@ -0,0 +1,249 @@ +/** + * smsLogger.ts β€” CSV-based logging for SMS sends and status checks. + * + * Provides two logger classes: + * - CsvSmsLogger: Logs initial send results (surveyCode, phone, status, etc.) + * - UpdatedCsvSmsLogger: Logs enriched records after fetching delivery status from Twilio + * + * Log files are stored in `server/src/scripts/sms-logs/` as plain CSV files. + * Each bulk-send or status-check creates a new file (logs are never overwritten). + */ + +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { parse } from 'csv-parse/sync'; + +// --- ESM path resolution --- +// In ES modules (ESM), `__filename` and `__dirname` are not available like they +// are in CommonJS. Instead, we derive them from `import.meta.url`, which gives +// the file:// URL of the current module (e.g. "file:///home/user/project/smsLogger.ts"). +// `fileURLToPath` converts that URL to a filesystem path, and `path.dirname` +// extracts the directory portion. +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +/** Directory where all SMS CSV log files are stored. */ +const SMS_LOGS_DIR = path.join(__dirname, '../scripts/sms-logs'); + +/** + * Shape of a single row in the initial send-log CSV. + * In TypeScript, `interface` defines the shape of an object β€” what keys it + * must have and what types those values must be. + */ +export interface SmsRecord { + surveyCode: string; + phone: string; + templateName: string; + smsText: string; + datetime: string; + /** Status at send time (e.g. "queued", "failed"). */ + status: string; + /** Twilio message SID (empty string if the send failed before reaching Twilio). */ + twilioSid: string; + numSegments: string; +} + +/** + * Extended record with delivery details fetched after the initial send. + * `extends SmsRecord` means this interface inherits all fields from SmsRecord + * and adds additional ones (dateSent, price, etc.). + */ +export interface UpdatedSmsRecord extends SmsRecord { + dateSent: string; + price: string; + priceUnit: string; + errorCode: string; + errorMessage: string; +} + +/** + * Interface (contract) that any SMS logger implementation must satisfy. + * Currently only CsvSmsLogger implements this, but the interface makes it + * easy to swap in alternative backends (e.g. database logging) in the future. + */ +export interface SmsLogger { + log(record: SmsRecord): Promise; + getLogs(): Promise; + getLogFilePath(): string; +} + +const CSV_HEADER = + 'surveyCode,phone,templateName,smsText,datetime,status,twilioSid,numSegments'; + +const UPDATED_CSV_HEADER = + 'surveyCode,phone,templateName,smsText,datetime,status,twilioSid,numSegments,' + + 'dateSent,price,priceUnit,errorCode,errorMessage'; + +/** + * Escape a single CSV field value per RFC 4180: + * If the value contains commas, double quotes, or newlines, wrap it in + * double quotes and escape any internal double quotes by doubling them. + */ +function escapeCsvField(value: string): string { + if (value.includes(',') || value.includes('"') || value.includes('\n')) { + return `"${value.replace(/"/g, '""')}"`; + } + return value; +} + +/** Convert an SmsRecord to a comma-separated CSV row string. */ +function recordToCsvRow(record: SmsRecord): string { + return [ + escapeCsvField(record.surveyCode), + escapeCsvField(record.phone), + escapeCsvField(record.templateName), + escapeCsvField(record.smsText), + escapeCsvField(record.datetime), + escapeCsvField(record.status), + escapeCsvField(record.twilioSid), + escapeCsvField(record.numSegments) + ].join(','); +} + +/** Convert an UpdatedSmsRecord (with delivery details) to a CSV row string. */ +function updatedRecordToCsvRow(record: UpdatedSmsRecord): string { + return [ + escapeCsvField(record.surveyCode), + escapeCsvField(record.phone), + escapeCsvField(record.templateName), + escapeCsvField(record.smsText), + escapeCsvField(record.datetime), + escapeCsvField(record.status), + escapeCsvField(record.twilioSid), + escapeCsvField(record.numSegments), + escapeCsvField(record.dateSent), + escapeCsvField(record.price), + escapeCsvField(record.priceUnit), + escapeCsvField(record.errorCode), + escapeCsvField(record.errorMessage) + ].join(','); +} + +/** + * CSV-based logger for initial SMS send results. + * + * `implements SmsLogger` means this class must provide all methods defined + * in the SmsLogger interface above. TypeScript will error at compile time + * if any required method is missing or has the wrong signature. + */ +export class CsvSmsLogger implements SmsLogger { + /** `private` means this field is only accessible within this class. */ + private filePath: string; + + /** + * @param filename - Name of the CSV file (e.g. "sms-log-2026-02-19-gift_card.csv"). + * The file is created in the SMS_LOGS_DIR directory. + */ + constructor(filename: string) { + if (!fs.existsSync(SMS_LOGS_DIR)) { + // `recursive: true` creates parent directories if they don't exist + fs.mkdirSync(SMS_LOGS_DIR, { recursive: true }); + } + this.filePath = path.join(SMS_LOGS_DIR, filename); + } + + getLogFilePath(): string { + return this.filePath; + } + + /** Append a single record to the CSV file. Creates the file with headers if needed. */ + async log(record: SmsRecord): Promise { + const fileExists = fs.existsSync(this.filePath); + const row = recordToCsvRow(record); + + if (!fileExists) { + fs.writeFileSync(this.filePath, CSV_HEADER + '\n' + row + '\n'); + } else { + fs.appendFileSync(this.filePath, row + '\n'); + } + } + + /** Read and parse all records from the CSV file. Returns [] if the file doesn't exist. */ + async getLogs(): Promise { + if (!fs.existsSync(this.filePath)) { + return []; + } + + const content = fs.readFileSync(this.filePath, 'utf-8'); + // csv-parse options: + // columns: true β€” use the first row as column headers (returns objects, not arrays) + // skip_empty_lines β€” ignore blank lines in the CSV + // relax_column_count β€” don't error if some rows have fewer/more columns than the header + // cast: false β€” keep all values as strings (don't auto-convert to numbers/booleans) + // `as SmsRecord[]` is a type assertion telling TypeScript to treat the + // parsed result as an array of SmsRecord objects. + return parse(content, { + columns: true, + skip_empty_lines: true, + relax_column_count: true, + cast: false + }) as SmsRecord[]; + } +} + +/** + * CSV logger for enriched SMS records (after fetching delivery status from Twilio). + * Similar to CsvSmsLogger but writes the extended UpdatedSmsRecord format + * with additional columns: dateSent, price, priceUnit, errorCode, errorMessage. + */ +export class UpdatedCsvSmsLogger { + private filePath: string; + + constructor(filename: string) { + if (!fs.existsSync(SMS_LOGS_DIR)) { + fs.mkdirSync(SMS_LOGS_DIR, { recursive: true }); + } + this.filePath = path.join(SMS_LOGS_DIR, filename); + } + + getLogFilePath(): string { + return this.filePath; + } + + /** Append an enriched record to the updated CSV file. Creates the file with headers if needed. */ + async log(record: UpdatedSmsRecord): Promise { + const fileExists = fs.existsSync(this.filePath); + const row = updatedRecordToCsvRow(record); + + if (!fileExists) { + fs.writeFileSync( + this.filePath, + UPDATED_CSV_HEADER + '\n' + row + '\n' + ); + } else { + fs.appendFileSync(this.filePath, row + '\n'); + } + } +} + +/** + * List all CSV log files in the sms-logs directory with row counts. + * + * @returns Array of objects with `filename` and `rows` (number of data rows, + * excluding the header). Returns an empty array if the directory + * doesn't exist yet. + */ +export function listLogFiles(): { filename: string; rows: number }[] { + if (!fs.existsSync(SMS_LOGS_DIR)) { + return []; + } + + return fs + .readdirSync(SMS_LOGS_DIR) + .filter(f => f.endsWith('.csv')) + .map(filename => { + const filePath = path.join(SMS_LOGS_DIR, filename); + const content = fs.readFileSync(filePath, 'utf-8'); + const records = parse(content, { + columns: true, + skip_empty_lines: true, + relax_column_count: true, + cast: false + }) as unknown[]; + return { + filename, + rows: records.length + }; + }); +} diff --git a/server/src/services/twilio.ts b/server/src/services/twilio.ts new file mode 100644 index 00000000..0ce411b6 --- /dev/null +++ b/server/src/services/twilio.ts @@ -0,0 +1,274 @@ +/** + * twilio.ts β€” Twilio SMS service layer. + * + * Wraps the Twilio Node SDK to provide simple functions for sending SMS, + * fetching message statuses, and listing outbound messages. Also includes + * utility helpers for phone number normalization and template interpolation. + * + * All Twilio API calls go through a lazily-initialized singleton client + * (see getTwilioClient below), so credentials are only required when the + * first API call is actually made. + */ + +import twilio from 'twilio'; + +// `as string` is a TypeScript "type assertion" β€” it tells the compiler to +// treat this value as a string even though process.env values are technically +// `string | undefined`. We validate it at runtime before use (see sendSms). +const twilioPhoneNumber = process.env.TWILIO_PHONE_NUMBER as string; + +/** + * Lazily-initialized Twilio client instance (singleton pattern). + * + * `ReturnType` is a TypeScript utility type that extracts the + * return type of the `twilio()` factory function β€” this avoids needing to + * import or reference Twilio's internal Client type directly. + */ +let _client: ReturnType | null = null; + +/** + * Returns the shared Twilio client, creating it on first call. + * Throws if TWILIO_ACCOUNT_SID or TWILIO_AUTH_TOKEN are not set. + */ +function getTwilioClient() { + if (!_client) { + const accountSid = process.env.TWILIO_ACCOUNT_SID; + const authToken = process.env.TWILIO_AUTH_TOKEN; + if (!accountSid || !authToken) { + throw new Error( + 'TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN must be set in your .env file.' + ); + } + _client = twilio(accountSid, authToken); + } + return _client; +} + +/** Result returned after sending a single SMS via Twilio. */ +export interface SmsSendResult { + /** Twilio's unique identifier for this message (e.g. "SM..."). */ + sid: string; + /** Initial status at send time β€” typically "queued". */ + status: string; + /** Twilio error code if the send failed immediately, otherwise null. */ + errorCode: string | null; + errorMessage: string | null; + /** Number of billing segments. Messages > 160 chars are split into segments. */ + numSegments: string; +} + +/** + * Send a single SMS via Twilio. + */ +export async function sendSms( + to: string, + body: string +): Promise { + if (!twilioPhoneNumber) { + throw new Error( + 'TWILIO_PHONE_NUMBER is not configured. Set it in your .env file.' + ); + } + + const message = await getTwilioClient().messages.create({ + to, + from: twilioPhoneNumber, + body + }); + + return { + sid: message.sid, + status: message.status, + errorCode: message.errorCode ? String(message.errorCode) : null, + // `??` is the "nullish coalescing" operator β€” returns the right-hand side + // only if the left-hand side is null or undefined (unlike `||` which also + // triggers on empty strings and 0). + errorMessage: message.errorMessage ?? null, + numSegments: message.numSegments + }; +} + +/** + * Shape of a message status response from Twilio. + * Fields like dateSent, price, and errorCode are only populated after + * the message leaves Twilio's network, so they may be null initially. + */ +export interface MessageStatusResult { + sid: string; + /** Current delivery status: "queued" β†’ "sent" β†’ "delivered" (or "undelivered"/"failed"). */ + status: string; + dateUpdated: Date | null; + dateSent: Date | null; + errorCode: number | null; + errorMessage: string | null; + /** Cost of the message (e.g. "-0.00750"), null until delivery. */ + price: string | null; + /** Currency code for the price (e.g. "USD"). */ + priceUnit: string | null; + numSegments: string; + /** Message direction: "outbound-api", "inbound", etc. */ + direction: string; +} + +/** + * Fetch the latest status of a previously sent message by Twilio SID. + * Includes delivery details (dateSent, price, errorCode) that are only + * available after the message leaves Twilio's network. + */ +export async function fetchMessageStatus( + sid: string +): Promise { + const message = await getTwilioClient().messages(sid).fetch(); + return { + sid: message.sid, + status: message.status, + dateUpdated: message.dateUpdated, + dateSent: message.dateSent ?? null, + errorCode: message.errorCode ?? null, + errorMessage: message.errorMessage ?? null, + price: message.price ?? null, + priceUnit: message.priceUnit ?? null, + numSegments: message.numSegments, + direction: message.direction + }; +} + +/** A single outbound SMS record as returned by listOutboundMessages(). */ +export interface OutboundMessageRecord { + sid: string; + to: string; + body: string; + status: string; + dateSent: Date | null; + dateCreated: Date | null; + numSegments: string; + direction: string; + errorCode: number | null; + errorMessage: string | null; + price: string | null; + priceUnit: string | null; +} + +/** + * Maximum number of messages to retrieve from the Twilio API in a single + * listOutboundMessages() call. The SDK auto-paginates internally (fetching + * pages of 50 records each) until this limit is reached. Set high enough + * to cover realistic bulk-send volumes (~3,700 messages per PIT count). + */ +export const TWILIO_LIST_LIMIT = 10_000; + +/** + * List all outbound messages sent from our Twilio number. + * + * Applies a high `limit` to ensure the Twilio SDK fetches all pages of + * results. Without an explicit limit the SDK defaults to ~50 records, + * which silently truncates results after bulk sends. + * + * Filters to direction === 'outbound-api' to exclude inbound replies. + * + * @param options.dateSentAfter - Only include messages sent after this date. + * @param options.dateSentBefore - Only include messages sent before this date. + * @returns Array of outbound message records. + */ +export async function listOutboundMessages(options?: { + dateSentAfter?: Date; + dateSentBefore?: Date; +}): Promise { + if (!twilioPhoneNumber) { + throw new Error( + 'TWILIO_PHONE_NUMBER is not configured. Set it in your .env file.' + ); + } + + const messages = await getTwilioClient().messages.list({ + from: twilioPhoneNumber, + limit: TWILIO_LIST_LIMIT, + // Conditional spread pattern: only include the date filter key in the + // object if the caller actually provided a value. If `dateSentAfter` is + // undefined, the `&&` short-circuits and the spread (`...`) receives + // `false`/`undefined`, which adds nothing to the object. + ...(options?.dateSentAfter && { dateSentAfter: options.dateSentAfter }), + ...(options?.dateSentBefore && { + dateSentBefore: options.dateSentBefore + }) + }); + + return messages + .filter(m => m.direction === 'outbound-api') + .map(m => ({ + sid: m.sid, + to: m.to, + body: m.body, + status: m.status, + dateSent: m.dateSent ?? null, + dateCreated: m.dateCreated ?? null, + numSegments: m.numSegments, + direction: m.direction, + errorCode: m.errorCode ?? null, + errorMessage: m.errorMessage ?? null, + price: m.price ?? null, + priceUnit: m.priceUnit ?? null + })); +} + +/** + * Normalizes a US phone number from various formats to E.164 (+1XXXXXXXXXX). + * E.164 is the international standard format required by Twilio's API. + * + * Handles: (555) 123-4567, 555-123-4567, 5551234567, +15551234567, etc. + * + * @param phone - The phone number in any common US format. + * @returns The phone number in E.164 format (e.g. "+15551234567"). + * @throws Error if the phone number is not a valid 10-digit US number. + */ +export function normalizePhoneToE164(phone: string): string { + // Strip all non-digit characters: parens, dashes, spaces, dots, plus sign + // \D is a regex shorthand for "any character that is NOT a digit" + const digits = phone.replace(/\D/g, ''); + + // 11 digits starting with '1' means country code was included (e.g. "15551234567") + if (digits.length === 11 && digits.startsWith('1')) { + return `+${digits}`; + } + + // 10 digits means just the local number (e.g. "5551234567") β€” prepend +1 + if (digits.length === 10) { + return `+1${digits}`; + } + + throw new Error( + `Invalid phone number: cannot normalize "${phone}" to E.164 format` + ); +} + +/** + * Interpolate template variables. Replaces `{varName}` placeholders with values. + * + * Uses a regex to find all `{word}` patterns in the template string and + * replaces each one with the corresponding value from the `variables` object. + * + * @param templateBody - The template string containing `{varName}` placeholders. + * @param variables - A key-value map of variable names to their values. + * `Record` is a TypeScript utility type + * equivalent to `{ [key: string]: string }`. + * @returns The interpolated string with all placeholders replaced. + * @throws Error if a placeholder references a variable not in `variables`. + */ +export function interpolateTemplate( + templateBody: string, + variables: Record +): string { + // Regex breakdown: \{(\w+)\} + // \{ β€” literal opening brace + // (\w+) β€” capture group: one or more word characters (the variable name) + // \} β€” literal closing brace + // g β€” global flag: replace ALL matches, not just the first + // The callback receives the full match and the captured group(s). + // _match is prefixed with underscore to indicate it's intentionally unused. + return templateBody.replace(/\{(\w+)\}/g, (_match, varName) => { + if (varName in variables) { + return variables[varName]; + } + throw new Error(`Missing template variable: {${varName}}`); + }); +} diff --git a/terraform/.gitignore b/terraform/.gitignore deleted file mode 100644 index 47d29fe6..00000000 --- a/terraform/.gitignore +++ /dev/null @@ -1,32 +0,0 @@ -# Local .terraform directories -**/.terraform/* - -# .tfstate files -*.tfstate -*.tfstate.* - -# Crash log files -crash.log -crash.*.log - -# Exclude all .tfvars files, which might contain sensitive data -*.tfvars -*.tfvars.json - -# Exclude specific .tfvars file -terraform.tfvars - -# Ignore override files as they are usually used for local settings -override.tf -override.tf.json -*_override.tf -*_override.tf.json - -# Include override files you want to add to version control -# !example_override.tf - -# Ignore CLI configuration files -.terraformrc -terraform.rc - -tfplan \ No newline at end of file diff --git a/terraform/.terraform.lock.hcl b/terraform/.terraform.lock.hcl deleted file mode 100644 index a7b1f943..00000000 --- a/terraform/.terraform.lock.hcl +++ /dev/null @@ -1,41 +0,0 @@ -# This file is maintained automatically by "terraform init". -# Manual edits may be lost in future updates. - -provider "registry.terraform.io/hashicorp/azurerm" { - version = "4.33.0" - constraints = "~> 4.33.0" - hashes = [ - "h1:3N8/4SkUbJcy+s4W74sx0KCM75T8U0ytUfSv/rCj3ok=", - "zh:1f61ce7671de78f09a8e7532bfe1366eff6e6af47050de0a06217162638b11a0", - "zh:20f103ad60399090c219685ef71d29713f46aba32499c02cc640508f8821b067", - "zh:22ce8ad46b32be74d7bd13c982f30e5ffc0749a42191de468309d4ca1ee427f2", - "zh:23ec8730b2f22701dbef4fd72459b95800bd4f87dfd517898064e291b807af20", - "zh:273202db879542def36a057072ef1b87aa0f1ccced81029c8ede55508d16080e", - "zh:2c5bc87083e7ddf55e49e3159a4836353a24a01288f78a846399453d33375938", - "zh:5dcae547287b377bc4c8e472e313d2178821264ac00cbf4fc6469dffb27a79cd", - "zh:6601669c92bea9b7c6fc7e1e20a957389e5d7c02d06cd2dcda44c6ef62f3d7df", - "zh:8b91e6153a586be514680c90b0d1a4aee7556a39376100e73dde8e9452537671", - "zh:9b6742b8b4a7bc4efa62794bd3425e39166dd3ac53fbdd8efb3214d79f36ffab", - "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", - "zh:f664e54e87c9466d54adc1f0dff41d8cca8d623aca317ff38aab29522b0d2508", - ] -} - -provider "registry.terraform.io/hashicorp/random" { - version = "3.7.2" - hashes = [ - "h1:KG4NuIBl1mRWU0KD/BGfCi1YN/j3F7H4YgeeM7iSdNs=", - "zh:14829603a32e4bc4d05062f059e545a91e27ff033756b48afbae6b3c835f508f", - "zh:1527fb07d9fea400d70e9e6eb4a2b918d5060d604749b6f1c361518e7da546dc", - "zh:1e86bcd7ebec85ba336b423ba1db046aeaa3c0e5f921039b3f1a6fc2f978feab", - "zh:24536dec8bde66753f4b4030b8f3ef43c196d69cccbea1c382d01b222478c7a3", - "zh:29f1786486759fad9b0ce4fdfbbfece9343ad47cd50119045075e05afe49d212", - "zh:4d701e978c2dd8604ba1ce962b047607701e65c078cb22e97171513e9e57491f", - "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", - "zh:7b8434212eef0f8c83f5a90c6d76feaf850f6502b61b53c329e85b3b281cba34", - "zh:ac8a23c212258b7976e1621275e3af7099e7e4a3d4478cf8d5d2a27f3bc3e967", - "zh:b516ca74431f3df4c6cf90ddcdb4042c626e026317a33c53f0b445a3d93b720d", - "zh:dc76e4326aec2490c1600d6871a95e78f9050f9ce427c71707ea412a2f2f1a62", - "zh:eac7b63e86c749c7d48f527671c7aee5b4e26c10be6ad7232d6860167f99dbb0", - ] -} diff --git a/terraform/README.md b/terraform/README.md deleted file mode 100644 index cb4c9265..00000000 --- a/terraform/README.md +++ /dev/null @@ -1,80 +0,0 @@ -# Azure App Service Terraform Configuration - -This directory contains Terraform configurations to deploy the Respondent-Driven Sampling application to Azure App Service. - -## Prerequisites - -1. [Terraform](https://www.terraform.io/downloads.html) installed (version 1.0.0 or later) -2. [Azure CLI](https://docs.microsoft.com/en-us/cli/azure/install-azure-cli) installed -3. Azure subscription - -## Setup - -1. Login to Azure: -```bash -az login -``` - -2. Initialize Terraform: -```bash -terraform init -``` - -3. Set up environment variables: - - Copy `terraform.tfvars.example` to `terraform.tfvars` - - Fill in your actual values in `terraform.tfvars` - ```bash - cp terraform.tfvars.example terraform.tfvars - ``` - - Edit `terraform.tfvars` with your actual values: - - MongoDB connection string - - Twilio credentials - - Other configuration values - -4. Review the planned changes: -```bash -terraform plan -out="tfplan" -``` - -5. Apply the configuration: -```bash -terraform apply "tfplan" -``` - -## Configuration - -The following variables can be customized in `variables.tf`: - -- `resource_group_name`: Name of the Azure resource group -- `location`: Azure region for deployment -- `app_service_plan_name`: Name of the App Service Plan -- `app_service_name`: Name of the App Service -- `node_version`: Node.js version to use -- `sku_name`: SKU name for the App Service Plan - -### Environment Variables - -The following sensitive environment variables must be set in `terraform.tfvars`: - -- `twilio_account_sid`: Twilio Account SID -- `twilio_auth_token`: Twilio Auth Token -- `twilio_verify_sid`: Twilio Verify Service SID - -These values will be securely stored in Azure App Service configuration and will not be visible in the Terraform state file. - -## Deployment - -After applying the Terraform configuration, you can deploy your application using Azure CLI or Azure DevOps pipelines. The App Service URL will be output after successful deployment. - -## Cleanup - -To destroy all created resources: -```bash -terraform destroy -``` - -## Security Notes - -1. Never commit `terraform.tfvars` to version control -2. Consider using Azure Key Vault for production secrets -3. Consider using Azure DevOps pipeline variables for CI/CD \ No newline at end of file diff --git a/terraform/main.tf b/terraform/main.tf deleted file mode 100644 index c58addca..00000000 --- a/terraform/main.tf +++ /dev/null @@ -1,109 +0,0 @@ -# Configuration Blocks -terraform { - required_providers { - azurerm = { - source = "hashicorp/azurerm" # Azure Resource Manager provider source - version = "~> 4.33.0" # Provider version constraint - } - } -} - -provider "azurerm" { - features {} # Required empty features block for the Azure provider - subscription_id = var.subscription_id -} - -# Resource Group -## Creates a logical container for all Azure resources in this deployment. -resource "azurerm_resource_group" "rds_resource_group" { - name = var.resource_group_name # Name from variables - location = var.location # Azure region from variables -} - -# Cosmos DB Account -## Creates a Cosmos DB account with MongoDB API -resource "azurerm_cosmosdb_account" "rds_cosmos_db" { - name = var.cosmos_db_account_name - location = azurerm_resource_group.rds_resource_group.location - resource_group_name = azurerm_resource_group.rds_resource_group.name - offer_type = var.cosmos_db_offer_type - kind = "MongoDB" - - capabilities { - name = "EnableMongo" - } - - consistency_policy { - consistency_level = var.cosmos_db_consistency_level - } - - geo_location { - location = azurerm_resource_group.rds_resource_group.location - failover_priority = 0 - } -} - -# Cosmos DB MongoDB Database -## Creates a database within the Cosmos DB account -resource "azurerm_cosmosdb_mongo_database" "rds_mongo_db" { - name = var.cosmos_db_database_name - resource_group_name = azurerm_cosmosdb_account.rds_cosmos_db.resource_group_name - account_name = azurerm_cosmosdb_account.rds_cosmos_db.name -} - -# App Service Plan -## Defines the compute resources for running the application. -resource "azurerm_service_plan" "rds_service_plan" { - # Defines the hosting plan for the web application - # Uses Linux as the operating system - # Pricing tier specified by sku_name variable - name = var.app_service_plan_name - resource_group_name = azurerm_resource_group.rds_resource_group.name - location = azurerm_resource_group.rds_resource_group.location - os_type = "Linux" - sku_name = var.sku_name -} - -# App Service for Web Application -## Deploys the Node.js application to Azure App Service with secure references to secrets. -resource "azurerm_linux_web_app" "rds_web_app" { - name = var.app_service_name - resource_group_name = azurerm_resource_group.rds_resource_group.name - location = azurerm_resource_group.rds_resource_group.location - service_plan_id = azurerm_service_plan.rds_service_plan.id - - # Configures Node version, always-on setting - site_config { - application_stack { - node_version = var.node_version - } - always_on = true - } - - # App settings include: - # - Node.js configuration - # - References to environment variables - app_settings = { - "WEBSITE_NODE_DEFAULT_VERSION" = var.node_version - "NODE_ENV" = var.node_env - "PORT" = "8080" - "SCM_DO_BUILD_DURING_DEPLOYMENT" = "true" - - # Environment Variables - "MONGO_URI" = azurerm_cosmosdb_account.rds_cosmos_db.primary_mongodb_connection_string - "TWILIO_ACCOUNT_SID" = var.twilio_account_sid - "TWILIO_AUTH_TOKEN" = var.twilio_auth_token - "TWILIO_VERIFY_SID" = var.twilio_verify_sid - } -} - -# Output the App Service URL -output "app_service_url" { - value = "https://${azurerm_linux_web_app.rds_web_app.default_hostname}" -} - -# Output the Cosmos DB connection string -output "cosmos_db_connection_string" { - value = azurerm_cosmosdb_account.rds_cosmos_db.primary_mongodb_connection_string - sensitive = true -} \ No newline at end of file diff --git a/terraform/terraform.tfvars.example b/terraform/terraform.tfvars.example deleted file mode 100644 index e19b2dc8..00000000 --- a/terraform/terraform.tfvars.example +++ /dev/null @@ -1,25 +0,0 @@ -subscription_id = "subscription_id" - -# Resource Group Configuration -resource_group_name = "<>" -location = "<>" - -# App Service Configuration -app_service_plan_name = "<>" -app_service_name = "<>" -node_version = "<>" -sku_name = "<>" - -# Cosmos DB Configuration -cosmos_db_account_name = "<>" -cosmos_db_database_name = "<>" -cosmos_db_consistency_level = "<>" -cosmos_db_offer_type = "<>" - -# Non-sensitive Configuration -node_env = "production" - -# Environment Variables -twilio_account_sid = "your-twilio-account-sid" -twilio_auth_token = "your-twilio-auth-token" -twilio_verify_sid = "your-twilio-verify-service-sid" \ No newline at end of file diff --git a/terraform/variables.tf b/terraform/variables.tf deleted file mode 100644 index ec66f21d..00000000 --- a/terraform/variables.tf +++ /dev/null @@ -1,78 +0,0 @@ -variable "subscription_id" { - description = "Azure subscription ID" - type = string -} - -variable "resource_group_name" { - description = "Name of the resource group" - type = string -} - -variable "location" { - description = "Azure region where resources will be created" - type = string -} - -variable "app_service_plan_name" { - description = "Name of the App Service Plan" - type = string -} - -variable "app_service_name" { - description = "Name of the App Service" - type = string -} - -variable "node_version" { - description = "Node.js version to use" - type = string -} - -variable "sku_name" { - description = "SKU name for the App Service Plan" - type = string -} - -variable "node_env" { - description = "Node environment" - type = string -} - -# Cosmos DB Configuration -variable "cosmos_db_account_name" { - description = "Name of the Cosmos DB account" - type = string -} - -variable "cosmos_db_database_name" { - description = "Name of the Cosmos DB database" - type = string -} - -variable "cosmos_db_consistency_level" { - description = "Consistency level for Cosmos DB" - type = string -} - -variable "cosmos_db_offer_type" { - description = "Offer type for Cosmos DB" - type = string -} - -variable "twilio_account_sid" { - description = "Twilio Account SID" - type = string - sensitive = true -} - -variable "twilio_auth_token" { - description = "Twilio Auth Token" - type = string - sensitive = true -} - -variable "twilio_verify_sid" { - description = "Twilio Verify Service SID" - type = string - sensitive = true -} \ No newline at end of file